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