diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index 1eca233..0000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,179 +0,0 @@
-# Keyboard Locker - GitHub Copilot Instructions
-
-## Project Overview
-
-This is a macOS status bar application built with Swift and SwiftUI using the modern MenuBarExtra API that allows users to quickly lock and unlock their keyboard to prevent accidental input.
-
-## Development Requirements
-
-### Core Development Standards
-- **Internationalization**: Maintain internationalization (i18n) throughout the project with English comments
-- **Logging**: Log messages do not require internationalization
-- **Documentation**: All markdown documentation must be updated to reflect the latest project status and written in English
-- **Code Quality**: Follow Swift API design guidelines and modern SwiftUI patterns
-
-## Project Structure
-
-```
-KeyboardLocker/
-├── KeyboardLocker/
-│ ├── KeyboardLockerApp.swift # Main app entry point using MenuBarExtra
-│ ├── ContentView.swift # Main interface displayed in popover
-│ ├── SettingsView.swift # Settings configuration interface
-│ ├── AboutView.swift # About page with app information
-│ ├── KeyboardLockManager.swift # Core keyboard locking functionality
-│ ├── Info.plist # App configuration (LSUIElement: true)
-│ ├── KeyboardLocker.entitlements # Security entitlements
-│ └── Assets.xcassets/ # App assets and icons
-├── KeyboardLocker.xcodeproj/ # Xcode project files
-├── README.md # Project documentation
-└── .gitignore # Git ignore rules
-```
-
-## Key Technologies
-
-- **Swift 5.7+** - Primary programming language
-- **SwiftUI** - UI framework for all interfaces
-- **MenuBarExtra** - Modern status bar management API (macOS 13.0+)
-- **AppKit** - macOS system integration
-- **NSEvent** - Global keyboard event monitoring
-- **UserNotifications** - Modern notification framework
-
-## Architecture Guidelines
-
-### App Lifecycle
-- The app runs as a status bar utility (no Dock icon)
-- `LSUIElement: true` in Info.plist prevents Dock appearance
-- Uses modern SwiftUI App protocol with MenuBarExtra
-- Requires macOS 13.0+ for MenuBarExtra API support
-
-### Status Bar Management
-- MenuBarExtra with `.window` style for popover interface
-- Status bar icon: `lock.shield` system symbol
-- Click shows main interface popover with lock controls
-- No context menu needed with modern design
-
-### Keyboard Locking System
-- `KeyboardLockManager` handles global event monitoring as ObservableObject
-- Uses `NSEvent.addGlobalMonitorForEvents` for keyboard interception
-- Unlock combination: `⌘ + ⌥ + L`
-- Supports auto-lock with configurable timeouts
-- Managed as @StateObject in KeyboardLockerApp and injected via @EnvironmentObject
-
-### UI Components
-- **ContentView**: Main interface with lock/unlock toggle and quick settings
-- **SettingsView**: Comprehensive settings for auto-lock, notifications, startup
-- **AboutView**: App information and feature list
-- All views use macOS 13.0+ compatible SwiftUI components with MenuBarExtra integration
-
-## Coding Standards
-
-### SwiftUI Best Practices
-- Use `@StateObject` for KeyboardLockManager in main app
-- Use `@EnvironmentObject` to access manager in child views
-- Use `@AppStorage` for user preferences persistence
-- Prefer composition over inheritance
-- Use modern SwiftUI patterns for macOS 13.0+
-
-### Event Handling
-- Global keyboard monitoring requires accessibility permissions
-- Always check for existing monitors before creating new ones
-- Properly clean up event monitors on app termination
-- Use weak references to prevent retain cycles
-
-### Settings Management
-- Use `@AppStorage` for user preferences with automatic persistence
-- Support these settings:
- - `autoLockDuration`: Auto-lock timeout (15, 30, 60 minutes, or never)
- - `showNotifications`: Toggle for lock/unlock notifications
-
-### Error Handling
-- Handle permission requests gracefully
-- Provide user-friendly error messages
-- Fall back to basic functionality if advanced features fail
-
-## Security Considerations
-
-### Entitlements
-```xml
-com.apple.security.automation.apple-events
-
-```
-
-### Permissions Required
-- **Accessibility Access**: For global keyboard event monitoring
-- **User Notifications**: For lock status notifications (optional)
-
-### Privacy
-- No data collection or external network requests
-- All settings stored locally using UserDefaults/@AppStorage
-- Keyboard events are only monitored, not logged or transmitted
-
-## Development Guidelines
-
-### Code Style
-- Use descriptive variable and function names
-- Comment complex keyboard event handling logic
-- Separate UI logic from business logic
-- Follow Swift API design guidelines
-- Use modern SwiftUI patterns and MenuBarExtra API
-
-### Testing Considerations
-- Test on macOS 13.0+ (minimum deployment target)
-- Verify accessibility permission handling
-- Test keyboard event interception thoroughly
-- Ensure proper cleanup on app termination
-
-### Common Pitfalls to Avoid
-- Don't use macOS 14.0+ only APIs without version checks
-- Always remove event monitors before adding new ones
-- Handle permission denied scenarios gracefully
-- Don't block the main thread with keyboard monitoring
-- Ensure MenuBarExtra compatibility with different macOS versions
-
-## Feature Implementation Notes
-
-### Modern MenuBarExtra Implementation
-- Use MenuBarExtra with `.window` style for native popover experience
-- No need for traditional NSStatusBar management
-- Cleaner architecture with SwiftUI-first approach
-- Automatic window management and positioning
-
-### Auto-Lock Feature
-- Implement with Timer or DispatchQueue.asyncAfter
-- Reset timer on any keyboard/mouse activity
-- Provide clear visual feedback when auto-lock is active
-
-### Notification System
-- Use UserNotifications framework (not deprecated NSUserNotification)
-- Request permission before showing notifications
-- Make notifications informative but not intrusive
-
-### Login Items
-- Use ServiceManagement framework for login item management
-- Handle user permission properly
-- Provide clear feedback about startup status
-
-## Debugging Tips
-
-- Use Console.app to view system logs
-- Test accessibility permissions in System Preferences
-- Use Xcode's debugger for SwiftUI view hierarchy
-- Monitor keyboard events with careful logging (remove in production)
-
-## Performance Considerations
-
-- MenuBarExtra provides optimized status bar updates
-- Use efficient event filtering for keyboard monitoring
-- Lazy load settings windows
-- Optimize SwiftUI view updates with proper @StateObject/@EnvironmentObject usage
-- Minimal memory footprint with modern SwiftUI patterns
-
-## System Requirements
-
-- **Minimum macOS Version**: 13.0 (for MenuBarExtra support)
-- **Recommended macOS Version**: 13.0 or later
-- **Xcode Version**: 14.0 or later for MenuBarExtra development
-- **Swift Version**: 5.7 or later
-
-Remember: This app prioritizes user privacy, system integration, and reliable keyboard locking functionality while maintaining a clean, native macOS experience using modern SwiftUI APIs.
diff --git a/.gitignore b/.gitignore
index 46b2e73..5a261c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,4 +96,9 @@ iOSInjectionProject/
# macOS
.DS_Store
+# xcode-build-server files
buildServer.json
+.compile
+
+[Ff]eatures.md
+[Tt]asks.md
diff --git a/.swift-version b/.swift-version
new file mode 100644
index 0000000..f9ce5a9
--- /dev/null
+++ b/.swift-version
@@ -0,0 +1 @@
+5.10
diff --git a/.swiftformat b/.swiftformat
new file mode 100644
index 0000000..618f612
--- /dev/null
+++ b/.swiftformat
@@ -0,0 +1,96 @@
+--acronyms ID,URL,UUID
+--allman false
+--anonymousforeach convert
+--assetliterals visual-width
+--asynccapturing
+--beforemarks
+--binarygrouping 4,8
+--callsiteparen default
+--categorymark "MARK: %c"
+--classthreshold 0
+--closingparen balanced
+--closurevoid remove
+--commas always
+--complexattrs preserve
+--computedvarattrs preserve
+--condassignment after-property
+--conflictmarkers reject
+--dateformat system
+--decimalgrouping 3,6
+--doccomments before-declarations
+--elseposition same-line
+--emptybraces no-space
+--enumnamespaces always
+--enumthreshold 0
+--exponentcase lowercase
+--exponentgrouping disabled
+--extensionacl on-extension
+--extensionlength 0
+--extensionmark "MARK: - %t + %c"
+--fractiongrouping disabled
+--fragment false
+--funcattributes preserve
+--generictypes
+--groupedextension "MARK: %c"
+--guardelse auto
+--header ignore
+--hexgrouping 4,8
+--hexliteralcase uppercase
+--ifdef indent
+--importgrouping alpha
+--indent 2
+--indentcase false
+--indentstrings false
+--initcodernil false
+--lifecycle
+--lineaftermarks true
+--linebreaks lf
+--markcategories true
+--markextensions always
+--marktypes always
+--maxwidth none
+--modifierorder
+--nevertrailing
+--nilinit remove
+--noncomplexattrs
+--nospaceoperators
+--nowrapoperators
+--octalgrouping 4,8
+--onelineforeach ignore
+--operatorfunc spaced
+--organizationmode visibility
+--organizetypes actor,class,enum,struct
+--patternlet hoist
+--ranges spaced
+--redundanttype infer-locals-only
+--self remove
+--selfrequired
+--semicolons inline
+--shortoptionals except-properties
+--smarttabs enabled
+--someany true
+--storedvarattrs preserve
+--stripunusedargs always
+--structthreshold 0
+--tabwidth unspecified
+--throwcapturing
+--timezone system
+--trailingclosures
+--trimwhitespace always
+--typeattributes preserve
+--typeblanklines remove
+--typedelimiter space-after
+--typemark "MARK: - %t"
+--voidtype void
+--wraparguments preserve
+--wrapcollections preserve
+--wrapconditions preserve
+--wrapeffects preserve
+--wrapenumcases always
+--wrapparameters default
+--wrapreturntype preserve
+--wrapternary before-operators
+--wraptypealiases preserve
+--xcodeindentation disabled
+--yodaswap always
+--enable blankLineAfterSwitchCase
diff --git a/Core/.gitignore b/Core/.gitignore
new file mode 100644
index 0000000..0023a53
--- /dev/null
+++ b/Core/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/Core/Package.swift b/Core/Package.swift
new file mode 100644
index 0000000..bf3afd0
--- /dev/null
+++ b/Core/Package.swift
@@ -0,0 +1,10 @@
+// swift-tools-version: 5.10
+
+import PackageDescription
+
+let package = Package(
+ name: "Core",
+ platforms: [.macOS(.v13)],
+ products: [.library(name: "Core", targets: ["Core"])],
+ targets: [.target(name: "Core")]
+)
diff --git a/Core/Sources/Core/Core.swift b/Core/Sources/Core/Core.swift
new file mode 100644
index 0000000..a36228f
--- /dev/null
+++ b/Core/Sources/Core/Core.swift
@@ -0,0 +1,7 @@
+// Core Library Public API Export
+// This file ensures all public types are properly exported from the Core module
+
+@_exported import ApplicationServices
+@_exported import Carbon
+@_exported import Combine
+@_exported import Foundation
diff --git a/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift
new file mode 100644
index 0000000..06aaf00
--- /dev/null
+++ b/Core/Sources/Core/CoreConfiguration.swift
@@ -0,0 +1,253 @@
+import Carbon
+import Combine
+import Foundation
+
+/// Core configuration management for KeyboardLocker
+/// Handles persistent settings and configuration synchronization across all targets
+public class CoreConfiguration: ObservableObject {
+ // MARK: - Singleton
+
+ public static let shared = CoreConfiguration()
+
+ // MARK: - UserDefaults Keys
+
+ private enum ConfigKeys: String, CaseIterable {
+ case autoLockDuration
+ case showNotifications
+ case hotkey
+ case appVersion
+ }
+
+ public enum Duration: Codable, Equatable, Hashable, Identifiable, RawRepresentable {
+ case never
+ case infinite
+ case minutes(Int) // Duration in minutes
+
+ // MARK: - RawRepresentable
+
+ public var rawValue: String {
+ switch self {
+ case .never:
+ "never"
+ case .infinite:
+ "infinite"
+ case let .minutes(minutes):
+ "minutes_\(minutes)"
+ }
+ }
+
+ public init?(rawValue: String) {
+ if rawValue == "never" {
+ self = .never
+ } else if rawValue == "infinite" {
+ self = .infinite
+ } else if rawValue.hasPrefix("minutes_"),
+ let minutesString = rawValue.components(separatedBy: "_").last,
+ let minutes = Int(minutesString)
+ {
+ self = .minutes(minutes)
+ } else {
+ return nil
+ }
+ }
+
+ // MARK: - Identifiable
+
+ public var id: String {
+ rawValue
+ }
+
+ /// Convert to minutes
+ public var minutes: Int {
+ switch self {
+ case .never:
+ 0
+ case .infinite:
+ .max
+ case let .minutes(minutes):
+ minutes
+ }
+ }
+
+ /// Convert to seconds
+ public var seconds: TimeInterval {
+ switch self {
+ case .never, .infinite:
+ 0
+ case let .minutes(minutes):
+ TimeInterval(minutes * 60)
+ }
+ }
+ }
+
+ // MARK: - UserDefaults
+
+ private let userDefaults = UserDefaults.standard
+
+ // MARK: - Published Properties
+
+ /// Auto-lock configuration using enum with RawRepresentable
+ @Published public var autoLockDuration: Duration = .never {
+ didSet {
+ userDefaults.set(autoLockDuration.rawValue, forKey: "io.lzhlovesjyq.keyboardlocker.autolockduration")
+ }
+ }
+
+ /// Whether to show system notifications
+ @Published public var showNotifications: Bool = true {
+ didSet {
+ userDefaults.set(showNotifications, forKey: "io.lzhlovesjyq.keyboardlocker.shownotifications")
+ }
+ }
+
+ /// Hotkey configuration using RawRepresentable
+ @Published public var hotkey: HotkeyConfiguration = .defaultHotkey() {
+ didSet {
+ userDefaults.set(hotkey.rawValue, forKey: "io.lzhlovesjyq.keyboardlocker.hotkey")
+ }
+ }
+
+ // MARK: - Computed Properties
+
+ /// Check if auto-lock is enabled
+ public var isAutoLockEnabled: Bool {
+ autoLockDuration != .never && autoLockDuration != .infinite
+ }
+
+ /// Auto-lock duration in seconds
+ public var autoLockDurationInSeconds: TimeInterval {
+ autoLockDuration.seconds
+ }
+
+ // MARK: - Initialization
+
+ private init() {
+ // Load values from UserDefaults
+ if let durationString = userDefaults.string(forKey: "io.lzhlovesjyq.keyboardlocker.autolockduration"),
+ let duration = Duration(rawValue: durationString)
+ {
+ autoLockDuration = duration
+ }
+
+ showNotifications = userDefaults.object(forKey: "io.lzhlovesjyq.keyboardlocker.shownotifications") as? Bool ?? true
+
+ if let hotkeyString = userDefaults.string(forKey: "io.lzhlovesjyq.keyboardlocker.hotkey"),
+ let hotkey = HotkeyConfiguration(rawValue: hotkeyString)
+ {
+ self.hotkey = hotkey
+ }
+ }
+
+ // MARK: - Configuration Management
+
+ /// Reset configuration to default values
+ public func resetToDefaults() {
+ autoLockDuration = .never
+ showNotifications = true
+ hotkey = HotkeyConfiguration.defaultHotkey()
+ }
+
+ /// Export configuration as dictionary
+ public func export(with appVersion: String) -> [String: Any] {
+ [
+ ConfigKeys.autoLockDuration.rawValue: autoLockDuration.rawValue,
+ ConfigKeys.showNotifications.rawValue: showNotifications,
+ ConfigKeys.hotkey.rawValue: hotkey.rawValue,
+ ConfigKeys.appVersion.rawValue: appVersion,
+ ]
+ }
+
+ /// Import configuration from dictionary
+ public func importConfiguration(_ config: [String: Any]) {
+ if let rawValue = config[ConfigKeys.autoLockDuration.rawValue] as? String,
+ let duration = Duration(rawValue: rawValue)
+ {
+ autoLockDuration = duration
+ }
+
+ if let notifications = config[ConfigKeys.showNotifications.rawValue] as? Bool {
+ showNotifications = notifications
+ }
+
+ if let rawValue = config[ConfigKeys.hotkey.rawValue] as? String,
+ let hotkeyConfig = HotkeyConfiguration(rawValue: rawValue)
+ {
+ hotkey = hotkeyConfig
+ }
+
+ if let _ = config[ConfigKeys.appVersion.rawValue] as? String {
+ /// Store app version for compatibility checks
+ }
+ }
+}
+
+// MARK: - Hotkey Configuration
+
+/// Hotkey configuration structure
+public struct HotkeyConfiguration: Codable, CustomStringConvertible, RawRepresentable {
+ public let keyCode: UInt16
+ public let modifierFlags: UInt32
+ public let displayString: String
+
+ public init(keyCode: UInt16, modifierFlags: UInt32, displayString: String) {
+ self.keyCode = keyCode
+ self.modifierFlags = modifierFlags
+ self.displayString = displayString
+ }
+
+ // MARK: - RawRepresentable
+
+ public var rawValue: String {
+ "\(keyCode):\(modifierFlags):\(displayString)"
+ }
+
+ public init?(rawValue: String) {
+ let components = rawValue.components(separatedBy: ":")
+ guard components.count == 3,
+ let keyCode = UInt16(components[0]),
+ let modifierFlags = UInt32(components[1])
+ else {
+ return nil
+ }
+
+ self.keyCode = keyCode
+ self.modifierFlags = modifierFlags
+ displayString = components[2]
+ }
+
+ /// Default hotkey: Command+Option+L
+ public static func defaultHotkey() -> HotkeyConfiguration {
+ HotkeyConfiguration(
+ keyCode: CoreConstants.defaultUnlockKeyCode,
+ modifierFlags: UInt32(cmdKey | optionKey),
+ displayString: "⌘⌥L"
+ )
+ }
+
+ /// Map Carbon modifier flags to `CGEventFlags` for event comparisons
+ public var eventModifierFlags: CGEventFlags {
+ var flags: CGEventFlags = []
+
+ if modifierFlags & UInt32(cmdKey) != 0 {
+ flags.insert(.maskCommand)
+ }
+
+ if modifierFlags & UInt32(optionKey) != 0 {
+ flags.insert(.maskAlternate)
+ }
+
+ if modifierFlags & UInt32(shiftKey) != 0 {
+ flags.insert(.maskShift)
+ }
+
+ if modifierFlags & UInt32(controlKey) != 0 {
+ flags.insert(.maskControl)
+ }
+
+ return flags
+ }
+
+ public var description: String {
+ displayString
+ }
+}
diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift
new file mode 100644
index 0000000..a1cd724
--- /dev/null
+++ b/Core/Sources/Core/KeyboardLockCore.swift
@@ -0,0 +1,271 @@
+import AppKit
+import Carbon
+
+/// Pure core keyboard locking engine - only handles low-level keyboard interception
+/// Business logic and UI concerns are handled by upper layers
+public class KeyboardLockCore {
+ // MARK: - Singleton
+
+ public static let shared = KeyboardLockCore()
+
+ // MARK: - State Properties (Read-Only)
+
+ var eventTap: CFMachPort?
+
+ private var runLoopSource: CFRunLoopSource?
+
+ /// Current keyboard lock state
+ public private(set) var isLocked = false
+
+ /// When keyboard was locked
+ public private(set) var lockedAt: Date?
+
+ // MARK: - Hotkey Configuration
+
+ /// Unlock hotkey configuration
+ public var unlockHotkey: HotkeyConfiguration = .defaultHotkey()
+
+ // MARK: - Callbacks for UI Layer
+
+ /// Callback triggered when lock state changes
+ public var onLockStateChanged: ((Bool, Date?) -> Void)?
+
+ /// Callback triggered when unlock hotkey is detected
+ public var onUnlockHotkeyDetected: (() -> Void)?
+
+ // MARK: - Hotkey State Tracking
+
+ private static let relevantModifierMask: CGEventFlags = [
+ .maskCommand,
+ .maskAlternate,
+ .maskShift,
+ .maskControl,
+ ]
+
+ static let eventMasks: CGEventMask =
+ (1 << CGEventType.keyDown.rawValue) |
+ (1 << CGEventType.keyUp.rawValue) |
+ (1 << CGEventType.flagsChanged.rawValue) |
+ (1 << CGEventType.otherMouseDown.rawValue) |
+ (1 << CGEventType.otherMouseUp.rawValue)
+
+ // MARK: - Initialization
+
+ private init() {}
+
+ deinit {
+ forceCleanup()
+ }
+
+ // MARK: - Hotkey Configuration Methods
+
+ /// Configure unlock hotkey combination
+ /// - Parameter hotkey: The hotkey configuration
+ public func configureUnlockHotkey(_ hotkey: HotkeyConfiguration) {
+ guard !isLocked else {
+ print("⚠️ Cannot change hotkey while keyboard is locked")
+ return
+ }
+
+ unlockHotkey = hotkey
+ print("🔧 Unlock hotkey configured: \(hotkey.description)")
+ }
+
+ /// Configure unlock hotkey combination (convenience method)
+ /// - Parameters:
+ /// - keyCode: The key code for the unlock key
+ /// - modifiers: The modifier flags (Command, Option, etc.)
+ /// - displayString: The display string for the hotkey
+ public func configureUnlockHotkey(keyCode: UInt16, modifiers: UInt32, displayString: String) {
+ let hotkey = HotkeyConfiguration(keyCode: keyCode, modifierFlags: modifiers, displayString: displayString)
+ configureUnlockHotkey(hotkey)
+ }
+
+ /// Reset unlock hotkey to default (Cmd+Option+L)
+ public func resetUnlockHotkeyToDefault() {
+ configureUnlockHotkey(.defaultHotkey())
+ }
+
+ // MARK: - Core Locking Methods
+
+ /// Lock keyboard input
+ /// - Throws: KeyboardLockError if locking fails
+ public func lockKeyboard() throws {
+ guard !isLocked else {
+ throw KeyboardLockError.alreadyLocked
+ }
+
+ // Check accessibility permission using PermissionHelper
+ guard PermissionHelper.checkAccessibilityPermission(promptUser: true) else {
+ throw KeyboardLockError.permissionDenied
+ }
+
+ try createEventTap()
+
+ isLocked = true
+ lockedAt = Date()
+
+ // Notify business layer
+ onLockStateChanged?(isLocked, lockedAt)
+ }
+
+ /// Unlock keyboard input
+ public func unlockKeyboard() {
+ guard isLocked else {
+ return
+ }
+
+ destroyEventTap()
+
+ isLocked = false
+ lockedAt = nil
+
+ // Notify business layer
+ onLockStateChanged?(isLocked, nil)
+ }
+
+ /// Toggle lock state
+ public func toggleLock() {
+ if isLocked {
+ unlockKeyboard()
+ } else {
+ do {
+ try lockKeyboard()
+ } catch {
+ print("❌ Failed to lock keyboard: \(error.localizedDescription)")
+ }
+ }
+ }
+
+ /// Force cleanup all resources
+ public func forceCleanup() {
+ print("🧹 KeyboardLockCore: Force cleanup initiated")
+
+ unlockKeyboard()
+ destroyEventTap()
+ }
+
+ // MARK: - Private Event Tap Methods
+
+ /// Create event tap for keyboard monitoring
+ private func createEventTap() throws {
+ eventTap = CGEvent.tapCreate(
+ tap: .cgSessionEventTap,
+ place: .headInsertEventTap,
+ options: .defaultTap,
+ eventsOfInterest: Self.eventMasks,
+ callback: globalEventCallback,
+ userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
+ )
+
+ guard let eventTap else {
+ throw KeyboardLockError.eventTapCreationFailed
+ }
+
+ runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
+ guard let runLoopSource else {
+ throw KeyboardLockError.runLoopSourceCreationFailed
+ }
+
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
+ CGEvent.tapEnable(tap: eventTap, enable: true)
+
+ print("🎯 Event tap created and enabled")
+ }
+
+ /// Destroy event tap and cleanup resources
+ private func destroyEventTap() {
+ if let eventTap {
+ CGEvent.tapEnable(tap: eventTap, enable: false)
+ CFMachPortInvalidate(eventTap)
+ self.eventTap = nil
+ print("🎯 Event tap disabled and invalidated")
+ }
+
+ if let runLoopSource {
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
+ self.runLoopSource = nil
+ print("🎯 Run loop source removed")
+ }
+ }
+
+ /// Handle keyboard events (internal for callback)
+ func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged? {
+ if shouldTriggerUnlock(for: type, event: event) {
+ print("🔑 Unlock hotkey pressed: \(unlockHotkey.displayString)")
+
+ DispatchQueue.main.async {
+ self.onUnlockHotkeyDetected?()
+ }
+ }
+
+ // Block all events from propagating while locked
+ return nil
+ }
+
+ private func shouldTriggerUnlock(for type: CGEventType, event: CGEvent) -> Bool {
+ guard event.flags.intersection(Self.relevantModifierMask) == unlockHotkey.eventModifierFlags else {
+ return false
+ }
+
+ switch type {
+ case .keyDown:
+ let keycodeValue = event.getIntegerValueField(.keyboardEventKeycode)
+ guard keycodeValue >= 0, keycodeValue <= Int64(UInt16.max) else {
+ return false
+ }
+
+ let eventKeyCode = CGKeyCode(UInt16(keycodeValue))
+ guard eventKeyCode == unlockHotkey.keyCode else {
+ return false
+ }
+
+ let isAutoRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) == 1
+ return !isAutoRepeat
+
+ case .flagsChanged:
+ if unlockHotkey.keyCode == 0 {
+ return true
+ }
+
+ return CGEventSource.keyState(.hidSystemState, key: unlockHotkey.keyCode)
+
+ default:
+ return false
+ }
+ }
+}
+
+// MARK: - Global Event Callback
+
+private func globalEventCallback(
+ proxy _: CGEventTapProxy,
+ type: CGEventType,
+ event: CGEvent,
+ refcon: UnsafeMutableRawPointer?
+) -> Unmanaged? {
+ guard let refcon else {
+ return Unmanaged.passUnretained(event)
+ }
+
+ let core = Unmanaged.fromOpaque(refcon).takeUnretainedValue()
+
+ // Handle tap disabled event
+ if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
+ print("⚠️ Event tap disabled by system, attempting to re-enable...")
+
+ if let eventTap = core.eventTap {
+ CGEvent.tapEnable(tap: eventTap, enable: true)
+ }
+
+ return Unmanaged.passUnretained(event)
+ }
+
+ // Only process events when locked
+ guard core.isLocked else {
+ return Unmanaged.passUnretained(event)
+ }
+
+ // Handle the event through core
+ return core.handleEvent(type: type, event: event)
+}
diff --git a/Core/Sources/Core/PermissionHelper.swift b/Core/Sources/Core/PermissionHelper.swift
new file mode 100644
index 0000000..81eac76
--- /dev/null
+++ b/Core/Sources/Core/PermissionHelper.swift
@@ -0,0 +1,40 @@
+import AppKit
+import ApplicationServices
+
+/// Helper class for checking system permissions required by KeyboardLocker
+public class PermissionHelper {
+ // MARK: - Accessibility Permission
+
+ /// Check if accessibility permission is currently granted
+ public static func hasAccessibilityPermission() -> Bool {
+ AXIsProcessTrusted()
+ }
+
+ /// Check accessibility permission with option to show system prompt
+ /// - Parameter promptUser: Whether to show system permission dialog
+ /// - Returns: Current permission status
+ public static func checkAccessibilityPermission(promptUser: Bool = false) -> Bool {
+ if promptUser {
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true]
+ return AXIsProcessTrustedWithOptions(options as CFDictionary)
+ } else {
+ return AXIsProcessTrusted()
+ }
+ }
+
+ /// Request accessibility permission by showing system dialog
+ public static func requestAccessibilityPermission() {
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true]
+ _ = AXIsProcessTrustedWithOptions(options as CFDictionary)
+ }
+
+ // MARK: - System URLs
+
+ /// Open System Preferences to Security & Privacy > Accessibility
+ public static func openAccessibilitySettings() {
+ guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else {
+ return
+ }
+ NSWorkspace.shared.open(url)
+ }
+}
diff --git a/Core/Sources/Core/SharedModels.swift b/Core/Sources/Core/SharedModels.swift
new file mode 100644
index 0000000..1bcc13a
--- /dev/null
+++ b/Core/Sources/Core/SharedModels.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+// MARK: - Error Types
+
+/// Keyboard lock operation errors
+public enum KeyboardLockError: Error, LocalizedError {
+ case permissionDenied
+ case eventTapCreationFailed
+ case runLoopSourceCreationFailed
+ case alreadyLocked
+ case notLocked
+ case invalidConfiguration
+ case systemError(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .permissionDenied:
+ "Accessibility permission is required to control keyboard input"
+ case .eventTapCreationFailed:
+ "Failed to create event tap for keyboard monitoring"
+ case .runLoopSourceCreationFailed:
+ "Failed to create run loop source"
+ case .alreadyLocked:
+ "Keyboard is already locked"
+ case .notLocked:
+ "Keyboard is not currently locked"
+ case .invalidConfiguration:
+ "Invalid configuration provided"
+ case let .systemError(message):
+ "System error: \(message)"
+ }
+ }
+
+ public var failureReason: String? {
+ errorDescription
+ }
+}
+
+// MARK: - Constants
+
+/// Shared constants used across the application
+public enum CoreConstants {
+ /// Main app bundle identifier
+ public static let mainAppBundleID = "io.lzhlovesjyq.KeyboardLocker"
+
+ /// Default unlock key combination (Cmd + Option + L)
+ public static let defaultUnlockKeyCode: UInt16 = 37 // 'L' key
+}
+
+// MARK: - Lock Status
+
+/// Current status of the keyboard lock
+public struct LockStatus: Codable {
+ public let isLocked: Bool
+ public let lockedAt: Date?
+
+ public init(
+ isLocked: Bool,
+ lockedAt: Date? = nil
+ ) {
+ self.isLocked = isLocked
+ self.lockedAt = lockedAt
+ }
+}
diff --git a/Core/Sources/Core/UserActivityMonitor.swift b/Core/Sources/Core/UserActivityMonitor.swift
new file mode 100644
index 0000000..a92f530
--- /dev/null
+++ b/Core/Sources/Core/UserActivityMonitor.swift
@@ -0,0 +1,222 @@
+import AppKit
+import ApplicationServices
+import Carbon
+
+/// User activity monitor for tracking keyboard and mouse activity
+/// Used to implement proper auto-lock behavior that only starts counting when user stops activity
+public class UserActivityMonitor {
+ // MARK: - Singleton
+
+ public static let shared = UserActivityMonitor()
+
+ // MARK: - Properties
+
+ private var activityEventTap: CFMachPort?
+ private var activityRunLoopSource: CFRunLoopSource?
+ private var lastActivityTime: Date = .init()
+ private var autoLockTimer: Timer?
+
+ /// Callback when auto-lock should be triggered
+ public var onAutoLockTriggered: (() -> Void)?
+
+ /// Current auto-lock duration in seconds (0 = disabled)
+ private var autoLockDuration: TimeInterval = 0
+
+ /// Whether auto-lock is currently enabled
+ public var isAutoLockEnabled: Bool {
+ autoLockDuration > 0
+ }
+
+ /// Time since last user activity
+ public var timeSinceLastActivity: TimeInterval {
+ Date().timeIntervalSince(lastActivityTime)
+ }
+
+ // MARK: - Initialization
+
+ private init() {}
+
+ deinit {
+ stopMonitoring()
+ }
+
+ // MARK: - Public Methods
+
+ /// Start monitoring user activity
+ public func startMonitoring() {
+ guard activityEventTap == nil else {
+ print("⚠️ Activity monitoring already started")
+ return
+ }
+
+ do {
+ try createActivityEventTap()
+ resetActivityTimer()
+ print("✅ User activity monitoring started")
+ } catch {
+ print("❌ Failed to start activity monitoring: \(error)")
+ }
+ }
+
+ /// Stop monitoring user activity
+ public func stopMonitoring() {
+ destroyActivityEventTap()
+ stopAutoLockTimer()
+ print("🛑 User activity monitoring stopped")
+ }
+
+ /// Reset the activity timer (called when user is active)
+ public func resetActivityTimer() {
+ lastActivityTime = Date()
+ updateAutoLockTimer()
+ print("🔄 User activity detected, timer reset")
+ }
+
+ /// Enable auto-lock with specified duration
+ /// - Parameter seconds: Duration in seconds (0 to disable)
+ public func enableAutoLock(seconds: TimeInterval) {
+ if seconds > 0 {
+ print("✅ Auto-lock enabled: \(seconds) seconds")
+
+ autoLockDuration = seconds
+ updateAutoLockTimer()
+ } else {
+ print("❌ Auto-lock disabled")
+ stopAutoLockTimer()
+ }
+ }
+
+ /// Disable auto-lock
+ public func disableAutoLock() {
+ autoLockDuration = 0
+ stopAutoLockTimer()
+ }
+
+ // MARK: - Private Methods
+
+ private func createActivityEventTap() throws {
+ // Check accessibility permission using PermissionHelper
+ guard PermissionHelper.hasAccessibilityPermission() else {
+ throw UserActivityError.permissionDenied
+ }
+
+ // Create event mask for all user activity
+ let keyEvents = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
+ let mouseEvents = (1 << CGEventType.leftMouseDown.rawValue) |
+ (1 << CGEventType.leftMouseUp.rawValue) |
+ (1 << CGEventType.rightMouseDown.rawValue) |
+ (1 << CGEventType.rightMouseUp.rawValue) |
+ (1 << CGEventType.otherMouseDown.rawValue) |
+ (1 << CGEventType.otherMouseUp.rawValue)
+ let motionEvents = (1 << CGEventType.mouseMoved.rawValue) |
+ (1 << CGEventType.leftMouseDragged.rawValue) |
+ (1 << CGEventType.rightMouseDragged.rawValue) |
+ (1 << CGEventType.scrollWheel.rawValue)
+
+ let eventMask = keyEvents | mouseEvents | motionEvents
+
+ // Create event tap
+ activityEventTap = CGEvent.tapCreate(
+ tap: .cgSessionEventTap,
+ place: .headInsertEventTap,
+ options: .listenOnly, // Only listen, don't block events
+ eventsOfInterest: CGEventMask(eventMask),
+ callback: { _, _, event, refcon in
+ guard let refcon else {
+ return Unmanaged.passUnretained(event)
+ }
+ let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue()
+ monitor.handleActivityEvent(event)
+ return Unmanaged.passUnretained(event)
+ },
+ userInfo: Unmanaged.passUnretained(self).toOpaque()
+ )
+
+ guard let eventTap = activityEventTap else {
+ throw UserActivityError.eventTapCreationFailed
+ }
+
+ // Create run loop source
+ activityRunLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
+ guard let runLoopSource = activityRunLoopSource else {
+ CFMachPortInvalidate(eventTap)
+ activityEventTap = nil
+ throw UserActivityError.runLoopSourceCreationFailed
+ }
+
+ // Add to run loop
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
+ CGEvent.tapEnable(tap: eventTap, enable: true)
+ }
+
+ private func destroyActivityEventTap() {
+ if let eventTap = activityEventTap {
+ CGEvent.tapEnable(tap: eventTap, enable: false)
+ CFMachPortInvalidate(eventTap)
+ activityEventTap = nil
+ }
+
+ if let runLoopSource = activityRunLoopSource {
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
+ activityRunLoopSource = nil
+ }
+ }
+
+ private func handleActivityEvent(_: CGEvent) {
+ // Reset activity timer on any user input
+ resetActivityTimer()
+ }
+
+ private func updateAutoLockTimer() {
+ stopAutoLockTimer()
+
+ guard autoLockDuration > 0 else {
+ return
+ }
+
+ // Start new timer
+ autoLockTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+ self?.checkAutoLock()
+ }
+ }
+
+ private func stopAutoLockTimer() {
+ autoLockTimer?.invalidate()
+ autoLockTimer = nil
+ }
+
+ private func checkAutoLock() {
+ guard autoLockDuration > 0 else {
+ stopAutoLockTimer()
+ return
+ }
+
+ let timeSinceActivity = Date().timeIntervalSince(lastActivityTime)
+
+ if timeSinceActivity >= autoLockDuration {
+ // Trigger auto-lock
+ stopAutoLockTimer()
+ onAutoLockTriggered?()
+ print("🔒 Auto-lock triggered after \(autoLockDuration) seconds of inactivity")
+ }
+ }
+}
+
+// MARK: - Error Types
+
+public enum UserActivityError: Error, LocalizedError {
+ case permissionDenied
+ case eventTapCreationFailed
+ case runLoopSourceCreationFailed
+
+ public var errorDescription: String? {
+ switch self {
+ case .permissionDenied:
+ "Accessibility permission required for activity monitoring"
+ case .eventTapCreationFailed:
+ "Failed to create activity event tap"
+ case .runLoopSourceCreationFailed:
+ "Failed to create run loop source for activity monitoring"
+ }
+ }
+}
diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj
index d54ba2d..68521eb 100644
--- a/KeyboardLocker.xcodeproj/project.pbxproj
+++ b/KeyboardLocker.xcodeproj/project.pbxproj
@@ -6,8 +6,49 @@
objectVersion = 70;
objects = {
+/* Begin PBXBuildFile section */
+ F203704C2E336AE400BDBAEE /* Core in Frameworks */ = {isa = PBXBuildFile; productRef = F203704B2E336AE400BDBAEE /* Core */; };
+ F203705C2E336D2F00BDBAEE /* KeyboardLockerTool in Embed CLI Tools */ = {isa = PBXBuildFile; fileRef = F20370512E336B8E00BDBAEE /* KeyboardLockerTool */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
+ F203705F2E336D6D00BDBAEE /* Core in Frameworks */ = {isa = PBXBuildFile; productRef = F203705E2E336D6D00BDBAEE /* Core */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ F20370592E336CFC00BDBAEE /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 026 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = F20370502E336B8E00BDBAEE;
+ remoteInfo = KeyboardLockerTool;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ F203704F2E336B8E00BDBAEE /* CopyFiles */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = /usr/share/man/man1/;
+ dstSubfolderSpec = 0;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 1;
+ };
+ F203705B2E336D0E00BDBAEE /* Embed CLI Tools */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 6;
+ files = (
+ F203705C2E336D2F00BDBAEE /* KeyboardLockerTool in Embed CLI Tools */,
+ );
+ name = "Embed CLI Tools";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
015 /* KeyboardLocker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLocker.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ F20370492E336A9E00BDBAEE /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; };
+ F20370512E336B8E00BDBAEE /* KeyboardLockerTool */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = KeyboardLockerTool; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -21,6 +62,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
+ F20370522E336B8E00BDBAEE /* KeyboardLockerTool */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = KeyboardLockerTool; sourceTree = ""; };
F24401E02E30C35400234AC1 /* KeyboardLocker */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F24401E92E30C35400234AC1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = KeyboardLocker; sourceTree = ""; };
/* End PBXFileSystemSynchronizedRootGroup section */
@@ -29,6 +71,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ F203704C2E336AE400BDBAEE /* Core in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ F203704E2E336B8E00BDBAEE /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ F203705F2E336D6D00BDBAEE /* Core in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -38,7 +89,9 @@
019 = {
isa = PBXGroup;
children = (
+ F20370492E336A9E00BDBAEE /* Core */,
F24401E02E30C35400234AC1 /* KeyboardLocker */,
+ F20370522E336B8E00BDBAEE /* KeyboardLockerTool */,
021 /* Products */,
);
sourceTree = "";
@@ -47,6 +100,7 @@
isa = PBXGroup;
children = (
015 /* KeyboardLocker.app */,
+ F20370512E336B8E00BDBAEE /* KeyboardLockerTool */,
);
name = Products;
sourceTree = "";
@@ -61,10 +115,12 @@
024 /* Sources */,
018 /* Frameworks */,
025 /* Resources */,
+ F203705B2E336D0E00BDBAEE /* Embed CLI Tools */,
);
buildRules = (
);
dependencies = (
+ F203705A2E336CFC00BDBAEE /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
F24401E02E30C35400234AC1 /* KeyboardLocker */,
@@ -74,6 +130,29 @@
productReference = 015 /* KeyboardLocker.app */;
productType = "com.apple.product-type.application";
};
+ F20370502E336B8E00BDBAEE /* KeyboardLockerTool */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = F20370552E336B8E00BDBAEE /* Build configuration list for PBXNativeTarget "KeyboardLockerTool" */;
+ buildPhases = (
+ F203704D2E336B8E00BDBAEE /* Sources */,
+ F203704E2E336B8E00BDBAEE /* Frameworks */,
+ F203704F2E336B8E00BDBAEE /* CopyFiles */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ F20370522E336B8E00BDBAEE /* KeyboardLockerTool */,
+ );
+ name = KeyboardLockerTool;
+ packageProductDependencies = (
+ F203705E2E336D6D00BDBAEE /* Core */,
+ );
+ productName = KeyboardLockerTool;
+ productReference = F20370512E336B8E00BDBAEE /* KeyboardLockerTool */;
+ productType = "com.apple.product-type.tool";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -81,12 +160,15 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 1500;
- LastUpgradeCheck = 1640;
+ LastSwiftUpdateCheck = 1640;
+ LastUpgradeCheck = 2610;
TargetAttributes = {
022 = {
CreatedOnToolsVersion = 15.0;
};
+ F20370502E336B8E00BDBAEE = {
+ CreatedOnToolsVersion = 16.4;
+ };
};
};
buildConfigurationList = 027 /* Build configuration list for PBXProject "KeyboardLocker" */;
@@ -104,6 +186,7 @@
projectRoot = "";
targets = (
022 /* KeyboardLocker */,
+ F20370502E336B8E00BDBAEE /* KeyboardLockerTool */,
);
};
/* End PBXProject section */
@@ -126,14 +209,30 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ F203704D2E336B8E00BDBAEE /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ F203705A2E336CFC00BDBAEE /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = F20370502E336B8E00BDBAEE /* KeyboardLockerTool */;
+ targetProxy = F20370592E336CFC00BDBAEE /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
028 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -165,6 +264,7 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = V65YCRQZ2M;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -188,6 +288,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -198,6 +299,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -229,6 +331,7 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = V65YCRQZ2M;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -245,6 +348,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@@ -256,12 +360,14 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
ASSETCATALOG_COMPILER_OPTIMIZATION = time;
+ AUTOMATION_APPLE_EVENTS = YES;
CODE_SIGN_ENTITLEMENTS = KeyboardLocker/Resources/KeyboardLocker.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_TEAM = "";
+ ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -289,12 +395,14 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
ASSETCATALOG_COMPILER_OPTIMIZATION = space;
+ AUTOMATION_APPLE_EVENTS = YES;
CODE_SIGN_ENTITLEMENTS = KeyboardLocker/Resources/KeyboardLocker.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_TEAM = "";
+ ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -315,6 +423,38 @@
};
name = Release;
};
+ F20370562E336B8E00BDBAEE /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = KeyboardLockerTool/KeyboardLockerTool.entitlements;
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
+ CODE_SIGN_STYLE = Automatic;
+ ENABLE_HARDENED_RUNTIME = YES;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ OTHER_CODE_SIGN_FLAGS = "$(inherited) -i $(PRODUCT_BUNDLE_IDENTIFIER)";
+ PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.KeyboardLocker.Tool;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ F20370572E336B8E00BDBAEE /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = KeyboardLockerTool/KeyboardLockerTool.entitlements;
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
+ CODE_SIGN_STYLE = Automatic;
+ ENABLE_HARDENED_RUNTIME = YES;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ OTHER_CODE_SIGN_FLAGS = "$(inherited) -i $(PRODUCT_BUNDLE_IDENTIFIER)";
+ PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.KeyboardLocker.Tool;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -336,7 +476,27 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ F20370552E336B8E00BDBAEE /* Build configuration list for PBXNativeTarget "KeyboardLockerTool" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ F20370562E336B8E00BDBAEE /* Debug */,
+ F20370572E336B8E00BDBAEE /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ F203704B2E336AE400BDBAEE /* Core */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Core;
+ };
+ F203705E2E336D6D00BDBAEE /* Core */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Core;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = 026 /* Project object */;
}
diff --git a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme
new file mode 100644
index 0000000..0e60521
--- /dev/null
+++ b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/KeyboardLocker/AboutView.swift b/KeyboardLocker/AboutView.swift
deleted file mode 100644
index 0c4f9eb..0000000
--- a/KeyboardLocker/AboutView.swift
+++ /dev/null
@@ -1,95 +0,0 @@
-import AppKit
-import SwiftUI
-
-struct AboutView: View {
- var body: some View {
- VStack(spacing: 12) {
- // App icon and name
- VStack(spacing: 6) {
- Image(nsImage: NSApp.applicationIconImage)
- .resizable()
- .frame(width: 100, height: 100)
-
- Text(LocalizationKey.appTitle.localized)
- .font(.title)
- .fontWeight(.bold)
-
- Text(Bundle.main.localizedVersionString)
- .font(.subheadline)
- .foregroundColor(.secondary)
- }
-
- Divider()
-
- // Core features only
- VStack(alignment: .leading, spacing: 15) {
- Text(LocalizationKey.aboutFeatures.localized)
- .font(.headline)
-
- VStack(alignment: .leading, spacing: 5) {
- FeatureRow(icon: "lock", text: LocalizationKey.aboutFeatureLock.localized)
- FeatureRow(icon: "keyboard", text: LocalizationKey.aboutFeatureShortcut.localized)
- FeatureRow(icon: "timer", text: LocalizationKey.aboutFeatureAutoLock.localized)
- }
- }
-
- Divider()
-
- // GitHub link
- Button(action: {
- openGitHubRepository()
- }) {
- HStack {
- Image(systemName: "link.circle.fill")
- .foregroundColor(.blue)
- Text(LocalizationKey.aboutGitHub.localized)
- .foregroundColor(.blue)
- }
- .font(.body)
- }
- .buttonStyle(PlainButtonStyle())
- .onHover { _ in
- NSCursor.pointingHand.set()
- }
-
- // Copyright information from Info.plist
- Text(Bundle.main.copyright)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- .padding()
- .navigationTitle(LocalizationKey.aboutTitle.localized)
- .frame(width: 300)
- }
-
- // MARK: - Private Methods
-
- private func openGitHubRepository() {
- if let url = URL(string: "https://github.com/LZhenHong/KeyboardLocker") {
- NSWorkspace.shared.open(url)
- }
- }
-}
-
-struct FeatureRow: View {
- let icon: String
- let text: String
-
- var body: some View {
- HStack(spacing: 6) {
- Image(systemName: icon)
- .foregroundColor(.blue)
- .frame(width: 16)
-
- Text(text)
- .font(.body)
- .foregroundColor(.primary)
- }
- }
-}
-
-#Preview {
- NavigationStack {
- AboutView()
- }
-}
diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift
deleted file mode 100644
index fa6e2bd..0000000
--- a/KeyboardLocker/ContentView.swift
+++ /dev/null
@@ -1,249 +0,0 @@
-import SwiftUI
-
-struct ContentView: View {
- @State private var isKeyboardLocked = false
- @EnvironmentObject var permissionManager: PermissionManager
- @EnvironmentObject var keyboardManager: KeyboardLockManager
-
- var body: some View {
- NavigationStack {
- if permissionManager.hasAccessibilityPermission {
- authorizedView
- } else {
- unauthorizedView
- }
- }
- .frame(width: 300)
- .background(Color(NSColor.windowBackgroundColor))
- .onAppear {
- permissionManager.checkAllPermissions()
- isKeyboardLocked = keyboardManager.isLocked
- }
- .onReceive(keyboardManager.$isLocked) { locked in
- isKeyboardLocked = locked
- }
- }
-
- // MARK: - Shared Components
-
- private var appTitleHeader: some View {
- VStack(spacing: 16) {
- HStack {
- Image(systemName: "lock.shield.fill")
- .foregroundColor(.blue)
- .font(.title2)
- Text(LocalizationKey.appTitle.localized)
- .font(.title2)
- .fontWeight(.semibold)
- }
- .padding(.top, 16)
-
- Divider()
- }
- }
-
- // MARK: - Authorized View (Main Interface)
-
- private var authorizedView: some View {
- VStack(spacing: 16) {
- appTitleHeader
-
- // Main functionality area
- VStack(spacing: 16) {
- // Lock status indicator
- HStack {
- Circle()
- .fill(isKeyboardLocked ? Color.red : Color.green)
- .frame(width: 12, height: 12)
- Text(
- isKeyboardLocked
- ? LocalizationKey.statusLocked.localized : LocalizationKey.statusUnlocked.localized
- )
- .font(.body)
- .foregroundColor(.primary)
- Spacer()
- }
-
- // Lock/unlock button
- VStack {
- HStack {
- Image(systemName: isKeyboardLocked ? "lock.open" : "lock")
- Text(
- isKeyboardLocked
- ? LocalizationKey.actionUnlock.localized : LocalizationKey.actionLock.localized)
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 10)
- .background(isKeyboardLocked ? Color.red : Color.green)
- .foregroundColor(.white)
- .cornerRadius(8)
- .onTapGesture {
- toggleKeyboardLock()
- }
- }
-
- // Quick navigation
- VStack(alignment: .leading, spacing: 12) {
- Text(LocalizationKey.quickActions.localized)
- .font(.headline)
- .foregroundColor(.secondary)
-
- VStack(spacing: 6) {
- NavigationLink(destination: SettingsView().environmentObject(keyboardManager)) {
- SettingRow(
- icon: "gear", title: LocalizationKey.settingsTitle.localized,
- subtitle: LocalizationKey.settingsSubtitle.localized
- )
- }
- .buttonStyle(PlainButtonStyle())
-
- NavigationLink(destination: AboutView()) {
- SettingRow(
- icon: "info.circle", title: LocalizationKey.aboutTitle.localized,
- subtitle: LocalizationKey.aboutSubtitle.localized
- )
- }
- .buttonStyle(PlainButtonStyle())
- }
- }
- }
- .padding(.horizontal, 16)
-
- // Bottom actions
- HStack {
- Text(LocalizationKey.shortcutHint.localized)
- .font(.caption)
- .foregroundColor(.secondary)
-
- Spacer()
-
- Button(LocalizationKey.actionQuit.localized) {
- NSApplication.shared.terminate(nil)
- }
- .buttonStyle(PlainButtonStyle())
- .foregroundColor(.red)
- }
- .padding(.horizontal, 16)
- .padding(.bottom, 16)
- }
- }
-
- // MARK: - Unauthorized View (Permission Required)
-
- private var unauthorizedView: some View {
- VStack(spacing: 20) {
- appTitleHeader
-
- // Permission required content
- VStack(spacing: 16) {
- // Warning icon
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundColor(.orange)
- .font(.system(size: 48))
-
- // Title
- Text(LocalizationKey.permissionRequired.localized)
- .font(.title2)
- .fontWeight(.semibold)
- .multilineTextAlignment(.center)
-
- // Description
- Text(LocalizationKey.permissionDescription.localized)
- .font(.body)
- .foregroundColor(.secondary)
- .multilineTextAlignment(.center)
- .fixedSize(horizontal: false, vertical: true)
-
- // Authorization button
- Button(action: {
- permissionManager.requestAccessibilityPermission()
- }) {
- HStack {
- Image(systemName: "gear")
- Text(LocalizationKey.openSystemPreferences.localized)
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- .background(Color.blue)
- .foregroundColor(.white)
- .cornerRadius(8)
- }
- .buttonStyle(PlainButtonStyle())
- .padding(.top, 8)
-
- // Auto-detection status info
- HStack {
- Image(systemName: "checkmark.circle.fill")
- .foregroundColor(.green)
- .font(.caption)
- Text(LocalizationKey.autoDetectionEnabled.localized)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- .padding(.top, 8)
- }
- .padding(.horizontal, 16)
-
- Spacer()
-
- // Bottom quit button
- HStack {
- Spacer()
- Button(LocalizationKey.actionQuit.localized) {
- NSApplication.shared.terminate(nil)
- }
- .buttonStyle(PlainButtonStyle())
- .foregroundColor(.red)
- }
- .padding(.horizontal, 16)
- .padding(.bottom, 16)
- }
- }
-
- // MARK: - Helper Methods
-
- private func toggleKeyboardLock() {
- if isKeyboardLocked {
- keyboardManager.unlockKeyboard()
- } else {
- keyboardManager.lockKeyboard()
- }
- }
-}
-
-struct SettingRow: View {
- let icon: String
- let title: String
- let subtitle: String
-
- var body: some View {
- HStack {
- Image(systemName: icon)
- .foregroundColor(.blue)
- .frame(width: 20)
-
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(.body)
- .foregroundColor(.primary)
- Text(subtitle)
- .font(.caption)
- .foregroundColor(.secondary)
- }
-
- Spacer()
-
- Image(systemName: "chevron.right")
- .foregroundColor(.secondary)
- .font(.caption)
- }
- .padding(.vertical, 4)
- .contentShape(Rectangle())
- }
-}
-
-#Preview {
- ContentView()
- .environmentObject(KeyboardLockManager())
- .environmentObject(PermissionManager())
-}
diff --git a/KeyboardLocker/KeyboardLockManager.swift b/KeyboardLocker/KeyboardLockManager.swift
deleted file mode 100644
index 4fdeeae..0000000
--- a/KeyboardLocker/KeyboardLockManager.swift
+++ /dev/null
@@ -1,451 +0,0 @@
-import Carbon
-import Cocoa
-import Foundation
-import SwiftUI
-
-/// Core keyboard locking functionality with comprehensive input blocking
-class KeyboardLockManager: ObservableObject {
- @Published var isLocked = false
- @AppStorage("showNotifications") private var showNotifications = true
-
- private var eventTap: CFMachPort?
- private var runLoopSource: CFRunLoopSource?
- private var globalHotkeyMonitor: Any?
- private var functionKeyMonitor: Any?
- private var comprehensiveMonitor: Any? // Additional comprehensive monitoring
-
- // Reference to NotificationManager
- private let notificationManager = NotificationManager.shared
-
- init() {
- setupGlobalHotkey()
- }
-
- deinit {
- cleanup()
- }
-
- /// Clean up resources when object is deallocated
- private func cleanup() {
- unlockKeyboard()
- removeGlobalHotkey()
- removeFunctionKeyMonitor()
- removeComprehensiveMonitor()
- }
-
- func lockKeyboard() {
- guard !isLocked else { return }
-
- // Verify accessibility permissions are granted
- guard AXIsProcessTrusted() else {
- print("Accessibility permission not granted")
- return
- }
-
- do {
- // Create the most comprehensive event mask possible
- // Include ALL possible event types that could involve keyboard input
- let eventTypes: [CGEventType] = [
- .keyDown,
- .keyUp,
- .flagsChanged,
- .scrollWheel, // Some scroll wheels can trigger shortcuts
- .tabletPointer,
- .tabletProximity,
- .otherMouseDown,
- .otherMouseUp,
- .otherMouseDragged,
- ]
-
- // Build comprehensive event mask
- var eventMask: CGEventMask = 0
- for eventType in eventTypes {
- eventMask |= CGEventMask(1 << eventType.rawValue)
- }
-
- // Also include system-defined events mask manually
- eventMask |= CGEventMask(1 << 14) // NX_SYSDEFINED
-
- // Create event tap for global input monitoring with highest possible interception level
- guard
- let tap = CGEvent.tapCreate(
- tap: .cgSessionEventTap, // Intercept at session level
- place: .headInsertEventTap, // Insert at the head for maximum priority
- options: .defaultTap, // Default options for maximum compatibility
- eventsOfInterest: eventMask,
- callback: { proxy, type, event, refcon in
- Unmanaged.fromOpaque(refcon!).takeUnretainedValue().handleKeyEvent(
- proxy: proxy, type: type, event: event
- )
- },
- userInfo: Unmanaged.passUnretained(self).toOpaque()
- )
- else {
- throw NSError(
- domain: "KeyboardLocker", code: 1,
- userInfo: [NSLocalizedDescriptionKey: "Failed to create event tap"]
- )
- }
-
- eventTap = tap
- runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
-
- guard let runLoopSource = runLoopSource else {
- throw NSError(
- domain: "KeyboardLocker", code: 2,
- userInfo: [NSLocalizedDescriptionKey: "Failed to create run loop source"]
- )
- }
-
- // Attach to current run loop for event processing
- CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
- CGEvent.tapEnable(tap: tap, enable: true)
-
- isLocked = true
- print("Keyboard locked successfully")
-
- // Setup additional function key monitoring
- setupFunctionKeyMonitor()
-
- // Setup comprehensive backup monitoring
- setupComprehensiveMonitor()
-
- // Send notification using NotificationManager with settings check
- notificationManager.sendNotificationIfEnabled(
- .keyboardLocked,
- showNotifications: showNotifications
- )
- } catch {
- print("Failed to lock keyboard: \(error)")
- recoverFromError()
- }
- }
-
- func unlockKeyboard() {
- guard isLocked else { return }
-
- // Disable and clean up event tap
- if let eventTap = eventTap {
- CGEvent.tapEnable(tap: eventTap, enable: false)
- CFMachPortInvalidate(eventTap)
- self.eventTap = nil
- }
-
- // Remove run loop source
- if let runLoopSource = runLoopSource {
- CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
- self.runLoopSource = nil
- }
-
- // Remove function key monitor
- removeFunctionKeyMonitor()
-
- // Remove comprehensive monitor
- removeComprehensiveMonitor()
-
- isLocked = false
- print("Keyboard unlocked successfully")
-
- // Send notification using NotificationManager with settings check
- notificationManager.sendNotificationIfEnabled(
- .keyboardUnlocked,
- showNotifications: showNotifications
- )
- }
-
- /// Check if the event matches unlock combination (⌘+⌥+L)
- private func isUnlockCombination(_ event: NSEvent) -> Bool {
- return event.modifierFlags.contains([.command, .option]) && event.keyCode == 37 // L key code
- }
-
- /// Handle intercepted events - comprehensive input blocking logic
- private func handleKeyEvent(
- proxy _: CGEventTapProxy,
- type: CGEventType,
- event: CGEvent
- ) -> Unmanaged? {
- let flags = event.flags
- let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
-
- // Handle different event types comprehensively
- switch type {
- case .keyDown, .keyUp:
- // Allow unlock combination (⌘+⌥+L) to pass through - only on keyDown
- if type == .keyDown, flags.contains([.maskCommand, .maskAlternate]), keyCode == 37 {
- // Allow this combination and unlock keyboard
- DispatchQueue.main.async {
- self.unlockKeyboard()
- }
- return Unmanaged.passRetained(event)
- }
-
- // Block ALL other keyboard events when locked
- print("Blocked keyboard event: type=\(type.rawValue), keyCode=\(keyCode)")
- return nil
-
- case .flagsChanged:
- // Block ALL modifier key changes to prevent any shortcuts
- print("Blocked modifier key change: flags=\(flags)")
- return nil
-
- case .scrollWheel:
- // Block scroll wheel events that might trigger shortcuts (like zoom)
- let scrollingDeltaX = event.getDoubleValueField(.scrollWheelEventDeltaAxis1)
- let scrollingDeltaY = event.getDoubleValueField(.scrollWheelEventDeltaAxis2)
-
- // Only block if there are modifier keys pressed (likely shortcuts)
- if !flags.intersection([.maskCommand, .maskAlternate, .maskControl, .maskShift]).isEmpty {
- print(
- "Blocked scroll shortcut: deltaX=\(scrollingDeltaX), deltaY=\(scrollingDeltaY), flags=\(flags)"
- )
- return nil
- }
-
- // Allow normal scrolling
- return Unmanaged.passRetained(event)
-
- case .otherMouseDown, .otherMouseUp, .otherMouseDragged:
- // Block additional mouse buttons that might trigger shortcuts
- let buttonNumber = event.getIntegerValueField(.mouseEventButtonNumber)
-
- // Block if modifier keys are pressed (likely shortcuts)
- if !flags.intersection([.maskCommand, .maskAlternate, .maskControl, .maskShift]).isEmpty {
- print("Blocked mouse shortcut: button=\(buttonNumber), flags=\(flags)")
- return nil
- }
-
- // Allow normal mouse events
- return Unmanaged.passRetained(event)
-
- case .tabletPointer, .tabletProximity:
- // Block tablet events that might have shortcut functions
- if !flags.intersection([.maskCommand, .maskAlternate, .maskControl, .maskShift]).isEmpty {
- print("Blocked tablet shortcut event")
- return nil
- }
-
- // Allow normal tablet events
- return Unmanaged.passRetained(event)
-
- default:
- // For any unknown event types, check if they have modifier keys
- if type.rawValue == 14 { // NX_SYSDEFINED - system defined events
- print("Blocked system-defined event")
- return nil // Block all system-defined events (media keys, function keys, etc.)
- }
-
- // Block other events if they have modifier keys (potential shortcuts)
- if !flags.intersection([.maskCommand, .maskAlternate, .maskControl, .maskShift]).isEmpty {
- print("Blocked unknown event with modifiers: type=\(type.rawValue), flags=\(flags)")
- return nil
- }
-
- // Allow events without modifier keys
- return Unmanaged.passRetained(event)
- }
- }
-
- // MARK: - Global Hotkey Management
-
- /// Setup global hotkey monitoring for ⌘+⌥+L and ⌘+⌥+⇧+L
- private func setupGlobalHotkey() {
- globalHotkeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) {
- [weak self] event in
- self?.handleGlobalHotkey(event: event)
- }
- print("Global hotkey monitor setup successfully")
- }
-
- /// Remove global hotkey monitoring
- private func removeGlobalHotkey() {
- if let monitor = globalHotkeyMonitor {
- NSEvent.removeMonitor(monitor)
- globalHotkeyMonitor = nil
- print("Global hotkey monitor removed")
- }
- }
-
- /// Setup comprehensive function key and system event monitoring
- private func setupFunctionKeyMonitor() {
- functionKeyMonitor = NSEvent.addGlobalMonitorForEvents(
- matching: [
- .keyDown, .keyUp, .systemDefined, .flagsChanged, .scrollWheel, .rightMouseDown,
- .rightMouseUp, .otherMouseDown, .otherMouseUp,
- ]
- ) { [weak self] event in
- self?.handleFunctionKeyEvent(event: event)
- }
- print("Comprehensive function key and system event monitor setup successfully")
- }
-
- /// Remove function key monitoring
- private func removeFunctionKeyMonitor() {
- if let monitor = functionKeyMonitor {
- NSEvent.removeMonitor(monitor)
- functionKeyMonitor = nil
- print("Function key monitor removed")
- }
- }
-
- /// Handle function key events and additional input types (F1-F12, media keys, etc.)
- private func handleFunctionKeyEvent(event: NSEvent) {
- guard isLocked else { return }
-
- let keyCode = event.keyCode
- let modifierFlags = event.modifierFlags
-
- // Block function keys (F1-F12) and their variants
- let functionKeyCodes: Set = [
- 122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, // F1-F12
- 119, 114, 115, 116, 117, // Additional function keys
- 113, 115, 116, 130, // Media keys and volume controls
- ]
-
- // Block based on event type
- switch event.type {
- case .keyDown, .keyUp:
- if functionKeyCodes.contains(keyCode) {
- print("NSEvent: Blocked function/media key: F\(keyCode)")
- // Note: NSEvent global monitors cannot prevent the event, but we log it
- }
-
- // Block any key event when locked (backup to CGEvent tap)
- if event.type == .keyDown {
- print("NSEvent: Blocked additional key down: \(keyCode)")
- }
-
- case .systemDefined:
- // Block ALL system-defined events (media keys, volume, brightness, etc.)
- print("NSEvent: Blocked system-defined event: subtype=\(event.subtype)")
-
- case .flagsChanged:
- // Block modifier key changes
- print("NSEvent: Blocked modifier change: \(modifierFlags)")
-
- case .scrollWheel:
- // Block scroll wheel with modifiers (zoom shortcuts, etc.)
- if !modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty {
- print("NSEvent: Blocked scroll with modifiers")
- }
-
- case .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp:
- // Block right-click and other mouse buttons with modifiers
- if !modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty {
- print("NSEvent: Blocked mouse event with modifiers")
- }
-
- default:
- break
- }
- }
-
- /// Setup comprehensive backup monitoring for any missed events
- private func setupComprehensiveMonitor() {
- // Monitor ALL possible NSEvent types as a backup layer
- let allEventTypes: NSEvent.EventTypeMask = [
- .keyDown, .keyUp, .flagsChanged,
- .leftMouseDown, .leftMouseUp, .leftMouseDragged,
- .rightMouseDown, .rightMouseUp, .rightMouseDragged,
- .otherMouseDown, .otherMouseUp, .otherMouseDragged,
- .mouseEntered, .mouseExited, .mouseMoved,
- .scrollWheel, .tabletPoint, .tabletProximity,
- .systemDefined, .applicationDefined, .periodic,
- .cursorUpdate, .rotate, .beginGesture, .endGesture,
- .magnify, .swipe, .smartMagnify,
- .pressure, .directTouch, .changeMode,
- ]
-
- comprehensiveMonitor = NSEvent.addGlobalMonitorForEvents(matching: allEventTypes) {
- [weak self] event in
- self?.handleComprehensiveEvent(event: event)
- }
- print("Comprehensive backup monitor setup successfully")
- }
-
- /// Remove comprehensive monitoring
- private func removeComprehensiveMonitor() {
- if let monitor = comprehensiveMonitor {
- NSEvent.removeMonitor(monitor)
- comprehensiveMonitor = nil
- print("Comprehensive monitor removed")
- }
- }
-
- /// Handle any events that might have been missed by primary monitoring
- private func handleComprehensiveEvent(event: NSEvent) {
- guard isLocked else { return }
-
- // Log any input events that occur while locked for debugging
- switch event.type {
- case .keyDown, .keyUp, .flagsChanged:
- print("BACKUP: Detected keyboard event: \(event.type) - keyCode: \(event.keyCode)")
-
- case .systemDefined:
- print("BACKUP: Detected system event: subtype \(event.subtype)")
-
- case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown,
- .otherMouseUp:
- if !event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty {
- print("BACKUP: Detected mouse shortcut attempt")
- }
-
- case .scrollWheel:
- if !event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty {
- print("BACKUP: Detected scroll shortcut attempt")
- }
-
- case .swipe, .magnify, .rotate, .smartMagnify:
- print("BACKUP: Detected gesture that might trigger shortcuts")
-
- default:
- if !event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty {
- print("BACKUP: Detected event with modifiers: \(event.type)")
- }
- }
- }
-
- /// Handle global hotkey events for lock/unlock
- private func handleGlobalHotkey(event: NSEvent) {
- // Check for ⌘+⌥+L combination
- guard isUnlockCombination(event) else { return }
-
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
-
- if self.isLocked {
- self.unlockKeyboard()
- } else {
- self.lockKeyboard()
- }
- }
- }
-
- // MARK: - Error Recovery
-
- /// Attempts to recover from errors by ensuring keyboard is unlocked
- private func recoverFromError() {
- print("Attempting error recovery...")
-
- // Force cleanup of any existing event taps
- if let eventTap = eventTap {
- CGEvent.tapEnable(tap: eventTap, enable: false)
- CFMachPortInvalidate(eventTap)
- self.eventTap = nil
- }
-
- if let runLoopSource = runLoopSource {
- CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
- self.runLoopSource = nil
- }
-
- // Reset state
- isLocked = false
- print("Error recovery completed - keyboard unlocked")
-
- // Show recovery notification using NotificationManager with settings check
- notificationManager.sendNotificationIfEnabled(.general(
- title: LocalizationKey.errorRecoveryTitle.localized,
- body: LocalizationKey.errorRecoveryMessage.localized
- ), showNotifications: showNotifications)
- }
-}
diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift
deleted file mode 100644
index 95d35db..0000000
--- a/KeyboardLocker/KeyboardLockerApp.swift
+++ /dev/null
@@ -1,96 +0,0 @@
-import SwiftUI
-
-/// Main app entry point using modern SwiftUI App protocol
-@main
-struct KeyboardLockerApp: App {
- @StateObject private var keyboardLockManager = KeyboardLockManager()
- @StateObject private var permissionManager = PermissionManager()
- @StateObject private var urlHandler = URLCommandHandler()
-
- init() {
- // Setup global exception handling for stability
- setupExceptionHandling()
- }
-
- var body: some Scene {
- // Modern MenuBarExtra for native menu bar integration
- MenuBarExtra("Keyboard Locker", systemImage: "lock.shield") {
- ContentView()
- .environmentObject(keyboardLockManager)
- .environmentObject(permissionManager)
- .environmentObject(urlHandler)
- .onAppear {
- // Set up URL handler with keyboard lock manager reference
- urlHandler.setKeyboardLockManager(keyboardLockManager)
- setupApplicationLifecycleHandlers()
- }
- .onOpenURL { url in
- handleIncomingURL(url)
- }
- }
- .menuBarExtraStyle(.window)
- }
-
- // MARK: - URL Handling
-
- /// Handle incoming URL requests
- /// - Parameter url: The URL to process
- private func handleIncomingURL(_ url: URL) {
- print("📱 Received URL: \(url)")
-
- let response = urlHandler.handleURL(url)
- urlHandler.showUserFeedback(for: response)
-
- // Log the result
- if response.isSuccess {
- print("✅ URL command executed successfully: \(response.message)")
- } else {
- print("❌ URL command failed: \(response.message)")
- }
- }
-
- // MARK: - Exception Handling
-
- /// Setup NSException handler for crash recovery
- private func setupExceptionHandling() {
- NSSetUncaughtExceptionHandler { exception in
- print("Uncaught exception: \(exception)")
- print("Stack trace: \(exception.callStackSymbols)")
-
- // Attempt to unlock keyboard before crash - safety measure
- DispatchQueue.main.async {
- // Force unlock by creating a temporary manager
- let tempManager = KeyboardLockManager()
- tempManager.unlockKeyboard()
-
- // Give time for cleanup before exit
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- exit(1) // Graceful exit
- }
- }
- }
- }
-
- private func setupApplicationLifecycleHandlers() {
- // Handle application termination
- NotificationCenter.default.addObserver(
- forName: NSApplication.willTerminateNotification,
- object: nil,
- queue: .main
- ) { _ in
- print("Application will terminate - cleaning up")
- // Ensure keyboard is unlocked before termination
- self.keyboardLockManager.unlockKeyboard()
- }
-
- // Handle application becoming inactive (e.g., logout, restart)
- NotificationCenter.default.addObserver(
- forName: NSApplication.willResignActiveNotification,
- object: nil,
- queue: .main
- ) { _ in
- print("Application will resign active - ensuring keyboard is unlocked")
- self.keyboardLockManager.unlockKeyboard()
- }
- }
-}
diff --git a/KeyboardLocker/Resources/KeyboardLocker.entitlements b/KeyboardLocker/Resources/KeyboardLocker.entitlements
index 7f58ac1..c5aa86c 100644
--- a/KeyboardLocker/Resources/KeyboardLocker.entitlements
+++ b/KeyboardLocker/Resources/KeyboardLocker.entitlements
@@ -2,8 +2,6 @@
- com.apple.security.automation.apple-events
-
com.apple.security.device.audio-input
com.apple.security.device.camera
diff --git a/KeyboardLocker/SettingsView.swift b/KeyboardLocker/SettingsView.swift
deleted file mode 100644
index 896e621..0000000
--- a/KeyboardLocker/SettingsView.swift
+++ /dev/null
@@ -1,114 +0,0 @@
-import SwiftUI
-
-struct SettingsView: View {
- @AppStorage("autoLockDuration") private var autoLockDuration = 30
- @AppStorage("showNotifications") private var showNotifications = true
- @EnvironmentObject var keyboardManager: KeyboardLockManager
-
- var body: some View {
- VStack(alignment: .leading, spacing: 20) {
- // Auto-lock settings
- VStack(alignment: .leading, spacing: 12) {
- Text(LocalizationKey.settingsAutoLock.localized)
- .font(.headline)
- .foregroundColor(.primary)
-
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Text(LocalizationKey.settingsAutoLockTime.localized)
- Spacer()
- Picker("", selection: $autoLockDuration) {
- Text(LocalizationKey.time15Minutes.localized).tag(15)
- Text(LocalizationKey.time30Minutes.localized).tag(30)
- Text(LocalizationKey.time60Minutes.localized).tag(60)
- Text(LocalizationKey.timeNever.localized).tag(0)
- }
- .pickerStyle(MenuPickerStyle())
- .frame(width: 100)
- }
-
- Text(LocalizationKey.settingsAutoLockDescription.localized)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- .padding(12)
- .background(Color(NSColor.controlBackgroundColor))
- .cornerRadius(8)
- }
-
- // Notification settings
- VStack(alignment: .leading, spacing: 12) {
- Text(LocalizationKey.settingsNotifications.localized)
- .font(.headline)
- .foregroundColor(.primary)
-
- VStack(alignment: .leading, spacing: 12) {
- Toggle(LocalizationKey.settingsShowNotifications.localized, isOn: $showNotifications)
-
- Text(LocalizationKey.settingsNotificationsDescription.localized)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- .padding(12)
- .background(Color(NSColor.controlBackgroundColor))
- .cornerRadius(8)
- }
-
- // Keyboard shortcut description
- VStack(alignment: .leading, spacing: 12) {
- Text(LocalizationKey.settingsKeyboard.localized)
- .font(.headline)
- .foregroundColor(.primary)
-
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text(
- LocalizationKey.actionLock.localized + "/" + LocalizationKey.actionUnlock.localized
- + ":")
- Spacer()
- Text("⌘ + ⌥ + L")
- .font(.system(.body, design: .monospaced))
- .padding(.horizontal, 8)
- .padding(.vertical, 2)
- .background(Color(NSColor.controlBackgroundColor))
- .cornerRadius(4)
- }
-
- Text(LocalizationKey.settingsKeyboardDescription.localized)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- .padding(12)
- .background(Color(NSColor.controlBackgroundColor))
- .cornerRadius(8)
- }
-
- Spacer()
-
- // Reset button
- HStack {
- Spacer()
- Button(LocalizationKey.settingsReset.localized) {
- resetSettings()
- }
- .buttonStyle(PlainButtonStyle())
- .foregroundColor(.red)
- }
- }
- .padding()
- .navigationTitle(LocalizationKey.settingsTitle.localized)
- .frame(width: 300)
- }
-
- private func resetSettings() {
- autoLockDuration = 30
- showNotifications = true
- }
-}
-
-#Preview {
- NavigationStack {
- SettingsView()
- .environmentObject(KeyboardLockManager())
- }
-}
diff --git a/KeyboardLocker/Sources/Application/AppDelegate.swift b/KeyboardLocker/Sources/Application/AppDelegate.swift
new file mode 100644
index 0000000..e76bf24
--- /dev/null
+++ b/KeyboardLocker/Sources/Application/AppDelegate.swift
@@ -0,0 +1,81 @@
+import AppKit
+import Core
+import SwiftUI
+
+/// Custom AppDelegate for handling URL schemes and application lifecycle
+class AppDelegate: NSObject, NSApplicationDelegate {
+ var urlHandler: URLCommandHandler?
+ var keyboardLockManager: KeyboardLockManager?
+
+ func configure(_ manager: KeyboardLockManager, _ handler: URLCommandHandler) {
+ keyboardLockManager = manager
+ urlHandler = handler
+ }
+
+ func applicationDidFinishLaunching(_: Notification) {
+ setupExceptionHandling()
+ }
+
+ func applicationWillTerminate(_: Notification) {
+ print("Application will terminate - cleaning up")
+ // Ensure keyboard is unlocked before termination
+ keyboardLockManager?.unlockKeyboard()
+ }
+
+ func applicationWillResignActive(_: Notification) {
+ print("Application will resign active - ensuring keyboard is unlocked")
+ keyboardLockManager?.unlockKeyboard()
+ }
+
+ // MARK: - Exception Handling
+
+ /// Setup NSException handler for crash recovery
+ private func setupExceptionHandling() {
+ NSSetUncaughtExceptionHandler { exception in
+ print("Uncaught exception: \(exception)")
+ print("Stack trace: \(exception.callStackSymbols)")
+
+ // Attempt to unlock keyboard before crash - safety measure
+ DispatchQueue.main.async {
+ // Force unlock using Core directly (emergency safety)
+ KeyboardLockCore.shared.unlockKeyboard()
+
+ // Give time for cleanup before exit
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ exit(1) // Graceful exit
+ }
+ }
+ }
+ }
+
+ // MARK: - URL Handling
+
+ /// Handle URL schemes through AppDelegate
+ /// - Parameters:
+ /// - application: The application instance
+ /// - urls: Array of URLs to handle
+ func application(_: NSApplication, open urls: [URL]) {
+ urls.forEach(handleIncomingURL(_:))
+ }
+
+ /// Process individual URL requests
+ /// - Parameter url: The URL to process
+ private func handleIncomingURL(_ url: URL) {
+ print("📱 Received URL: \(url)")
+
+ guard let urlHandler else {
+ print("❌ URLHandler not configured")
+ return
+ }
+
+ let response = urlHandler.handleURL(url)
+ urlHandler.showUserFeedback(for: response)
+
+ // Log the result
+ if response.isSuccess {
+ print("✅ URL command executed successfully: \(response.message)")
+ } else {
+ print("❌ URL command failed: \(response.message)")
+ }
+ }
+}
diff --git a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift
new file mode 100644
index 0000000..16fa04f
--- /dev/null
+++ b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift
@@ -0,0 +1,44 @@
+import Core
+import SwiftUI
+
+/// Main app entry point using modern SwiftUI App protocol with AppDelegate
+@main
+struct KeyboardLockerApp: App {
+ // Application dependencies container
+ private let dependencies = AppDependencies()
+
+ // Use AppDelegate for URL handling
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ // Modern MenuBarExtra for native menu bar integration
+ MenuBarExtra {
+ ContentView()
+ .environmentObject(dependencies.keyboardLockManager)
+ .environmentObject(dependencies.permissionManager)
+ .onAppear {
+ appDelegate.configure(dependencies.keyboardLockManager, dependencies.urlHandler)
+ }
+ } label: {
+ MenuBarLabelView(keyboardLockManager: dependencies.keyboardLockManager)
+ }
+ .menuBarExtraStyle(.window)
+ .handlesExternalEvents(matching: ["keyboardlocker"])
+ }
+}
+
+private struct MenuBarLabelView: View {
+ @ObservedObject var keyboardLockManager: KeyboardLockManager
+
+ private var statusBarIcon: String {
+ keyboardLockManager.isLocked ? "lock.fill" : "lock.open.fill"
+ }
+
+ var body: some View {
+ Label {
+ Text(localized: LocalizationKey.appMenuTitle)
+ } icon: {
+ Image(systemName: statusBarIcon)
+ }
+ }
+}
diff --git a/KeyboardLocker/Sources/Extensions/CGFloat+.swift b/KeyboardLocker/Sources/Extensions/CGFloat+.swift
new file mode 100644
index 0000000..ebd1665
--- /dev/null
+++ b/KeyboardLocker/Sources/Extensions/CGFloat+.swift
@@ -0,0 +1,5 @@
+import Foundation
+
+extension CGFloat {
+ static let viewWidth: Self = 300
+}
diff --git a/KeyboardLocker/Sources/Extensions/Duration+.swift b/KeyboardLocker/Sources/Extensions/Duration+.swift
new file mode 100644
index 0000000..fceef06
--- /dev/null
+++ b/KeyboardLocker/Sources/Extensions/Duration+.swift
@@ -0,0 +1,44 @@
+import Core
+
+/// Business logic helper for lock duration management and display
+extension CoreConfiguration.Duration {
+ // MARK: - Preset Collections
+
+ /// Preset durations for auto-lock UI
+ static let autoLockPresets: [Self] = [
+ .never,
+ .minutes(15),
+ .minutes(30),
+ .minutes(60),
+ ]
+
+ // MARK: - Display Logic
+
+ /// Get localized display string for UI
+ var localized: String {
+ switch self {
+ case .never:
+ LocalizationKey.durationNever.localized
+ case .infinite:
+ LocalizationKey.durationInfinite.localized
+ case let .minutes(minutes):
+ minutes.formatted
+ }
+ }
+}
+
+private extension Int {
+ var formatted: String {
+ if self < 60 {
+ return LocalizationKey.durationMinutes.localized(self)
+ } else {
+ let hours = self / 60
+ let remainingMinutes = self % 60
+ if remainingMinutes == 0 {
+ return LocalizationKey.durationHours.localized(hours)
+ } else {
+ return LocalizationKey.durationHoursMinutes.localized(hours, remainingMinutes)
+ }
+ }
+ }
+}
diff --git a/KeyboardLocker/Sources/Extensions/TimeInterval+.swift b/KeyboardLocker/Sources/Extensions/TimeInterval+.swift
new file mode 100644
index 0000000..5894197
--- /dev/null
+++ b/KeyboardLocker/Sources/Extensions/TimeInterval+.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+public extension TimeInterval {
+ var formattedCountdown: String {
+ let totalSeconds = Int(self)
+ if totalSeconds <= 0 {
+ return "00:00"
+ }
+
+ let hours = totalSeconds / 3600
+ let minutes = (totalSeconds % 3600) / 60
+ let seconds = totalSeconds % 60
+
+ if hours > 0 {
+ return String(format: "%d:%02d:%02d", hours, minutes, seconds)
+ } else {
+ return String(format: "%02d:%02d", minutes, seconds)
+ }
+ }
+}
diff --git a/KeyboardLocker/Sources/Helpers/AppDependencies.swift b/KeyboardLocker/Sources/Helpers/AppDependencies.swift
new file mode 100644
index 0000000..91b9704
--- /dev/null
+++ b/KeyboardLocker/Sources/Helpers/AppDependencies.swift
@@ -0,0 +1,47 @@
+import Core
+
+/// Application dependency container
+/// Responsible for creating and managing all dependencies, ensuring single responsibility and clear dependency flow
+final class AppDependencies {
+ // MARK: - Core Dependencies (from Core module)
+
+ let keyboardCore: KeyboardLockCore
+ let coreConfiguration: CoreConfiguration
+ let activityMonitor: UserActivityMonitor
+
+ // MARK: - UI Layer Dependencies
+
+ let notificationManager: NotificationManager
+ let permissionManager: PermissionManager
+ let urlHandler: URLCommandHandler
+ let keyboardLockManager: KeyboardLockManager
+
+ // MARK: - Initialization
+
+ init() {
+ // 1. Initialize Core dependencies (keep as singletons since they are system-level resources)
+ keyboardCore = KeyboardLockCore.shared
+ coreConfiguration = CoreConfiguration.shared
+ activityMonitor = UserActivityMonitor.shared
+
+ // 2. Initialize UI layer dependencies (prioritize those without dependencies)
+ notificationManager = NotificationManager()
+
+ // 3. Initialize components with dependencies
+ permissionManager = PermissionManager(notificationManager: notificationManager)
+
+ // 4. Initialize managers
+ keyboardLockManager = KeyboardLockManager(
+ core: keyboardCore,
+ config: coreConfiguration,
+ activityMonitor: activityMonitor,
+ notificationManager: notificationManager
+ )
+
+ // 5. Initialize URL handler
+ urlHandler = URLCommandHandler(
+ keyboardLockManager: keyboardLockManager,
+ notificationManager: notificationManager
+ )
+ }
+}
diff --git a/KeyboardLocker/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift
similarity index 75%
rename from KeyboardLocker/LocalizationHelper.swift
rename to KeyboardLocker/Sources/Helpers/LocalizationHelper.swift
index a4aecf6..cd1e7d2 100644
--- a/KeyboardLocker/LocalizationHelper.swift
+++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift
@@ -1,4 +1,3 @@
-import Foundation
import SwiftUI
// MARK: - Bundle Extensions for App Info
@@ -7,17 +6,17 @@ import SwiftUI
extension Bundle {
/// Get app version number from Info.plist
var appVersion: String {
- return infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
+ infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
/// Get build version number from Info.plist
var buildVersion: String {
- return infoDictionary?["CFBundleVersion"] as? String ?? "1"
+ infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
/// Human readable copyright information from Info.plist
var copyright: String {
- return object(forInfoDictionaryKey: "NSHumanReadableCopyright") as? String ?? ""
+ object(forInfoDictionaryKey: "NSHumanReadableCopyright") as? String ?? ""
}
/// Formatted version string using localized format
@@ -33,7 +32,7 @@ extension Bundle {
extension String {
/// Returns a localized string for the given key using the modern .xcstrings format
var localized: String {
- return String(localized: String.LocalizationValue(self))
+ String(localized: String.LocalizationValue(self))
}
/// Returns a localized string with format arguments using .xcstrings
@@ -57,6 +56,7 @@ extension Text {
enum LocalizationKey {
// App
static let appTitle = "app.title"
+ static let appMenuTitle = "app.menu.title"
// Main Interface
static let statusLocked = "status.locked"
@@ -77,7 +77,6 @@ enum LocalizationKey {
// Settings
static let settingsAutoLock = "settings.auto.lock"
- static let settingsAutoLockTime = "settings.auto.lock.time"
static let settingsAutoLockDescription = "settings.auto.lock.description"
static let settingsNotifications = "settings.notifications"
static let settingsShowNotifications = "settings.show.notifications"
@@ -86,11 +85,22 @@ enum LocalizationKey {
static let settingsKeyboardDescription = "settings.keyboard.description"
static let settingsReset = "settings.reset"
- // Time durations
- static let time15Minutes = "time.15.minutes"
- static let time30Minutes = "time.30.minutes"
- static let time60Minutes = "time.60.minutes"
- static let timeNever = "time.never"
+ // Time durations and duration display
+ static let timeActivityText = "time.activity.text"
+ static let timeAutoLockDuration = "time.auto.lock.duration"
+ static let autoLockStatus = "auto.lock.status"
+ static let autoLockDisabled = "auto.lock.disabled"
+ static let autoLockReadyToLock = "auto.lock.ready.to.lock"
+ static let autoLockCountdownFormat = "auto.lock.countdown.format"
+
+ // Duration basic values (shared between time and duration contexts)
+ static let durationNever = "duration.never"
+ static let durationInfinite = "duration.infinite"
+
+ // Duration display - parameterized forms
+ static let durationMinutes = "duration.minutes" // "%d minute(s)"
+ static let durationHours = "duration.hours" // "%d hour(s)"
+ static let durationHoursMinutes = "duration.hours.minutes" // "%d hour(s) %d minute(s)"
// About
static let aboutVersionFormat = "about.version.format"
@@ -99,8 +109,6 @@ enum LocalizationKey {
static let aboutFeatureShortcut = "about.feature.shortcut"
static let aboutFeatureNotifications = "about.feature.notifications"
static let aboutFeatureAutoLock = "about.feature.auto.lock"
- static let aboutFeedback = "about.feedback"
- static let aboutHelp = "about.help"
static let aboutGitHub = "about.github"
// Notifications
@@ -108,19 +116,16 @@ enum LocalizationKey {
static let notificationKeyboardUnlocked = "notification.keyboard.unlocked"
static let notificationLockedMessage = "notification.locked.message"
static let notificationUnlockedMessage = "notification.unlocked.message"
+ static let notificationUrlCommand = "notification.url.command"
+ static let notificationError = "notification.error"
+
+ // Lock Duration
+ static let lockDurationFormat = "lock.duration.format"
// Permissions
- static let permissionAccessibilityTitle = "permission.accessibility.title"
- static let permissionAccessibilityMessage = "permission.accessibility.message"
static let permissionRequired = "permission.required"
static let permissionDescription = "permission.description"
static let openSystemPreferences = "open.system.preferences"
- static let refreshPermission = "refresh.permission"
- static let autoDetectionEnabled = "auto.detection.enabled"
-
- // Error Recovery
- static let errorRecoveryTitle = "error.recovery.title"
- static let errorRecoveryMessage = "error.recovery.message"
// URL Schemes - User facing messages only
static let urlErrorInvalidScheme = "url.error.invalid.scheme"
diff --git a/KeyboardLocker/URLHandler.swift b/KeyboardLocker/Sources/Helpers/URLHandler.swift
similarity index 70%
rename from KeyboardLocker/URLHandler.swift
rename to KeyboardLocker/Sources/Helpers/URLHandler.swift
index d3e5a09..c41cf57 100644
--- a/KeyboardLocker/URLHandler.swift
+++ b/KeyboardLocker/Sources/Helpers/URLHandler.swift
@@ -1,8 +1,7 @@
import AppKit
-import Foundation
/// Handles URL scheme requests for keyboard control operations
-class URLCommandHandler: ObservableObject {
+class URLCommandHandler {
/// Supported URL commands
enum URLCommand: String, CaseIterable {
case lock
@@ -13,13 +12,13 @@ class URLCommandHandler: ObservableObject {
var localizedDescription: String {
switch self {
case .lock:
- return LocalizationKey.actionLock.localized
+ LocalizationKey.actionLock.localized
case .unlock:
- return LocalizationKey.actionUnlock.localized
+ LocalizationKey.actionUnlock.localized
case .toggle:
- return "Toggle keyboard lock state" // No UI display, no need for i18n
+ "Toggle keyboard lock state" // No UI display, no need for i18n
case .status:
- return "Get keyboard lock status" // No UI display, no need for i18n
+ "Get keyboard lock status" // No UI display, no need for i18n
}
}
}
@@ -32,35 +31,32 @@ class URLCommandHandler: ObservableObject {
var message: String {
switch self {
case let .success(message):
- return message
+ message
case let .error(error):
- return error
+ error
}
}
var isSuccess: Bool {
switch self {
case .success:
- return true
+ true
case .error:
- return false
+ false
}
}
}
private weak var keyboardLockManager: KeyboardLockManager?
- private let notificationManager = NotificationManager.shared
+ private let notificationManager: NotificationManager
- /// Initialize URL handler with optional keyboard lock manager reference
- /// - Parameter keyboardLockManager: The keyboard lock manager instance (can be set later)
- init(keyboardLockManager: KeyboardLockManager? = nil) {
+ /// Create URLCommandHandler with dependencies
+ /// - Parameters:
+ /// - keyboardLockManager: Manager for keyboard operations
+ /// - notificationManager: Manager for notifications
+ init(keyboardLockManager: KeyboardLockManager, notificationManager: NotificationManager) {
self.keyboardLockManager = keyboardLockManager
- }
-
- /// Set the keyboard lock manager reference
- /// - Parameter manager: The keyboard lock manager instance
- func setKeyboardLockManager(_ manager: KeyboardLockManager) {
- keyboardLockManager = manager
+ self.notificationManager = notificationManager
}
/// Process incoming URL and execute the appropriate command
@@ -85,7 +81,7 @@ class URLCommandHandler: ObservableObject {
// Parse command
guard let command = URLCommand(rawValue: host.lowercased()) else {
- let supportedCommands = URLCommand.allCases.map { $0.rawValue }.joined(separator: ", ")
+ let supportedCommands = URLCommand.allCases.map(\.rawValue).joined(separator: ", ")
let error = LocalizationKey.urlErrorUnknownCommand.localized(host, supportedCommands)
print("❌ Unknown command: \(host)")
return .error(error)
@@ -178,7 +174,8 @@ class URLCommandHandler: ObservableObject {
private func executeStatusCommand(_ manager: KeyboardLockManager) -> CommandResponse {
let statusText =
manager.isLocked
- ? LocalizationKey.statusLocked.localized : LocalizationKey.statusUnlocked.localized
+ ? LocalizationKey.statusLocked.localized
+ : LocalizationKey.statusUnlocked.localized
print("📊 Current status: \(manager.isLocked ? "locked" : "unlocked")")
return .success(statusText)
@@ -190,38 +187,11 @@ class URLCommandHandler: ObservableObject {
DispatchQueue.main.async {
print("💬 User feedback: \(response.message)")
- // Send notification to user about the URL command result
- self.sendNotification(
- title: "KeyboardLocker",
+ self.notificationManager.sendNotification(
+ title: LocalizationKey.appTitle.localized,
body: response.message,
isError: !response.isSuccess
)
}
}
-
- /// Send notification to user using NotificationManager
- /// - Parameters:
- /// - title: Notification title
- /// - body: Notification body message
- /// - isError: Whether this is an error notification
- private func sendNotification(title _: String, body: String, isError: Bool = false) {
- if isError {
- notificationManager.notifyURLCommandError(body)
- } else {
- notificationManager.notifyURLCommandSuccess(body)
- }
- }
-}
-
-/// Extension to provide convenience methods for testing
-extension URLCommandHandler {
- /// Test URL creation helper
- static func createTestURL(for command: URLCommand) -> URL? {
- return URL(string: "keyboardlocker://\(command.rawValue)")
- }
-
- /// Get all supported commands for documentation
- static func getSupportedCommands() -> [String] {
- return URLCommand.allCases.map { "keyboardlocker://\($0.rawValue)" }
- }
}
diff --git a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift
new file mode 100644
index 0000000..1920d81
--- /dev/null
+++ b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift
@@ -0,0 +1,285 @@
+import Combine
+import Core
+import SwiftUI
+
+/// UI-focused keyboard lock manager that bridges Core functionality and UI state
+/// This layer handles UI state management and integrates with the Core library
+///
+/// Design Philosophy:
+/// - Uses nested types to encapsulate related functionality
+/// - Extensions group methods by responsibility
+/// - Clear separation between public API and private implementation
+/// - Follows Single Responsibility Principle
+class KeyboardLockManager: ObservableObject {
+ // MARK: - Published State
+
+ @Published private(set) var isLocked = false
+
+ // MARK: - Dependencies
+
+ private let core: KeyboardLockCore
+ private let config: CoreConfiguration
+ private let activityMonitor: UserActivityMonitor
+ private let notificationManager: NotificationManager
+
+ // MARK: - State
+
+ private var isUserOperation = false
+ private var cancellables = Set()
+
+ // MARK: - Lifecycle
+
+ /// Create KeyboardLockManager with injected dependencies
+ /// - Parameters:
+ /// - core: Core keyboard functionality
+ /// - config: Configuration management
+ /// - activityMonitor: User activity monitoring
+ /// - notificationManager: Notification handling
+ init(
+ core: KeyboardLockCore,
+ config: CoreConfiguration,
+ activityMonitor: UserActivityMonitor,
+ notificationManager: NotificationManager
+ ) {
+ self.core = core
+ self.config = config
+ self.activityMonitor = activityMonitor
+ self.notificationManager = notificationManager
+
+ configureSubscriptions()
+ syncInitialState()
+ }
+
+ /// Configure reactive state subscriptions from Core components
+ private func configureSubscriptions() {
+ core.onLockStateChanged = { [weak self] isLocked, _ in
+ self?.handleLockStateChange(isLocked)
+ }
+
+ core.onUnlockHotkeyDetected = { [weak self] in
+ DispatchQueue.main.async {
+ self?.unlockKeyboard()
+ }
+ }
+
+ config.$autoLockDuration
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ self?.handleAutoLockConfigurationChange()
+ }
+ .store(in: &cancellables)
+
+ config.objectWillChange
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ self?.objectWillChange.send()
+ }
+ .store(in: &cancellables)
+
+ updateAutoLockState()
+ }
+
+ /// Sync initial state from Core components
+ private func syncInitialState() {
+ DispatchQueue.main.async {
+ self.isLocked = self.core.isLocked
+ }
+ }
+
+ /// Handle lock state change coming from Core layer
+ private func handleLockStateChange(_ isLocked: Bool) {
+ DispatchQueue.main.async {
+ self.isLocked = isLocked
+ self.notifyIfNeeded(isLocked: isLocked)
+ }
+ }
+
+ /// Send notifications for non-user initiated state changes
+ private func notifyIfNeeded(isLocked: Bool) {
+ guard !isUserOperation else { return }
+
+ let notificationType: NotificationManager.NotificationType = isLocked ? .keyboardLocked : .keyboardUnlocked
+
+ notificationManager.sendNotificationIfEnabled(
+ notificationType,
+ showNotifications: config.showNotifications
+ )
+ }
+
+ deinit {
+ cancellables.removeAll()
+ }
+}
+
+// MARK: - Public API
+
+extension KeyboardLockManager {
+ /// Lock the keyboard (user-initiated, no notification)
+ func lockKeyboard() {
+ performUserOperation {
+ try core.lockKeyboard()
+ }
+ }
+
+ /// Unlock the keyboard (user-initiated, no notification)
+ func unlockKeyboard() {
+ guard core.isLocked else { return }
+
+ performUserOperation {
+ core.unlockKeyboard()
+ }
+ }
+
+ /// Toggle keyboard lock state (user-initiated)
+ func toggleLock() {
+ performUserOperation {
+ core.toggleLock()
+ }
+ }
+
+ /// Get time since last user activity (for UI display)
+ func getTimeSinceLastActivity() -> TimeInterval {
+ activityMonitor.timeSinceLastActivity
+ }
+
+ /// Reset user activity timer manually
+ func resetUserActivityTimer() {
+ activityMonitor.resetActivityTimer()
+ }
+
+ /// Check if required permissions are granted
+ func checkPermissions() -> Bool {
+ PermissionHelper.hasAccessibilityPermission()
+ }
+
+ /// Request required permissions from the user
+ func requestPermissions() {
+ PermissionHelper.requestAccessibilityPermission()
+ }
+
+ /// Force cleanup for Core resources and resync state
+ func forceCleanup() {
+ core.forceCleanup()
+ syncInitialState()
+ }
+}
+
+// MARK: - Computed Properties
+
+extension KeyboardLockManager {
+ /// Auto-lock duration in minutes for UI display
+ var autoLockDuration: Int {
+ config.autoLockDuration.minutes
+ }
+
+ /// Check if auto-lock is enabled
+ var isAutoLockEnabled: Bool {
+ config.isAutoLockEnabled
+ }
+
+ /// Format lock duration as string for UI display
+ var lockDurationText: String? {
+ guard let duration = calculateLockDuration() else { return nil }
+
+ let minutes = Int(duration / 60)
+ let seconds = Int(duration.truncatingRemainder(dividingBy: 60))
+ return String(format: "%02d:%02d", minutes, seconds)
+ }
+
+ /// Format auto-lock remaining time as string for UI display
+ var autoLockStatusText: String {
+ let duration = autoLockDuration
+ if duration == 0 {
+ return LocalizationKey.autoLockDisabled.localized
+ }
+
+ guard let remainingTime = calculateAutoLockRemainingTime() else {
+ return LocalizationKey.autoLockReadyToLock.localized
+ }
+
+ if remainingTime > 0 {
+ let countdownString = remainingTime.formattedCountdown
+ return LocalizationKey.autoLockCountdownFormat.localized(countdownString)
+ } else {
+ return LocalizationKey.autoLockReadyToLock.localized
+ }
+ }
+}
+
+// MARK: - Auto-Lock Management
+
+extension KeyboardLockManager {
+ /// Handle updates when auto-lock configuration changes
+ private func handleAutoLockConfigurationChange() {
+ updateAutoLockState()
+ }
+
+ /// Update auto-lock state based on current configuration
+ private func updateAutoLockState() {
+ if isAutoLockEnabled {
+ enableAutoLockMonitoring()
+ } else {
+ activityMonitor.stopMonitoring()
+ }
+ }
+
+ /// Enable auto-lock monitoring with current configuration
+ private func enableAutoLockMonitoring() {
+ let duration = config.autoLockDuration
+ guard isAutoLockEnabled else { return }
+
+ activityMonitor.enableAutoLock(seconds: duration.seconds)
+ activityMonitor.onAutoLockTriggered = { [weak self] in
+ self?.handleAutoLockTrigger()
+ }
+ activityMonitor.startMonitoring()
+ }
+
+ /// Triggered by auto-lock system (sends notification)
+ private func handleAutoLockTrigger() {
+ do {
+ try core.lockKeyboard()
+ print("🤖 Auto-lock activated")
+ } catch {
+ print("❌ Auto-lock failed: \(error.localizedDescription)")
+ }
+ }
+}
+
+// MARK: - State Calculation
+
+extension KeyboardLockManager {
+ /// Calculate lock duration in seconds for UI display
+ private func calculateLockDuration() -> TimeInterval? {
+ guard core.isLocked else { return nil }
+
+ // Otherwise show elapsed time since lock started
+ guard let lockedAt = core.lockedAt else { return nil }
+ return Date().timeIntervalSince(lockedAt)
+ }
+
+ /// Calculate auto-lock remaining time in seconds for UI display
+ private func calculateAutoLockRemainingTime() -> TimeInterval? {
+ let duration = autoLockDuration
+ guard duration > 0, isAutoLockEnabled else { return nil }
+
+ let timeSinceActivity = getTimeSinceLastActivity()
+ return max(0, TimeInterval(duration * 60) - timeSinceActivity)
+ }
+}
+
+// MARK: - Helpers
+
+extension KeyboardLockManager {
+ /// Execute a user-initiated operation (no notifications)
+ private func performUserOperation(_ operation: () throws -> Void) {
+ isUserOperation = true
+ defer { isUserOperation = false }
+
+ do {
+ try operation()
+ } catch {
+ print("❌ User operation failed: \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/KeyboardLocker/NotificationManager.swift b/KeyboardLocker/Sources/Managers/NotificationManager.swift
similarity index 69%
rename from KeyboardLocker/NotificationManager.swift
rename to KeyboardLocker/Sources/Managers/NotificationManager.swift
index 5af7d05..e55995c 100644
--- a/KeyboardLocker/NotificationManager.swift
+++ b/KeyboardLocker/Sources/Managers/NotificationManager.swift
@@ -2,11 +2,7 @@ import Foundation
import UserNotifications
/// Centralized notification management for the app
-class NotificationManager {
- // MARK: - Singleton
-
- static let shared = NotificationManager()
-
+class NotificationManager: ObservableObject {
// MARK: - Published Properties
@Published var isAuthorized = false
@@ -24,7 +20,7 @@ class NotificationManager {
case general = "GENERAL"
var identifier: String {
- return rawValue
+ rawValue
}
}
@@ -40,59 +36,81 @@ class NotificationManager {
var title: String {
switch self {
case .keyboardLocked:
- return LocalizationKey.notificationKeyboardLocked.localized
+ LocalizationKey.notificationKeyboardLocked.localized
case .keyboardUnlocked:
- return LocalizationKey.notificationKeyboardUnlocked.localized
+ LocalizationKey.notificationKeyboardUnlocked.localized
case .urlCommandSuccess:
- return "URL Command".localized // Success notification title
+ LocalizationKey.notificationUrlCommand.localized
case .urlCommandError:
- return "Error".localized // Error notification title
+ LocalizationKey.notificationError.localized
case let .general(title, _):
- return title
+ title
}
}
var body: String {
switch self {
case .keyboardLocked:
- return LocalizationKey.notificationLockedMessage.localized
+ LocalizationKey.notificationLockedMessage.localized
case .keyboardUnlocked:
- return LocalizationKey.notificationUnlockedMessage.localized
+ LocalizationKey.notificationUnlockedMessage.localized
case let .urlCommandSuccess(message):
- return message
+ message
case let .urlCommandError(message):
- return message
+ message
case let .general(_, body):
- return body
+ body
}
}
var category: NotificationCategory {
switch self {
case .keyboardLocked, .keyboardUnlocked:
- return .keyboardStatus
+ .keyboardStatus
case .urlCommandSuccess:
- return .urlCommand
+ .urlCommand
case .urlCommandError:
- return .urlError
+ .urlError
case .general:
- return .general
+ .general
}
}
var sound: UNNotificationSound {
switch self {
case .urlCommandError:
- return .defaultCritical
+ .defaultCritical
default:
- return .default
+ .default
}
}
}
+ /// Send a notification of the specified type if enabled
+ /// - Parameters:
+ /// - type: The type of notification to send
+ /// - showNotifications: Whether notifications are enabled
+ func sendNotificationIfEnabled(_ type: NotificationType, showNotifications: Bool) {
+ guard shouldSendNotification(showNotifications: showNotifications) else {
+ print("🔔 Notification skipped - disabled in settings or not authorized")
+ return
+ }
+ sendNotification(type)
+ }
+
+ /// Send a custom notification
+ /// - Parameters:
+ /// - title: Notification title
+ /// - body: Notification body
+ /// - isError: Whether this is an error notification
+ func sendNotification(title: String, body: String, isError _: Bool) {
+ sendNotification(.general(title: title, body: body))
+ }
+
// MARK: - Initialization
- private init() {
+ /// Create a new NotificationManager instance
+ init() {
setupNotificationCategories()
checkAuthorizationStatus()
}
@@ -102,12 +120,13 @@ class NotificationManager {
/// Request notification permission from user
/// - Parameter completion: Completion handler with authorization result
func requestAuthorization(completion: @escaping (Bool, Error?) -> Void = { _, _ in }) {
- notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in
+ notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) {
+ [weak self] granted, error in
DispatchQueue.main.async {
self?.isAuthorized = granted
completion(granted, error)
- if let error = error {
+ if let error {
print("❌ Failed to request notification permission: \(error.localizedDescription)")
} else {
print("✅ Notification permission \(granted ? "granted" : "denied")")
@@ -125,9 +144,7 @@ class NotificationManager {
// Only update if status changed to avoid unnecessary UI updates
if self?.isAuthorized != isAuthorized {
self?.isAuthorized = isAuthorized
- print(
- "📱 Notification authorization status: \(isAuthorized ? "authorized" : "not authorized")"
- )
+ print("📱 Notification authorization status: \(isAuthorized ? "authorized" : "not authorized")")
}
}
}
@@ -148,6 +165,14 @@ class NotificationManager {
return
}
+ // Remove previous keyboard status notifications to keep only one
+ switch type {
+ case .keyboardLocked, .keyboardUnlocked:
+ removePreviousKeyboardNotifications()
+ default:
+ break
+ }
+
let content = UNMutableNotificationContent()
content.title = type.title
content.body = type.body
@@ -168,7 +193,7 @@ class NotificationManager {
notificationCenter.add(request) { error in
DispatchQueue.main.async {
- if let error = error {
+ if let error {
print("❌ Failed to send notification: \(error.localizedDescription)")
} else {
print("✅ Notification sent: \(type.title)")
@@ -194,28 +219,21 @@ class NotificationManager {
/// - Parameter category: The category to remove
func removeNotifications(for category: NotificationCategory) {
notificationCenter.getPendingNotificationRequests { [weak self] requests in
- let identifiersToRemove =
- requests
- .filter { $0.content.categoryIdentifier == category.identifier }
- .map { $0.identifier }
-
- self?.notificationCenter.removePendingNotificationRequests(
- withIdentifiers: identifiersToRemove)
- print(
- "🗑️ Removed \(identifiersToRemove.count) pending notifications for category: \(category.identifier)"
- )
+ let identifiersToRemove = requests
+ .filter { $0.content.categoryIdentifier == category.identifier }
+ .map(\.identifier)
+
+ self?.notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToRemove)
+ print("🗑️ Removed \(identifiersToRemove.count) pending notifications for category: \(category.identifier)")
}
notificationCenter.getDeliveredNotifications { [weak self] notifications in
- let identifiersToRemove =
- notifications
- .filter { $0.request.content.categoryIdentifier == category.identifier }
- .map { $0.request.identifier }
+ let identifiersToRemove = notifications
+ .filter { $0.request.content.categoryIdentifier == category.identifier }
+ .map(\.request.identifier)
self?.notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToRemove)
- print(
- "🗑️ Removed \(identifiersToRemove.count) delivered notifications for category: \(category.identifier)"
- )
+ print("🗑️ Removed \(identifiersToRemove.count) delivered notifications for category: \(category.identifier)")
}
}
@@ -256,24 +274,40 @@ class NotificationManager {
}
notificationCenter.setNotificationCategories(Set(categories))
- print("📋 Notification categories configured: \(categories.map { $0.identifier })")
+ print("📋 Notification categories configured: \(categories.map(\.identifier))")
}
private func generateNotificationIdentifier(for type: NotificationType) -> String {
- let timestamp = Date().timeIntervalSince1970
+ // Use fixed identifiers for keyboard status to replace old notifications
switch type {
case .keyboardLocked:
- return "keyboard_locked_\(timestamp)"
+ return "keyboard_status_locked"
+
case .keyboardUnlocked:
- return "keyboard_unlocked_\(timestamp)"
+ return "keyboard_status_unlocked"
+
case .urlCommandSuccess:
+ let timestamp = Date().timeIntervalSince1970
return "url_success_\(timestamp)"
+
case .urlCommandError:
+ let timestamp = Date().timeIntervalSince1970
return "url_error_\(timestamp)"
+
case .general:
+ let timestamp = Date().timeIntervalSince1970
return "general_\(timestamp)"
}
}
+
+ /// Remove previous keyboard status notifications to keep only one in notification center
+ private func removePreviousKeyboardNotifications() {
+ let identifiersToRemove = ["keyboard_status_locked", "keyboard_status_unlocked"]
+
+ // Remove both pending and delivered notifications
+ notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToRemove)
+ notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToRemove)
+ }
}
// MARK: - Error Types
@@ -286,11 +320,11 @@ enum NotificationError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .notAuthorized:
- return "Notifications not authorized".localized
+ "Notifications not authorized"
case .invalidContent:
- return "Invalid notification content".localized
+ "Invalid notification content"
case let .systemError(error):
- return "System error".localized + ": \(error.localizedDescription)"
+ "System error: \(error.localizedDescription)"
}
}
}
@@ -302,7 +336,7 @@ extension NotificationManager {
/// - Parameter showNotifications: User's notification preference
/// - Returns: Whether notifications should be sent
func shouldSendNotification(showNotifications: Bool) -> Bool {
- return showNotifications && isAuthorized
+ showNotifications && isAuthorized
}
/// Send notification conditionally based on user settings
diff --git a/KeyboardLocker/PermissionManager.swift b/KeyboardLocker/Sources/Managers/PermissionManager.swift
similarity index 59%
rename from KeyboardLocker/PermissionManager.swift
rename to KeyboardLocker/Sources/Managers/PermissionManager.swift
index 4c984c6..cdb0025 100644
--- a/KeyboardLocker/PermissionManager.swift
+++ b/KeyboardLocker/Sources/Managers/PermissionManager.swift
@@ -1,6 +1,5 @@
import AppKit
-import Carbon
-import Foundation
+import Core
/// Permission management for accessibility and notification permissions
class PermissionManager: ObservableObject {
@@ -10,16 +9,17 @@ class PermissionManager: ObservableObject {
// Computed property that delegates to NotificationManager
var hasNotificationPermission: Bool {
- return notificationManager.isAuthorized
+ notificationManager.isAuthorized
}
// MARK: - Private Properties
- private let notificationManager = NotificationManager.shared
+ let notificationManager: NotificationManager
// MARK: - Initialization
- init() {
+ init(notificationManager: NotificationManager) {
+ self.notificationManager = notificationManager
checkAllPermissions()
setupApplicationFocusMonitoring()
}
@@ -30,36 +30,31 @@ class PermissionManager: ObservableObject {
// MARK: - Public Methods
- /// Check all permission statuses and update published properties
+ /// Check accessibility permission status (required permission)
func checkAllPermissions() {
checkAccessibilityPermission()
- checkNotificationPermission()
}
/// Request accessibility permission by opening system settings
func requestAccessibilityPermission() {
- // Show immediate authorization prompt if available
- if !AXIsProcessTrusted() {
- // Try to request with prompt first
- let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as CFDictionary
- let trusted = AXIsProcessTrustedWithOptions(options)
+ // Use Core library's permission helper
+ let currentStatus = PermissionHelper.checkAccessibilityPermission(promptUser: true)
- DispatchQueue.main.async {
- self.hasAccessibilityPermission = trusted
- }
+ DispatchQueue.main.async {
+ self.hasAccessibilityPermission = currentStatus
+ }
- // If still not trusted, open system preferences
- if !trusted {
- openAccessibilitySettings()
- }
+ // If still not trusted, open system preferences
+ if !currentStatus {
+ PermissionHelper.openAccessibilitySettings()
}
}
/// Request notification permission using NotificationManager
+ /// Should only be called when user enables notifications in settings
func requestNotificationPermission() {
- notificationManager.requestAuthorization { [weak self] _, error in
- // The NotificationManager handles state updates
- if let error = error {
+ notificationManager.requestAuthorization { [weak self] (_: Bool, error: Error?) in
+ if let error {
print("Failed to request notification permission: \(error)")
}
// Trigger objectWillChange to update any UI that depends on hasNotificationPermission
@@ -88,18 +83,15 @@ class PermissionManager: ObservableObject {
name: NSWindow.didBecomeKeyNotification,
object: nil
)
-
- print("Application focus monitoring setup completed")
}
/// Handle application becoming active - check permissions
@objc private func applicationDidBecomeActive() {
- print("Application became active - checking permissions")
checkAllPermissions()
}
private func checkAccessibilityPermission() {
- let currentPermission = AXIsProcessTrusted()
+ let currentPermission = PermissionHelper.hasAccessibilityPermission()
// Only update and log if the permission status has changed
if currentPermission != hasAccessibilityPermission {
@@ -110,23 +102,8 @@ class PermissionManager: ObservableObject {
}
}
- private func checkNotificationPermission() {
- // Delegate to NotificationManager to check permission status
- notificationManager.checkAuthorizationStatus()
-
- // Trigger objectWillChange to update any UI that depends on hasNotificationPermission
- DispatchQueue.main.async {
- self.objectWillChange.send()
- }
- }
-
private func openAccessibilitySettings() {
- guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else {
- return
- }
- NSWorkspace.shared.open(url)
-
- // No need for delayed checking - focus monitoring will handle it
+ PermissionHelper.openAccessibilitySettings()
print("Opened accessibility settings")
}
}
diff --git a/KeyboardLocker/Sources/Views/AboutView.swift b/KeyboardLocker/Sources/Views/AboutView.swift
new file mode 100644
index 0000000..ed4bfc9
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/AboutView.swift
@@ -0,0 +1,95 @@
+import AppKit
+import SwiftUI
+
+struct AboutView: View {
+ private var appInfo: some View {
+ VStack(spacing: 6) {
+ Image(nsImage: NSApp.applicationIconImage)
+ .resizable()
+ .frame(width: 100, height: 100)
+
+ Text(LocalizationKey.appTitle.localized)
+ .font(.title)
+ .fontWeight(.bold)
+
+ Text(Bundle.main.localizedVersionString)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ private var coreFeatures: some View {
+ VStack(alignment: .leading, spacing: 15) {
+ Text(LocalizationKey.aboutFeatures.localized)
+ .font(.headline)
+
+ VStack(alignment: .leading, spacing: 5) {
+ FeatureRow(icon: "lock", text: LocalizationKey.aboutFeatureLock.localized)
+ FeatureRow(icon: "keyboard", text: LocalizationKey.aboutFeatureShortcut.localized)
+ FeatureRow(icon: "timer", text: LocalizationKey.aboutFeatureAutoLock.localized)
+ FeatureRow(icon: "bell", text: LocalizationKey.aboutFeatureNotifications.localized)
+ }
+ }
+ }
+
+ private var githubLink: some View {
+ Button(action: {
+ if let url = URL(string: "https://github.com/LZhenHong/KeyboardLocker") {
+ NSWorkspace.shared.open(url)
+ }
+ }) {
+ HStack {
+ Image(systemName: "link.circle.fill")
+ .foregroundColor(.accentColor)
+ Text(LocalizationKey.aboutGitHub.localized)
+ .foregroundColor(.accentColor)
+ }
+ .font(.body)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .onHover { _ in
+ NSCursor.pointingHand.set()
+ }
+ }
+
+ var body: some View {
+ VStack(spacing: 12) {
+ appInfo
+ Divider()
+ coreFeatures
+ Divider()
+ githubLink
+
+ // Copyright information from Info.plist
+ Text(Bundle.main.copyright)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ .navigationTitle(LocalizationKey.aboutTitle.localized)
+ .frame(width: 300)
+ }
+}
+
+struct FeatureRow: View {
+ let icon: String
+ let text: String
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Image(systemName: icon)
+ .foregroundColor(.accentColor)
+ .frame(width: 16)
+
+ Text(text)
+ .font(.body)
+ .foregroundColor(.primary)
+ }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ AboutView()
+ }
+}
diff --git a/KeyboardLocker/Sources/Views/ContentView.swift b/KeyboardLocker/Sources/Views/ContentView.swift
new file mode 100644
index 0000000..79f8f1e
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/ContentView.swift
@@ -0,0 +1,49 @@
+import Core
+import SwiftUI
+
+struct ContentView: View {
+ @StateObject private var viewState = ContentViewState()
+ @EnvironmentObject var permissionManager: PermissionManager
+ @EnvironmentObject var keyboardManager: KeyboardLockManager
+
+ var body: some View {
+ NavigationStack {
+ if permissionManager.hasAccessibilityPermission {
+ MainContentView(state: viewState)
+ } else {
+ PermissionRequiredView()
+ }
+ }
+ .frame(width: .viewWidth)
+ .background(Color(NSColor.windowBackgroundColor))
+ .onAppear(perform: setupInitialState)
+ .onDisappear(perform: viewState.cleanup)
+ }
+
+ private func setupInitialState() {
+ permissionManager.checkAllPermissions()
+ viewState.setup(with: keyboardManager)
+ }
+}
+
+private struct MainContentView: View {
+ @ObservedObject var state: ContentViewState
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ VStack(spacing: 16) {
+ AppTitleHeaderView()
+
+ VStack(spacing: 16) {
+ StatusSectionView(isKeyboardLocked: state.isKeyboardLocked)
+
+ LockControlButtonView(state: state)
+
+ QuickActionsView()
+ }
+ .padding(.horizontal, 16)
+
+ BottomActionsView()
+ }
+ }
+}
diff --git a/KeyboardLocker/Sources/Views/ContentViewState.swift b/KeyboardLocker/Sources/Views/ContentViewState.swift
new file mode 100644
index 0000000..25b9fd9
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/ContentViewState.swift
@@ -0,0 +1,47 @@
+import Core
+import SwiftUI
+
+@MainActor
+class ContentViewState: ObservableObject {
+ // MARK: - Published Properties
+
+ @Published var isKeyboardLocked = false
+
+ // MARK: - Dependencies
+
+ var keyboardManager: KeyboardLockManager?
+ private var cancellables = Set()
+
+ // MARK: - Lifecycle
+
+ func setup(with keyboardManager: KeyboardLockManager) {
+ self.keyboardManager = keyboardManager
+ setupSubscriptions()
+ syncInitialState()
+ }
+
+ func cleanup() {
+ cancellables.removeAll()
+ }
+
+ // MARK: - Private Methods
+
+ private func setupSubscriptions() {
+ guard let keyboardManager else { return }
+
+ keyboardManager.$isLocked
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isLocked in
+ self?.handleLockStateChange(isLocked)
+ }
+ .store(in: &cancellables)
+ }
+
+ private func syncInitialState() {
+ handleLockStateChange(keyboardManager?.isLocked ?? false)
+ }
+
+ private func handleLockStateChange(_ locked: Bool) {
+ isKeyboardLocked = locked
+ }
+}
diff --git a/KeyboardLocker/Sources/Views/LockControlView.swift b/KeyboardLocker/Sources/Views/LockControlView.swift
new file mode 100644
index 0000000..8756ed1
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/LockControlView.swift
@@ -0,0 +1,46 @@
+import Core
+import SwiftUI
+
+struct LockControlButtonView: View {
+ @ObservedObject var state: ContentViewState
+
+ var body: some View {
+ HStack(spacing: 8) {
+ MainLockButton(state: state)
+ }
+ }
+}
+
+private struct MainLockButton: View {
+ @ObservedObject var state: ContentViewState
+ @EnvironmentObject private var keyboardManager: KeyboardLockManager
+
+ var body: some View {
+ Button(action: toggleLock) {
+ HStack {
+ Image(systemName: state.isKeyboardLocked ? "lock.open" : "lock")
+ Text(lockButtonText)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(state.isKeyboardLocked ? Color.red : Color.green)
+ .foregroundColor(.white)
+ .cornerRadius(8)
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+
+ private var lockButtonText: String {
+ state.isKeyboardLocked
+ ? LocalizationKey.actionUnlock.localized
+ : LocalizationKey.actionLock.localized
+ }
+
+ private func toggleLock() {
+ if state.isKeyboardLocked {
+ keyboardManager.unlockKeyboard()
+ } else {
+ keyboardManager.lockKeyboard()
+ }
+ }
+}
diff --git a/KeyboardLocker/Sources/Views/PermissionView.swift b/KeyboardLocker/Sources/Views/PermissionView.swift
new file mode 100644
index 0000000..4110315
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/PermissionView.swift
@@ -0,0 +1,63 @@
+import Core
+import SwiftUI
+
+struct PermissionRequiredView: View {
+ var body: some View {
+ VStack(spacing: 20) {
+ AppTitleHeaderView()
+ PermissionContent()
+ Spacer()
+
+ HStack {
+ Spacer()
+ Button(LocalizationKey.actionQuit.localized) {
+ NSApplication.shared.terminate(nil)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .foregroundColor(.red)
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 16)
+ }
+ }
+}
+
+private struct PermissionContent: View {
+ @EnvironmentObject private var permissionManager: PermissionManager
+
+ var body: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ .font(.system(size: 48))
+
+ VStack(spacing: 16) {
+ Text(LocalizationKey.permissionRequired.localized)
+ .font(.title2)
+ .fontWeight(.semibold)
+ .multilineTextAlignment(.center)
+
+ Text(LocalizationKey.permissionDescription.localized)
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Button(action: permissionManager.requestAccessibilityPermission) {
+ HStack {
+ Image(systemName: "gear")
+ Text(LocalizationKey.openSystemPreferences.localized)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .background(Color.accentColor)
+ .foregroundColor(.white)
+ .cornerRadius(8)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .padding(.top, 8)
+ }
+ .padding(.horizontal, 16)
+ }
+}
diff --git a/KeyboardLocker/Sources/Views/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift
new file mode 100644
index 0000000..328900d
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/SettingsView.swift
@@ -0,0 +1,142 @@
+import Core
+import SwiftUI
+
+struct SettingsView: View {
+ @ObservedObject private var coreConfig = CoreConfiguration.shared
+ @EnvironmentObject private var permissionManager: PermissionManager
+
+ private typealias AutoLockInterval = CoreConfiguration.Duration
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ autoLockSection
+ notificationSection
+ keyboardSection
+ Spacer()
+ resetSection
+ }
+ .padding()
+ .navigationTitle(LocalizationKey.settingsTitle.localized)
+ .frame(width: 300)
+ }
+
+ // MARK: - View Components
+
+ private var autoLockSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(LocalizationKey.settingsAutoLock.localized)
+ .font(.headline)
+ .foregroundColor(.primary)
+
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Picker(
+ LocalizationKey.timeAutoLockDuration.localized, selection: $coreConfig.autoLockDuration
+ ) {
+ ForEach(AutoLockInterval.autoLockPresets, id: \.self) { duration in
+ Text(duration.localized)
+ .tag(duration)
+ }
+ }
+ .pickerStyle(MenuPickerStyle())
+ }
+
+ // Show current activity status if auto-lock is enabled
+ if coreConfig.isAutoLockEnabled {
+ HStack {
+ Image(systemName: "timer")
+ .foregroundColor(.secondary)
+ Text(LocalizationKey.timeActivityText.localized)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ }
+ }
+
+ Text(LocalizationKey.settingsAutoLockDescription.localized)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding(12)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(8)
+ }
+ }
+
+ private var notificationSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(LocalizationKey.settingsNotifications.localized)
+ .font(.headline)
+ .foregroundColor(.primary)
+
+ VStack(alignment: .leading, spacing: 12) {
+ Toggle(
+ LocalizationKey.settingsShowNotifications.localized,
+ isOn: Binding(
+ get: { coreConfig.showNotifications },
+ set: { newValue in
+ coreConfig.showNotifications = newValue
+ // Request notification permission when user enables notifications
+ if newValue, !permissionManager.hasNotificationPermission {
+ permissionManager.requestNotificationPermission()
+ }
+ }
+ )
+ )
+
+ Text(LocalizationKey.settingsNotificationsDescription.localized)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding(12)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(8)
+ }
+ }
+
+ private var keyboardSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(LocalizationKey.settingsKeyboard.localized)
+ .font(.headline)
+ .foregroundColor(.primary)
+
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(LocalizationKey.actionLock.localized + "/" + LocalizationKey.actionUnlock.localized + ":")
+ Spacer()
+ Text("⌘ + ⌥ + L".localized)
+ .font(.system(.body, design: .monospaced))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(4)
+ }
+
+ Text(LocalizationKey.settingsKeyboardDescription.localized)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding(12)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(8)
+ }
+ }
+
+ private var resetSection: some View {
+ HStack {
+ Spacer()
+ Button(LocalizationKey.settingsReset.localized) {
+ coreConfig.resetToDefaults()
+ }
+ .buttonStyle(PlainButtonStyle())
+ .foregroundColor(.red)
+ }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ SettingsView()
+ .environmentObject(AppDependencies().keyboardLockManager)
+ }
+}
diff --git a/KeyboardLocker/Sources/Views/SharedComponents.swift b/KeyboardLocker/Sources/Views/SharedComponents.swift
new file mode 100644
index 0000000..f1c3ea6
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/SharedComponents.swift
@@ -0,0 +1,109 @@
+import Core
+import SwiftUI
+
+// MARK: - Shared Header Components
+
+struct AppTitleHeaderView: View {
+ var body: some View {
+ VStack(spacing: 16) {
+ HStack {
+ Image(systemName: "lock.shield.fill")
+ .foregroundColor(.accentColor)
+ .font(.title2)
+ Text(LocalizationKey.appTitle.localized)
+ .font(.title2)
+ .fontWeight(.semibold)
+ }
+ .padding(.top, 16)
+
+ Divider()
+ }
+ }
+}
+
+// MARK: - Quick Actions Components
+
+struct QuickActionsView: View {
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(LocalizationKey.quickActions.localized)
+ .font(.headline)
+ .foregroundColor(.secondary)
+
+ VStack(spacing: 6) {
+ NavigationLink(destination: SettingsView()) {
+ SettingRow(
+ icon: "gear",
+ title: LocalizationKey.settingsTitle.localized,
+ subtitle: LocalizationKey.settingsSubtitle.localized
+ )
+ }
+ .buttonStyle(PlainButtonStyle())
+
+ NavigationLink(destination: AboutView()) {
+ SettingRow(
+ icon: "info.circle",
+ title: LocalizationKey.aboutTitle.localized,
+ subtitle: LocalizationKey.aboutSubtitle.localized
+ )
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+ }
+ }
+}
+
+// MARK: - Bottom Actions
+
+struct BottomActionsView: View {
+ var body: some View {
+ HStack {
+ Text(LocalizationKey.shortcutHint.localized)
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ Spacer()
+
+ Button(LocalizationKey.actionQuit.localized) {
+ NSApplication.shared.terminate(nil)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .foregroundColor(.red)
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 16)
+ }
+}
+
+// MARK: - Setting Row Component
+
+struct SettingRow: View {
+ let icon: String
+ let title: String
+ let subtitle: String
+
+ var body: some View {
+ HStack {
+ Image(systemName: icon)
+ .foregroundColor(.accentColor)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.body)
+ .foregroundColor(.primary)
+ Text(subtitle)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ }
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ }
+}
diff --git a/KeyboardLocker/Sources/Views/StatusView.swift b/KeyboardLocker/Sources/Views/StatusView.swift
new file mode 100644
index 0000000..d461311
--- /dev/null
+++ b/KeyboardLocker/Sources/Views/StatusView.swift
@@ -0,0 +1,89 @@
+import Core
+import SwiftUI
+
+struct StatusSectionView: View {
+ let isKeyboardLocked: Bool
+ @EnvironmentObject private var keyboardManager: KeyboardLockManager
+
+ private var statusText: String {
+ isKeyboardLocked
+ ? LocalizationKey.statusLocked.localized
+ : LocalizationKey.statusUnlocked.localized
+ }
+
+ private var mainStatusView: some View {
+ HStack {
+ Circle()
+ .fill(isKeyboardLocked ? Color.red : Color.green)
+ .frame(width: 12, height: 12)
+
+ Text(statusText)
+ .font(.body)
+ .foregroundColor(.primary)
+
+ Spacer()
+ }
+ }
+
+ private var autoLockStatusView: some View {
+ TimelineView(.periodic(from: .now, by: 1)) { _ in
+ HStack {
+ Image(systemName: "timer")
+ .foregroundColor(.orange)
+ .font(.caption)
+ Text(LocalizationKey.autoLockStatus.localized(keyboardManager.autoLockStatusText))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ }
+ .padding(.leading, 16)
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ mainStatusView
+
+ if isKeyboardLocked {
+ LockDurationRow()
+ }
+
+ if !isKeyboardLocked, keyboardManager.isAutoLockEnabled {
+ autoLockStatusView
+ }
+ }
+ }
+}
+
+private struct LockDurationRow: View {
+ @EnvironmentObject private var keyboardManager: KeyboardLockManager
+
+ var body: some View {
+ TimelineView(.periodic(from: .now, by: 1)) { _ in
+ if let durationText = keyboardManager.lockDurationText {
+ let displayText = getLockDurationDisplayText(durationText)
+ if !displayText.isEmpty {
+ HStack {
+ Image(systemName: "clock")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ Text(displayText)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ }
+ .padding(.leading, 16)
+ }
+ }
+ }
+ }
+
+ private func getLockDurationDisplayText(_ durationString: String) -> String {
+ if durationString.contains(":") {
+ LocalizationKey.lockDurationFormat.localized(durationString)
+ } else {
+ // Fallback: show a generic message
+ LocalizationKey.statusLocked.localized()
+ }
+ }
+}
diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings
index d5ed196..46fd370 100644
--- a/KeyboardLocker/i18n/Localizable.xcstrings
+++ b/KeyboardLocker/i18n/Localizable.xcstrings
@@ -1,25 +1,6 @@
{
"sourceLanguage" : "en",
"strings" : {
- "" : {
- "shouldTranslate" : false
- },
- "⌘ + ⌥ + L" : {
- "localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "⌘ + ⌥ + L"
- }
- },
- "zh-Hans" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "⌘ + ⌥ + L"
- }
- }
- }
- },
"about.feature.auto.lock" : {
"extractionState" : "manual",
"localizations" : {
@@ -105,719 +86,750 @@
}
}
},
- "about.feedback" : {
+ "about.github" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Report Issue"
+ "value" : "View on GitHub"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "反馈问题"
+ "value" : "在 GitHub 上查看"
}
}
}
},
- "about.help" : {
+ "about.subtitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Help"
+ "value" : "App information and version"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "使用帮助"
+ "value" : "应用信息和版本"
}
}
}
},
- "about.github" : {
+ "about.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "View on GitHub"
+ "value" : "About"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "在 GitHub 上查看"
+ "value" : "关于"
}
}
}
},
- "about.subtitle" : {
+ "about.version.format" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "App information and version"
+ "value" : "Version %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "应用信息和版本"
+ "value" : "版本 %@"
}
}
}
},
- "about.title" : {
+ "action.lock" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "About"
+ "value" : "Lock Keyboard"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "关于"
+ "value" : "锁定键盘"
}
}
}
},
- "about.version.format" : {
+ "action.quit" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Version %@"
+ "value" : "Quit"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "版本 %@"
+ "value" : "退出"
}
}
}
},
- "action.lock" : {
+ "action.unlock" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Lock Keyboard"
+ "value" : "Unlock Keyboard"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "锁定键盘"
+ "value" : "解锁键盘"
}
}
}
},
- "action.quit" : {
+ "app.menu.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Quit"
+ "value" : "Keyboard Locker"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "退出"
+ "value" : "键盘锁"
}
}
}
},
- "action.unlock" : {
+ "app.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Unlock Keyboard"
+ "value" : "Keyboard Locker"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "解锁键盘"
+ "value" : "键盘锁"
}
}
}
},
- "app.title" : {
+ "auto.lock.countdown.format" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Locker"
+ "value" : "Keyboard will auto lock in %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "键盘锁"
+ "value" : "剩余%@会自动锁定键盘"
}
}
}
},
- "Keyboard Locker" : {
- "shouldTranslate" : false
+ "auto.lock.disabled" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Disabled"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已禁用"
+ }
+ }
+ }
},
- "notification.keyboard.locked" : {
+ "auto.lock.ready.to.lock" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Locked"
+ "value" : "Ready to lock"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "键盘已锁定"
+ "value" : "准备锁定"
}
}
}
},
- "notification.keyboard.unlocked" : {
+ "auto.lock.status" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Unlocked"
+ "value" : "Auto-lock: %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "键盘已解锁"
+ "value" : "自动锁定: %@"
}
}
}
},
- "notification.locked.message" : {
+ "duration.hours" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Use ⌘+⌥+L to unlock keyboard"
+ "value" : "%d hour(s)"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "使用 ⌘+⌥+L 解锁键盘"
+ "value" : "%d小时"
}
}
}
},
- "notification.unlocked.message" : {
+ "duration.hours.minutes" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard input restored to normal"
+ "value" : "%d hour(s) %d minute(s)"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "键盘输入已恢复正常"
+ "value" : "%d小时%d分钟"
}
}
}
},
- "open.system.preferences" : {
+ "duration.infinite" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Open System Settings"
+ "value" : "Infinite"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "打开系统设置"
+ "value" : "无限期"
}
}
}
},
- "permission.accessibility.message" : {
+ "duration.minutes" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Please grant Keyboard Locker accessibility permission in System Settings > Privacy & Security > Accessibility to enable keyboard locking functionality."
+ "value" : "%d minute(s)"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "请在系统设置 > 隐私与安全性 > 辅助功能中,允许 Keyboard Locker 控制您的电脑以启用键盘锁定功能。"
+ "value" : "%d分钟"
}
}
}
},
- "permission.accessibility.title" : {
+ "duration.never" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Accessibility Permission Required"
+ "value" : "Never"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "需要辅助功能权限"
+ "value" : "从不"
}
}
}
},
- "permission.description" : {
+ "lock.duration.format" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Locker needs accessibility permission to control your keyboard. This allows the app to temporarily block keyboard input when activated."
+ "value" : "Locked for %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Locker 需要辅助功能权限来控制您的键盘。这允许应用程序在激活时暂时阻止键盘输入。"
+ "value" : "已锁定 %@"
}
}
}
},
- "permission.required" : {
+ "notification.error" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Permission Required"
+ "value" : "Error"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "需要权限"
+ "value" : "错误"
}
}
}
},
- "quick.actions" : {
+ "notification.keyboard.locked" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Quick Actions"
+ "value" : "Keyboard Locked"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "快速操作"
+ "value" : "键盘已锁定"
}
}
}
},
- "auto.detection.enabled" : {
+ "notification.keyboard.unlocked" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Auto-detection enabled"
+ "value" : "Keyboard Unlocked"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "已启用自动检测"
+ "value" : "键盘已解锁"
}
}
}
},
- "refresh.permission" : {
+ "notification.locked.message" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Check Permission Status"
+ "value" : "Use ⌘+⌥+L to unlock keyboard"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "检查权限状态"
+ "value" : "使用 ⌘+⌥+L 解锁键盘"
}
}
}
},
- "settings.auto.lock" : {
+ "notification.unlocked.message" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Auto Lock"
+ "value" : "Keyboard input restored to normal"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "自动锁定"
+ "value" : "键盘输入已恢复正常"
}
}
}
},
- "settings.auto.lock.description" : {
+ "notification.url.command" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Set the time to automatically lock keyboard after inactivity"
+ "value" : "URL Command"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "设置无操作后自动锁定键盘的时间"
+ "value" : "URL 命令"
}
}
}
},
- "settings.auto.lock.time" : {
+ "open.system.preferences" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Auto Lock Duration:"
+ "value" : "Open System Settings"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "自动锁定时间:"
+ "value" : "打开系统设置"
}
}
}
},
- "settings.keyboard" : {
+ "permission.description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard"
+ "value" : "Keyboard Locker needs accessibility permission to control your keyboard. This allows the app to temporarily block keyboard input when activated."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "快捷键"
+ "value" : "Keyboard Locker 需要辅助功能权限来控制您的键盘。这允许应用程序在激活时暂时阻止键盘输入。"
}
}
}
},
- "settings.keyboard.description" : {
+ "permission.required" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Use shortcut to quickly lock/unlock keyboard"
+ "value" : "Permission Required"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "使用快捷键快速锁定或解锁键盘"
+ "value" : "需要权限"
}
}
}
},
- "settings.notifications" : {
+ "quick.actions" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Notifications"
+ "value" : "Quick Actions"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "通知"
+ "value" : "快速操作"
}
}
}
},
- "settings.notifications.description" : {
+ "settings.auto.lock" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Display system notifications when locking and unlocking"
+ "value" : "Auto Lock"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "锁定和解锁时显示系统通知"
+ "value" : "自动锁定"
}
}
}
},
- "settings.reset" : {
+ "settings.auto.lock.description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Reset Settings"
+ "value" : "Set the time to automatically lock keyboard after inactivity"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "重置设置"
+ "value" : "设置无操作后自动锁定键盘的时间"
}
}
}
},
- "settings.show.notifications" : {
+ "settings.keyboard" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Show Lock Notifications"
+ "value" : "Keyboard"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "显示锁定通知"
+ "value" : "快捷键"
}
}
}
},
- "settings.subtitle" : {
+ "settings.keyboard.description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Configure app preferences"
+ "value" : "Use shortcut to quickly lock/unlock keyboard"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "配置应用偏好设置"
+ "value" : "使用快捷键快速锁定或解锁键盘"
}
}
}
},
- "settings.title" : {
+ "settings.notifications" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Settings"
+ "value" : "Notifications"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "设置"
+ "value" : "通知"
}
}
}
},
- "shortcut.hint" : {
+ "settings.notifications.description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Shortcut: ⌘+⌥+L"
+ "value" : "Display system notifications when locking and unlocking"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "快捷键: ⌘+⌥+L"
+ "value" : "锁定和解锁时显示系统通知"
}
}
}
},
- "status.locked" : {
+ "settings.reset" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Locked"
+ "value" : "Reset Settings"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "键盘已锁定"
+ "value" : "重置设置"
}
}
}
},
- "status.unlocked" : {
+ "settings.show.notifications" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Unlocked"
+ "value" : "Show Lock Notifications"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "键盘未锁定"
+ "value" : "显示锁定通知"
}
}
}
},
- "time.15.minutes" : {
+ "settings.subtitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "15 Minutes"
+ "value" : "Configure app preferences"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "15分钟"
+ "value" : "配置应用偏好设置"
}
}
}
},
- "time.30.minutes" : {
+ "settings.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "30 Minutes"
+ "value" : "Settings"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "30分钟"
+ "value" : "设置"
}
}
}
},
- "time.60.minutes" : {
+ "shortcut.hint" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "60 Minutes"
+ "value" : "Shortcut: ⌘+⌥+L"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "60分钟"
+ "value" : "快捷键: ⌘+⌥+L"
}
}
}
},
- "time.never" : {
+ "status.locked" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Never"
+ "value" : "Keyboard Locked"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "从不"
+ "value" : "键盘已锁定"
+ }
+ }
+ }
+ },
+ "status.unlocked" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Keyboard Unlocked"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "键盘未锁定"
}
}
}
},
- "error.recovery.title" : {
+ "time.activity.text" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keyboard Locker Recovery"
+ "value" : "Starts counting when you stop typing or using the mouse"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "键盘锁定器恢复"
+ "value" : "当您停止输入或使用鼠标时开始计时"
}
}
}
},
- "error.recovery.message" : {
+ "time.auto.lock.duration" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Application recovered from an error. Keyboard has been unlocked."
+ "value" : "Auto-lock Duration"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "应用程序已从错误中恢复。键盘已解锁。"
+ "value" : "自动锁定时长"
}
}
}
diff --git a/KeyboardLockerTool/KeyboardLockerTool.entitlements b/KeyboardLockerTool/KeyboardLockerTool.entitlements
new file mode 100644
index 0000000..794eada
--- /dev/null
+++ b/KeyboardLockerTool/KeyboardLockerTool.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.inherit
+
+
+
diff --git a/KeyboardLockerTool/main.swift b/KeyboardLockerTool/main.swift
new file mode 100644
index 0000000..e2bd8a3
--- /dev/null
+++ b/KeyboardLockerTool/main.swift
@@ -0,0 +1,11 @@
+//
+// main.swift
+// KeyboardLockerTool
+//
+// Created by Eden on 2025/7/25.
+//
+
+import Foundation
+
+// TODO: Implement command line interface for KeyboardLocker
+// This tool will provide CLI access to keyboard locking functionality