From 5f5d6a85ffaa845b9b404ea98fbf463ef18b6c2b Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 25 Jul 2025 15:45:16 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20Core=20module?= =?UTF-8?q?=20and=20KeyboardLockerTool.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Core/.gitignore | 8 + Core/Package.swift | 14 ++ KeyboardLocker.xcodeproj/project.pbxproj | 160 +++++++++++++++++- .../KeyboardLockerTool.entitlements | 10 ++ KeyboardLockerTool/main.swift | 11 ++ 6 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 Core/.gitignore create mode 100644 Core/Package.swift create mode 100644 KeyboardLockerTool/KeyboardLockerTool.entitlements create mode 100644 KeyboardLockerTool/main.swift diff --git a/.gitignore b/.gitignore index 46b2e73..52bf5c9 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ iOSInjectionProject/ .DS_Store buildServer.json +[Ff]eatures.md 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..2da1a49 --- /dev/null +++ b/Core/Package.swift @@ -0,0 +1,14 @@ +// 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/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index d54ba2d..b543afa 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; + LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 1640; 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,8 +209,23 @@ ); 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; @@ -257,11 +355,12 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; ASSETCATALOG_COMPILER_OPTIMIZATION = time; 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 = ""; + DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -290,11 +389,12 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; ASSETCATALOG_COMPILER_OPTIMIZATION = space; 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 = ""; + DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -315,6 +415,40 @@ }; name = Release; }; + F20370562E336B8E00BDBAEE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = KeyboardLockerTool/KeyboardLockerTool.entitlements; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = V65YCRQZ2M; + 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; + DEVELOPMENT_TEAM = V65YCRQZ2M; + 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 +470,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/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..1ce9c7d --- /dev/null +++ b/KeyboardLockerTool/main.swift @@ -0,0 +1,11 @@ +// +// main.swift +// KeyboardLockerTool +// +// Created by Eden on 2025/7/25. +// + +import Foundation + +print("Hello, World!") + From fbb795374f8772fcf6172b53d49a98108821ae75 Mon Sep 17 00:00:00 2001 From: Eden Date: Sun, 27 Jul 2025 02:00:02 +0800 Subject: [PATCH 02/21] feature: Core library base. --- Core/Package.swift | 8 +- Core/Sources/Core/IPCManager.swift | 262 ++++++++ Core/Sources/Core/KeyboardLockCore.swift | 264 ++++++++ Core/Sources/Core/PermissionHelper.swift | 117 ++++ Core/Sources/Core/SharedModels.swift | 166 +++++ KeyboardLocker/AboutView.swift | 1 + KeyboardLocker/AppConfiguration.swift | 51 ++ KeyboardLocker/ContentView.swift | 63 +- KeyboardLocker/DependencyFactory.swift | 109 ++++ KeyboardLocker/KeyboardLockManager.swift | 594 ++++++++---------- KeyboardLocker/KeyboardLockerApp.swift | 75 +-- .../KeyboardLockerAppDelegate.swift | 54 ++ KeyboardLocker/LocalizationHelper.swift | 6 +- KeyboardLocker/NotificationManager.swift | 38 +- KeyboardLocker/PermissionManager.swift | 38 +- KeyboardLocker/Protocols.swift | 104 +++ KeyboardLocker/SafeEventHandling.swift | 84 +++ KeyboardLocker/SettingsView.swift | 19 +- KeyboardLocker/URLHandler.swift | 37 +- KeyboardLocker/i18n/Localizable.xcstrings | 183 +++--- 20 files changed, 1730 insertions(+), 543 deletions(-) create mode 100644 Core/Sources/Core/IPCManager.swift create mode 100644 Core/Sources/Core/KeyboardLockCore.swift create mode 100644 Core/Sources/Core/PermissionHelper.swift create mode 100644 Core/Sources/Core/SharedModels.swift create mode 100644 KeyboardLocker/AppConfiguration.swift create mode 100644 KeyboardLocker/DependencyFactory.swift create mode 100644 KeyboardLocker/KeyboardLockerAppDelegate.swift create mode 100644 KeyboardLocker/Protocols.swift create mode 100644 KeyboardLocker/SafeEventHandling.swift diff --git a/Core/Package.swift b/Core/Package.swift index 2da1a49..bf3afd0 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -5,10 +5,6 @@ import PackageDescription let package = Package( name: "Core", platforms: [.macOS(.v13)], - products: [ - .library(name: "Core", targets: ["Core"]), - ], - targets: [ - .target(name: "Core"), - ] + products: [.library(name: "Core", targets: ["Core"])], + targets: [.target(name: "Core")] ) diff --git a/Core/Sources/Core/IPCManager.swift b/Core/Sources/Core/IPCManager.swift new file mode 100644 index 0000000..08a0fd5 --- /dev/null +++ b/Core/Sources/Core/IPCManager.swift @@ -0,0 +1,262 @@ +import AppKit +import Foundation + +// MARK: - XPC Service Protocol + +/// Protocol for XPC communication between main app and CLI tool +@objc protocol IPCServiceProtocol { + func executeCommand(_ command: String, withReply reply: @escaping ([String: Any]) -> Void) +} + +// MARK: - IPC Manager + +/// Manager for Inter-Process Communication between main app and CLI tool +public class IPCManager: NSObject { + public static let shared = IPCManager() + + // MARK: - Properties + + private let serviceName = CoreConstants.ipcServiceName + private var listener: NSXPCListener? + private var isServerRunning = false + + // MARK: - Initialization + + override private init() { + super.init() + } + + // MARK: - Server Methods (Main App) + + /// Start IPC server in main app + public func startServer() { + guard !isServerRunning else { + print("IPC Server already running") + return + } + + listener = NSXPCListener(machServiceName: serviceName) + listener?.delegate = self + listener?.resume() + isServerRunning = true + + print("🚀 IPC Server started on service: \(serviceName)") + } + + /// Stop IPC server + public func stopServer() { + listener?.invalidate() + listener = nil + isServerRunning = false + print("🛑 IPC Server stopped") + } + + // MARK: - Client Methods (CLI Tool) + + /// Send command to main app from CLI tool + /// - Parameters: + /// - command: The command to execute + /// - timeout: Timeout in seconds + /// - completion: Completion handler with response + public func sendCommand( + _ command: IPCCommand, + timeout _: TimeInterval = CoreConstants.ipcTimeout, + completion: @escaping (Result) -> Void + ) { + // Check if main app is running first + guard isMainAppRunning() else { + completion(.failure(CoreError.mainAppNotRunning)) + return + } + + let connection = NSXPCConnection(machServiceName: serviceName, options: []) + connection.remoteObjectInterface = NSXPCInterface(with: IPCServiceProtocol.self) + + connection.interruptionHandler = { + completion(.failure(CoreError.ipcConnectionFailed)) + } + + connection.invalidationHandler = { + // Connection will be cleaned up automatically + } + + connection.resume() + + guard let service = connection.remoteObjectProxy as? IPCServiceProtocol else { + connection.invalidate() + completion(.failure(CoreError.ipcConnectionFailed)) + return + } + + service.executeCommand(command.rawValue) { responseDict in + DispatchQueue.main.async { + connection.invalidate() + + let response = self.parseResponse(from: responseDict) + completion(.success(response)) + } + } + } + + /// Simplified async/await version for modern Swift + @available(macOS 10.15, *) + public func sendCommand(_ command: IPCCommand, timeout: TimeInterval = CoreConstants.ipcTimeout) + async throws -> IPCResponse + { + return try await withCheckedThrowingContinuation { continuation in + sendCommand(command, timeout: timeout) { result in + continuation.resume(with: result) + } + } + } + + /// Check if main app is running + public func isMainAppRunning() -> Bool { + let runningApps = NSWorkspace.shared.runningApplications + return runningApps.contains { app in + app.bundleIdentifier == CoreConstants.mainAppBundleID + } + } + + // MARK: - Helper Methods + + /// Parse response dictionary into IPCResponse + private func parseResponse(from dict: [String: Any]) -> IPCResponse { + let success = dict["success"] as? Bool ?? false + let message = dict["message"] as? String ?? "Unknown response" + let data = dict["data"] as? [String: String] + + return IPCResponse(success: success, message: message, data: data) + } + + /// Convert IPCResponse to dictionary for XPC + func responseToDict(_ response: IPCResponse) -> [String: Any] { + var dict: [String: Any] = [ + "success": response.success, + "message": response.message, + "timestamp": response.timestamp.timeIntervalSince1970, + ] + + if let data = response.data { + dict["data"] = data + } + + return dict + } +} + +// MARK: - XPC Listener Delegate + +extension IPCManager: NSXPCListenerDelegate { + public func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) + -> Bool + { + newConnection.exportedInterface = NSXPCInterface(with: IPCServiceProtocol.self) + newConnection.exportedObject = IPCServiceHandler() + newConnection.resume() + return true + } +} + +// MARK: - IPC Service Handler + +/// Handles incoming IPC commands from CLI tool +public class IPCServiceHandler: NSObject, IPCServiceProtocol { + public func executeCommand(_ command: String, withReply reply: @escaping ([String: Any]) -> Void) { + guard let ipcCommand = IPCCommand(rawValue: command) else { + let response = IPCResponse.error("Unknown command: \(command)") + reply(IPCManager.shared.responseToDict(response)) + return + } + + // Execute command on main queue + DispatchQueue.main.async { + let response = self.handleCommand(ipcCommand) + reply(IPCManager.shared.responseToDict(response)) + } + } + + /// Handle the actual command execution + private func handleCommand(_ command: IPCCommand) -> IPCResponse { + let lockCore = KeyboardLockCore.shared + + do { + switch command { + case .lock: + if try lockCore.lockKeyboard() { + return IPCResponse.success("Keyboard locked successfully") + } else { + return IPCResponse.error("Keyboard is already locked") + } + + case .unlock: + if lockCore.unlockKeyboard() { + return IPCResponse.success("Keyboard unlocked successfully") + } else { + return IPCResponse.error("Keyboard is not locked") + } + + case .toggle: + let newStatus = try lockCore.toggleLock() + let statusMessage = newStatus ? "locked" : "unlocked" + return IPCResponse.success("Keyboard \(statusMessage) successfully") + + case .status: + let status = lockCore.lockStatus + return IPCResponse.success( + "Keyboard is currently \(status.isLocked ? "locked" : "unlocked")", + data: status.toDictionary() + ) + + case .quit: + // Schedule app termination after sending response + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NSApplication.shared.terminate(nil) + } + return IPCResponse.success("Quitting application") + } + + } catch let error as CoreError { + return IPCResponse.error(error.localizedDescription) + } catch { + return IPCResponse.error("Unexpected error: \(error.localizedDescription)") + } + } +} + +// MARK: - Extensions + +public extension IPCManager { + /// Convenience method to get status + func getStatus(completion: @escaping (Result) -> Void) { + sendCommand(.status) { result in + switch result { + case let .success(response): + if response.success, let data = response.data { + let isLocked = data["locked"] == "true" + let autoLockEnabled = data["autoLockEnabled"] == "true" + let autoLockInterval = Int(data["autoLockInterval"] ?? "0") ?? 0 + + var lockedAt: Date? + if let lockedAtString = data["lockedAt"] { + let formatter = ISO8601DateFormatter() + lockedAt = formatter.date(from: lockedAtString) + } + + let status = LockStatus( + isLocked: isLocked, + lockedAt: lockedAt, + autoLockEnabled: autoLockEnabled, + autoLockInterval: autoLockInterval + ) + completion(.success(status)) + } else { + completion(.failure(CoreError.invalidCommand)) + } + + case let .failure(error): + completion(.failure(error)) + } + } + } +} diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift new file mode 100644 index 0000000..4ebae4e --- /dev/null +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -0,0 +1,264 @@ +import ApplicationServices +import Carbon +import Foundation + +/// Core keyboard locking functionality that can be shared between main app and CLI +public class KeyboardLockCore { + // MARK: - Singleton + + public static let shared = KeyboardLockCore() + + // MARK: - Private Properties + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var isLocked = false + private var lockedAt: Date? + private var autoLockTimer: Timer? + private var autoLockInterval: TimeInterval = 0 // 0 means disabled + + // MARK: - Public Properties + + /// Current lock status + public var lockStatus: LockStatus { + return LockStatus( + isLocked: isLocked, + lockedAt: lockedAt, + autoLockEnabled: autoLockInterval > 0, + autoLockInterval: Int(autoLockInterval / 60) + ) + } + + /// Whether keyboard is currently locked + public var isKeyboardLocked: Bool { + return isLocked + } + + // MARK: - Initialization + + private init() { + // Private initializer for singleton + } + + deinit { + unlockKeyboard() + stopAutoLockTimer() + } + + // MARK: - Public Methods + + /// Lock the keyboard with comprehensive event blocking + /// - Throws: CoreError if locking fails + /// - Returns: True if successfully locked, false if already locked + @discardableResult + public func lockKeyboard() throws -> Bool { + guard !isLocked else { + throw CoreError.alreadyLocked + } + + // Validate permissions first + try PermissionHelper.validatePermissions() + + // Create event tap to intercept keyboard events + let eventMask = (1 << CGEventType.keyDown.rawValue) | + (1 << CGEventType.keyUp.rawValue) | + (1 << CGEventType.flagsChanged.rawValue) + + eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: CGEventMask(eventMask), + callback: { proxy, type, event, refcon -> Unmanaged? in + return KeyboardLockCore.eventCallback( + proxy: proxy, type: type, event: event, refcon: refcon + ) + }, + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + + guard let eventTap = eventTap else { + throw CoreError.eventTapCreationFailed + } + + // Create run loop source and add to current run loop + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + guard let runLoopSource = runLoopSource else { + CFMachPortInvalidate(eventTap) + self.eventTap = nil + throw CoreError.eventTapCreationFailed + } + + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + + // Enable the event tap + CGEvent.tapEnable(tap: eventTap, enable: true) + + // Update state + isLocked = true + lockedAt = Date() + + print("🔒 Keyboard locked successfully") + return true + } + + /// Unlock the keyboard + /// - Returns: True if successfully unlocked, false if not locked + @discardableResult + public func unlockKeyboard() -> Bool { + guard isLocked else { + return false + } + + // Disable event tap + if let eventTap = eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + } + + // Remove run loop source + if let runLoopSource = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + } + + // Invalidate and clean up + if let eventTap = eventTap { + CFMachPortInvalidate(eventTap) + } + + eventTap = nil + runLoopSource = nil + isLocked = false + lockedAt = nil + + print("🔓 Keyboard unlocked successfully") + return true + } + + /// Toggle keyboard lock status + /// - Throws: CoreError if operation fails + /// - Returns: New lock status (true = locked, false = unlocked) + @discardableResult + public func toggleLock() throws -> Bool { + if isLocked { + unlockKeyboard() + return false + } else { + try lockKeyboard() + return true + } + } + + // MARK: - Auto-Lock Feature + + /// Set auto-lock timer + /// - Parameter interval: Time interval in seconds, 0 to disable + public func setAutoLockInterval(_ interval: TimeInterval) { + autoLockInterval = interval + + if interval > 0 { + startAutoLockTimer() + } else { + stopAutoLockTimer() + } + } + + /// Start auto-lock timer + private func startAutoLockTimer() { + stopAutoLockTimer() // Stop existing timer + + guard autoLockInterval > 0 else { return } + + autoLockTimer = Timer.scheduledTimer(withTimeInterval: autoLockInterval, repeats: false) { + [weak self] _ in + guard let self = self, !self.isLocked else { return } + + do { + try self.lockKeyboard() + print("🔒 Auto-lock activated after \(Int(self.autoLockInterval / 60)) minutes") + } catch { + print("❌ Auto-lock failed: \(error.localizedDescription)") + } + } + } + + /// Stop auto-lock timer + private func stopAutoLockTimer() { + autoLockTimer?.invalidate() + autoLockTimer = nil + } + + /// Reset auto-lock timer (call this on user activity) + public func resetAutoLockTimer() { + guard autoLockInterval > 0, !isLocked else { return } + startAutoLockTimer() + } + + // MARK: - Event Handling + + /// Check if the given event represents the unlock hotkey combination + /// Default: Cmd + Option + L + private func isUnlockHotkey(event: CGEvent) -> Bool { + let flags = event.flags + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + + // Check for Cmd + Option + L (keycode 37 is 'L') + return flags.contains(.maskCommand) && flags.contains(.maskAlternate) + && keyCode == CoreConstants.defaultUnlockKeyCode + } + + /// Event callback function for event tap + private static func eventCallback( + proxy _: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + refcon: UnsafeMutableRawPointer? + ) -> Unmanaged? { + guard let refcon = refcon else { return nil } + let keyboardLock = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + + // Handle tap disabled case + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let eventTap = keyboardLock.eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + return nil + } + + // Only process events when locked + guard keyboardLock.isLocked else { + return Unmanaged.passRetained(event) + } + + // Check for unlock hotkey before blocking + if keyboardLock.isUnlockHotkey(event: event) { + keyboardLock.unlockKeyboard() + return nil // Block this event too + } + + // Block all other keyboard events when locked + return nil + } + + // MARK: - Utility Methods + + /// Get formatted lock duration string + public func getLockDurationString() -> String? { + guard let lockedAt = lockedAt else { return nil } + + let duration = Date().timeIntervalSince(lockedAt) + let minutes = Int(duration / 60) + let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) + + if minutes > 0 { + return "\(minutes)m \(seconds)s" + } else { + return "\(seconds)s" + } + } + + /// Force cleanup (for emergency situations) + public func forceCleanup() { + unlockKeyboard() + stopAutoLockTimer() + } +} diff --git a/Core/Sources/Core/PermissionHelper.swift b/Core/Sources/Core/PermissionHelper.swift new file mode 100644 index 0000000..52f77d4 --- /dev/null +++ b/Core/Sources/Core/PermissionHelper.swift @@ -0,0 +1,117 @@ +import AppKit +import ApplicationServices +import Foundation + +/// 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 { + return 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: - Screen Recording Permission (if needed for enhanced security) + + /// Check if screen recording permission is granted + /// This might be required for some advanced event monitoring + public static func hasScreenRecordingPermission() -> Bool { + if #available(macOS 10.15, *) { + // For now, we'll assume screen recording permission is not strictly required + // In a real implementation, you might use ScreenCaptureKit or other methods + return true + } else { + // Screen recording permission not required on older macOS versions + return true + } + } + + // MARK: - Permission Status Summary + + /// Get a summary of all required permissions + /// - Returns: Dictionary with permission names and their status + public static func getPermissionStatus() -> [String: Bool] { + return [ + "accessibility": hasAccessibilityPermission(), + "screenRecording": hasScreenRecordingPermission(), + ] + } + + /// Check if all required permissions are granted + /// - Returns: True if all required permissions are available + public static func hasAllRequiredPermissions() -> Bool { + return hasAccessibilityPermission() + // Add other required permissions here if needed + // && hasScreenRecordingPermission() + } + + // 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) + } + + /// Open System Preferences to Security & Privacy > Screen Recording + public static func openScreenRecordingSettings() { + if #available(macOS 10.15, *) { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { + return + } + NSWorkspace.shared.open(url) + } + } + + // MARK: - Validation Helpers + + /// Validate that the current process can perform keyboard locking operations + /// - Throws: CoreError if required permissions are not available + public static func validatePermissions() throws { + guard hasAccessibilityPermission() else { + throw CoreError.accessibilityPermissionDenied + } + + // Add additional permission checks here if needed + } + + /// Get user-friendly permission status message + /// - Returns: Localized string describing permission status + public static func getPermissionStatusMessage() -> String { + if hasAllRequiredPermissions() { + return "All required permissions are granted" + } else { + var missingPermissions: [String] = [] + + if !hasAccessibilityPermission() { + missingPermissions.append("Accessibility") + } + + if !hasScreenRecordingPermission() { + missingPermissions.append("Screen Recording") + } + + return "Missing permissions: \(missingPermissions.joined(separator: ", "))" + } + } +} diff --git a/Core/Sources/Core/SharedModels.swift b/Core/Sources/Core/SharedModels.swift new file mode 100644 index 0000000..439a2cd --- /dev/null +++ b/Core/Sources/Core/SharedModels.swift @@ -0,0 +1,166 @@ +import Foundation + +// MARK: - IPC Commands + +/// Commands that can be sent from CLI tool to main app +public enum IPCCommand: String, Codable, CaseIterable { + case lock + case unlock + case toggle + case status + case quit + + public var description: String { + switch self { + case .lock: + return "Lock the keyboard" + case .unlock: + return "Unlock the keyboard" + case .toggle: + return "Toggle keyboard lock status" + case .status: + return "Get current keyboard lock status" + case .quit: + return "Quit the main application" + } + } +} + +// MARK: - IPC Response + +/// Response structure for IPC communication +public struct IPCResponse: Codable { + public let success: Bool + public let message: String + public let data: [String: String]? + public let timestamp: Date + + public init(success: Bool, message: String, data: [String: String]? = nil) { + self.success = success + self.message = message + self.data = data + timestamp = Date() + } + + /// Convenience initializer for success responses + public static func success(_ message: String, data: [String: String]? = nil) -> IPCResponse { + return IPCResponse(success: true, message: message, data: data) + } + + /// Convenience initializer for error responses + public static func error(_ message: String) -> IPCResponse { + return IPCResponse(success: false, message: message, data: nil) + } +} + +// MARK: - Error Types + +/// Errors that can occur in Core operations +public enum CoreError: Error, LocalizedError { + case accessibilityPermissionDenied + case eventTapCreationFailed + case ipcConnectionFailed + case invalidCommand + case mainAppNotRunning + case alreadyLocked + case notLocked + + public var errorDescription: String? { + switch self { + case .accessibilityPermissionDenied: + return "Accessibility permission is required to control keyboard input" + case .eventTapCreationFailed: + return "Failed to create event tap for keyboard monitoring" + case .ipcConnectionFailed: + return "Failed to connect to main application" + case .invalidCommand: + return "Invalid command provided" + case .mainAppNotRunning: + return "Main application is not running" + case .alreadyLocked: + return "Keyboard is already locked" + case .notLocked: + return "Keyboard is not currently locked" + } + } +} + +// MARK: - Constants + +/// Shared constants used across the application +public enum CoreConstants { + /// IPC service name for XPC communication + public static let ipcServiceName = "com.keyboardlocker.ipc" + + /// Main app bundle identifier + public static let mainAppBundleID = "com.keyboardlocker.app" + + /// Default unlock key combination (Cmd + Option + L) + public static let defaultUnlockKeyCode: UInt16 = 37 // 'L' key + + /// Timeout for IPC connections (in seconds) + public static let ipcTimeout: TimeInterval = 5.0 + + /// Auto-lock timer intervals (in minutes) + public enum AutoLockInterval: Int, CaseIterable { + case never = 0 + case fifteen = 15 + case thirty = 30 + case sixty = 60 + + public var description: String { + switch self { + case .never: + return "Never" + case .fifteen: + return "15 minutes" + case .thirty: + return "30 minutes" + case .sixty: + return "1 hour" + } + } + + public var timeInterval: TimeInterval { + return TimeInterval(rawValue * 60) + } + } +} + +// MARK: - Lock Status + +/// Current status of the keyboard lock +public struct LockStatus: Codable { + public let isLocked: Bool + public let lockedAt: Date? + public let autoLockEnabled: Bool + public let autoLockInterval: Int // minutes, 0 for never + + public init( + isLocked: Bool, + lockedAt: Date? = nil, + autoLockEnabled: Bool = false, + autoLockInterval: Int = 0 + ) { + self.isLocked = isLocked + self.lockedAt = lockedAt + self.autoLockEnabled = autoLockEnabled + self.autoLockInterval = autoLockInterval + } + + /// Convert to dictionary for IPC data + public func toDictionary() -> [String: String] { + var dict: [String: String] = [ + "locked": isLocked ? "true" : "false", + "autoLockEnabled": autoLockEnabled ? "true" : "false", + "autoLockInterval": "\(autoLockInterval)", + ] + + if let lockedAt = lockedAt { + let formatter = ISO8601DateFormatter() + dict["lockedAt"] = formatter.string(from: lockedAt) + } + + return dict + } +} diff --git a/KeyboardLocker/AboutView.swift b/KeyboardLocker/AboutView.swift index 0c4f9eb..870cc62 100644 --- a/KeyboardLocker/AboutView.swift +++ b/KeyboardLocker/AboutView.swift @@ -30,6 +30,7 @@ struct AboutView: View { 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) } } diff --git a/KeyboardLocker/AppConfiguration.swift b/KeyboardLocker/AppConfiguration.swift new file mode 100644 index 0000000..d412622 --- /dev/null +++ b/KeyboardLocker/AppConfiguration.swift @@ -0,0 +1,51 @@ +import Foundation +import SwiftUI + +/// Configuration manager for application settings and preferences +class AppConfiguration: ObservableObject { + /// Shared configuration instance + static let shared = AppConfiguration() + + // MARK: - Core Settings + + @AppStorage("showNotifications") var showNotifications: Bool = true { + willSet { objectWillChange.send() } + } + + @AppStorage("autoLockDuration") var autoLockDuration: Int = 0 { // in minutes, 0 = never + willSet { objectWillChange.send() } + } + + private init() {} + + // MARK: - Convenience Properties + + /// Whether auto-lock is enabled (when duration > 0) + var isAutoLockEnabled: Bool { + return autoLockDuration > 0 + } + + /// Auto-lock duration in seconds for internal use + var autoLockDurationInSeconds: TimeInterval { + return TimeInterval(autoLockDuration * 60) + } + + // MARK: - Reset Method + + /// Reset all settings to defaults + func resetToDefaults() { + showNotifications = true + autoLockDuration = 0 + print("� Configuration reset to defaults") + } +} + +// MARK: - Configuration Constants + +extension AppConfiguration { + /// Default values for configuration + enum Defaults { + static let showNotifications = true + static let autoLockDuration = 0 // minutes, 0 = never + } +} diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift index fa6e2bd..efb79ef 100644 --- a/KeyboardLocker/ContentView.swift +++ b/KeyboardLocker/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ContentView: View { @State private var isKeyboardLocked = false + @State private var lockDurationTimer: Timer? @EnvironmentObject var permissionManager: PermissionManager @EnvironmentObject var keyboardManager: KeyboardLockManager @@ -18,9 +19,14 @@ struct ContentView: View { .onAppear { permissionManager.checkAllPermissions() isKeyboardLocked = keyboardManager.isLocked + setupLockDurationTimer() } .onReceive(keyboardManager.$isLocked) { locked in isKeyboardLocked = locked + setupLockDurationTimer() + } + .onDisappear { + lockDurationTimer?.invalidate() } } @@ -51,17 +57,34 @@ struct ContentView: View { // 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() + VStack(alignment: .leading, spacing: 4) { + 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() + } + + // Show lock duration when locked + if isKeyboardLocked, let durationString = keyboardManager.getLockDurationString() { + HStack { + Image(systemName: "clock") + .foregroundColor(.secondary) + .font(.caption) + Text(LocalizationKey.lockDurationFormat.localized(durationString)) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.leading, 16) // Align with status text + } } // Lock/unlock button @@ -70,7 +93,8 @@ struct ContentView: View { Image(systemName: isKeyboardLocked ? "lock.open" : "lock") Text( isKeyboardLocked - ? LocalizationKey.actionUnlock.localized : LocalizationKey.actionLock.localized) + ? LocalizationKey.actionUnlock.localized + : LocalizationKey.actionLock.localized) } .frame(maxWidth: .infinity) .padding(.vertical, 10) @@ -209,6 +233,21 @@ struct ContentView: View { keyboardManager.lockKeyboard() } } + + private func setupLockDurationTimer() { + // Invalidate existing timer + lockDurationTimer?.invalidate() + + // Only start timer when locked + if isKeyboardLocked { + lockDurationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + // Force UI update by triggering objectWillChange + DispatchQueue.main.async { + self.keyboardManager.objectWillChange.send() + } + } + } + } } struct SettingRow: View { diff --git a/KeyboardLocker/DependencyFactory.swift b/KeyboardLocker/DependencyFactory.swift new file mode 100644 index 0000000..9142619 --- /dev/null +++ b/KeyboardLocker/DependencyFactory.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Factory for creating and managing application dependencies +/// This helps reduce coupling and makes testing easier +class DependencyFactory { + /// Shared instance for application-wide dependency management + static let shared = DependencyFactory() + + private init() {} + + // MARK: - Factory Methods + + /// Create a notification manager instance + /// - Returns: NotificationManaging instance + func makeNotificationManager() -> NotificationManaging { + return NotificationManager.shared + } + + /// Create a keyboard lock manager instance + /// - Parameters: + /// - notificationManager: Optional notification manager, uses default if nil + /// - configuration: Optional configuration, uses shared if nil + /// - Returns: KeyboardLockManaging instance + func makeKeyboardLockManager( + notificationManager: NotificationManaging? = nil, + configuration: AppConfiguration? = nil + ) -> KeyboardLockManaging { + let notificationMgr = notificationManager ?? makeNotificationManager() + let config = configuration ?? AppConfiguration.shared + return KeyboardLockManager(notificationManager: notificationMgr, configuration: config) + } + + /// Create a URL command handler instance + /// - Parameter notificationManager: Optional notification manager, uses default if nil + /// - Returns: URLCommandHandler instance + func makeURLCommandHandler( + notificationManager _: NotificationManaging? = nil + ) -> URLCommandHandler { + // Since URLCommandHandler uses a singleton pattern, we return the shared instance + // In a more sophisticated dependency injection system, we might create new instances + return URLCommandHandler.shared + } + + /// Create a permission manager instance + /// - Parameter notificationManager: Optional notification manager, uses default if nil + /// - Returns: PermissionManager instance + func makePermissionManager( + notificationManager: NotificationManager? = nil + ) -> PermissionManager { + let notificationMgr: NotificationManager + if let providedManager = notificationManager { + notificationMgr = providedManager + } else if let defaultManager = makeNotificationManager() as? NotificationManager { + notificationMgr = defaultManager + } else { + // Fallback: create a new instance directly + notificationMgr = NotificationManager.shared + } + return PermissionManager(notificationManager: notificationMgr) + } +} + +// MARK: - Testing Support + +#if DEBUG + extension DependencyFactory { + /// Create a mock notification manager for testing + /// - Returns: Mock NotificationManaging instance + func makeMockNotificationManager() -> NotificationManaging { + return MockNotificationManager() + } + + /// Create a keyboard lock manager with mock dependencies for testing + /// - Returns: KeyboardLockManaging instance with mock dependencies + func makeMockKeyboardLockManager() -> KeyboardLockManaging { + let mockNotificationManager = makeMockNotificationManager() + return KeyboardLockManager(notificationManager: mockNotificationManager) + } + } + + /// Mock notification manager for testing purposes + class MockNotificationManager: NotificationManaging { + var sentNotifications: [(type: NotificationManager.NotificationType, showNotifications: Bool)] = [] + var customNotifications: [(title: String, body: String, isError: Bool)] = [] + + // Implement the required protocol property + var isAuthorized: Bool = true + + func sendNotificationIfEnabled(_ type: NotificationManager.NotificationType, showNotifications: Bool) { + sentNotifications.append((type: type, showNotifications: showNotifications)) + print("Mock: Would send notification \(type) with showNotifications=\(showNotifications)") + } + + func sendNotification(title: String, body: String, isError: Bool) { + customNotifications.append((title: title, body: body, isError: isError)) + print("Mock: Would send custom notification - Title: \(title), Body: \(body), Error: \(isError)") + } + + func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) { + print("Mock: Would request authorization") + // Simulate success for testing + completion(true, nil) + } + + func checkAuthorizationStatus() { + print("Mock: Would check authorization status") + } + } +#endif diff --git a/KeyboardLocker/KeyboardLockManager.swift b/KeyboardLocker/KeyboardLockManager.swift index 4fdeeae..345da56 100644 --- a/KeyboardLocker/KeyboardLockManager.swift +++ b/KeyboardLocker/KeyboardLockManager.swift @@ -3,22 +3,31 @@ import Cocoa import Foundation import SwiftUI -/// Core keyboard locking functionality with comprehensive input blocking -class KeyboardLockManager: ObservableObject { +/// UI-focused keyboard lock manager with full functionality +class KeyboardLockManager: ObservableObject, KeyboardLockManaging { @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() + private var autoLockTimer: Timer? + private var lastActivityTime = Date() + private var lockStartTime: Date? + + // Use protocol to reduce coupling + private let notificationManager: NotificationManaging + private let configuration: AppConfiguration + + // Constants for hotkey + private let unlockKeyCode: UInt16 = 37 // 'L' key + private let unlockModifiers: UInt32 = .init(cmdKey | optionKey) // Cmd+Option + + init( + notificationManager: NotificationManaging = NotificationManager.shared, + configuration: AppConfiguration = AppConfiguration.shared + ) { + self.notificationManager = notificationManager + self.configuration = configuration + setupActivityMonitoring() } deinit { @@ -28,424 +37,333 @@ class KeyboardLockManager: ObservableObject { /// Clean up resources when object is deallocated private func cleanup() { unlockKeyboard() - removeGlobalHotkey() - removeFunctionKeyMonitor() - removeComprehensiveMonitor() + stopAutoLock() } + // MARK: - Public Interface + func lockKeyboard() { guard !isLocked else { return } + do { + try performLockKeyboard() + } catch { + print("Failed to lock keyboard: \(error.localizedDescription)") + } + } + + private func performLockKeyboard() throws { // Verify accessibility permissions are granted guard AXIsProcessTrusted() else { - print("Accessibility permission not granted") - return + throw KeyboardLockerError.accessibilityPermissionDenied } - 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) + // Use safe event type factory to create event mask + let eventMask = EventTypeFactory.createEventMask() + + // Create event tap for intercepting input events + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: { proxy, type, event, refcon in + guard let refcon = refcon else { return Unmanaged.passUnretained(event) } + let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return manager.handleKeyEvent(proxy: proxy, type: type, event: event) + }, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + else { + throw KeyboardLockerError.eventTapCreationFailed + } - guard let runLoopSource = runLoopSource else { - throw NSError( - domain: "KeyboardLocker", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to create run loop source"] - ) - } + eventTap = tap + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - // Attach to current run loop for event processing - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) + guard let runLoopSource = runLoopSource else { + throw KeyboardLockerError.runLoopSourceCreationFailed + } - isLocked = true - print("Keyboard locked successfully") + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) - // Setup additional function key monitoring - setupFunctionKeyMonitor() + isLocked = true + lockStartTime = Date() + print("Keyboard locked successfully") - // Setup comprehensive backup monitoring - setupComprehensiveMonitor() + // Send notification to user + notificationManager.sendNotificationIfEnabled( + .keyboardLocked, + showNotifications: configuration.showNotifications + ) - // Send notification using NotificationManager with settings check - notificationManager.sendNotificationIfEnabled( - .keyboardLocked, - showNotifications: showNotifications - ) - } catch { - print("Failed to lock keyboard: \(error)") - recoverFromError() - } + print("🔒 Keyboard locked successfully") } func unlockKeyboard() { guard isLocked else { return } - // Disable and clean up event tap + // Disable 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() + // Invalidate and clean up + if let eventTap = eventTap { + CFMachPortInvalidate(eventTap) + } + eventTap = nil + runLoopSource = nil isLocked = false - print("Keyboard unlocked successfully") + lockStartTime = nil - // Send notification using NotificationManager with settings check + // Send notification to user notificationManager.sendNotificationIfEnabled( .keyboardUnlocked, - showNotifications: showNotifications + showNotifications: configuration.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 + print("🔓 Keyboard unlocked successfully") } - /// 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 - } + func toggleLock() { + if isLocked { + unlockKeyboard() + } else { + lockKeyboard() + } + } - // Allow normal mouse events - return Unmanaged.passRetained(event) + // MARK: - Auto-Lock Management - 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 - } + func startAutoLock() { + guard !configuration.isAutoLockEnabled else { + print("Auto-lock is already enabled") + return + } - // Allow normal tablet events - return Unmanaged.passRetained(event) + // Update configuration to enable auto-lock + configuration.autoLockDuration = max(configuration.autoLockDuration, 15) // Minimum 15 minutes + scheduleAutoLock() + print("Auto-lock enabled with \(configuration.autoLockDuration) minutes duration") + } - 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.) - } + func stopAutoLock() { + // 禁用自动锁定 + configuration.autoLockDuration = 0 - // 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 - } + // 停止并清理计时器 + autoLockTimer?.invalidate() + autoLockTimer = nil - // Allow events without modifier keys - return Unmanaged.passRetained(event) - } + print("Auto-lock disabled") } - // 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) + func toggleAutoLock() { + if configuration.isAutoLockEnabled { + stopAutoLock() + } else { + startAutoLock() } - 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") + func updateAutoLockSettings() { + if configuration.isAutoLockEnabled { + // 自动锁定已启用,重新调度计时器 + scheduleAutoLock() + print("Auto-lock settings updated: enabled with \(configuration.autoLockDuration) minutes") + } else { + // 自动锁定已禁用,停止计时器 + autoLockTimer?.invalidate() + autoLockTimer = nil + print("Auto-lock settings updated: disabled") } } - /// 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) + private func setupActivityMonitoring() { + // Monitor global events for activity detection + NSEvent.addGlobalMonitorForEvents(matching: [ + .keyDown, .leftMouseDown, .rightMouseDown, .mouseMoved, + ]) { _ in + self.lastActivityTime = Date() + // 每次用户有活动时,重新调度自动锁定计时器 + if self.configuration.isAutoLockEnabled, !self.isLocked { + self.scheduleAutoLock() + } } - 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") + // 初始化时如果启用了自动锁定,开始计时 + if configuration.isAutoLockEnabled { + scheduleAutoLock() } } - /// 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 + private func scheduleAutoLock() { + // 停止现有的计时器 + autoLockTimer?.invalidate() - // 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 - ] + // 如果自动锁定被禁用,不设置新的计时器 + guard configuration.isAutoLockEnabled else { + autoLockTimer = nil + return + } - // 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 + // 设置新的计时器,从现在开始计算指定的时间 + autoLockTimer = Timer.scheduledTimer(withTimeInterval: configuration.autoLockDurationInSeconds, repeats: false) { _ in + DispatchQueue.main.async { + // 双重检查:确保自动锁定仍然启用且键盘未锁定 + if self.configuration.isAutoLockEnabled, !self.isLocked { + print("Auto-lock triggered after \(self.configuration.autoLockDuration) minutes of inactivity") + self.lockKeyboard() + } } + } + + print("Auto-lock timer scheduled for \(configuration.autoLockDuration) minutes from now") + } + + // MARK: - Event Handling - // Block any key event when locked (backup to CGEvent tap) - if event.type == .keyDown { - print("NSEvent: Blocked additional key down: \(keyCode)") + /// Handle intercepted events - comprehensive input blocking logic + private func handleKeyEvent( + proxy _: CGEventTapProxy, + type: CGEventType, + event: CGEvent + ) -> Unmanaged? { + // Handle tap disabled case + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let eventTap = eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) } + return nil + } - case .systemDefined: - // Block ALL system-defined events (media keys, volume, brightness, etc.) - print("NSEvent: Blocked system-defined event: subtype=\(event.subtype)") + // If not locked, allow all events + guard isLocked else { + return Unmanaged.passUnretained(event) + } - case .flagsChanged: - // Block modifier key changes - print("NSEvent: Blocked modifier change: \(modifierFlags)") + let flags = SafeEventHandler.getFlags(from: event) + let keyCode = SafeEventHandler.getKeycode(from: event) ?? 0 - case .scrollWheel: - // Block scroll wheel with modifiers (zoom shortcuts, etc.) - if !modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty { - print("NSEvent: Blocked scroll with modifiers") - } + // Handle different event types comprehensively + switch type { + case .keyDown, .keyUp: + return handleKeyboardEvent(type: type, event: event, keyCode: keyCode) - 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") - } + case .flagsChanged: + return handleModifierEvent(flags: flags) default: - break + return handleOtherEvent(type: type, event: event, flags: flags) } } - /// 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) + private func handleKeyboardEvent(type: CGEventType, event: CGEvent, keyCode: Int64) -> Unmanaged< + CGEvent + >? { + // Allow unlock combination (⌘+⌥+L) to pass through - only on keyDown + if type == .keyDown, isUnlockHotkey(event: event) { + DispatchQueue.main.async { + self.unlockKeyboard() + } + return nil // Consume this event, don't pass to system } - print("Comprehensive backup monitor setup successfully") + + // Block ALL other keyboard events when locked + print("Blocked keyboard event: type=\(type.rawValue), keyCode=\(keyCode)") + return nil } - /// Remove comprehensive monitoring - private func removeComprehensiveMonitor() { - if let monitor = comprehensiveMonitor { - NSEvent.removeMonitor(monitor) - comprehensiveMonitor = nil - print("Comprehensive monitor removed") - } + private func handleModifierEvent(flags: CGEventFlags) -> Unmanaged? { + // Block ALL modifier key changes to prevent any shortcuts + print("Blocked modifier key change: flags=\(flags)") + return nil } - /// Handle any events that might have been missed by primary monitoring - private func handleComprehensiveEvent(event: NSEvent) { - guard isLocked else { return } + private func handleOtherEvent(type: CGEventType, event: CGEvent, flags: CGEventFlags) -> Unmanaged? { + // Check if this is a system-defined event (function keys, etc.) + if EventTypeFactory.isSystemDefinedEvent(type) { + // This is the key for function keys! Block ALL system-defined events + if let subtype = EventTypeFactory.getSystemDefinedSubtype(from: event) { + print("Blocked system-defined event: subtype=\(subtype)") + } else { + print("Blocked system-defined event: unable to get subtype") + } + // Directly block all system-defined events, including volume, brightness, and other function keys + return nil + } - // 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)") + // Block unknown event types with modifier keys + if SafeEventHandler.hasModifiers(event, [.maskCommand, .maskAlternate, .maskControl, .maskShift]) { + print("Blocked unknown event with modifiers: type=\(type.rawValue), flags=\(flags)") + return nil + } - case .systemDefined: - print("BACKUP: Detected system event: subtype \(event.subtype)") + // Allow events without modifier keys + return Unmanaged.passUnretained(event) + } - case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, - .otherMouseUp: - if !event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty { - print("BACKUP: Detected mouse shortcut attempt") - } + private func isUnlockHotkey(event: CGEvent) -> Bool { + let flags = SafeEventHandler.getFlags(from: event) + let keyCode = SafeEventHandler.getKeycode(from: event) ?? 0 - case .scrollWheel: - if !event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty { - print("BACKUP: Detected scroll shortcut attempt") - } + // Check for configured unlock hotkey (default: Cmd + Option + L) + let expectedModifiers = unlockModifiers + let expectedKeyCode = unlockKeyCode - case .swipe, .magnify, .rotate, .smartMagnify: - print("BACKUP: Detected gesture that might trigger shortcuts") + var hasRequiredModifiers = true - default: - if !event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty { - print("BACKUP: Detected event with modifiers: \(event.type)") - } + if expectedModifiers & UInt32(cmdKey) != 0 { + hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskCommand) + } + if expectedModifiers & UInt32(optionKey) != 0 { + hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskAlternate) + } + if expectedModifiers & UInt32(controlKey) != 0 { + hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskControl) + } + if expectedModifiers & UInt32(shiftKey) != 0 { + hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskShift) } - } - /// Handle global hotkey events for lock/unlock - private func handleGlobalHotkey(event: NSEvent) { - // Check for ⌘+⌥+L combination - guard isUnlockCombination(event) else { return } + return hasRequiredModifiers && UInt16(keyCode) == expectedKeyCode + } - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } + // MARK: - Hotkey Management - if self.isLocked { - self.unlockKeyboard() - } else { - self.lockKeyboard() - } - } + func updateUnlockHotkey(keyCode _: UInt16, modifiers _: UInt32) { + // Hotkeys are now fixed as constants since they are standard + print("Unlock hotkey is fixed: Cmd+Option+L (keyCode=37, modifiers=\(unlockModifiers))") } - // MARK: - Error Recovery - - /// Attempts to recover from errors by ensuring keyboard is unlocked - private func recoverFromError() { - print("Attempting error recovery...") + // MARK: - Utility Methods - // Force cleanup of any existing event taps - if let eventTap = eventTap { - CGEvent.tapEnable(tap: eventTap, enable: false) - CFMachPortInvalidate(eventTap) - self.eventTap = nil + func getLockDurationString() -> String? { + guard let lockStartTime = lockStartTime, isLocked else { + return nil } - if let runLoopSource = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - self.runLoopSource = nil - } + let duration = Date().timeIntervalSince(lockStartTime) + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 - // Reset state - isLocked = false - print("Error recovery completed - keyboard unlocked") + if minutes > 0 { + return String(format: "%dm %ds", minutes, seconds) + } else { + return String(format: "%ds", seconds) + } + } - // Show recovery notification using NotificationManager with settings check - notificationManager.sendNotificationIfEnabled(.general( - title: LocalizationKey.errorRecoveryTitle.localized, - body: LocalizationKey.errorRecoveryMessage.localized - ), showNotifications: showNotifications) + func forceCleanup() { + unlockKeyboard() + stopAutoLock() } } diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift index 95d35db..2a89825 100644 --- a/KeyboardLocker/KeyboardLockerApp.swift +++ b/KeyboardLocker/KeyboardLockerApp.swift @@ -1,15 +1,31 @@ +import Core import SwiftUI -/// Main app entry point using modern SwiftUI App protocol +/// Main app entry point using modern SwiftUI App protocol with AppDelegate @main struct KeyboardLockerApp: App { - @StateObject private var keyboardLockManager = KeyboardLockManager() - @StateObject private var permissionManager = PermissionManager() - @StateObject private var urlHandler = URLCommandHandler() + // Use dependency factory to create managers with proper dependency injection + @StateObject private var keyboardLockManager: KeyboardLockManager + @StateObject private var permissionManager = DependencyFactory.shared.makePermissionManager() + + // Use AppDelegate for URL handling + @NSApplicationDelegateAdaptor(KeyboardLockerAppDelegate.self) var appDelegate init() { + // Create keyboard lock manager safely without force casting + let manager = DependencyFactory.shared.makeKeyboardLockManager() + if let concreteManager = manager as? KeyboardLockManager { + _keyboardLockManager = StateObject(wrappedValue: concreteManager) + } else { + // Fallback: create a new instance directly + _keyboardLockManager = StateObject(wrappedValue: KeyboardLockManager()) + } + // Setup global exception handling for stability setupExceptionHandling() + + // Initialize IPC server for external communication + IPCManager.shared.startServer() } var body: some Scene { @@ -18,35 +34,15 @@ struct KeyboardLockerApp: App { 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) + URLCommandHandler.shared.setKeyboardLockManager(keyboardLockManager) + // Inject dependencies into AppDelegate + appDelegate.keyboardLockManager = keyboardLockManager } } .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)") - } + .handlesExternalEvents(matching: ["keyboardlocker"]) } // MARK: - Exception Handling @@ -70,27 +66,4 @@ struct KeyboardLockerApp: App { } } } - - 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/KeyboardLockerAppDelegate.swift b/KeyboardLocker/KeyboardLockerAppDelegate.swift new file mode 100644 index 0000000..1b2e19b --- /dev/null +++ b/KeyboardLocker/KeyboardLockerAppDelegate.swift @@ -0,0 +1,54 @@ +import AppKit +import Core +import SwiftUI + +/// Custom AppDelegate for handling URL schemes and application lifecycle +class KeyboardLockerAppDelegate: NSObject, NSApplicationDelegate { + var urlHandler: URLCommandHandler = .shared + var keyboardLockManager: KeyboardLockManaging? + + func applicationDidFinishLaunching(_: Notification) { + print("Application did finish launching") + } + + func applicationWillTerminate(_: Notification) { + print("Application will terminate - cleaning up") + // Stop IPC server + IPCManager.shared.stopServer() + // Ensure keyboard is unlocked before termination + keyboardLockManager?.unlockKeyboard() + } + + func applicationWillResignActive(_: Notification) { + print("Application will resign active - ensuring keyboard is unlocked") + keyboardLockManager?.unlockKeyboard() + } + + // 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]) { + for url in urls { + handleIncomingURL(url) + } + } + + /// Process individual 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)") + } + } +} diff --git a/KeyboardLocker/LocalizationHelper.swift b/KeyboardLocker/LocalizationHelper.swift index a4aecf6..7b299bc 100644 --- a/KeyboardLocker/LocalizationHelper.swift +++ b/KeyboardLocker/LocalizationHelper.swift @@ -108,6 +108,11 @@ 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" @@ -115,7 +120,6 @@ enum LocalizationKey { 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 diff --git a/KeyboardLocker/NotificationManager.swift b/KeyboardLocker/NotificationManager.swift index 5af7d05..597a2b2 100644 --- a/KeyboardLocker/NotificationManager.swift +++ b/KeyboardLocker/NotificationManager.swift @@ -2,7 +2,7 @@ import Foundation import UserNotifications /// Centralized notification management for the app -class NotificationManager { +class NotificationManager: ObservableObject, NotificationManaging { // MARK: - Singleton static let shared = NotificationManager() @@ -44,9 +44,9 @@ class NotificationManager { case .keyboardUnlocked: return LocalizationKey.notificationKeyboardUnlocked.localized case .urlCommandSuccess: - return "URL Command".localized // Success notification title + return LocalizationKey.notificationUrlCommand.localized case .urlCommandError: - return "Error".localized // Error notification title + return LocalizationKey.notificationError.localized case let .general(title, _): return title } @@ -90,6 +90,29 @@ class NotificationManager { } } + // MARK: - NotificationManaging Protocol Conformance + + /// 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() { @@ -102,7 +125,8 @@ 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) @@ -286,11 +310,11 @@ enum NotificationError: Error, LocalizedError { var errorDescription: String? { switch self { case .notAuthorized: - return "Notifications not authorized".localized + return "Notifications not authorized" case .invalidContent: - return "Invalid notification content".localized + return "Invalid notification content" case let .systemError(error): - return "System error".localized + ": \(error.localizedDescription)" + return "System error: \(error.localizedDescription)" } } } diff --git a/KeyboardLocker/PermissionManager.swift b/KeyboardLocker/PermissionManager.swift index 4c984c6..8b058cb 100644 --- a/KeyboardLocker/PermissionManager.swift +++ b/KeyboardLocker/PermissionManager.swift @@ -1,5 +1,5 @@ import AppKit -import Carbon +import Core import Foundation /// Permission management for accessibility and notification permissions @@ -15,11 +15,12 @@ class PermissionManager: ObservableObject { // MARK: - Private Properties - private let notificationManager = NotificationManager.shared + private let notificationManager: NotificationManaging // MARK: - Initialization - init() { + init(notificationManager: NotificationManaging = NotificationManager.shared) { + self.notificationManager = notificationManager checkAllPermissions() setupApplicationFocusMonitoring() } @@ -38,26 +39,22 @@ class PermissionManager: ObservableObject { /// 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 func requestNotificationPermission() { - notificationManager.requestAuthorization { [weak self] _, error in + notificationManager.requestAuthorization { [weak self] (_: Bool, error: Error?) in // The NotificationManager handles state updates if let error = error { print("Failed to request notification permission: \(error)") @@ -99,7 +96,7 @@ class PermissionManager: ObservableObject { } private func checkAccessibilityPermission() { - let currentPermission = AXIsProcessTrusted() + let currentPermission = PermissionHelper.hasAccessibilityPermission() // Only update and log if the permission status has changed if currentPermission != hasAccessibilityPermission { @@ -121,12 +118,7 @@ class PermissionManager: ObservableObject { } 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/Protocols.swift b/KeyboardLocker/Protocols.swift new file mode 100644 index 0000000..9ad7d19 --- /dev/null +++ b/KeyboardLocker/Protocols.swift @@ -0,0 +1,104 @@ +import Foundation + +// MARK: - Notification Manager Protocol + +/// Protocol for notification management to reduce coupling +protocol NotificationManaging { + var isAuthorized: Bool { get } + + /// Send a notification if notifications are enabled + /// - Parameters: + /// - type: The type of notification to send + /// - showNotifications: Whether notifications are enabled + func sendNotificationIfEnabled(_ type: NotificationManager.NotificationType, showNotifications: Bool) + + /// 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) + + /// Request authorization for notifications + /// - Parameter completion: Completion handler with success status and optional error + func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) + + /// Check current authorization status + func checkAuthorizationStatus() +} + +// MARK: - Keyboard Lock Managing Protocol + +/// Protocol for keyboard lock management to reduce coupling +protocol KeyboardLockManaging: AnyObject { + var isLocked: Bool { get } + + func lockKeyboard() + func unlockKeyboard() + func toggleLock() + func getLockDurationString() -> String? + func forceCleanup() + + // Auto-lock management + func startAutoLock() + func stopAutoLock() + func toggleAutoLock() + func updateAutoLockSettings() +} + +/// Enhanced protocol with result-based operations for better error handling +protocol KeyboardLockManagingAdvanced: KeyboardLockManaging { + func lockKeyboard() -> KeyboardLockResult + func unlockKeyboard() -> KeyboardLockResult +} + +// MARK: - Operation Result + +/// Result type for keyboard operations +enum KeyboardLockResult { + case success + case failure(KeyboardLockerError) + + var isSuccess: Bool { + switch self { + case .success: + return true + case .failure: + return false + } + } + + var error: KeyboardLockerError? { + switch self { + case .success: + return nil + case let .failure(error): + return error + } + } +} + +// MARK: - Error Types + +enum KeyboardLockerError: LocalizedError { + case accessibilityPermissionDenied + case eventTapCreationFailed + case runLoopSourceCreationFailed + case invalidEventType + case managerNotAvailable + + var errorDescription: String? { + switch self { + case .accessibilityPermissionDenied: + return "Accessibility permission not granted" + case .eventTapCreationFailed: + return "Failed to create event tap" + case .runLoopSourceCreationFailed: + return "Failed to create run loop source" + case .invalidEventType: + return "Invalid event type encountered" + case .managerNotAvailable: + return "Keyboard lock manager not available" + } + } +} diff --git a/KeyboardLocker/SafeEventHandling.swift b/KeyboardLocker/SafeEventHandling.swift new file mode 100644 index 0000000..1d27b79 --- /dev/null +++ b/KeyboardLocker/SafeEventHandling.swift @@ -0,0 +1,84 @@ +import CoreGraphics +import Foundation + +/// Safe factory for creating CGEventType instances and handling system events +enum EventTypeFactory { + /// System-defined event type (NX_SYSDEFINED) + static let systemDefinedEventType: CGEventType? = CGEventType(rawValue: 14) + + /// System-defined event subtype field + static let systemDefinedSubtypeField: CGEventField? = CGEventField(rawValue: 2) + + /// Get all supported event types for keyboard monitoring + /// - Returns: Array of valid CGEventType instances + static func getSupportedEventTypes() -> [CGEventType] { + var eventTypes: [CGEventType] = [ + .keyDown, + .keyUp, + .flagsChanged, + ] + + // Safely add system-defined events if available + if let systemDefined = systemDefinedEventType { + eventTypes.append(systemDefined) + } + + return eventTypes + } + + /// Create event mask from supported event types + /// - Returns: CGEventMask for all supported events + static func createEventMask() -> CGEventMask { + let eventTypes = getSupportedEventTypes() + var eventMask: CGEventMask = 0 + + for eventType in eventTypes { + eventMask |= CGEventMask(1 << eventType.rawValue) + } + + return eventMask + } + + /// Check if an event type is a system-defined event + /// - Parameter eventType: The event type to check + /// - Returns: True if this is a system-defined event + static func isSystemDefinedEvent(_ eventType: CGEventType) -> Bool { + guard let systemDefined = systemDefinedEventType else { return false } + return eventType.rawValue == systemDefined.rawValue + } + + /// Get system-defined event subtype safely + /// - Parameter event: The CGEvent to extract subtype from + /// - Returns: Subtype value if available, nil otherwise + static func getSystemDefinedSubtype(from event: CGEvent) -> Int64? { + guard let subtypeField = systemDefinedSubtypeField else { return nil } + return event.getIntegerValueField(subtypeField) + } +} + +/// Safe wrapper for CGEvent operations +enum SafeEventHandler { + /// Safely get keyboard event keycode + /// - Parameter event: The CGEvent to extract keycode from + /// - Returns: Keycode value if available, nil otherwise + static func getKeycode(from event: CGEvent) -> Int64? { + return event.getIntegerValueField(.keyboardEventKeycode) + } + + /// Safely get event flags + /// - Parameter event: The CGEvent to extract flags from + /// - Returns: CGEventFlags for the event + static func getFlags(from event: CGEvent) -> CGEventFlags { + return event.flags + } + + /// Check if event has specific modifier flags + /// - Parameters: + /// - event: The CGEvent to check + /// - modifiers: Array of modifier flags to check for + /// - Returns: True if event has any of the specified modifiers + static func hasModifiers(_ event: CGEvent, _ modifiers: [CGEventFlags]) -> Bool { + let flags = getFlags(from: event) + return !flags.intersection(CGEventFlags(modifiers)).isEmpty + } +} diff --git a/KeyboardLocker/SettingsView.swift b/KeyboardLocker/SettingsView.swift index 896e621..cf4a047 100644 --- a/KeyboardLocker/SettingsView.swift +++ b/KeyboardLocker/SettingsView.swift @@ -1,8 +1,7 @@ import SwiftUI struct SettingsView: View { - @AppStorage("autoLockDuration") private var autoLockDuration = 30 - @AppStorage("showNotifications") private var showNotifications = true + @StateObject private var appConfig = AppConfiguration.shared @EnvironmentObject var keyboardManager: KeyboardLockManager var body: some View { @@ -17,7 +16,7 @@ struct SettingsView: View { HStack { Text(LocalizationKey.settingsAutoLockTime.localized) Spacer() - Picker("", selection: $autoLockDuration) { + Picker("", selection: $appConfig.autoLockDuration) { Text(LocalizationKey.time15Minutes.localized).tag(15) Text(LocalizationKey.time30Minutes.localized).tag(30) Text(LocalizationKey.time60Minutes.localized).tag(60) @@ -25,6 +24,9 @@ struct SettingsView: View { } .pickerStyle(MenuPickerStyle()) .frame(width: 100) + .onChange(of: appConfig.autoLockDuration) { _ in + keyboardManager.updateAutoLockSettings() + } } Text(LocalizationKey.settingsAutoLockDescription.localized) @@ -43,7 +45,7 @@ struct SettingsView: View { .foregroundColor(.primary) VStack(alignment: .leading, spacing: 12) { - Toggle(LocalizationKey.settingsShowNotifications.localized, isOn: $showNotifications) + Toggle(LocalizationKey.settingsShowNotifications.localized, isOn: $appConfig.showNotifications) Text(LocalizationKey.settingsNotificationsDescription.localized) .font(.caption) @@ -66,7 +68,7 @@ struct SettingsView: View { LocalizationKey.actionLock.localized + "/" + LocalizationKey.actionUnlock.localized + ":") Spacer() - Text("⌘ + ⌥ + L") + Text("⌘ + ⌥ + L".localized) .font(.system(.body, design: .monospaced)) .padding(.horizontal, 8) .padding(.vertical, 2) @@ -89,7 +91,7 @@ struct SettingsView: View { HStack { Spacer() Button(LocalizationKey.settingsReset.localized) { - resetSettings() + appConfig.resetToDefaults() } .buttonStyle(PlainButtonStyle()) .foregroundColor(.red) @@ -99,11 +101,6 @@ struct SettingsView: View { .navigationTitle(LocalizationKey.settingsTitle.localized) .frame(width: 300) } - - private func resetSettings() { - autoLockDuration = 30 - showNotifications = true - } } #Preview { diff --git a/KeyboardLocker/URLHandler.swift b/KeyboardLocker/URLHandler.swift index d3e5a09..a9c606c 100644 --- a/KeyboardLocker/URLHandler.swift +++ b/KeyboardLocker/URLHandler.swift @@ -2,7 +2,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 @@ -48,18 +48,18 @@ class URLCommandHandler: ObservableObject { } } - private weak var keyboardLockManager: KeyboardLockManager? - private let notificationManager = NotificationManager.shared + static let shared = URLCommandHandler() - /// Initialize URL handler with optional keyboard lock manager reference - /// - Parameter keyboardLockManager: The keyboard lock manager instance (can be set later) - init(keyboardLockManager: KeyboardLockManager? = nil) { - self.keyboardLockManager = keyboardLockManager + private weak var keyboardLockManager: KeyboardLockManaging? + private let notificationManager: NotificationManaging + + private init(notificationManager: NotificationManaging = NotificationManager.shared) { + self.notificationManager = notificationManager } /// Set the keyboard lock manager reference /// - Parameter manager: The keyboard lock manager instance - func setKeyboardLockManager(_ manager: KeyboardLockManager) { + func setKeyboardLockManager(_ manager: KeyboardLockManaging) { keyboardLockManager = manager } @@ -120,7 +120,7 @@ class URLCommandHandler: ObservableObject { } /// Execute lock command - private func executeLockCommand(_ manager: KeyboardLockManager) -> CommandResponse { + private func executeLockCommand(_ manager: KeyboardLockManaging) -> CommandResponse { if manager.isLocked { let message = LocalizationKey.statusLocked.localized print("ℹ️ Keyboard already locked") @@ -142,7 +142,7 @@ class URLCommandHandler: ObservableObject { } /// Execute unlock command - private func executeUnlockCommand(_ manager: KeyboardLockManager) -> CommandResponse { + private func executeUnlockCommand(_ manager: KeyboardLockManaging) -> CommandResponse { if !manager.isLocked { let message = LocalizationKey.statusUnlocked.localized print("ℹ️ Keyboard already unlocked") @@ -164,7 +164,7 @@ class URLCommandHandler: ObservableObject { } /// Execute toggle command - private func executeToggleCommand(_ manager: KeyboardLockManager) -> CommandResponse { + private func executeToggleCommand(_ manager: KeyboardLockManaging) -> CommandResponse { let wasLocked = manager.isLocked if wasLocked { @@ -175,10 +175,11 @@ class URLCommandHandler: ObservableObject { } /// Execute status command - private func executeStatusCommand(_ manager: KeyboardLockManager) -> CommandResponse { + private func executeStatusCommand(_ manager: KeyboardLockManaging) -> 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) @@ -192,7 +193,7 @@ class URLCommandHandler: ObservableObject { // Send notification to user about the URL command result self.sendNotification( - title: "KeyboardLocker", + title: LocalizationKey.appTitle.localized, body: response.message, isError: !response.isSuccess ) @@ -204,12 +205,8 @@ class URLCommandHandler: ObservableObject { /// - 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) - } + private func sendNotification(title: String, body: String, isError: Bool = false) { + notificationManager.sendNotification(title: title, body: body, isError: isError) } } diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings index d5ed196..6520bc0 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -5,6 +5,7 @@ "shouldTranslate" : false }, "⌘ + ⌥ + L" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -122,36 +123,36 @@ } } }, - "about.help" : { + "about.github" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Help" + "value" : "View on GitHub" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用帮助" + "value" : "在 GitHub 上查看" } } } }, - "about.github" : { + "about.help" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "View on GitHub" + "value" : "Help" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在 GitHub 上查看" + "value" : "使用帮助" } } } @@ -275,9 +276,94 @@ } } }, + "auto.detection.enabled" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto-detection enabled" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已启用自动检测" + } + } + } + }, + "error.recovery.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Application recovered from an error. Keyboard has been unlocked." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用程序已从错误中恢复。键盘已解锁。" + } + } + } + }, + "error.recovery.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keyboard Locker Recovery" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "键盘锁定器恢复" + } + } + } + }, "Keyboard Locker" : { "shouldTranslate" : false }, + "lock.duration.format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Locked for %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已锁定 %@" + } + } + } + }, + "notification.error" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "错误" + } + } + } + }, "notification.keyboard.locked" : { "extractionState" : "manual", "localizations" : { @@ -346,6 +432,23 @@ } } }, + "notification.url.command" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL Command" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL 命令" + } + } + } + }, "open.system.preferences" : { "extractionState" : "manual", "localizations" : { @@ -448,40 +551,6 @@ } } }, - "auto.detection.enabled" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto-detection enabled" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已启用自动检测" - } - } - } - }, - "refresh.permission" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Check Permission Status" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "检查权限状态" - } - } - } - }, "settings.auto.lock" : { "extractionState" : "manual", "localizations" : { @@ -788,40 +857,6 @@ } } }, - "error.recovery.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keyboard Locker Recovery" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "键盘锁定器恢复" - } - } - } - }, - "error.recovery.message" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Application recovered from an error. Keyboard has been unlocked." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "应用程序已从错误中恢复。键盘已解锁。" - } - } - } - }, "url.error.invalid.scheme" : { "extractionState" : "manual", "localizations" : { From 824d58e564e7051be10397ce438bdab7a55222f7 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 28 Jul 2025 14:28:49 +0800 Subject: [PATCH 03/21] Refactor AboutView and remove AppConfiguration - Split AboutView into smaller private views for better readability and maintainability. - Removed AppConfiguration class as its functionality is now handled by Core API. - Introduced AppDelegate for handling application lifecycle and URL schemes. - Updated ContentView to display auto-lock status when enabled. - Modified DependencyFactory to remove configuration dependency from KeyboardLockManager. - Enhanced KeyboardLockManager to utilize Core API for keyboard locking and unlocking. - Updated SettingsView to reflect changes in configuration management. - Improved notification handling in NotificationManager. - Cleaned up unused code and improved overall structure for better clarity and performance. --- .swift-version | 1 + .swiftformat | 96 +++++ Core/Sources/Core/Core.swift | 7 + Core/Sources/Core/CoreConfiguration.swift | 224 ++++++++++ Core/Sources/Core/IPCManager.swift | 29 +- Core/Sources/Core/KeyboardLockCore.swift | 405 ++++++++++-------- Core/Sources/Core/KeyboardLockerAPI.swift | 183 ++++++++ Core/Sources/Core/PermissionHelper.swift | 10 +- Core/Sources/Core/SharedModels.swift | 76 +++- Core/Sources/Core/UserActivityMonitor.swift | 219 ++++++++++ KeyboardLocker/AboutView.swift | 99 +++-- KeyboardLocker/AppConfiguration.swift | 51 --- ...kerAppDelegate.swift => AppDelegate.swift} | 2 +- KeyboardLocker/ContentView.swift | 36 +- KeyboardLocker/DependencyFactory.swift | 34 +- KeyboardLocker/KeyboardLockManager.swift | 394 +++++------------ KeyboardLocker/KeyboardLockerApp.swift | 2 +- KeyboardLocker/LocalizationHelper.swift | 8 +- KeyboardLocker/NotificationManager.swift | 52 +-- KeyboardLocker/PermissionManager.swift | 5 +- KeyboardLocker/Protocols.swift | 18 +- KeyboardLocker/SafeEventHandling.swift | 4 +- KeyboardLocker/SettingsView.swift | 46 +- KeyboardLocker/URLHandler.swift | 22 +- KeyboardLockerTool/main.swift | 1 - 25 files changed, 1324 insertions(+), 700 deletions(-) create mode 100644 .swift-version create mode 100644 .swiftformat create mode 100644 Core/Sources/Core/Core.swift create mode 100644 Core/Sources/Core/CoreConfiguration.swift create mode 100644 Core/Sources/Core/KeyboardLockerAPI.swift create mode 100644 Core/Sources/Core/UserActivityMonitor.swift delete mode 100644 KeyboardLocker/AppConfiguration.swift rename KeyboardLocker/{KeyboardLockerAppDelegate.swift => AppDelegate.swift} (95%) 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/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..5fabc73 --- /dev/null +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -0,0 +1,224 @@ +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 launchAtLogin + case enableSounds + case hotkey + case isFirstLaunch + case appVersion + } + + // MARK: - Published Properties + + /// Auto-lock duration in minutes (0 = disabled) + @Published public var autoLockDuration: Int = 0 { + didSet { + UserDefaults.standard.set(autoLockDuration, forKey: ConfigKeys.autoLockDuration.rawValue) + print("📝 Auto-lock duration updated: \(autoLockDuration) minutes") + } + } + + /// Whether to show system notifications + @Published public var showNotifications: Bool = true { + didSet { + UserDefaults.standard.set(showNotifications, forKey: ConfigKeys.showNotifications.rawValue) + print("📝 Notifications setting updated: \(showNotifications)") + } + } + + /// Whether to launch app at login + @Published public var launchAtLogin: Bool = false { + didSet { + UserDefaults.standard.set(launchAtLogin, forKey: ConfigKeys.launchAtLogin.rawValue) + print("📝 Launch at login updated: \(launchAtLogin)") + } + } + + /// Whether to enable sound effects + @Published public var enableSounds: Bool = true { + didSet { + UserDefaults.standard.set(enableSounds, forKey: ConfigKeys.enableSounds.rawValue) + print("📝 Sound effects updated: \(enableSounds)") + } + } + + /// Hotkey configuration (using Carbon key codes) + @Published public var hotkey: HotkeyConfiguration = .defaultHotkey() { + didSet { + if let data = try? JSONEncoder().encode(hotkey) { + UserDefaults.standard.set(data, forKey: ConfigKeys.hotkey.rawValue) + print("📝 Hotkey configuration updated: \(hotkey)") + } + } + } + + // MARK: - Computed Properties + + /// Check if auto-lock is enabled + public var isAutoLockEnabled: Bool { + autoLockDuration > 0 + } + + /// Auto-lock duration in seconds + public var autoLockDurationInSeconds: TimeInterval { + TimeInterval(autoLockDuration * 60) // Convert minutes to seconds + } + + // MARK: - Non-Published Properties + + /// Whether this is the first app launch + public var isFirstLaunch: Bool { + get { + UserDefaults.standard.bool(forKey: ConfigKeys.isFirstLaunch.rawValue) + } + set { + UserDefaults.standard.set(newValue, forKey: ConfigKeys.isFirstLaunch.rawValue) + } + } + + /// Current app version + public var appVersion: String { + get { + UserDefaults.standard.string(forKey: ConfigKeys.appVersion.rawValue) ?? "1.0.0" + } + set { + UserDefaults.standard.set(newValue, forKey: ConfigKeys.appVersion.rawValue) + } + } + + // MARK: - Initialization + + private init() { + loadConfiguration() + print("🚀 CoreConfiguration initialized") + } + + // MARK: - Configuration Management + + /// Load configuration from UserDefaults + public func loadConfiguration() { + autoLockDuration = UserDefaults.standard.integer(forKey: ConfigKeys.autoLockDuration.rawValue) + showNotifications = + UserDefaults.standard.object(forKey: ConfigKeys.showNotifications.rawValue) as? Bool ?? true + launchAtLogin = UserDefaults.standard.bool(forKey: ConfigKeys.launchAtLogin.rawValue) + enableSounds = + UserDefaults.standard.object(forKey: ConfigKeys.enableSounds.rawValue) as? Bool ?? true + + // Load hotkey configuration + if let data = UserDefaults.standard.data(forKey: ConfigKeys.hotkey.rawValue), + let decodedHotkey = try? JSONDecoder().decode(HotkeyConfiguration.self, from: data) + { + hotkey = decodedHotkey + } else { + hotkey = HotkeyConfiguration.defaultHotkey() + } + + // Set first launch flag if not set + if UserDefaults.standard.object(forKey: ConfigKeys.isFirstLaunch.rawValue) == nil { + isFirstLaunch = true + } + + print("📁 Configuration loaded from UserDefaults") + } + + /// Reset configuration to default values + public func resetToDefaults() { + autoLockDuration = 0 + showNotifications = true + launchAtLogin = false + enableSounds = true + hotkey = HotkeyConfiguration.defaultHotkey() + isFirstLaunch = false + + print("🔄 Configuration reset to defaults") + } + + /// Export configuration as dictionary + public func exportConfiguration() -> [String: Any] { + [ + ConfigKeys.autoLockDuration.rawValue: autoLockDuration, + ConfigKeys.showNotifications.rawValue: showNotifications, + ConfigKeys.launchAtLogin.rawValue: launchAtLogin, + ConfigKeys.enableSounds.rawValue: enableSounds, + ConfigKeys.hotkey.rawValue: try! JSONEncoder().encode(hotkey), + ConfigKeys.isFirstLaunch.rawValue: isFirstLaunch, + ConfigKeys.appVersion.rawValue: appVersion, + ] + } + + /// Import configuration from dictionary + public func importConfiguration(_ config: [String: Any]) { + if let duration = config[ConfigKeys.autoLockDuration.rawValue] as? Int { + autoLockDuration = duration + } + + if let notifications = config[ConfigKeys.showNotifications.rawValue] as? Bool { + showNotifications = notifications + } + + if let login = config[ConfigKeys.launchAtLogin.rawValue] as? Bool { + launchAtLogin = login + } + + if let sounds = config[ConfigKeys.enableSounds.rawValue] as? Bool { + enableSounds = sounds + } + + if let hotkeyData = config[ConfigKeys.hotkey.rawValue] as? Data, + let decodedHotkey = try? JSONDecoder().decode(HotkeyConfiguration.self, from: hotkeyData) + { + hotkey = decodedHotkey + } + + if let firstLaunch = config[ConfigKeys.isFirstLaunch.rawValue] as? Bool { + isFirstLaunch = firstLaunch + } + + if let version = config[ConfigKeys.appVersion.rawValue] as? String { + appVersion = version + } + + print("📥 Configuration imported from dictionary") + } +} + +// MARK: - Hotkey Configuration + +/// Hotkey configuration structure +public struct HotkeyConfiguration: Codable, CustomStringConvertible { + 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 + } + + /// Default hotkey: Command+Shift+L + public static func defaultHotkey() -> HotkeyConfiguration { + HotkeyConfiguration( + keyCode: 37, // 'L' key + modifierFlags: UInt32(cmdKey | shiftKey), + displayString: "⌘⇧L" + ) + } + + public var description: String { + displayString + } +} diff --git a/Core/Sources/Core/IPCManager.swift b/Core/Sources/Core/IPCManager.swift index 08a0fd5..3ba8f9e 100644 --- a/Core/Sources/Core/IPCManager.swift +++ b/Core/Sources/Core/IPCManager.swift @@ -103,7 +103,7 @@ public class IPCManager: NSObject { public func sendCommand(_ command: IPCCommand, timeout: TimeInterval = CoreConstants.ipcTimeout) async throws -> IPCResponse { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in sendCommand(command, timeout: timeout) { result in continuation.resume(with: result) } @@ -183,26 +183,27 @@ public class IPCServiceHandler: NSObject, IPCServiceProtocol { do { switch command { case .lock: - if try lockCore.lockKeyboard() { - return IPCResponse.success("Keyboard locked successfully") - } else { - return IPCResponse.error("Keyboard is already locked") - } + try lockCore.lockKeyboard() + return IPCResponse.success("Keyboard locked successfully") case .unlock: - if lockCore.unlockKeyboard() { - return IPCResponse.success("Keyboard unlocked successfully") - } else { - return IPCResponse.error("Keyboard is not locked") - } + lockCore.unlockKeyboard() + return IPCResponse.success("Keyboard unlocked successfully") case .toggle: - let newStatus = try lockCore.toggleLock() - let statusMessage = newStatus ? "locked" : "unlocked" + lockCore.toggleLock() + let coreInfo = lockCore.basicLockInfo + let statusMessage = coreInfo.isLocked ? "locked" : "unlocked" return IPCResponse.success("Keyboard \(statusMessage) successfully") case .status: - let status = lockCore.lockStatus + let coreInfo = lockCore.basicLockInfo + let status = LockStatus( + isLocked: coreInfo.isLocked, + lockedAt: coreInfo.lockedAt, + autoLockEnabled: false, // Auto-lock is now in business layer + autoLockInterval: 0 // Auto-lock is now in business layer + ) return IPCResponse.success( "Keyboard is currently \(status.isLocked ? "locked" : "unlocked")", data: status.toDictionary() diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift index 4ebae4e..38a3197 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -1,264 +1,301 @@ +import AppKit import ApplicationServices import Carbon import Foundation -/// Core keyboard locking functionality that can be shared between main app and CLI +// MARK: - Event Handling Helpers + +private enum EventTypeFactory { + static func createEventMask() -> CGEventMask { + (1 << CGEventType.keyDown.rawValue) | + (1 << CGEventType.keyUp.rawValue) | + (1 << CGEventType.flagsChanged.rawValue) | + (1 << CGEventType.otherMouseDown.rawValue) | + (1 << CGEventType.otherMouseUp.rawValue) + } +} + +private enum SafeEventHandler { + static func getFlags(from event: CGEvent) -> CGEventFlags { + event.flags + } + + static func getKeycode(from event: CGEvent) -> Int64? { + event.getIntegerValueField(.keyboardEventKeycode) + } +} + +/// 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: - Private Properties + // MARK: - State Properties (Read-Only) private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? - private var isLocked = false - private var lockedAt: Date? - private var autoLockTimer: Timer? - private var autoLockInterval: TimeInterval = 0 // 0 means disabled - - // MARK: - Public Properties - - /// Current lock status - public var lockStatus: LockStatus { - return LockStatus( - isLocked: isLocked, - lockedAt: lockedAt, - autoLockEnabled: autoLockInterval > 0, - autoLockInterval: Int(autoLockInterval / 60) - ) + private var _isLocked = false + private var _lockedAt: Date? + + // Internal access for callback + var internalEventTap: CFMachPort? { + eventTap } - /// Whether keyboard is currently locked + // Constants for hotkey detection + private let unlockKeyCode: UInt16 = 37 // 'L' key + private let unlockModifiers: UInt32 = .init(cmdKey | optionKey) // Cmd+Option + + // 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: - Public Read-Only Properties + + /// Current keyboard lock state public var isKeyboardLocked: Bool { - return isLocked + _isLocked } - // MARK: - Initialization + /// When keyboard was locked + public var keyboardLockedAt: Date? { + _lockedAt + } - private init() { - // Private initializer for singleton + /// Current lock status for external systems (simplified for Core layer) + public var basicLockInfo: (isLocked: Bool, lockedAt: Date?) { + (_isLocked, _lockedAt) } - deinit { - unlockKeyboard() - stopAutoLockTimer() + // MARK: - Deprecated Properties (for backward compatibility) + + public var autoLockDuration: Int { + 0 // Auto-lock logic moved to business layer + } + + public var isAutoLockEnabled: Bool { + false // Auto-lock logic moved to business layer } - // MARK: - Public Methods + public var autoLockDurationInSeconds: TimeInterval { + 0 // Auto-lock logic moved to business layer + } - /// Lock the keyboard with comprehensive event blocking - /// - Throws: CoreError if locking fails - /// - Returns: True if successfully locked, false if already locked - @discardableResult - public func lockKeyboard() throws -> Bool { - guard !isLocked else { - throw CoreError.alreadyLocked - } + // MARK: - Initialization - // Validate permissions first - try PermissionHelper.validatePermissions() + private init() { + print("🔑 KeyboardLockCore initialized") + } - // Create event tap to intercept keyboard events - let eventMask = (1 << CGEventType.keyDown.rawValue) | - (1 << CGEventType.keyUp.rawValue) | - (1 << CGEventType.flagsChanged.rawValue) + deinit { + print("🔑 KeyboardLockCore deallocated") + forceCleanup() + } - eventTap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: CGEventMask(eventMask), - callback: { proxy, type, event, refcon -> Unmanaged? in - return KeyboardLockCore.eventCallback( - proxy: proxy, type: type, event: event, refcon: refcon - ) - }, - userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) + // MARK: - Core Locking Methods - guard let eventTap = eventTap else { - throw CoreError.eventTapCreationFailed + /// Lock keyboard input + /// - Throws: KeyboardLockError if locking fails + public func lockKeyboard() throws { + guard !_isLocked else { + throw KeyboardLockError.alreadyLocked } - // Create run loop source and add to current run loop - runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) - guard let runLoopSource = runLoopSource else { - CFMachPortInvalidate(eventTap) - self.eventTap = nil - throw CoreError.eventTapCreationFailed + // Use AX API directly. + let axOptions = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false] + guard AXIsProcessTrustedWithOptions(axOptions as CFDictionary) else { + throw KeyboardLockError.permissionDenied } - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + try createEventTap() - // Enable the event tap - CGEvent.tapEnable(tap: eventTap, enable: true) + _isLocked = true + _lockedAt = Date() - // Update state - isLocked = true - lockedAt = Date() + // Notify business layer + onLockStateChanged?(_isLocked, _lockedAt) - print("🔒 Keyboard locked successfully") - return true + print("🔒 Keyboard locked at \(Date())") } - /// Unlock the keyboard - /// - Returns: True if successfully unlocked, false if not locked - @discardableResult - public func unlockKeyboard() -> Bool { - guard isLocked else { - return false + /// Unlock keyboard input + public func unlockKeyboard() { + guard _isLocked else { + print("⚠️ Keyboard is already unlocked") + return } - // Disable event tap - if let eventTap = eventTap { - CGEvent.tapEnable(tap: eventTap, enable: false) - } + destroyEventTap() - // Remove run loop source - if let runLoopSource = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - } - - // Invalidate and clean up - if let eventTap = eventTap { - CFMachPortInvalidate(eventTap) - } + _isLocked = false + let wasLockedAt = _lockedAt + _lockedAt = nil - eventTap = nil - runLoopSource = nil - isLocked = false - lockedAt = nil + // Notify business layer + onLockStateChanged?(_isLocked, nil) - print("🔓 Keyboard unlocked successfully") - return true + if let lockedAt = wasLockedAt { + let duration = Date().timeIntervalSince(lockedAt) + print("🔓 Keyboard unlocked after \(formatDuration(duration))") + } } - /// Toggle keyboard lock status - /// - Throws: CoreError if operation fails - /// - Returns: New lock status (true = locked, false = unlocked) - @discardableResult - public func toggleLock() throws -> Bool { - if isLocked { + /// Toggle lock state + public func toggleLock() { + if _isLocked { unlockKeyboard() - return false } else { - try lockKeyboard() - return true + do { + try lockKeyboard() + } catch { + print("❌ Failed to lock keyboard: \(error.localizedDescription)") + } } } - // MARK: - Auto-Lock Feature + // MARK: - Utility Methods - /// Set auto-lock timer - /// - Parameter interval: Time interval in seconds, 0 to disable - public func setAutoLockInterval(_ interval: TimeInterval) { - autoLockInterval = interval + /// Get lock duration string + public func getLockDurationString() -> String? { + guard let lockedAt = _lockedAt else { return nil } + let duration = Date().timeIntervalSince(lockedAt) + return formatDuration(duration) + } - if interval > 0 { - startAutoLockTimer() - } else { - stopAutoLockTimer() + /// Force cleanup all resources + public func forceCleanup() { + print("🧹 KeyboardLockCore: Force cleanup initiated") + + if _isLocked { + unlockKeyboard() } + + destroyEventTap() } - /// Start auto-lock timer - private func startAutoLockTimer() { - stopAutoLockTimer() // Stop existing timer + // MARK: - Private Event Tap Methods - guard autoLockInterval > 0 else { return } + /// Create event tap for keyboard monitoring + private func createEventTap() throws { + let eventMask = EventTypeFactory.createEventMask() - autoLockTimer = Timer.scheduledTimer(withTimeInterval: autoLockInterval, repeats: false) { - [weak self] _ in - guard let self = self, !self.isLocked else { return } + eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: globalEventCallback, + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) - do { - try self.lockKeyboard() - print("🔒 Auto-lock activated after \(Int(self.autoLockInterval / 60)) minutes") - } catch { - print("❌ Auto-lock failed: \(error.localizedDescription)") - } + guard let eventTap else { + throw KeyboardLockError.eventTapCreationFailed } - } - - /// Stop auto-lock timer - private func stopAutoLockTimer() { - autoLockTimer?.invalidate() - autoLockTimer = nil - } - /// Reset auto-lock timer (call this on user activity) - public func resetAutoLockTimer() { - guard autoLockInterval > 0, !isLocked else { return } - startAutoLockTimer() - } - - // MARK: - Event Handling + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + guard let runLoopSource else { + throw KeyboardLockError.runLoopSourceCreationFailed + } - /// Check if the given event represents the unlock hotkey combination - /// Default: Cmd + Option + L - private func isUnlockHotkey(event: CGEvent) -> Bool { - let flags = event.flags - let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) - // Check for Cmd + Option + L (keycode 37 is 'L') - return flags.contains(.maskCommand) && flags.contains(.maskAlternate) - && keyCode == CoreConstants.defaultUnlockKeyCode + print("🎯 Event tap created and enabled") } - /// Event callback function for event tap - private static func eventCallback( - proxy _: CGEventTapProxy, - type: CGEventType, - event: CGEvent, - refcon: UnsafeMutableRawPointer? - ) -> Unmanaged? { - guard let refcon = refcon else { return nil } - let keyboardLock = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - - // Handle tap disabled case - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - if let eventTap = keyboardLock.eventTap { - CGEvent.tapEnable(tap: eventTap, enable: true) - } - return nil + /// 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") } - // Only process events when locked - guard keyboardLock.isLocked else { - return Unmanaged.passRetained(event) + if let runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + self.runLoopSource = nil + print("🎯 Run loop source removed") } + } - // Check for unlock hotkey before blocking - if keyboardLock.isUnlockHotkey(event: event) { - keyboardLock.unlockKeyboard() - return nil // Block this event too + /// Handle keyboard events (internal for callback) + func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { + // Check for unlock hotkey combination + if type == .keyDown { + let keycode = SafeEventHandler.getKeycode(from: event) + let flags = SafeEventHandler.getFlags(from: event) + + if keycode == Int64(unlockKeyCode), flags.contains(.maskCommand), + flags.contains(.maskAlternate) + { + print("🔑 Unlock hotkey detected: ⌘+⌥+L") + + // Notify business layer through callback + DispatchQueue.main.async { + self.onUnlockHotkeyDetected?() + } + + // Don't pass through this event + return nil + } } - // Block all other keyboard events when locked + // Block all keyboard events when locked return nil } - // MARK: - Utility Methods - - /// Get formatted lock duration string - public func getLockDurationString() -> String? { - guard let lockedAt = lockedAt else { return nil } + // MARK: - Helper Methods - let duration = Date().timeIntervalSince(lockedAt) - let minutes = Int(duration / 60) - let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) + /// Format duration for display + private func formatDuration(_ duration: TimeInterval) -> String { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 if minutes > 0 { - return "\(minutes)m \(seconds)s" + return String(format: "%d:%02d", minutes, seconds) } else { - return "\(seconds)s" + return String(format: "%ds", seconds) } } +} - /// Force cleanup (for emergency situations) - public func forceCleanup() { - unlockKeyboard() - stopAutoLockTimer() +// 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.internalEventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + + return Unmanaged.passUnretained(event) + } + + // Only process events when locked + guard core.isKeyboardLocked else { + return Unmanaged.passUnretained(event) + } + + // Handle the event through core + return core.handleEvent(type: type, event: event) } diff --git a/Core/Sources/Core/KeyboardLockerAPI.swift b/Core/Sources/Core/KeyboardLockerAPI.swift new file mode 100644 index 0000000..797c51b --- /dev/null +++ b/Core/Sources/Core/KeyboardLockerAPI.swift @@ -0,0 +1,183 @@ +import Combine +import Foundation + +/// Simplified Core API for KeyboardLocker +/// This provides a unified interface for both CLI and GUI applications +public class KeyboardLockerAPI: ObservableObject { + // MARK: - Singleton + + public static let shared = KeyboardLockerAPI() + + // MARK: - Core Components + + private let core = KeyboardLockCore.shared + private let activityMonitor = UserActivityMonitor.shared + + /// The shared configuration instance + public var configuration: CoreConfiguration { + CoreConfiguration.shared + } + + // MARK: - Initialization + + private init() { + setupActivityMonitor() + setupConfigurationObserver() + print("🚀 KeyboardLockerAPI initialized") + } + + // MARK: - Lock/Unlock Operations + + /// Lock the keyboard + public func lockKeyboard() throws { + try core.lockKeyboard() + print("🔒 Keyboard locked via API") + } + + /// Unlock the keyboard + public func unlockKeyboard() { + core.unlockKeyboard() + print("🔓 Keyboard unlocked via API") + } + + /// Toggle keyboard lock state + public func toggleKeyboardLock() { + core.toggleLock() + } + + /// Get current lock status + public var isLocked: Bool { + core.basicLockInfo.isLocked + } + + /// Get locked at timestamp + public var lockedAt: Date? { + core.basicLockInfo.lockedAt + } + + /// Get lock duration string + public func getLockDurationString() -> String? { + guard let lockedAt else { return nil } + let duration = Date().timeIntervalSince(lockedAt) + let minutes = Int(duration / 60) + let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", minutes, seconds) + } + + // MARK: - Auto-lock Configuration (Core Logic Only) + + /// Enable auto-lock with specified duration in seconds + /// Core只关心传入的时间值,不管理UI选项 + public func enableAutoLock(seconds: TimeInterval) { + configuration.autoLockDuration = Int(seconds) + activityMonitor.enableAutoLock(seconds: seconds) + + // Start activity monitoring if not already started + if seconds > 0 { + activityMonitor.startMonitoring() + print("✅ Auto-lock enabled: \(Int(seconds / 60)) minutes") + } else { + activityMonitor.stopMonitoring() + print("❌ Auto-lock disabled") + } + } + + /// Enable auto-lock with specified duration in minutes (convenience method) + public func enableAutoLock(minutes: Int) { + enableAutoLock(seconds: TimeInterval(minutes * 60)) + } + + /// Disable auto-lock + public func disableAutoLock() { + enableAutoLock(seconds: 0) + } + + /// Get current auto-lock status + public func isAutoLockEnabled() -> Bool { + configuration.isAutoLockEnabled + } + + /// Get auto-lock duration in seconds (Core stores in seconds) + public func getAutoLockDurationSeconds() -> Int { + configuration.autoLockDuration + } + + /// Get auto-lock duration in minutes (convenience method for UI) + public func getAutoLockDuration() -> Int { + configuration.autoLockDuration / 60 + } + + /// Get time since last user activity (for UI display) + public func getTimeSinceLastActivity() -> TimeInterval { + activityMonitor.timeSinceLastActivity + } + + /// Reset user activity timer manually + public func resetUserActivityTimer() { + activityMonitor.resetActivityTimer() + } + + // MARK: - Configuration Management + + /// Export current configuration + public func exportConfiguration() -> [String: Any] { + configuration.exportConfiguration() + } + + /// Import configuration + public func importConfiguration(_ newConfig: [String: Any]) { + configuration.importConfiguration(newConfig) + } + + /// Reset configuration to defaults + public func resetConfiguration() { + configuration.resetToDefaults() + } + + /// Set notification preferences + public func setNotificationsEnabled(_ enabled: Bool) { + configuration.showNotifications = enabled + } + + /// Get notification preferences + public func isNotificationsEnabled() -> Bool { + configuration.showNotifications + } + + // MARK: - Permission Management + + /// Check if accessibility permission is granted + public func hasAccessibilityPermission() -> Bool { + // Basic implementation - could be enhanced with proper permission checking + true + } + + /// Request accessibility permission + public func requestAccessibilityPermission() { + print("⚠️ Accessibility permission required") + } + + // MARK: - Private Setup Methods + + /// Setup activity monitor with auto-lock callback + private func setupActivityMonitor() { + activityMonitor.onAutoLockTriggered = { [weak self] in + do { + try self?.lockKeyboard() + print("🔒 Auto-lock triggered - keyboard locked") + } catch { + print("❌ Auto-lock failed: \(error.localizedDescription)") + } + } + } + + /// Setup configuration observer to sync auto-lock settings + private func setupConfigurationObserver() { + // Sync initial auto-lock configuration + let duration = configuration.autoLockDuration + if duration > 0 { + activityMonitor.enableAutoLock(seconds: TimeInterval(duration)) + activityMonitor.startMonitoring() + } + } +} diff --git a/Core/Sources/Core/PermissionHelper.swift b/Core/Sources/Core/PermissionHelper.swift index 52f77d4..bf68057 100644 --- a/Core/Sources/Core/PermissionHelper.swift +++ b/Core/Sources/Core/PermissionHelper.swift @@ -8,7 +8,7 @@ public class PermissionHelper { /// Check if accessibility permission is currently granted public static func hasAccessibilityPermission() -> Bool { - return AXIsProcessTrusted() + AXIsProcessTrusted() } /// Check accessibility permission with option to show system prompt @@ -37,10 +37,10 @@ public class PermissionHelper { if #available(macOS 10.15, *) { // For now, we'll assume screen recording permission is not strictly required // In a real implementation, you might use ScreenCaptureKit or other methods - return true + true } else { // Screen recording permission not required on older macOS versions - return true + true } } @@ -49,7 +49,7 @@ public class PermissionHelper { /// Get a summary of all required permissions /// - Returns: Dictionary with permission names and their status public static func getPermissionStatus() -> [String: Bool] { - return [ + [ "accessibility": hasAccessibilityPermission(), "screenRecording": hasScreenRecordingPermission(), ] @@ -58,7 +58,7 @@ public class PermissionHelper { /// Check if all required permissions are granted /// - Returns: True if all required permissions are available public static func hasAllRequiredPermissions() -> Bool { - return hasAccessibilityPermission() + hasAccessibilityPermission() // Add other required permissions here if needed // && hasScreenRecordingPermission() } diff --git a/Core/Sources/Core/SharedModels.swift b/Core/Sources/Core/SharedModels.swift index 439a2cd..6a57b43 100644 --- a/Core/Sources/Core/SharedModels.swift +++ b/Core/Sources/Core/SharedModels.swift @@ -1,5 +1,41 @@ 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: - IPC Commands /// Commands that can be sent from CLI tool to main app @@ -13,15 +49,15 @@ public enum IPCCommand: String, Codable, CaseIterable { public var description: String { switch self { case .lock: - return "Lock the keyboard" + "Lock the keyboard" case .unlock: - return "Unlock the keyboard" + "Unlock the keyboard" case .toggle: - return "Toggle keyboard lock status" + "Toggle keyboard lock status" case .status: - return "Get current keyboard lock status" + "Get current keyboard lock status" case .quit: - return "Quit the main application" + "Quit the main application" } } } @@ -44,12 +80,12 @@ public struct IPCResponse: Codable { /// Convenience initializer for success responses public static func success(_ message: String, data: [String: String]? = nil) -> IPCResponse { - return IPCResponse(success: true, message: message, data: data) + IPCResponse(success: true, message: message, data: data) } /// Convenience initializer for error responses public static func error(_ message: String) -> IPCResponse { - return IPCResponse(success: false, message: message, data: nil) + IPCResponse(success: false, message: message, data: nil) } } @@ -68,19 +104,19 @@ public enum CoreError: Error, LocalizedError { public var errorDescription: String? { switch self { case .accessibilityPermissionDenied: - return "Accessibility permission is required to control keyboard input" + "Accessibility permission is required to control keyboard input" case .eventTapCreationFailed: - return "Failed to create event tap for keyboard monitoring" + "Failed to create event tap for keyboard monitoring" case .ipcConnectionFailed: - return "Failed to connect to main application" + "Failed to connect to main application" case .invalidCommand: - return "Invalid command provided" + "Invalid command provided" case .mainAppNotRunning: - return "Main application is not running" + "Main application is not running" case .alreadyLocked: - return "Keyboard is already locked" + "Keyboard is already locked" case .notLocked: - return "Keyboard is not currently locked" + "Keyboard is not currently locked" } } } @@ -111,18 +147,18 @@ public enum CoreConstants { public var description: String { switch self { case .never: - return "Never" + "Never" case .fifteen: - return "15 minutes" + "15 minutes" case .thirty: - return "30 minutes" + "30 minutes" case .sixty: - return "1 hour" + "1 hour" } } public var timeInterval: TimeInterval { - return TimeInterval(rawValue * 60) + TimeInterval(rawValue * 60) } } } @@ -156,7 +192,7 @@ public struct LockStatus: Codable { "autoLockInterval": "\(autoLockInterval)", ] - if let lockedAt = lockedAt { + if let lockedAt { let formatter = ISO8601DateFormatter() dict["lockedAt"] = formatter.string(from: lockedAt) } diff --git a/Core/Sources/Core/UserActivityMonitor.swift b/Core/Sources/Core/UserActivityMonitor.swift new file mode 100644 index 0000000..1db25a1 --- /dev/null +++ b/Core/Sources/Core/UserActivityMonitor.swift @@ -0,0 +1,219 @@ +import AppKit +import ApplicationServices +import Carbon +import Foundation + +/// 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) + public var autoLockDuration: TimeInterval = 0 { + didSet { + updateAutoLockTimer() + } + } + + /// 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() { + print("🔍 UserActivityMonitor initialized") + } + + deinit { + stopMonitoring() + print("🔍 UserActivityMonitor deallocated") + } + + // 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) { + autoLockDuration = seconds + if seconds > 0 { + print("✅ Auto-lock enabled: \(Int(seconds / 60)) minutes") + } else { + print("❌ Auto-lock disabled") + } + } + + /// Disable auto-lock + public func disableAutoLock() { + autoLockDuration = 0 + } + + // MARK: - Private Methods + + private func createActivityEventTap() throws { + // Check accessibility permission + let axOptions = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false] + guard AXIsProcessTrustedWithOptions(axOptions as CFDictionary) 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 + let monitor = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() + monitor.handleActivityEvent(event) + return Unmanaged.passRetained(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 { return } + + let timeSinceActivity = Date().timeIntervalSince(lastActivityTime) + + if timeSinceActivity >= autoLockDuration { + // Trigger auto-lock + stopAutoLockTimer() + onAutoLockTriggered?() + print("🔒 Auto-lock triggered after \(Int(autoLockDuration / 60)) minutes 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/AboutView.swift b/KeyboardLocker/AboutView.swift index 870cc62..4d527ea 100644 --- a/KeyboardLocker/AboutView.swift +++ b/KeyboardLocker/AboutView.swift @@ -2,56 +2,63 @@ 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) + 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) - } + Text(LocalizationKey.appTitle.localized) + .font(.title) + .fontWeight(.bold) - Divider() + Text(Bundle.main.localizedVersionString) + .font(.subheadline) + .foregroundColor(.secondary) + } + } - // Core features only - VStack(alignment: .leading, spacing: 15) { - Text(LocalizationKey.aboutFeatures.localized) - .font(.headline) + 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) - } + 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) } + } + } - Divider() - - // GitHub link - Button(action: { - openGitHubRepository() - }) { - HStack { - Image(systemName: "link.circle.fill") - .foregroundColor(.blue) - Text(LocalizationKey.aboutGitHub.localized) - .foregroundColor(.blue) - } - .font(.body) + private var githubLink: some View { + Button(action: { + if let url = URL(string: "https://github.com/LZhenHong/KeyboardLocker") { + NSWorkspace.shared.open(url) } - .buttonStyle(PlainButtonStyle()) - .onHover { _ in - NSCursor.pointingHand.set() + }) { + HStack { + Image(systemName: "link.circle.fill") + .foregroundColor(.blue) + Text(LocalizationKey.aboutGitHub.localized) + .foregroundColor(.blue) } + .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) @@ -62,14 +69,6 @@ struct AboutView: View { .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 { diff --git a/KeyboardLocker/AppConfiguration.swift b/KeyboardLocker/AppConfiguration.swift deleted file mode 100644 index d412622..0000000 --- a/KeyboardLocker/AppConfiguration.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation -import SwiftUI - -/// Configuration manager for application settings and preferences -class AppConfiguration: ObservableObject { - /// Shared configuration instance - static let shared = AppConfiguration() - - // MARK: - Core Settings - - @AppStorage("showNotifications") var showNotifications: Bool = true { - willSet { objectWillChange.send() } - } - - @AppStorage("autoLockDuration") var autoLockDuration: Int = 0 { // in minutes, 0 = never - willSet { objectWillChange.send() } - } - - private init() {} - - // MARK: - Convenience Properties - - /// Whether auto-lock is enabled (when duration > 0) - var isAutoLockEnabled: Bool { - return autoLockDuration > 0 - } - - /// Auto-lock duration in seconds for internal use - var autoLockDurationInSeconds: TimeInterval { - return TimeInterval(autoLockDuration * 60) - } - - // MARK: - Reset Method - - /// Reset all settings to defaults - func resetToDefaults() { - showNotifications = true - autoLockDuration = 0 - print("� Configuration reset to defaults") - } -} - -// MARK: - Configuration Constants - -extension AppConfiguration { - /// Default values for configuration - enum Defaults { - static let showNotifications = true - static let autoLockDuration = 0 // minutes, 0 = never - } -} diff --git a/KeyboardLocker/KeyboardLockerAppDelegate.swift b/KeyboardLocker/AppDelegate.swift similarity index 95% rename from KeyboardLocker/KeyboardLockerAppDelegate.swift rename to KeyboardLocker/AppDelegate.swift index 1b2e19b..fa3a50e 100644 --- a/KeyboardLocker/KeyboardLockerAppDelegate.swift +++ b/KeyboardLocker/AppDelegate.swift @@ -3,7 +3,7 @@ import Core import SwiftUI /// Custom AppDelegate for handling URL schemes and application lifecycle -class KeyboardLockerAppDelegate: NSObject, NSApplicationDelegate { +class AppDelegate: NSObject, NSApplicationDelegate { var urlHandler: URLCommandHandler = .shared var keyboardLockManager: KeyboardLockManaging? diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift index efb79ef..8c44ac4 100644 --- a/KeyboardLocker/ContentView.swift +++ b/KeyboardLocker/ContentView.swift @@ -85,6 +85,20 @@ struct ContentView: View { } .padding(.leading, 16) // Align with status text } + + // Show auto-lock status when enabled and not locked + if !isKeyboardLocked, keyboardManager.isAutoLockEnabled { + HStack { + Image(systemName: "timer") + .foregroundColor(.orange) + .font(.caption) + Text("Auto-lock: \(autoLockStatusText)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.leading, 16) + } } // Lock/unlock button @@ -243,11 +257,31 @@ struct ContentView: View { lockDurationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in // Force UI update by triggering objectWillChange DispatchQueue.main.async { - self.keyboardManager.objectWillChange.send() + keyboardManager.objectWillChange.send() } } } } + + /// Get auto-lock status text for display + private var autoLockStatusText: String { + let duration = keyboardManager.autoLockDuration + if duration == 0 { + return "Disabled" + } + + // Get time since last activity + let timeSinceActivity = keyboardManager.getTimeSinceLastActivity() + let remainingTime = max(0, TimeInterval(duration * 60) - timeSinceActivity) + + if remainingTime > 0 { + let minutes = Int(remainingTime / 60) + let seconds = Int(remainingTime.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", minutes, seconds) + } else { + return "Ready to lock" + } + } } struct SettingRow: View { diff --git a/KeyboardLocker/DependencyFactory.swift b/KeyboardLocker/DependencyFactory.swift index 9142619..beb309c 100644 --- a/KeyboardLocker/DependencyFactory.swift +++ b/KeyboardLocker/DependencyFactory.swift @@ -1,4 +1,4 @@ -import Foundation +import Core /// Factory for creating and managing application dependencies /// This helps reduce coupling and makes testing easier @@ -13,21 +13,18 @@ class DependencyFactory { /// Create a notification manager instance /// - Returns: NotificationManaging instance func makeNotificationManager() -> NotificationManaging { - return NotificationManager.shared + NotificationManager.shared } /// Create a keyboard lock manager instance /// - Parameters: /// - notificationManager: Optional notification manager, uses default if nil - /// - configuration: Optional configuration, uses shared if nil /// - Returns: KeyboardLockManaging instance func makeKeyboardLockManager( - notificationManager: NotificationManaging? = nil, - configuration: AppConfiguration? = nil + notificationManager: NotificationManaging? = nil ) -> KeyboardLockManaging { let notificationMgr = notificationManager ?? makeNotificationManager() - let config = configuration ?? AppConfiguration.shared - return KeyboardLockManager(notificationManager: notificationMgr, configuration: config) + return KeyboardLockManager(notificationManager: notificationMgr) } /// Create a URL command handler instance @@ -38,7 +35,7 @@ class DependencyFactory { ) -> URLCommandHandler { // Since URLCommandHandler uses a singleton pattern, we return the shared instance // In a more sophisticated dependency injection system, we might create new instances - return URLCommandHandler.shared + URLCommandHandler.shared } /// Create a permission manager instance @@ -47,14 +44,13 @@ class DependencyFactory { func makePermissionManager( notificationManager: NotificationManager? = nil ) -> PermissionManager { - let notificationMgr: NotificationManager - if let providedManager = notificationManager { - notificationMgr = providedManager + let notificationMgr: NotificationManager = if let providedManager = notificationManager { + providedManager } else if let defaultManager = makeNotificationManager() as? NotificationManager { - notificationMgr = defaultManager + defaultManager } else { // Fallback: create a new instance directly - notificationMgr = NotificationManager.shared + NotificationManager.shared } return PermissionManager(notificationManager: notificationMgr) } @@ -67,7 +63,7 @@ class DependencyFactory { /// Create a mock notification manager for testing /// - Returns: Mock NotificationManaging instance func makeMockNotificationManager() -> NotificationManaging { - return MockNotificationManager() + MockNotificationManager() } /// Create a keyboard lock manager with mock dependencies for testing @@ -80,20 +76,24 @@ class DependencyFactory { /// Mock notification manager for testing purposes class MockNotificationManager: NotificationManaging { - var sentNotifications: [(type: NotificationManager.NotificationType, showNotifications: Bool)] = [] + var sentNotifications: [(type: NotificationManager.NotificationType, showNotifications: Bool)] = + [] var customNotifications: [(title: String, body: String, isError: Bool)] = [] // Implement the required protocol property var isAuthorized: Bool = true - func sendNotificationIfEnabled(_ type: NotificationManager.NotificationType, showNotifications: Bool) { + func sendNotificationIfEnabled( + _ type: NotificationManager.NotificationType, showNotifications: Bool + ) { sentNotifications.append((type: type, showNotifications: showNotifications)) print("Mock: Would send notification \(type) with showNotifications=\(showNotifications)") } func sendNotification(title: String, body: String, isError: Bool) { customNotifications.append((title: title, body: body, isError: isError)) - print("Mock: Would send custom notification - Title: \(title), Body: \(body), Error: \(isError)") + print( + "Mock: Would send custom notification - Title: \(title), Body: \(body), Error: \(isError)") } func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) { diff --git a/KeyboardLocker/KeyboardLockManager.swift b/KeyboardLocker/KeyboardLockManager.swift index 345da56..68a4fbf 100644 --- a/KeyboardLocker/KeyboardLockManager.swift +++ b/KeyboardLocker/KeyboardLockManager.swift @@ -1,33 +1,26 @@ -import Carbon -import Cocoa -import Foundation +import Core import SwiftUI -/// UI-focused keyboard lock manager with full functionality +/// UI-focused keyboard lock manager that uses Core Library's unified API +/// This layer handles only UI-specific concerns like notifications class KeyboardLockManager: ObservableObject, KeyboardLockManaging { @Published var isLocked = false + @Published var autoLockEnabled = false - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - private var autoLockTimer: Timer? - private var lastActivityTime = Date() - private var lockStartTime: Date? + // Core API - unified interface for all functionality + private let coreAPI = KeyboardLockerAPI.shared - // Use protocol to reduce coupling + // UI-specific dependencies private let notificationManager: NotificationManaging - private let configuration: AppConfiguration - - // Constants for hotkey - private let unlockKeyCode: UInt16 = 37 // 'L' key - private let unlockModifiers: UInt32 = .init(cmdKey | optionKey) // Cmd+Option init( - notificationManager: NotificationManaging = NotificationManager.shared, - configuration: AppConfiguration = AppConfiguration.shared + notificationManager: NotificationManaging = NotificationManager.shared ) { self.notificationManager = notificationManager - self.configuration = configuration - setupActivityMonitoring() + + // Setup observers and sync + setupCoreObservers() + syncInitialState() } deinit { @@ -36,334 +29,163 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { /// Clean up resources when object is deallocated private func cleanup() { - unlockKeyboard() - stopAutoLock() + // Core API handles its own cleanup + print("🧹 KeyboardLockManager cleanup completed") } - // MARK: - Public Interface + // MARK: - Public Interface (UI Actions) func lockKeyboard() { - guard !isLocked else { return } - do { - try performLockKeyboard() + try coreAPI.lockKeyboard() + print("✅ Keyboard locked successfully") + + // Send notification to user (UI concern) + notificationManager.sendNotificationIfEnabled( + .keyboardLocked, // Use locked notification for auto-lock + showNotifications: coreAPI.isNotificationsEnabled() + ) } catch { - print("Failed to lock keyboard: \(error.localizedDescription)") + print("❌ Failed to lock keyboard: \(error.localizedDescription)") } } - private func performLockKeyboard() throws { - // Verify accessibility permissions are granted - guard AXIsProcessTrusted() else { - throw KeyboardLockerError.accessibilityPermissionDenied - } - - // Use safe event type factory to create event mask - let eventMask = EventTypeFactory.createEventMask() - - // Create event tap for intercepting input events - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: eventMask, - callback: { proxy, type, event, refcon in - guard let refcon = refcon else { return Unmanaged.passUnretained(event) } - let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - return manager.handleKeyEvent(proxy: proxy, type: type, event: event) - }, - userInfo: Unmanaged.passUnretained(self).toOpaque() - ) - else { - throw KeyboardLockerError.eventTapCreationFailed - } - - eventTap = tap - runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - - guard let runLoopSource = runLoopSource else { - throw KeyboardLockerError.runLoopSourceCreationFailed - } - - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) - - isLocked = true - lockStartTime = Date() - print("Keyboard locked successfully") - - // Send notification to user - notificationManager.sendNotificationIfEnabled( - .keyboardLocked, - showNotifications: configuration.showNotifications - ) - - print("🔒 Keyboard locked successfully") - } - func unlockKeyboard() { - guard isLocked else { return } - - // Disable event tap - if let eventTap = eventTap { - CGEvent.tapEnable(tap: eventTap, enable: false) - } - - // Remove run loop source - if let runLoopSource = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - } - - // Invalidate and clean up - if let eventTap = eventTap { - CFMachPortInvalidate(eventTap) + guard coreAPI.isLocked else { + return } - eventTap = nil - runLoopSource = nil - isLocked = false - lockStartTime = nil + coreAPI.unlockKeyboard() + print("✅ Keyboard unlocked successfully") - // Send notification to user + // Send notification to user (UI concern) notificationManager.sendNotificationIfEnabled( .keyboardUnlocked, - showNotifications: configuration.showNotifications + showNotifications: coreAPI.isNotificationsEnabled() ) - - print("🔓 Keyboard unlocked successfully") } func toggleLock() { - if isLocked { - unlockKeyboard() - } else { - lockKeyboard() - } + coreAPI.toggleKeyboardLock() + + // Send notification based on new state + let notificationType: NotificationManager.NotificationType = coreAPI.isLocked ? .keyboardLocked : .keyboardUnlocked + notificationManager.sendNotificationIfEnabled( + notificationType, + showNotifications: coreAPI.isNotificationsEnabled() + ) } - // MARK: - Auto-Lock Management + // MARK: - Auto-Lock Management (using Core API directly) func startAutoLock() { - guard !configuration.isAutoLockEnabled else { - print("Auto-lock is already enabled") - return - } - - // Update configuration to enable auto-lock - configuration.autoLockDuration = max(configuration.autoLockDuration, 15) // Minimum 15 minutes - scheduleAutoLock() - print("Auto-lock enabled with \(configuration.autoLockDuration) minutes duration") + let durationMinutes = max(coreAPI.configuration.autoLockDuration / 60, 1) // Convert to minutes, minimum 1 + coreAPI.configuration.autoLockDuration = durationMinutes * 60 // This will trigger Core API update + print("✅ Auto-lock enabled with \(durationMinutes) minutes duration (activity-based)") } func stopAutoLock() { - // 禁用自动锁定 - configuration.autoLockDuration = 0 - - // 停止并清理计时器 - autoLockTimer?.invalidate() - autoLockTimer = nil - - print("Auto-lock disabled") + coreAPI.configuration.autoLockDuration = 0 // This will trigger Core API update + print("✅ Auto-lock disabled") } func toggleAutoLock() { - if configuration.isAutoLockEnabled { - stopAutoLock() + if coreAPI.configuration.autoLockDuration > 0 { + coreAPI.configuration.autoLockDuration = 0 } else { - startAutoLock() + coreAPI.configuration.autoLockDuration = 1800 // Default 30 minutes } + print("✅ Auto-lock toggled") + // Update UI state + syncAutoLockConfiguration() } func updateAutoLockSettings() { - if configuration.isAutoLockEnabled { - // 自动锁定已启用,重新调度计时器 - scheduleAutoLock() - print("Auto-lock settings updated: enabled with \(configuration.autoLockDuration) minutes") - } else { - // 自动锁定已禁用,停止计时器 - autoLockTimer?.invalidate() - autoLockTimer = nil - print("Auto-lock settings updated: disabled") - } + // Settings are now managed directly through Core API + print("✅ Auto-lock settings updated with activity monitoring") } - private func setupActivityMonitoring() { - // Monitor global events for activity detection - NSEvent.addGlobalMonitorForEvents(matching: [ - .keyDown, .leftMouseDown, .rightMouseDown, .mouseMoved, - ]) { _ in - self.lastActivityTime = Date() - // 每次用户有活动时,重新调度自动锁定计时器 - if self.configuration.isAutoLockEnabled, !self.isLocked { - self.scheduleAutoLock() - } - } - - // 初始化时如果启用了自动锁定,开始计时 - if configuration.isAutoLockEnabled { - scheduleAutoLock() - } + /// Get time since last user activity (for UI display) + func getTimeSinceLastActivity() -> TimeInterval { + coreAPI.getTimeSinceLastActivity() } - private func scheduleAutoLock() { - // 停止现有的计时器 - autoLockTimer?.invalidate() - - // 如果自动锁定被禁用,不设置新的计时器 - guard configuration.isAutoLockEnabled else { - autoLockTimer = nil - return - } - - // 设置新的计时器,从现在开始计算指定的时间 - autoLockTimer = Timer.scheduledTimer(withTimeInterval: configuration.autoLockDurationInSeconds, repeats: false) { _ in - DispatchQueue.main.async { - // 双重检查:确保自动锁定仍然启用且键盘未锁定 - if self.configuration.isAutoLockEnabled, !self.isLocked { - print("Auto-lock triggered after \(self.configuration.autoLockDuration) minutes of inactivity") - self.lockKeyboard() - } - } - } - - print("Auto-lock timer scheduled for \(configuration.autoLockDuration) minutes from now") + /// Reset user activity timer manually + func resetUserActivityTimer() { + coreAPI.resetUserActivityTimer() } - // MARK: - Event Handling - - /// Handle intercepted events - comprehensive input blocking logic - private func handleKeyEvent( - proxy _: CGEventTapProxy, - type: CGEventType, - event: CGEvent - ) -> Unmanaged? { - // Handle tap disabled case - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - if let eventTap = eventTap { - CGEvent.tapEnable(tap: eventTap, enable: true) - } - return nil - } - - // If not locked, allow all events - guard isLocked else { - return Unmanaged.passUnretained(event) - } - - let flags = SafeEventHandler.getFlags(from: event) - let keyCode = SafeEventHandler.getKeycode(from: event) ?? 0 + // MARK: - Status and Information - // Handle different event types comprehensively - switch type { - case .keyDown, .keyUp: - return handleKeyboardEvent(type: type, event: event, keyCode: keyCode) - - case .flagsChanged: - return handleModifierEvent(flags: flags) - - default: - return handleOtherEvent(type: type, event: event, flags: flags) - } + func getLockDurationString() -> String? { + coreAPI.getLockDurationString() } - private func handleKeyboardEvent(type: CGEventType, event: CGEvent, keyCode: Int64) -> Unmanaged< - CGEvent - >? { - // Allow unlock combination (⌘+⌥+L) to pass through - only on keyDown - if type == .keyDown, isUnlockHotkey(event: event) { - DispatchQueue.main.async { - self.unlockKeyboard() - } - return nil // Consume this event, don't pass to system - } - - // Block ALL other keyboard events when locked - print("Blocked keyboard event: type=\(type.rawValue), keyCode=\(keyCode)") - return nil + func checkPermissions() -> Bool { + coreAPI.hasAccessibilityPermission() } - private func handleModifierEvent(flags: CGEventFlags) -> Unmanaged? { - // Block ALL modifier key changes to prevent any shortcuts - print("Blocked modifier key change: flags=\(flags)") - return nil + func requestPermissions() { + coreAPI.requestAccessibilityPermission() + print("ℹ️ Permission request sent. Please grant accessibility permission in System Settings.") } - private func handleOtherEvent(type: CGEventType, event: CGEvent, flags: CGEventFlags) -> Unmanaged? { - // Check if this is a system-defined event (function keys, etc.) - if EventTypeFactory.isSystemDefinedEvent(type) { - // This is the key for function keys! Block ALL system-defined events - if let subtype = EventTypeFactory.getSystemDefinedSubtype(from: event) { - print("Blocked system-defined event: subtype=\(subtype)") - } else { - print("Blocked system-defined event: unable to get subtype") - } - // Directly block all system-defined events, including volume, brightness, and other function keys - return nil - } + // MARK: - Configuration Access (forwarded to Core directly) - // Block unknown event types with modifier keys - if SafeEventHandler.hasModifiers(event, [.maskCommand, .maskAlternate, .maskControl, .maskShift]) { - print("Blocked unknown event with modifiers: type=\(type.rawValue), flags=\(flags)") - return nil - } - - // Allow events without modifier keys - return Unmanaged.passUnretained(event) + /// Auto-lock duration in minutes for UI display (using Core config) + var autoLockDuration: Int { + coreAPI.configuration.autoLockDuration / 60 // Convert seconds to minutes for UI } - private func isUnlockHotkey(event: CGEvent) -> Bool { - let flags = SafeEventHandler.getFlags(from: event) - let keyCode = SafeEventHandler.getKeycode(from: event) ?? 0 - - // Check for configured unlock hotkey (default: Cmd + Option + L) - let expectedModifiers = unlockModifiers - let expectedKeyCode = unlockKeyCode + /// Check if auto-lock is enabled (using Core config) + var isAutoLockEnabled: Bool { + coreAPI.configuration.autoLockDuration > 0 + } - var hasRequiredModifiers = true + /// Get/set notification preference (using Core directly) + var showNotifications: Bool { + get { coreAPI.isNotificationsEnabled() } + set { coreAPI.setNotificationsEnabled(newValue) } + } - if expectedModifiers & UInt32(cmdKey) != 0 { - hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskCommand) - } - if expectedModifiers & UInt32(optionKey) != 0 { - hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskAlternate) - } - if expectedModifiers & UInt32(controlKey) != 0 { - hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskControl) - } - if expectedModifiers & UInt32(shiftKey) != 0 { - hasRequiredModifiers = hasRequiredModifiers && flags.contains(.maskShift) - } + // MARK: - Utility Methods - return hasRequiredModifiers && UInt16(keyCode) == expectedKeyCode + func forceCleanup() { + // Core API manages its own cleanup + print("🧹 KeyboardLockManager force cleanup completed") + syncInitialState() } - // MARK: - Hotkey Management + // MARK: - Private Methods - func updateUnlockHotkey(keyCode _: UInt16, modifiers _: UInt32) { - // Hotkeys are now fixed as constants since they are standard - print("Unlock hotkey is fixed: Cmd+Option+L (keyCode=37, modifiers=\(unlockModifiers))") + /// Setup observers for Core configuration changes + private func setupCoreObservers() { + // Check lock status and auto-lock status periodically + Timer.publish(every: 0.5, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.isLocked = self?.coreAPI.isLocked ?? false + self?.autoLockEnabled = (self?.coreAPI.configuration.autoLockDuration ?? 0) > 0 + } + .store(in: &cancellables) } - // MARK: - Utility Methods - - func getLockDurationString() -> String? { - guard let lockStartTime = lockStartTime, isLocked else { - return nil + /// Sync initial state from Core + private func syncInitialState() { + DispatchQueue.main.async { + self.isLocked = self.coreAPI.isLocked + self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration > 0 } + } - let duration = Date().timeIntervalSince(lockStartTime) - let minutes = Int(duration) / 60 - let seconds = Int(duration) % 60 - - if minutes > 0 { - return String(format: "%dm %ds", minutes, seconds) - } else { - return String(format: "%ds", seconds) + /// Sync auto-lock configuration from Core + private func syncAutoLockConfiguration() { + DispatchQueue.main.async { + self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration > 0 } } - func forceCleanup() { - unlockKeyboard() - stopAutoLock() - } + // MARK: - Combine Support + + private var cancellables = Set() } diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift index 2a89825..fbb9f82 100644 --- a/KeyboardLocker/KeyboardLockerApp.swift +++ b/KeyboardLocker/KeyboardLockerApp.swift @@ -9,7 +9,7 @@ struct KeyboardLockerApp: App { @StateObject private var permissionManager = DependencyFactory.shared.makePermissionManager() // Use AppDelegate for URL handling - @NSApplicationDelegateAdaptor(KeyboardLockerAppDelegate.self) var appDelegate + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate init() { // Create keyboard lock manager safely without force casting diff --git a/KeyboardLocker/LocalizationHelper.swift b/KeyboardLocker/LocalizationHelper.swift index 7b299bc..c2df951 100644 --- a/KeyboardLocker/LocalizationHelper.swift +++ b/KeyboardLocker/LocalizationHelper.swift @@ -7,17 +7,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 +33,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 diff --git a/KeyboardLocker/NotificationManager.swift b/KeyboardLocker/NotificationManager.swift index 597a2b2..739d4be 100644 --- a/KeyboardLocker/NotificationManager.swift +++ b/KeyboardLocker/NotificationManager.swift @@ -24,7 +24,7 @@ class NotificationManager: ObservableObject, NotificationManaging { case general = "GENERAL" var identifier: String { - return rawValue + rawValue } } @@ -40,52 +40,52 @@ class NotificationManager: ObservableObject, NotificationManaging { 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 LocalizationKey.notificationUrlCommand.localized + LocalizationKey.notificationUrlCommand.localized case .urlCommandError: - return LocalizationKey.notificationError.localized + 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 } } } @@ -131,7 +131,7 @@ class NotificationManager: ObservableObject, NotificationManaging { 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")") @@ -192,7 +192,7 @@ class NotificationManager: ObservableObject, NotificationManaging { 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)") @@ -221,7 +221,7 @@ class NotificationManager: ObservableObject, NotificationManaging { let identifiersToRemove = requests .filter { $0.content.categoryIdentifier == category.identifier } - .map { $0.identifier } + .map(\.identifier) self?.notificationCenter.removePendingNotificationRequests( withIdentifiers: identifiersToRemove) @@ -234,7 +234,7 @@ class NotificationManager: ObservableObject, NotificationManaging { let identifiersToRemove = notifications .filter { $0.request.content.categoryIdentifier == category.identifier } - .map { $0.request.identifier } + .map(\.request.identifier) self?.notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToRemove) print( @@ -280,7 +280,7 @@ class NotificationManager: ObservableObject, NotificationManaging { } 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 { @@ -310,11 +310,11 @@ enum NotificationError: Error, LocalizedError { var errorDescription: String? { switch self { case .notAuthorized: - return "Notifications not authorized" + "Notifications not authorized" case .invalidContent: - return "Invalid notification content" + "Invalid notification content" case let .systemError(error): - return "System error: \(error.localizedDescription)" + "System error: \(error.localizedDescription)" } } } @@ -326,7 +326,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/PermissionManager.swift index 8b058cb..b3f503f 100644 --- a/KeyboardLocker/PermissionManager.swift +++ b/KeyboardLocker/PermissionManager.swift @@ -1,6 +1,5 @@ import AppKit import Core -import Foundation /// Permission management for accessibility and notification permissions class PermissionManager: ObservableObject { @@ -10,7 +9,7 @@ class PermissionManager: ObservableObject { // Computed property that delegates to NotificationManager var hasNotificationPermission: Bool { - return notificationManager.isAuthorized + notificationManager.isAuthorized } // MARK: - Private Properties @@ -56,7 +55,7 @@ class PermissionManager: ObservableObject { func requestNotificationPermission() { notificationManager.requestAuthorization { [weak self] (_: Bool, error: Error?) in // The NotificationManager handles state updates - if let error = error { + if let error { print("Failed to request notification permission: \(error)") } // Trigger objectWillChange to update any UI that depends on hasNotificationPermission diff --git a/KeyboardLocker/Protocols.swift b/KeyboardLocker/Protocols.swift index 9ad7d19..bfda875 100644 --- a/KeyboardLocker/Protocols.swift +++ b/KeyboardLocker/Protocols.swift @@ -62,18 +62,18 @@ enum KeyboardLockResult { var isSuccess: Bool { switch self { case .success: - return true + true case .failure: - return false + false } } var error: KeyboardLockerError? { switch self { case .success: - return nil + nil case let .failure(error): - return error + error } } } @@ -90,15 +90,15 @@ enum KeyboardLockerError: LocalizedError { var errorDescription: String? { switch self { case .accessibilityPermissionDenied: - return "Accessibility permission not granted" + "Accessibility permission not granted" case .eventTapCreationFailed: - return "Failed to create event tap" + "Failed to create event tap" case .runLoopSourceCreationFailed: - return "Failed to create run loop source" + "Failed to create run loop source" case .invalidEventType: - return "Invalid event type encountered" + "Invalid event type encountered" case .managerNotAvailable: - return "Keyboard lock manager not available" + "Keyboard lock manager not available" } } } diff --git a/KeyboardLocker/SafeEventHandling.swift b/KeyboardLocker/SafeEventHandling.swift index 1d27b79..4b6fc2e 100644 --- a/KeyboardLocker/SafeEventHandling.swift +++ b/KeyboardLocker/SafeEventHandling.swift @@ -62,14 +62,14 @@ enum SafeEventHandler { /// - Parameter event: The CGEvent to extract keycode from /// - Returns: Keycode value if available, nil otherwise static func getKeycode(from event: CGEvent) -> Int64? { - return event.getIntegerValueField(.keyboardEventKeycode) + event.getIntegerValueField(.keyboardEventKeycode) } /// Safely get event flags /// - Parameter event: The CGEvent to extract flags from /// - Returns: CGEventFlags for the event static func getFlags(from event: CGEvent) -> CGEventFlags { - return event.flags + event.flags } /// Check if event has specific modifier flags diff --git a/KeyboardLocker/SettingsView.swift b/KeyboardLocker/SettingsView.swift index cf4a047..e1fca2b 100644 --- a/KeyboardLocker/SettingsView.swift +++ b/KeyboardLocker/SettingsView.swift @@ -1,8 +1,18 @@ +import Core import SwiftUI struct SettingsView: View { - @StateObject private var appConfig = AppConfiguration.shared - @EnvironmentObject var keyboardManager: KeyboardLockManager + @ObservedObject private var coreConfig = CoreConfiguration.shared + + // Auto-lock duration options (in seconds) + private let durationOptions: [(Int, String)] = [ + (0, "Never"), + (900, "15 minutes"), + (1800, "30 minutes"), + (3600, "1 hour"), + (7200, "2 hours"), + (14400, "4 hours"), + ] var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -14,18 +24,23 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { HStack { - Text(LocalizationKey.settingsAutoLockTime.localized) - Spacer() - Picker("", selection: $appConfig.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) + Picker("Auto-lock Duration", selection: $coreConfig.autoLockDuration) { + ForEach(durationOptions, id: \.0) { value, label in + Text(label).tag(value) + } } .pickerStyle(MenuPickerStyle()) - .frame(width: 100) - .onChange(of: appConfig.autoLockDuration) { _ in - keyboardManager.updateAutoLockSettings() + } + + // Show current activity status if auto-lock is enabled + if coreConfig.autoLockDuration > 0 { + HStack { + Image(systemName: "timer") + .foregroundColor(.secondary) + Text("Starts counting when you stop typing or using the mouse") + .font(.caption) + .foregroundColor(.secondary) + Spacer() } } @@ -45,7 +60,10 @@ struct SettingsView: View { .foregroundColor(.primary) VStack(alignment: .leading, spacing: 12) { - Toggle(LocalizationKey.settingsShowNotifications.localized, isOn: $appConfig.showNotifications) + Toggle( + LocalizationKey.settingsShowNotifications.localized, + isOn: $coreConfig.showNotifications + ) Text(LocalizationKey.settingsNotificationsDescription.localized) .font(.caption) @@ -91,7 +109,7 @@ struct SettingsView: View { HStack { Spacer() Button(LocalizationKey.settingsReset.localized) { - appConfig.resetToDefaults() + coreConfig.resetToDefaults() } .buttonStyle(PlainButtonStyle()) .foregroundColor(.red) diff --git a/KeyboardLocker/URLHandler.swift b/KeyboardLocker/URLHandler.swift index a9c606c..304b586 100644 --- a/KeyboardLocker/URLHandler.swift +++ b/KeyboardLocker/URLHandler.swift @@ -13,13 +13,13 @@ class URLCommandHandler { 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,18 +32,18 @@ class URLCommandHandler { 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 } } } @@ -85,7 +85,7 @@ class URLCommandHandler { // 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) @@ -214,11 +214,11 @@ class URLCommandHandler { extension URLCommandHandler { /// Test URL creation helper static func createTestURL(for command: URLCommand) -> URL? { - return URL(string: "keyboardlocker://\(command.rawValue)") + URL(string: "keyboardlocker://\(command.rawValue)") } /// Get all supported commands for documentation static func getSupportedCommands() -> [String] { - return URLCommand.allCases.map { "keyboardlocker://\($0.rawValue)" } + URLCommand.allCases.map { "keyboardlocker://\($0.rawValue)" } } } diff --git a/KeyboardLockerTool/main.swift b/KeyboardLockerTool/main.swift index 1ce9c7d..c746fd2 100644 --- a/KeyboardLockerTool/main.swift +++ b/KeyboardLockerTool/main.swift @@ -8,4 +8,3 @@ import Foundation print("Hello, World!") - From 083b0aad751feff91c9c4fac8a8cb73bcbd13cb0 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 28 Jul 2025 15:27:35 +0800 Subject: [PATCH 04/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Refactor=20auto-lock?= =?UTF-8?q?=20configuration=20and=20UI=20integration.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 179 ------------------- Core/Sources/Core/CoreConfiguration.swift | 116 ++++++------- Core/Sources/Core/KeyboardLockCore.swift | 14 -- Core/Sources/Core/KeyboardLockerAPI.swift | 45 ++--- Core/Sources/Core/SharedModels.swift | 25 --- KeyboardLocker/KeyboardLockManager.swift | 34 ++-- KeyboardLocker/LocalizationHelper.swift | 4 +- KeyboardLocker/SettingsView.swift | 203 ++++++++++++---------- 8 files changed, 210 insertions(+), 410 deletions(-) delete mode 100644 .github/copilot-instructions.md 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/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift index 5fabc73..4967e22 100644 --- a/Core/Sources/Core/CoreConfiguration.swift +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -1,6 +1,7 @@ import Carbon import Combine import Foundation +import SwiftUI /// Core configuration management for KeyboardLocker /// Handles persistent settings and configuration synchronization across all targets @@ -21,40 +22,60 @@ public class CoreConfiguration: ObservableObject { case appVersion } - // MARK: - Published Properties + public enum AutoLockDuration: Codable, Equatable, Hashable, Identifiable { + case never + case minutes(Int) // Duration in minutes - /// Auto-lock duration in minutes (0 = disabled) - @Published public var autoLockDuration: Int = 0 { - didSet { - UserDefaults.standard.set(autoLockDuration, forKey: ConfigKeys.autoLockDuration.rawValue) - print("📝 Auto-lock duration updated: \(autoLockDuration) minutes") + // MARK: - Identifiable + + public var id: String { + switch self { + case .never: + return "never" + case let .minutes(minutes): + return "minutes_\(minutes)" + } } - } - /// Whether to show system notifications - @Published public var showNotifications: Bool = true { - didSet { - UserDefaults.standard.set(showNotifications, forKey: ConfigKeys.showNotifications.rawValue) - print("📝 Notifications setting updated: \(showNotifications)") + /// Convert to minutes + public var minutes: Int { + switch self { + case .never: + return 0 + case let .minutes(minutes): + return minutes + } } - } - /// Whether to launch app at login - @Published public var launchAtLogin: Bool = false { - didSet { - UserDefaults.standard.set(launchAtLogin, forKey: ConfigKeys.launchAtLogin.rawValue) - print("📝 Launch at login updated: \(launchAtLogin)") + /// Convert to seconds + public var seconds: TimeInterval { + TimeInterval(minutes * 60) + } + + /// Whether auto-lock is enabled + public var isEnabled: Bool { + return self != .never } } - /// Whether to enable sound effects - @Published public var enableSounds: Bool = true { + // MARK: - Published Properties with AppStorage + + /// Auto-lock configuration using enum instead of raw values + @AppStorage("autoLockDuration") private var storedAutoLockMinutes: Int = 0 + + @Published public var autoLockDuration: AutoLockDuration = .never { didSet { - UserDefaults.standard.set(enableSounds, forKey: ConfigKeys.enableSounds.rawValue) - print("📝 Sound effects updated: \(enableSounds)") + storedAutoLockMinutes = autoLockDuration.minutes + print("📝 Auto-lock duration updated: \(autoLockDuration.minutes)") } } + /// Whether to show system notifications + @AppStorage("showNotifications") public var showNotifications: Bool = true + + /// Whether to launch app at login + @AppStorage("launchAtLogin") public var launchAtLogin: Bool = false + /// Hotkey configuration (using Carbon key codes) @Published public var hotkey: HotkeyConfiguration = .defaultHotkey() { didSet { @@ -69,35 +90,21 @@ public class CoreConfiguration: ObservableObject { /// Check if auto-lock is enabled public var isAutoLockEnabled: Bool { - autoLockDuration > 0 + autoLockDuration.isEnabled } /// Auto-lock duration in seconds public var autoLockDurationInSeconds: TimeInterval { - TimeInterval(autoLockDuration * 60) // Convert minutes to seconds + autoLockDuration.seconds } - // MARK: - Non-Published Properties + // MARK: - Non-Published Properties with AppStorage /// Whether this is the first app launch - public var isFirstLaunch: Bool { - get { - UserDefaults.standard.bool(forKey: ConfigKeys.isFirstLaunch.rawValue) - } - set { - UserDefaults.standard.set(newValue, forKey: ConfigKeys.isFirstLaunch.rawValue) - } - } + @AppStorage("isFirstLaunch") public var isFirstLaunch: Bool = true /// Current app version - public var appVersion: String { - get { - UserDefaults.standard.string(forKey: ConfigKeys.appVersion.rawValue) ?? "1.0.0" - } - set { - UserDefaults.standard.set(newValue, forKey: ConfigKeys.appVersion.rawValue) - } - } + @AppStorage("appVersion") public var appVersion: String = "1.0.0" // MARK: - Initialization @@ -110,12 +117,8 @@ public class CoreConfiguration: ObservableObject { /// Load configuration from UserDefaults public func loadConfiguration() { - autoLockDuration = UserDefaults.standard.integer(forKey: ConfigKeys.autoLockDuration.rawValue) - showNotifications = - UserDefaults.standard.object(forKey: ConfigKeys.showNotifications.rawValue) as? Bool ?? true - launchAtLogin = UserDefaults.standard.bool(forKey: ConfigKeys.launchAtLogin.rawValue) - enableSounds = - UserDefaults.standard.object(forKey: ConfigKeys.enableSounds.rawValue) as? Bool ?? true + // Load auto-lock duration from stored minutes + autoLockDuration = storedAutoLockMinutes == 0 ? .never : .minutes(storedAutoLockMinutes) // Load hotkey configuration if let data = UserDefaults.standard.data(forKey: ConfigKeys.hotkey.rawValue), @@ -126,20 +129,14 @@ public class CoreConfiguration: ObservableObject { hotkey = HotkeyConfiguration.defaultHotkey() } - // Set first launch flag if not set - if UserDefaults.standard.object(forKey: ConfigKeys.isFirstLaunch.rawValue) == nil { - isFirstLaunch = true - } - print("📁 Configuration loaded from UserDefaults") } /// Reset configuration to default values public func resetToDefaults() { - autoLockDuration = 0 + autoLockDuration = .never showNotifications = true launchAtLogin = false - enableSounds = true hotkey = HotkeyConfiguration.defaultHotkey() isFirstLaunch = false @@ -149,11 +146,10 @@ public class CoreConfiguration: ObservableObject { /// Export configuration as dictionary public func exportConfiguration() -> [String: Any] { [ - ConfigKeys.autoLockDuration.rawValue: autoLockDuration, + ConfigKeys.autoLockDuration.rawValue: autoLockDuration.minutes, ConfigKeys.showNotifications.rawValue: showNotifications, ConfigKeys.launchAtLogin.rawValue: launchAtLogin, - ConfigKeys.enableSounds.rawValue: enableSounds, - ConfigKeys.hotkey.rawValue: try! JSONEncoder().encode(hotkey), + ConfigKeys.hotkey.rawValue: (try? JSONEncoder().encode(hotkey)) ?? Data(), ConfigKeys.isFirstLaunch.rawValue: isFirstLaunch, ConfigKeys.appVersion.rawValue: appVersion, ] @@ -162,7 +158,7 @@ public class CoreConfiguration: ObservableObject { /// Import configuration from dictionary public func importConfiguration(_ config: [String: Any]) { if let duration = config[ConfigKeys.autoLockDuration.rawValue] as? Int { - autoLockDuration = duration + autoLockDuration = duration == 0 ? .never : .minutes(duration) } if let notifications = config[ConfigKeys.showNotifications.rawValue] as? Bool { @@ -173,10 +169,6 @@ public class CoreConfiguration: ObservableObject { launchAtLogin = login } - if let sounds = config[ConfigKeys.enableSounds.rawValue] as? Bool { - enableSounds = sounds - } - if let hotkeyData = config[ConfigKeys.hotkey.rawValue] as? Data, let decodedHotkey = try? JSONDecoder().decode(HotkeyConfiguration.self, from: hotkeyData) { diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift index 38a3197..c48dc89 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -73,20 +73,6 @@ public class KeyboardLockCore { (_isLocked, _lockedAt) } - // MARK: - Deprecated Properties (for backward compatibility) - - public var autoLockDuration: Int { - 0 // Auto-lock logic moved to business layer - } - - public var isAutoLockEnabled: Bool { - false // Auto-lock logic moved to business layer - } - - public var autoLockDurationInSeconds: TimeInterval { - 0 // Auto-lock logic moved to business layer - } - // MARK: - Initialization private init() { diff --git a/Core/Sources/Core/KeyboardLockerAPI.swift b/Core/Sources/Core/KeyboardLockerAPI.swift index 797c51b..ac4eae7 100644 --- a/Core/Sources/Core/KeyboardLockerAPI.swift +++ b/Core/Sources/Core/KeyboardLockerAPI.swift @@ -66,30 +66,35 @@ public class KeyboardLockerAPI: ObservableObject { // MARK: - Auto-lock Configuration (Core Logic Only) - /// Enable auto-lock with specified duration in seconds - /// Core只关心传入的时间值,不管理UI选项 - public func enableAutoLock(seconds: TimeInterval) { - configuration.autoLockDuration = Int(seconds) - activityMonitor.enableAutoLock(seconds: seconds) + /// Enable auto-lock with specified duration in minutes + public func enableAutoLock(minutes: Int) { + let autoLockSetting: CoreConfiguration.AutoLockDuration = minutes == 0 ? .never : .minutes(minutes) + + configuration.autoLockDuration = autoLockSetting + activityMonitor.enableAutoLock(seconds: autoLockSetting.seconds) - // Start activity monitoring if not already started - if seconds > 0 { + // Start activity monitoring if enabled + if autoLockSetting.isEnabled { activityMonitor.startMonitoring() - print("✅ Auto-lock enabled: \(Int(seconds / 60)) minutes") + print("✅ Auto-lock enabled: \(minutes)") } else { activityMonitor.stopMonitoring() print("❌ Auto-lock disabled") } } - /// Enable auto-lock with specified duration in minutes (convenience method) - public func enableAutoLock(minutes: Int) { - enableAutoLock(seconds: TimeInterval(minutes * 60)) + /// Enable auto-lock with specified duration in seconds (for backward compatibility) + public func enableAutoLock(seconds: TimeInterval) { + let minutes = Int(seconds / 60) + enableAutoLock(minutes: minutes) } /// Disable auto-lock public func disableAutoLock() { - enableAutoLock(seconds: 0) + configuration.autoLockDuration = .never + activityMonitor.enableAutoLock(seconds: 0) + activityMonitor.stopMonitoring() + print("❌ Auto-lock disabled") } /// Get current auto-lock status @@ -97,14 +102,14 @@ public class KeyboardLockerAPI: ObservableObject { configuration.isAutoLockEnabled } - /// Get auto-lock duration in seconds (Core stores in seconds) + /// Get auto-lock duration in seconds public func getAutoLockDurationSeconds() -> Int { - configuration.autoLockDuration + Int(configuration.autoLockDurationInSeconds) } - /// Get auto-lock duration in minutes (convenience method for UI) + /// Get auto-lock duration in minutes public func getAutoLockDuration() -> Int { - configuration.autoLockDuration / 60 + configuration.autoLockDuration.minutes } /// Get time since last user activity (for UI display) @@ -173,10 +178,10 @@ public class KeyboardLockerAPI: ObservableObject { /// Setup configuration observer to sync auto-lock settings private func setupConfigurationObserver() { - // Sync initial auto-lock configuration - let duration = configuration.autoLockDuration - if duration > 0 { - activityMonitor.enableAutoLock(seconds: TimeInterval(duration)) + // Sync initial auto-lock configuration using new enum + let autoLockConfig = configuration.autoLockDuration + if autoLockConfig.isEnabled { + activityMonitor.enableAutoLock(seconds: autoLockConfig.seconds) activityMonitor.startMonitoring() } } diff --git a/Core/Sources/Core/SharedModels.swift b/Core/Sources/Core/SharedModels.swift index 6a57b43..0b0909f 100644 --- a/Core/Sources/Core/SharedModels.swift +++ b/Core/Sources/Core/SharedModels.swift @@ -136,31 +136,6 @@ public enum CoreConstants { /// Timeout for IPC connections (in seconds) public static let ipcTimeout: TimeInterval = 5.0 - - /// Auto-lock timer intervals (in minutes) - public enum AutoLockInterval: Int, CaseIterable { - case never = 0 - case fifteen = 15 - case thirty = 30 - case sixty = 60 - - public var description: String { - switch self { - case .never: - "Never" - case .fifteen: - "15 minutes" - case .thirty: - "30 minutes" - case .sixty: - "1 hour" - } - } - - public var timeInterval: TimeInterval { - TimeInterval(rawValue * 60) - } - } } // MARK: - Lock Status diff --git a/KeyboardLocker/KeyboardLockManager.swift b/KeyboardLocker/KeyboardLockManager.swift index 68a4fbf..9e914ad 100644 --- a/KeyboardLocker/KeyboardLockManager.swift +++ b/KeyboardLocker/KeyboardLockManager.swift @@ -79,21 +79,25 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { // MARK: - Auto-Lock Management (using Core API directly) func startAutoLock() { - let durationMinutes = max(coreAPI.configuration.autoLockDuration / 60, 1) // Convert to minutes, minimum 1 - coreAPI.configuration.autoLockDuration = durationMinutes * 60 // This will trigger Core API update - print("✅ Auto-lock enabled with \(durationMinutes) minutes duration (activity-based)") + // Use thirtyMinutes as default when enabling auto-lock if currently disabled + if !coreAPI.configuration.autoLockDuration.isEnabled { + coreAPI.configuration.autoLockDuration = .minutes(30) + } + print( + "✅ Auto-lock enabled with \(coreAPI.configuration.autoLockDuration.minutes) duration (activity-based)" + ) } func stopAutoLock() { - coreAPI.configuration.autoLockDuration = 0 // This will trigger Core API update + coreAPI.configuration.autoLockDuration = .never print("✅ Auto-lock disabled") } func toggleAutoLock() { - if coreAPI.configuration.autoLockDuration > 0 { - coreAPI.configuration.autoLockDuration = 0 + if coreAPI.configuration.autoLockDuration.isEnabled { + coreAPI.configuration.autoLockDuration = .never } else { - coreAPI.configuration.autoLockDuration = 1800 // Default 30 minutes + coreAPI.configuration.autoLockDuration = .minutes(30) } print("✅ Auto-lock toggled") // Update UI state @@ -130,16 +134,16 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { print("ℹ️ Permission request sent. Please grant accessibility permission in System Settings.") } - // MARK: - Configuration Access (forwarded to Core directly) + // MARK: - Configuration Access (直接使用CoreConfiguration) - /// Auto-lock duration in minutes for UI display (using Core config) + /// Auto-lock duration in minutes for UI display var autoLockDuration: Int { - coreAPI.configuration.autoLockDuration / 60 // Convert seconds to minutes for UI + CoreConfiguration.shared.autoLockDuration.minutes } - /// Check if auto-lock is enabled (using Core config) + /// Check if auto-lock is enabled var isAutoLockEnabled: Bool { - coreAPI.configuration.autoLockDuration > 0 + CoreConfiguration.shared.autoLockDuration.isEnabled } /// Get/set notification preference (using Core directly) @@ -165,7 +169,7 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { .autoconnect() .sink { [weak self] _ in self?.isLocked = self?.coreAPI.isLocked ?? false - self?.autoLockEnabled = (self?.coreAPI.configuration.autoLockDuration ?? 0) > 0 + self?.autoLockEnabled = self?.coreAPI.configuration.autoLockDuration.isEnabled ?? false } .store(in: &cancellables) } @@ -174,14 +178,14 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { private func syncInitialState() { DispatchQueue.main.async { self.isLocked = self.coreAPI.isLocked - self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration > 0 + self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration.isEnabled } } /// Sync auto-lock configuration from Core private func syncAutoLockConfiguration() { DispatchQueue.main.async { - self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration > 0 + self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration.isEnabled } } diff --git a/KeyboardLocker/LocalizationHelper.swift b/KeyboardLocker/LocalizationHelper.swift index c2df951..1bf8617 100644 --- a/KeyboardLocker/LocalizationHelper.swift +++ b/KeyboardLocker/LocalizationHelper.swift @@ -87,9 +87,7 @@ enum LocalizationKey { 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 timeMinutes = "time.minutes" static let timeNever = "time.never" // About diff --git a/KeyboardLocker/SettingsView.swift b/KeyboardLocker/SettingsView.swift index e1fca2b..57e9d21 100644 --- a/KeyboardLocker/SettingsView.swift +++ b/KeyboardLocker/SettingsView.swift @@ -4,120 +4,139 @@ import SwiftUI struct SettingsView: View { @ObservedObject private var coreConfig = CoreConfiguration.shared - // Auto-lock duration options (in seconds) - private let durationOptions: [(Int, String)] = [ - (0, "Never"), - (900, "15 minutes"), - (1800, "30 minutes"), - (3600, "1 hour"), - (7200, "2 hours"), - (14400, "4 hours"), + private typealias AutoLockInterval = CoreConfiguration.AutoLockDuration + + private let durationOptions: [AutoLockInterval] = [ + .never, + .minutes(15), + .minutes(30), + .minutes(60), ] 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) + autoLockSection + notificationSection + keyboardSection + Spacer() + resetSection + } + .padding() + .navigationTitle(LocalizationKey.settingsTitle.localized) + .frame(width: 300) + } - VStack(alignment: .leading, spacing: 12) { - HStack { - Picker("Auto-lock Duration", selection: $coreConfig.autoLockDuration) { - ForEach(durationOptions, id: \.0) { value, label in - Text(label).tag(value) - } - } - .pickerStyle(MenuPickerStyle()) - } + // MARK: - View Components - // Show current activity status if auto-lock is enabled - if coreConfig.autoLockDuration > 0 { - HStack { - Image(systemName: "timer") - .foregroundColor(.secondary) - Text("Starts counting when you stop typing or using the mouse") - .font(.caption) - .foregroundColor(.secondary) - Spacer() + 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("Auto-lock Duration", selection: $coreConfig.autoLockDuration) { + ForEach(durationOptions, id: \.self) { duration in + Text(formatDuration(duration)) + .tag(duration) } } - - Text(LocalizationKey.settingsAutoLockDescription.localized) - .font(.caption) - .foregroundColor(.secondary) + .pickerStyle(MenuPickerStyle()) } - .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: $coreConfig.showNotifications - ) - - Text(LocalizationKey.settingsNotificationsDescription.localized) - .font(.caption) - .foregroundColor(.secondary) + // Show current activity status if auto-lock is enabled + if coreConfig.autoLockDuration.isEnabled { + HStack { + Image(systemName: "timer") + .foregroundColor(.secondary) + Text("Starts counting when you stop typing or using the mouse") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } } - .padding(12) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(8) + + 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) - // Keyboard shortcut description VStack(alignment: .leading, spacing: 12) { - Text(LocalizationKey.settingsKeyboard.localized) - .font(.headline) - .foregroundColor(.primary) + Toggle( + LocalizationKey.settingsShowNotifications.localized, + isOn: $coreConfig.showNotifications + ) - 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.settingsNotificationsDescription.localized) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(8) + } + } - Text(LocalizationKey.settingsKeyboardDescription.localized) - .font(.caption) - .foregroundColor(.secondary) + 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) } - .padding(12) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(8) + + Text(LocalizationKey.settingsKeyboardDescription.localized) + .font(.caption) + .foregroundColor(.secondary) } + .padding(12) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(8) + } + } + private var resetSection: some View { + HStack { Spacer() - - // Reset button - HStack { - Spacer() - Button(LocalizationKey.settingsReset.localized) { - coreConfig.resetToDefaults() - } - .buttonStyle(PlainButtonStyle()) - .foregroundColor(.red) + Button(LocalizationKey.settingsReset.localized) { + coreConfig.resetToDefaults() } + .buttonStyle(PlainButtonStyle()) + .foregroundColor(.red) + } + } + + private func formatDuration(_ interval: AutoLockInterval) -> String { + switch interval { + case .never: + return LocalizationKey.timeNever + case let .minutes(m): + return LocalizationKey.timeMinutes.localized(m) } - .padding() - .navigationTitle(LocalizationKey.settingsTitle.localized) - .frame(width: 300) } } From f67a28c0e6a69d44f9bbba997507984d3db8fd92 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 28 Jul 2025 15:48:19 +0800 Subject: [PATCH 05/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20localization=20?= =?UTF-8?q?for=20auto-lock=20status=20and=20menu=20title.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KeyboardLocker/ContentView.swift | 6 +- KeyboardLocker/KeyboardLockerApp.swift | 2 +- KeyboardLocker/LocalizationHelper.swift | 15 +- KeyboardLocker/SettingsView.swift | 12 +- KeyboardLocker/i18n/Localizable.xcstrings | 170 ++++++---------------- 5 files changed, 64 insertions(+), 141 deletions(-) diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift index 8c44ac4..593fb05 100644 --- a/KeyboardLocker/ContentView.swift +++ b/KeyboardLocker/ContentView.swift @@ -92,7 +92,7 @@ struct ContentView: View { Image(systemName: "timer") .foregroundColor(.orange) .font(.caption) - Text("Auto-lock: \(autoLockStatusText)") + Text(LocalizationKey.autoLockStatus.localized(autoLockStatusText)) .font(.caption) .foregroundColor(.secondary) Spacer() @@ -267,7 +267,7 @@ struct ContentView: View { private var autoLockStatusText: String { let duration = keyboardManager.autoLockDuration if duration == 0 { - return "Disabled" + return LocalizationKey.autoLockDisabled.localized } // Get time since last activity @@ -279,7 +279,7 @@ struct ContentView: View { let seconds = Int(remainingTime.truncatingRemainder(dividingBy: 60)) return String(format: "%02d:%02d", minutes, seconds) } else { - return "Ready to lock" + return LocalizationKey.autoLockReadyToLock.localized } } } diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift index fbb9f82..91ff27f 100644 --- a/KeyboardLocker/KeyboardLockerApp.swift +++ b/KeyboardLocker/KeyboardLockerApp.swift @@ -30,7 +30,7 @@ struct KeyboardLockerApp: App { var body: some Scene { // Modern MenuBarExtra for native menu bar integration - MenuBarExtra("Keyboard Locker", systemImage: "lock.shield") { + MenuBarExtra(LocalizationKey.appMenuTitle.localized, systemImage: "lock.shield") { ContentView() .environmentObject(keyboardLockManager) .environmentObject(permissionManager) diff --git a/KeyboardLocker/LocalizationHelper.swift b/KeyboardLocker/LocalizationHelper.swift index 1bf8617..46e6a8b 100644 --- a/KeyboardLocker/LocalizationHelper.swift +++ b/KeyboardLocker/LocalizationHelper.swift @@ -57,6 +57,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 +78,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" @@ -89,6 +89,11 @@ enum LocalizationKey { // Time durations static let timeMinutes = "time.minutes" static let timeNever = "time.never" + 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" // About static let aboutVersionFormat = "about.version.format" @@ -97,8 +102,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 @@ -113,17 +116,11 @@ enum LocalizationKey { 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 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" static let urlErrorMissingCommand = "url.error.missing.command" diff --git a/KeyboardLocker/SettingsView.swift b/KeyboardLocker/SettingsView.swift index 57e9d21..fa77b70 100644 --- a/KeyboardLocker/SettingsView.swift +++ b/KeyboardLocker/SettingsView.swift @@ -36,7 +36,9 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { HStack { - Picker("Auto-lock Duration", selection: $coreConfig.autoLockDuration) { + Picker( + LocalizationKey.timeAutoLockDuration.localized, selection: $coreConfig.autoLockDuration + ) { ForEach(durationOptions, id: \.self) { duration in Text(formatDuration(duration)) .tag(duration) @@ -50,7 +52,7 @@ struct SettingsView: View { HStack { Image(systemName: "timer") .foregroundColor(.secondary) - Text("Starts counting when you stop typing or using the mouse") + Text(LocalizationKey.timeActivityText.localized) .font(.caption) .foregroundColor(.secondary) Spacer() @@ -97,9 +99,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 8) { HStack { - Text( - LocalizationKey.actionLock.localized + "/" + LocalizationKey.actionUnlock.localized - + ":") + Text(LocalizationKey.actionLock.localized + "/" + LocalizationKey.actionUnlock.localized + ":") Spacer() Text("⌘ + ⌥ + L".localized) .font(.system(.body, design: .monospaced)) @@ -133,7 +133,7 @@ struct SettingsView: View { private func formatDuration(_ interval: AutoLockInterval) -> String { switch interval { case .never: - return LocalizationKey.timeNever + return LocalizationKey.timeNever.localized case let .minutes(m): return LocalizationKey.timeMinutes.localized(m) } diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings index 6520bc0..0eb575f 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -1,26 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - "shouldTranslate" : false - }, - "⌘ + ⌥ + L" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "⌘ + ⌥ + L" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "⌘ + ⌥ + L" - } - } - } - }, "about.feature.auto.lock" : { "extractionState" : "manual", "localizations" : { @@ -106,23 +86,6 @@ } } }, - "about.feedback" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Report Issue" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "反馈问题" - } - } - } - }, "about.github" : { "extractionState" : "manual", "localizations" : { @@ -140,23 +103,6 @@ } } }, - "about.help" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Help" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用帮助" - } - } - } - }, "about.subtitle" : { "extractionState" : "manual", "localizations" : { @@ -259,6 +205,23 @@ } } }, + "app.menu.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keyboard Locker" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "键盘锁" + } + } + } + }, "app.title" : { "extractionState" : "manual", "localizations" : { @@ -293,42 +256,56 @@ } } }, - "error.recovery.message" : { + "auto.lock.disabled" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Application recovered from an error. Keyboard has been unlocked." + "value" : "Disabled" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "应用程序已从错误中恢复。键盘已解锁。" + "value" : "已禁用" } } } }, - "error.recovery.title" : { + "auto.lock.ready.to.lock" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard Locker Recovery" + "value" : "Ready to lock" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "键盘锁定器恢复" + "value" : "准备锁定" } } } }, - "Keyboard Locker" : { - "shouldTranslate" : false + "auto.lock.status" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto-lock: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动锁定: %@" + } + } + } }, "lock.duration.format" : { "extractionState" : "manual", @@ -466,40 +443,6 @@ } } }, - "permission.accessibility.message" : { - "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." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "请在系统设置 > 隐私与安全性 > 辅助功能中,允许 Keyboard Locker 控制您的电脑以启用键盘锁定功能。" - } - } - } - }, - "permission.accessibility.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accessibility Permission Required" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "需要辅助功能权限" - } - } - } - }, "permission.description" : { "extractionState" : "manual", "localizations" : { @@ -585,23 +528,6 @@ } } }, - "settings.auto.lock.time" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto Lock Duration:" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "自动锁定时间:" - } - } - } - }, "settings.keyboard" : { "extractionState" : "manual", "localizations" : { @@ -789,53 +715,53 @@ } } }, - "time.15.minutes" : { + "time.activity.text" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "15 Minutes" + "value" : "Starts counting when you stop typing or using the mouse" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "15分钟" + "value" : "当您停止输入或使用鼠标时开始计时" } } } }, - "time.30.minutes" : { + "time.auto.lock.duration" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "30 Minutes" + "value" : "Auto-lock Duration" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "30分钟" + "value" : "自动锁定时长" } } } }, - "time.60.minutes" : { + "time.minutes" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "60 Minutes" + "value" : "%d minutes" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "60分钟" + "value" : "%d分钟" } } } From 49cc651aee443b648c7c1a4aa0b8c658936f7a73 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 28 Jul 2025 16:02:47 +0800 Subject: [PATCH 06/21] chore: Rename with folders. --- KeyboardLocker/{ => Sources/Application}/AppDelegate.swift | 0 KeyboardLocker/{ => Sources/Application}/KeyboardLockerApp.swift | 0 KeyboardLocker/{ => Sources/Helpers}/DependencyFactory.swift | 0 KeyboardLocker/{ => Sources/Helpers}/LocalizationHelper.swift | 0 KeyboardLocker/{ => Sources/Helpers}/SafeEventHandling.swift | 0 KeyboardLocker/{ => Sources/Helpers}/URLHandler.swift | 0 KeyboardLocker/{ => Sources/Managers}/KeyboardLockManager.swift | 0 KeyboardLocker/{ => Sources/Managers}/NotificationManager.swift | 0 KeyboardLocker/{ => Sources/Managers}/PermissionManager.swift | 0 KeyboardLocker/{ => Sources/Protocols}/Protocols.swift | 0 KeyboardLocker/{ => Sources/Views}/AboutView.swift | 0 KeyboardLocker/{ => Sources/Views}/ContentView.swift | 0 KeyboardLocker/{ => Sources/Views}/SettingsView.swift | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename KeyboardLocker/{ => Sources/Application}/AppDelegate.swift (100%) rename KeyboardLocker/{ => Sources/Application}/KeyboardLockerApp.swift (100%) rename KeyboardLocker/{ => Sources/Helpers}/DependencyFactory.swift (100%) rename KeyboardLocker/{ => Sources/Helpers}/LocalizationHelper.swift (100%) rename KeyboardLocker/{ => Sources/Helpers}/SafeEventHandling.swift (100%) rename KeyboardLocker/{ => Sources/Helpers}/URLHandler.swift (100%) rename KeyboardLocker/{ => Sources/Managers}/KeyboardLockManager.swift (100%) rename KeyboardLocker/{ => Sources/Managers}/NotificationManager.swift (100%) rename KeyboardLocker/{ => Sources/Managers}/PermissionManager.swift (100%) rename KeyboardLocker/{ => Sources/Protocols}/Protocols.swift (100%) rename KeyboardLocker/{ => Sources/Views}/AboutView.swift (100%) rename KeyboardLocker/{ => Sources/Views}/ContentView.swift (100%) rename KeyboardLocker/{ => Sources/Views}/SettingsView.swift (100%) diff --git a/KeyboardLocker/AppDelegate.swift b/KeyboardLocker/Sources/Application/AppDelegate.swift similarity index 100% rename from KeyboardLocker/AppDelegate.swift rename to KeyboardLocker/Sources/Application/AppDelegate.swift diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift similarity index 100% rename from KeyboardLocker/KeyboardLockerApp.swift rename to KeyboardLocker/Sources/Application/KeyboardLockerApp.swift diff --git a/KeyboardLocker/DependencyFactory.swift b/KeyboardLocker/Sources/Helpers/DependencyFactory.swift similarity index 100% rename from KeyboardLocker/DependencyFactory.swift rename to KeyboardLocker/Sources/Helpers/DependencyFactory.swift diff --git a/KeyboardLocker/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift similarity index 100% rename from KeyboardLocker/LocalizationHelper.swift rename to KeyboardLocker/Sources/Helpers/LocalizationHelper.swift diff --git a/KeyboardLocker/SafeEventHandling.swift b/KeyboardLocker/Sources/Helpers/SafeEventHandling.swift similarity index 100% rename from KeyboardLocker/SafeEventHandling.swift rename to KeyboardLocker/Sources/Helpers/SafeEventHandling.swift diff --git a/KeyboardLocker/URLHandler.swift b/KeyboardLocker/Sources/Helpers/URLHandler.swift similarity index 100% rename from KeyboardLocker/URLHandler.swift rename to KeyboardLocker/Sources/Helpers/URLHandler.swift diff --git a/KeyboardLocker/KeyboardLockManager.swift b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift similarity index 100% rename from KeyboardLocker/KeyboardLockManager.swift rename to KeyboardLocker/Sources/Managers/KeyboardLockManager.swift diff --git a/KeyboardLocker/NotificationManager.swift b/KeyboardLocker/Sources/Managers/NotificationManager.swift similarity index 100% rename from KeyboardLocker/NotificationManager.swift rename to KeyboardLocker/Sources/Managers/NotificationManager.swift diff --git a/KeyboardLocker/PermissionManager.swift b/KeyboardLocker/Sources/Managers/PermissionManager.swift similarity index 100% rename from KeyboardLocker/PermissionManager.swift rename to KeyboardLocker/Sources/Managers/PermissionManager.swift diff --git a/KeyboardLocker/Protocols.swift b/KeyboardLocker/Sources/Protocols/Protocols.swift similarity index 100% rename from KeyboardLocker/Protocols.swift rename to KeyboardLocker/Sources/Protocols/Protocols.swift diff --git a/KeyboardLocker/AboutView.swift b/KeyboardLocker/Sources/Views/AboutView.swift similarity index 100% rename from KeyboardLocker/AboutView.swift rename to KeyboardLocker/Sources/Views/AboutView.swift diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/Sources/Views/ContentView.swift similarity index 100% rename from KeyboardLocker/ContentView.swift rename to KeyboardLocker/Sources/Views/ContentView.swift diff --git a/KeyboardLocker/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift similarity index 100% rename from KeyboardLocker/SettingsView.swift rename to KeyboardLocker/Sources/Views/SettingsView.swift From c837e3ec78ed956e34f1104371ff5ff1f7a9c5e6 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 28 Jul 2025 18:04:54 +0800 Subject: [PATCH 07/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Enhance=20auto-lock?= =?UTF-8?q?=20duration=20management.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Core/CoreConfiguration.swift | 31 ++- .../Sources/Helpers/CountdownFormatter.swift | 44 ++++ .../Sources/Helpers/LocalizationHelper.swift | 31 ++- .../Sources/Helpers/LockDurationHelper.swift | 155 +++++++++++ .../Sources/Views/ContentView.swift | 15 +- .../Sources/Views/SettingsView.swift | 20 +- KeyboardLocker/i18n/Localizable.xcstrings | 242 ++++++++++++++++-- 7 files changed, 477 insertions(+), 61 deletions(-) create mode 100644 KeyboardLocker/Sources/Helpers/CountdownFormatter.swift create mode 100644 KeyboardLocker/Sources/Helpers/LockDurationHelper.swift diff --git a/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift index 4967e22..513811c 100644 --- a/Core/Sources/Core/CoreConfiguration.swift +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -22,8 +22,9 @@ public class CoreConfiguration: ObservableObject { case appVersion } - public enum AutoLockDuration: Codable, Equatable, Hashable, Identifiable { + public enum Duration: Codable, Equatable, Hashable, Identifiable { case never + case infinite case minutes(Int) // Duration in minutes // MARK: - Identifiable @@ -31,9 +32,11 @@ public class CoreConfiguration: ObservableObject { public var id: String { switch self { case .never: - return "never" + "never" + case .infinite: + "infinite" case let .minutes(minutes): - return "minutes_\(minutes)" + "minutes_\(minutes)" } } @@ -41,23 +44,37 @@ public class CoreConfiguration: ObservableObject { public var minutes: Int { switch self { case .never: - return 0 + 0 + case .infinite: + .max case let .minutes(minutes): - return minutes + minutes } } /// Convert to seconds public var seconds: TimeInterval { - TimeInterval(minutes * 60) + switch self { + case .never: + 0 + + case .infinite: + 0 + + case let .minutes(minutes): + TimeInterval(minutes * 60) + } } /// Whether auto-lock is enabled public var isEnabled: Bool { - return self != .never + self != .never } } + /// Backward compatibility alias + public typealias AutoLockDuration = Duration + // MARK: - Published Properties with AppStorage /// Auto-lock configuration using enum instead of raw values diff --git a/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift b/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift new file mode 100644 index 0000000..1b06660 --- /dev/null +++ b/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift @@ -0,0 +1,44 @@ +import Core +import Foundation + +class CountdownFormatter { + /// Format remaining time as countdown string + static func countdownString(from timeInterval: TimeInterval) -> String { + let totalSeconds = Int(timeInterval) + + 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) + } + } + + /// Format remaining time as human readable string + static func humanReadableCountdown(from timeInterval: TimeInterval) -> String { + let totalSeconds = Int(timeInterval) + + if totalSeconds <= 0 { + return LocalizationKey.countdownFinished.localized + } + + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 { + return LocalizationKey.countdownHoursFormat.localized(hours, minutes, seconds) + } else if minutes > 0 { + return LocalizationKey.countdownMinutesFormat.localized(minutes, seconds) + } else { + return LocalizationKey.countdownSecondsFormat.localized(seconds) + } + } +} diff --git a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift index 46e6a8b..cfc1df2 100644 --- a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift @@ -86,15 +86,41 @@ enum LocalizationKey { static let settingsKeyboardDescription = "settings.keyboard.description" static let settingsReset = "settings.reset" - // Time durations + // Time durations and duration display static let timeMinutes = "time.minutes" - static let timeNever = "time.never" 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" + // Duration basic values (shared between time and duration contexts) + static let durationNever = "duration.never" + static let durationInfinite = "duration.infinite" + + // Backward compatibility aliases + static let timeNever = durationNever + static let timeInfinite = durationInfinite + + // 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)" + + // Duration descriptions for settings + static let durationNeverDescription = "duration.never.description" + static let durationInfiniteDescription = "duration.infinite.description" + static let durationMinutesDescription = "duration.minutes.description" + static let durationHoursDescription = "duration.hours.description" + static let durationHoursMinutesDescription = "duration.hours.minutes.description" + static let durationAutoUnlock = "duration.auto.unlock" // "Auto unlock after %@" + + // Countdown formatting + static let countdownFinished = "countdown.finished" + static let countdownHoursFormat = "countdown.hours.format" + static let countdownMinutesFormat = "countdown.minutes.format" + static let countdownSecondsFormat = "countdown.seconds.format" + // About static let aboutVersionFormat = "about.version.format" static let aboutFeatures = "about.features" @@ -119,7 +145,6 @@ enum LocalizationKey { static let permissionRequired = "permission.required" static let permissionDescription = "permission.description" static let openSystemPreferences = "open.system.preferences" - static let autoDetectionEnabled = "auto.detection.enabled" // URL Schemes - User facing messages only static let urlErrorInvalidScheme = "url.error.invalid.scheme" diff --git a/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift b/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift new file mode 100644 index 0000000..ad7206d --- /dev/null +++ b/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift @@ -0,0 +1,155 @@ +import Core +import Foundation + +/// Business logic helper for lock duration management and display +class LockDurationHelper { + // MARK: - Preset Collections + + /// Preset durations for timed lock UI + static let timedLockPresets: [CoreConfiguration.AutoLockDuration] = [ + .infinite, + .minutes(1), + .minutes(5), + .minutes(15), + .minutes(30), + .minutes(60), // 1 hour + .minutes(120), // 2 hours + .minutes(240), // 4 hours + ] + + /// Preset durations for auto-lock UI + static let autoLockPresets: [CoreConfiguration.AutoLockDuration] = [ + .never, + .minutes(15), + .minutes(30), + .minutes(60), + ] + + /// Quick preset durations for timed lock + static let quickTimedPresets: [CoreConfiguration.AutoLockDuration] = [ + .infinite, + .minutes(1), + .minutes(5), + .minutes(15), + .minutes(30), + ] + + // MARK: - Display Logic + + /// Get localized display string for UI + static func localizedDisplayString(for duration: CoreConfiguration.AutoLockDuration) -> String { + switch duration { + case .never: + LocalizationKey.durationNever.localized + case .infinite: + LocalizationKey.durationInfinite.localized + case let .minutes(minutes): + formatMinutes(minutes) + } + } + + /// Get description text for duration settings + static func localizedDescriptionString(for duration: CoreConfiguration.AutoLockDuration) -> String { + switch duration { + case .never: + return LocalizationKey.durationNeverDescription.localized + case .infinite: + return LocalizationKey.durationInfiniteDescription.localized + case let .minutes(minutes): + if minutes < 60 { + return LocalizationKey.durationMinutesDescription.localized(minutes) + } else { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + if remainingMinutes == 0 { + return LocalizationKey.durationHoursDescription.localized(hours) + } else { + return LocalizationKey.durationHoursMinutesDescription.localized(hours, remainingMinutes) + } + } + } + } + + // MARK: - Time Formatting Helpers + + private static func formatMinutes(_ minutes: Int) -> String { + if minutes < 60 { + return LocalizationKey.durationMinutes.localized(minutes) + } else { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + if remainingMinutes == 0 { + return LocalizationKey.durationHours.localized(hours) + } else { + return LocalizationKey.durationHoursMinutes.localized(hours, remainingMinutes) + } + } + } + + // MARK: - Factory Methods + + /// Create duration from seconds with smart conversion + static func durationFromSeconds(_ seconds: TimeInterval) -> CoreConfiguration.AutoLockDuration { + let totalSeconds = Int(seconds) + + if totalSeconds == 0 { + return .infinite + } else if totalSeconds < 60 { + // For very short durations, round up to 1 minute + return .minutes(1) + } else { + let minutes = totalSeconds / 60 + return .minutes(minutes) + } + } + + /// Create duration from total seconds (exact) + static func durationFromSecondsExact(_ seconds: TimeInterval) + -> CoreConfiguration.AutoLockDuration + { + let totalSeconds = Int(seconds) + + if totalSeconds == 0 { + return .infinite + } else { + let minutes = max(1, totalSeconds / 60) // Minimum 1 minute + return .minutes(minutes) + } + } + + // MARK: - Validation + + /// Check if this duration is valid for timed lock + static func isValidForTimedLock(_ duration: CoreConfiguration.AutoLockDuration) -> Bool { + switch duration { + case .never: + false // Never is not valid for timed lock + case .infinite, .minutes: + true + } + } + + /// Check if this duration is valid for auto-lock + static func isValidForAutoLock(_ duration: CoreConfiguration.AutoLockDuration) -> Bool { + switch duration { + case .never, .minutes: + true + case .infinite: + false // Infinite is not valid for auto-lock + } + } + + // MARK: - Comparison Helpers + + /// Get sort order for duration comparison + static func sortOrder(for duration: CoreConfiguration.AutoLockDuration) -> Int { + switch duration { + case .never: + 0 + case .infinite: + Int.max + case let .minutes(minutes): + minutes + } + } +} diff --git a/KeyboardLocker/Sources/Views/ContentView.swift b/KeyboardLocker/Sources/Views/ContentView.swift index 593fb05..e0d226b 100644 --- a/KeyboardLocker/Sources/Views/ContentView.swift +++ b/KeyboardLocker/Sources/Views/ContentView.swift @@ -208,17 +208,6 @@ struct ContentView: View { } .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) @@ -275,9 +264,7 @@ struct ContentView: View { let remainingTime = max(0, TimeInterval(duration * 60) - timeSinceActivity) if remainingTime > 0 { - let minutes = Int(remainingTime / 60) - let seconds = Int(remainingTime.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", minutes, seconds) + return CountdownFormatter.countdownString(from: remainingTime) } else { return LocalizationKey.autoLockReadyToLock.localized } diff --git a/KeyboardLocker/Sources/Views/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift index fa77b70..07c1ac6 100644 --- a/KeyboardLocker/Sources/Views/SettingsView.swift +++ b/KeyboardLocker/Sources/Views/SettingsView.swift @@ -6,13 +6,6 @@ struct SettingsView: View { private typealias AutoLockInterval = CoreConfiguration.AutoLockDuration - private let durationOptions: [AutoLockInterval] = [ - .never, - .minutes(15), - .minutes(30), - .minutes(60), - ] - var body: some View { VStack(alignment: .leading, spacing: 20) { autoLockSection @@ -39,8 +32,8 @@ struct SettingsView: View { Picker( LocalizationKey.timeAutoLockDuration.localized, selection: $coreConfig.autoLockDuration ) { - ForEach(durationOptions, id: \.self) { duration in - Text(formatDuration(duration)) + ForEach(LockDurationHelper.autoLockPresets, id: \.self) { duration in + Text(LockDurationHelper.localizedDisplayString(for: duration)) .tag(duration) } } @@ -129,15 +122,6 @@ struct SettingsView: View { .foregroundColor(.red) } } - - private func formatDuration(_ interval: AutoLockInterval) -> String { - switch interval { - case .never: - return LocalizationKey.timeNever.localized - case let .minutes(m): - return LocalizationKey.timeMinutes.localized(m) - } - } } #Preview { diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings index 0eb575f..e55f251 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -239,23 +239,6 @@ } } }, - "auto.detection.enabled" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto-detection enabled" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已启用自动检测" - } - } - } - }, "auto.lock.disabled" : { "extractionState" : "manual", "localizations" : { @@ -766,7 +749,7 @@ } } }, - "time.never" : { + "duration.never" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -783,6 +766,227 @@ } } }, + "duration.infinite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Infinite" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无限期" + } + } + } + }, + "duration.minutes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d minute(s)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d分钟" + } + } + } + }, + "duration.hours" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hour(s)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d小时" + } + } + } + }, + "duration.hours.minutes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hour(s) %d minute(s)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d小时%d分钟" + } + } + } + }, + "duration.never.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Never auto unlock" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "永不自动解锁" + } + } + } + }, + "duration.infinite.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lock indefinitely" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无限期锁定" + } + } + } + }, + "duration.minutes.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto unlock after %d minutes" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d分钟后自动解锁" + } + } + } + }, + "duration.hours.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto unlock after %d hours" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d小时后自动解锁" + } + } + } + }, + "duration.hours.minutes.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto unlock after %d hours %d minutes" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d小时%d分钟后自动解锁" + } + } + } + }, + "countdown.finished" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finished" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已完成" + } + } + } + }, + "countdown.hours.format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d:%02d:%02d remaining" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余 %d:%02d:%02d" + } + } + } + }, + "countdown.minutes.format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d:%02d remaining" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余 %d:%02d" + } + } + } + }, + "countdown.seconds.format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d seconds remaining" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余 %d 秒" + } + } + } + }, "url.error.invalid.scheme" : { "extractionState" : "manual", "localizations" : { @@ -887,4 +1091,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 89d06efa0cf599036a404fba2006a05ea1a6ef67 Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 5 Aug 2025 17:11:40 +0800 Subject: [PATCH 08/21] feat: Implement ContentViewState and associated views for keyboard locking functionality - Added ContentViewState to manage keyboard lock state and timed lock options. - Created LockControlView for main lock button and timed lock options. - Introduced PermissionView to handle accessibility permission requests. - Updated SettingsView to use new CoreConfiguration.Duration type. - Added SharedComponents for reusable UI elements like headers and setting rows. - Implemented StatusView to display current keyboard lock status and auto-lock information. - Developed TimedLockView for managing timed lock controls and custom durations. - Enhanced localization strings for new features and improved translations. - Updated main.swift with a TODO for future CLI implementation of KeyboardLocker. --- Core/Sources/Core/CoreConfiguration.swift | 22 +- Core/Sources/Core/KeyboardLockCore.swift | 65 +++- Core/Sources/Core/KeyboardLockerAPI.swift | 48 ++- .../Sources/Application/AppDelegate.swift | 9 +- .../Application/KeyboardLockerApp.swift | 21 +- .../Sources/Extensions/Constants.swift | 5 + .../Sources/Helpers/CountdownFormatter.swift | 2 +- .../Sources/Helpers/DependencyFactory.swift | 22 +- .../Sources/Helpers/LocalizationHelper.swift | 6 + .../Sources/Helpers/LockDurationHelper.swift | 22 +- .../Managers/KeyboardLockManager.swift | 54 ++- .../Sources/Protocols/Protocols.swift | 1 - KeyboardLocker/Sources/Views/AboutView.swift | 6 +- .../Sources/Views/ContentView.swift | 311 ++--------------- .../Sources/Views/ContentViewState.swift | 75 +++++ .../Sources/Views/LockControlView.swift | 72 ++++ .../Sources/Views/PermissionView.swift | 88 +++++ .../Sources/Views/SettingsView.swift | 2 +- .../Sources/Views/SharedComponents.swift | 124 +++++++ KeyboardLocker/Sources/Views/StatusView.swift | 120 +++++++ .../Sources/Views/TimedLockView.swift | 128 +++++++ KeyboardLocker/i18n/Localizable.xcstrings | 317 +++++++++++------- KeyboardLockerTool/main.swift | 3 +- 23 files changed, 1010 insertions(+), 513 deletions(-) create mode 100644 KeyboardLocker/Sources/Extensions/Constants.swift create mode 100644 KeyboardLocker/Sources/Views/ContentViewState.swift create mode 100644 KeyboardLocker/Sources/Views/LockControlView.swift create mode 100644 KeyboardLocker/Sources/Views/PermissionView.swift create mode 100644 KeyboardLocker/Sources/Views/SharedComponents.swift create mode 100644 KeyboardLocker/Sources/Views/StatusView.swift create mode 100644 KeyboardLocker/Sources/Views/TimedLockView.swift diff --git a/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift index 513811c..5ab0d67 100644 --- a/Core/Sources/Core/CoreConfiguration.swift +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -55,12 +55,8 @@ public class CoreConfiguration: ObservableObject { /// Convert to seconds public var seconds: TimeInterval { switch self { - case .never: + case .never, .infinite: 0 - - case .infinite: - 0 - case let .minutes(minutes): TimeInterval(minutes * 60) } @@ -72,18 +68,14 @@ public class CoreConfiguration: ObservableObject { } } - /// Backward compatibility alias - public typealias AutoLockDuration = Duration - // MARK: - Published Properties with AppStorage /// Auto-lock configuration using enum instead of raw values @AppStorage("autoLockDuration") private var storedAutoLockMinutes: Int = 0 - @Published public var autoLockDuration: AutoLockDuration = .never { + @Published public var autoLockDuration: Duration = .never { didSet { storedAutoLockMinutes = autoLockDuration.minutes - print("📝 Auto-lock duration updated: \(autoLockDuration.minutes)") } } @@ -98,7 +90,6 @@ public class CoreConfiguration: ObservableObject { didSet { if let data = try? JSONEncoder().encode(hotkey) { UserDefaults.standard.set(data, forKey: ConfigKeys.hotkey.rawValue) - print("📝 Hotkey configuration updated: \(hotkey)") } } } @@ -127,7 +118,6 @@ public class CoreConfiguration: ObservableObject { private init() { loadConfiguration() - print("🚀 CoreConfiguration initialized") } // MARK: - Configuration Management @@ -145,8 +135,6 @@ public class CoreConfiguration: ObservableObject { } else { hotkey = HotkeyConfiguration.defaultHotkey() } - - print("📁 Configuration loaded from UserDefaults") } /// Reset configuration to default values @@ -156,8 +144,6 @@ public class CoreConfiguration: ObservableObject { launchAtLogin = false hotkey = HotkeyConfiguration.defaultHotkey() isFirstLaunch = false - - print("🔄 Configuration reset to defaults") } /// Export configuration as dictionary @@ -199,8 +185,6 @@ public class CoreConfiguration: ObservableObject { if let version = config[ConfigKeys.appVersion.rawValue] as? String { appVersion = version } - - print("📥 Configuration imported from dictionary") } } @@ -221,7 +205,7 @@ public struct HotkeyConfiguration: Codable, CustomStringConvertible { /// Default hotkey: Command+Shift+L public static func defaultHotkey() -> HotkeyConfiguration { HotkeyConfiguration( - keyCode: 37, // 'L' key + keyCode: CoreConstants.defaultUnlockKeyCode, modifierFlags: UInt32(cmdKey | shiftKey), displayString: "⌘⇧L" ) diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift index c48dc89..f01e35c 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -38,6 +38,8 @@ public class KeyboardLockCore { private var runLoopSource: CFRunLoopSource? private var _isLocked = false private var _lockedAt: Date? + private var timedLockTimer: Timer? + private var timedLockDuration: CoreConfiguration.Duration? // Internal access for callback var internalEventTap: CFMachPort? { @@ -45,7 +47,7 @@ public class KeyboardLockCore { } // Constants for hotkey detection - private let unlockKeyCode: UInt16 = 37 // 'L' key + private let unlockKeyCode: UInt16 = CoreConstants.defaultUnlockKeyCode private let unlockModifiers: UInt32 = .init(cmdKey | optionKey) // Cmd+Option // MARK: - Callbacks for UI Layer @@ -75,12 +77,9 @@ public class KeyboardLockCore { // MARK: - Initialization - private init() { - print("🔑 KeyboardLockCore initialized") - } + private init() {} deinit { - print("🔑 KeyboardLockCore deallocated") forceCleanup() } @@ -106,19 +105,21 @@ public class KeyboardLockCore { // Notify business layer onLockStateChanged?(_isLocked, _lockedAt) - - print("🔒 Keyboard locked at \(Date())") } /// Unlock keyboard input public func unlockKeyboard() { guard _isLocked else { - print("⚠️ Keyboard is already unlocked") return } destroyEventTap() + // Clean up timed lock resources + timedLockTimer?.invalidate() + timedLockTimer = nil + timedLockDuration = nil + _isLocked = false let wasLockedAt = _lockedAt _lockedAt = nil @@ -145,6 +146,54 @@ public class KeyboardLockCore { } } + /// Lock keyboard with specified duration (timed lock) + /// - Parameter duration: Duration for which to lock the keyboard + /// - Throws: KeyboardLockError if locking fails + public func lockKeyboardWithDuration(_ duration: CoreConfiguration.Duration) throws { + // First lock the keyboard normally + try lockKeyboard() + + // Store the duration + timedLockDuration = duration + + // Set up timer for auto-unlock (only for finite durations) + if case let .minutes(minutes) = duration, minutes > 0 { + let timeInterval = TimeInterval(minutes * 60) + timedLockTimer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { + [weak self] _ in + DispatchQueue.main.async { + self?.unlockKeyboard() + print("⏰ Timed lock completed after \(minutes) minutes") + } + } + print("⏰ Timed lock set for \(minutes) minutes") + } else if case .infinite = duration { + print("♾️ Infinite timed lock started (manual unlock required)") + } + } + + /// Get the current timed lock duration (if any) + public var currentTimedLockDuration: CoreConfiguration.Duration? { + timedLockDuration + } + + /// Get remaining time for timed lock + public func getTimedLockRemainingTime() -> TimeInterval? { + guard let duration = timedLockDuration, + let lockedAt = _lockedAt, + case let .minutes(minutes) = duration, + minutes > 0 + else { + return nil + } + + let totalDuration = TimeInterval(minutes * 60) + let elapsed = Date().timeIntervalSince(lockedAt) + let remaining = max(0, totalDuration - elapsed) + + return remaining > 0 ? remaining : nil + } + // MARK: - Utility Methods /// Get lock duration string diff --git a/Core/Sources/Core/KeyboardLockerAPI.swift b/Core/Sources/Core/KeyboardLockerAPI.swift index ac4eae7..c97f274 100644 --- a/Core/Sources/Core/KeyboardLockerAPI.swift +++ b/Core/Sources/Core/KeyboardLockerAPI.swift @@ -23,7 +23,6 @@ public class KeyboardLockerAPI: ObservableObject { private init() { setupActivityMonitor() setupConfigurationObserver() - print("🚀 KeyboardLockerAPI initialized") } // MARK: - Lock/Unlock Operations @@ -31,13 +30,11 @@ public class KeyboardLockerAPI: ObservableObject { /// Lock the keyboard public func lockKeyboard() throws { try core.lockKeyboard() - print("🔒 Keyboard locked via API") } /// Unlock the keyboard public func unlockKeyboard() { core.unlockKeyboard() - print("🔓 Keyboard unlocked via API") } /// Toggle keyboard lock state @@ -45,6 +42,11 @@ public class KeyboardLockerAPI: ObservableObject { core.toggleLock() } + /// Lock keyboard with specified duration (timed lock) + public func lockKeyboardWithDuration(_ duration: CoreConfiguration.Duration) throws { + try core.lockKeyboardWithDuration(duration) + } + /// Get current lock status public var isLocked: Bool { core.basicLockInfo.isLocked @@ -58,6 +60,24 @@ public class KeyboardLockerAPI: ObservableObject { /// Get lock duration string public func getLockDurationString() -> String? { guard let lockedAt else { return nil } + + // Check if this is a timed lock + if let timedDuration = core.currentTimedLockDuration { + if case .infinite = timedDuration { + // For infinite timed lock, show elapsed time + let duration = Date().timeIntervalSince(lockedAt) + let minutes = Int(duration / 60) + let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", minutes, seconds) + } else if let remainingTime = core.getTimedLockRemainingTime() { + // For finite timed lock, show remaining time + let minutes = Int(remainingTime / 60) + let seconds = Int(remainingTime.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", minutes, seconds) + } + } + + // For regular lock, show elapsed time let duration = Date().timeIntervalSince(lockedAt) let minutes = Int(duration / 60) let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) @@ -68,7 +88,7 @@ public class KeyboardLockerAPI: ObservableObject { /// Enable auto-lock with specified duration in minutes public func enableAutoLock(minutes: Int) { - let autoLockSetting: CoreConfiguration.AutoLockDuration = minutes == 0 ? .never : .minutes(minutes) + let autoLockSetting: CoreConfiguration.Duration = minutes == 0 ? .never : .minutes(minutes) configuration.autoLockDuration = autoLockSetting activityMonitor.enableAutoLock(seconds: autoLockSetting.seconds) @@ -76,10 +96,8 @@ public class KeyboardLockerAPI: ObservableObject { // Start activity monitoring if enabled if autoLockSetting.isEnabled { activityMonitor.startMonitoring() - print("✅ Auto-lock enabled: \(minutes)") } else { activityMonitor.stopMonitoring() - print("❌ Auto-lock disabled") } } @@ -94,7 +112,6 @@ public class KeyboardLockerAPI: ObservableObject { configuration.autoLockDuration = .never activityMonitor.enableAutoLock(seconds: 0) activityMonitor.stopMonitoring() - print("❌ Auto-lock disabled") } /// Get current auto-lock status @@ -149,6 +166,20 @@ public class KeyboardLockerAPI: ObservableObject { configuration.showNotifications } + // MARK: - State Change Callbacks + + /// Set callback for lock state changes + /// - Parameter callback: Called when lock state changes with (isLocked, lockedAt) + public func setLockStateChangeCallback(_ callback: @escaping (Bool, Date?) -> Void) { + core.onLockStateChanged = callback + } + + /// Set callback for unlock hotkey detection + /// - Parameter callback: Called when unlock hotkey is detected + public func setUnlockHotkeyCallback(_ callback: @escaping () -> Void) { + core.onUnlockHotkeyDetected = callback + } + // MARK: - Permission Management /// Check if accessibility permission is granted @@ -159,7 +190,7 @@ public class KeyboardLockerAPI: ObservableObject { /// Request accessibility permission public func requestAccessibilityPermission() { - print("⚠️ Accessibility permission required") + PermissionHelper.requestAccessibilityPermission() } // MARK: - Private Setup Methods @@ -169,7 +200,6 @@ public class KeyboardLockerAPI: ObservableObject { activityMonitor.onAutoLockTriggered = { [weak self] in do { try self?.lockKeyboard() - print("🔒 Auto-lock triggered - keyboard locked") } catch { print("❌ Auto-lock failed: \(error.localizedDescription)") } diff --git a/KeyboardLocker/Sources/Application/AppDelegate.swift b/KeyboardLocker/Sources/Application/AppDelegate.swift index fa3a50e..e23969b 100644 --- a/KeyboardLocker/Sources/Application/AppDelegate.swift +++ b/KeyboardLocker/Sources/Application/AppDelegate.swift @@ -7,6 +7,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { var urlHandler: URLCommandHandler = .shared var keyboardLockManager: KeyboardLockManaging? + func configure(_ manager: KeyboardLockManaging) { + keyboardLockManager = manager + urlHandler.setKeyboardLockManager(manager) + } + func applicationDidFinishLaunching(_: Notification) { print("Application did finish launching") } @@ -31,9 +36,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// - application: The application instance /// - urls: Array of URLs to handle func application(_: NSApplication, open urls: [URL]) { - for url in urls { - handleIncomingURL(url) - } + urls.forEach(handleIncomingURL(_:)) } /// Process individual URL requests diff --git a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift index 91ff27f..236b8a6 100644 --- a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift +++ b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift @@ -11,21 +11,25 @@ struct KeyboardLockerApp: App { // Use AppDelegate for URL handling @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - init() { - // Create keyboard lock manager safely without force casting + // Create keyboard lock manager safely without force casting + private static func makeKeyboardLockManager() -> KeyboardLockManager { let manager = DependencyFactory.shared.makeKeyboardLockManager() if let concreteManager = manager as? KeyboardLockManager { - _keyboardLockManager = StateObject(wrappedValue: concreteManager) + return concreteManager } else { // Fallback: create a new instance directly - _keyboardLockManager = StateObject(wrappedValue: KeyboardLockManager()) + return KeyboardLockManager() } + } - // Setup global exception handling for stability - setupExceptionHandling() + init() { + _keyboardLockManager = StateObject(wrappedValue: Self.makeKeyboardLockManager()) // Initialize IPC server for external communication IPCManager.shared.startServer() + + // Setup global exception handling for stability + setupExceptionHandling() } var body: some Scene { @@ -35,10 +39,7 @@ struct KeyboardLockerApp: App { .environmentObject(keyboardLockManager) .environmentObject(permissionManager) .onAppear { - // Set up URL handler with keyboard lock manager reference - URLCommandHandler.shared.setKeyboardLockManager(keyboardLockManager) - // Inject dependencies into AppDelegate - appDelegate.keyboardLockManager = keyboardLockManager + appDelegate.configure(keyboardLockManager) } } .menuBarExtraStyle(.window) diff --git a/KeyboardLocker/Sources/Extensions/Constants.swift b/KeyboardLocker/Sources/Extensions/Constants.swift new file mode 100644 index 0000000..ebd1665 --- /dev/null +++ b/KeyboardLocker/Sources/Extensions/Constants.swift @@ -0,0 +1,5 @@ +import Foundation + +extension CGFloat { + static let viewWidth: Self = 300 +} diff --git a/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift b/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift index 1b06660..2167ea5 100644 --- a/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift +++ b/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift @@ -1,7 +1,7 @@ import Core import Foundation -class CountdownFormatter { +enum CountdownFormatter { /// Format remaining time as countdown string static func countdownString(from timeInterval: TimeInterval) -> String { let totalSeconds = Int(timeInterval) diff --git a/KeyboardLocker/Sources/Helpers/DependencyFactory.swift b/KeyboardLocker/Sources/Helpers/DependencyFactory.swift index beb309c..a485901 100644 --- a/KeyboardLocker/Sources/Helpers/DependencyFactory.swift +++ b/KeyboardLocker/Sources/Helpers/DependencyFactory.swift @@ -28,11 +28,8 @@ class DependencyFactory { } /// Create a URL command handler instance - /// - Parameter notificationManager: Optional notification manager, uses default if nil /// - Returns: URLCommandHandler instance - func makeURLCommandHandler( - notificationManager _: NotificationManaging? = nil - ) -> URLCommandHandler { + func makeURLCommandHandler() -> URLCommandHandler { // Since URLCommandHandler uses a singleton pattern, we return the shared instance // In a more sophisticated dependency injection system, we might create new instances URLCommandHandler.shared @@ -44,14 +41,15 @@ class DependencyFactory { func makePermissionManager( notificationManager: NotificationManager? = nil ) -> PermissionManager { - let notificationMgr: NotificationManager = if let providedManager = notificationManager { - providedManager - } else if let defaultManager = makeNotificationManager() as? NotificationManager { - defaultManager - } else { - // Fallback: create a new instance directly - NotificationManager.shared - } + let notificationMgr: NotificationManager = + if let providedManager = notificationManager { + providedManager + } else if let defaultManager = makeNotificationManager() as? NotificationManager { + defaultManager + } else { + // Fallback: create a new instance directly + NotificationManager.shared + } return PermissionManager(notificationManager: notificationMgr) } } diff --git a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift index cfc1df2..a4b20a7 100644 --- a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift @@ -121,6 +121,11 @@ enum LocalizationKey { static let countdownMinutesFormat = "countdown.minutes.format" static let countdownSecondsFormat = "countdown.seconds.format" + // Timed Lock + static let timedLockTitle = "timed.lock.title" + static let timedLockStart = "timed.lock.start" + static let timedLockCustom = "timed.lock.custom" + // About static let aboutVersionFormat = "about.version.format" static let aboutFeatures = "about.features" @@ -140,6 +145,7 @@ enum LocalizationKey { // Lock Duration static let lockDurationFormat = "lock.duration.format" + static let timedLockRemaining = "timed.lock.remaining" // Permissions static let permissionRequired = "permission.required" diff --git a/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift b/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift index ad7206d..433e63d 100644 --- a/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift @@ -6,7 +6,7 @@ class LockDurationHelper { // MARK: - Preset Collections /// Preset durations for timed lock UI - static let timedLockPresets: [CoreConfiguration.AutoLockDuration] = [ + static let timedLockPresets: [CoreConfiguration.Duration] = [ .infinite, .minutes(1), .minutes(5), @@ -18,7 +18,7 @@ class LockDurationHelper { ] /// Preset durations for auto-lock UI - static let autoLockPresets: [CoreConfiguration.AutoLockDuration] = [ + static let autoLockPresets: [CoreConfiguration.Duration] = [ .never, .minutes(15), .minutes(30), @@ -26,7 +26,7 @@ class LockDurationHelper { ] /// Quick preset durations for timed lock - static let quickTimedPresets: [CoreConfiguration.AutoLockDuration] = [ + static let quickTimedPresets: [CoreConfiguration.Duration] = [ .infinite, .minutes(1), .minutes(5), @@ -37,7 +37,7 @@ class LockDurationHelper { // MARK: - Display Logic /// Get localized display string for UI - static func localizedDisplayString(for duration: CoreConfiguration.AutoLockDuration) -> String { + static func localizedDisplayString(for duration: CoreConfiguration.Duration) -> String { switch duration { case .never: LocalizationKey.durationNever.localized @@ -49,7 +49,7 @@ class LockDurationHelper { } /// Get description text for duration settings - static func localizedDescriptionString(for duration: CoreConfiguration.AutoLockDuration) -> String { + static func localizedDescriptionString(for duration: CoreConfiguration.Duration) -> String { switch duration { case .never: return LocalizationKey.durationNeverDescription.localized @@ -89,7 +89,7 @@ class LockDurationHelper { // MARK: - Factory Methods /// Create duration from seconds with smart conversion - static func durationFromSeconds(_ seconds: TimeInterval) -> CoreConfiguration.AutoLockDuration { + static func durationFromSeconds(_ seconds: TimeInterval) -> CoreConfiguration.Duration { let totalSeconds = Int(seconds) if totalSeconds == 0 { @@ -104,9 +104,7 @@ class LockDurationHelper { } /// Create duration from total seconds (exact) - static func durationFromSecondsExact(_ seconds: TimeInterval) - -> CoreConfiguration.AutoLockDuration - { + static func durationFromSecondsExact(_ seconds: TimeInterval) -> CoreConfiguration.Duration { let totalSeconds = Int(seconds) if totalSeconds == 0 { @@ -120,7 +118,7 @@ class LockDurationHelper { // MARK: - Validation /// Check if this duration is valid for timed lock - static func isValidForTimedLock(_ duration: CoreConfiguration.AutoLockDuration) -> Bool { + static func isValidForTimedLock(_ duration: CoreConfiguration.Duration) -> Bool { switch duration { case .never: false // Never is not valid for timed lock @@ -130,7 +128,7 @@ class LockDurationHelper { } /// Check if this duration is valid for auto-lock - static func isValidForAutoLock(_ duration: CoreConfiguration.AutoLockDuration) -> Bool { + static func isValidForAutoLock(_ duration: CoreConfiguration.Duration) -> Bool { switch duration { case .never, .minutes: true @@ -142,7 +140,7 @@ class LockDurationHelper { // MARK: - Comparison Helpers /// Get sort order for duration comparison - static func sortOrder(for duration: CoreConfiguration.AutoLockDuration) -> Int { + static func sortOrder(for duration: CoreConfiguration.Duration) -> Int { switch duration { case .never: 0 diff --git a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift index 9e914ad..8d9797b 100644 --- a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift +++ b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift @@ -18,8 +18,8 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { ) { self.notificationManager = notificationManager - // Setup observers and sync - setupCoreObservers() + // Setup state change callback and sync + setupLockStateCallback() syncInitialState() } @@ -30,7 +30,6 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { /// Clean up resources when object is deallocated private func cleanup() { // Core API handles its own cleanup - print("🧹 KeyboardLockManager cleanup completed") } // MARK: - Public Interface (UI Actions) @@ -38,7 +37,6 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { func lockKeyboard() { do { try coreAPI.lockKeyboard() - print("✅ Keyboard locked successfully") // Send notification to user (UI concern) notificationManager.sendNotificationIfEnabled( @@ -56,7 +54,6 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { } coreAPI.unlockKeyboard() - print("✅ Keyboard unlocked successfully") // Send notification to user (UI concern) notificationManager.sendNotificationIfEnabled( @@ -76,6 +73,21 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { ) } + /// Start a timed lock with specified duration + func lockKeyboard(with duration: CoreConfiguration.Duration) { + do { + try coreAPI.lockKeyboardWithDuration(duration) + + // Send notification to user (UI concern) + notificationManager.sendNotificationIfEnabled( + .keyboardLocked, + showNotifications: coreAPI.isNotificationsEnabled() + ) + } catch { + print("❌ Failed to start timed lock: \(error.localizedDescription)") + } + } + // MARK: - Auto-Lock Management (using Core API directly) func startAutoLock() { @@ -83,14 +95,10 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { if !coreAPI.configuration.autoLockDuration.isEnabled { coreAPI.configuration.autoLockDuration = .minutes(30) } - print( - "✅ Auto-lock enabled with \(coreAPI.configuration.autoLockDuration.minutes) duration (activity-based)" - ) } func stopAutoLock() { coreAPI.configuration.autoLockDuration = .never - print("✅ Auto-lock disabled") } func toggleAutoLock() { @@ -99,16 +107,10 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { } else { coreAPI.configuration.autoLockDuration = .minutes(30) } - print("✅ Auto-lock toggled") // Update UI state syncAutoLockConfiguration() } - func updateAutoLockSettings() { - // Settings are now managed directly through Core API - print("✅ Auto-lock settings updated with activity monitoring") - } - /// Get time since last user activity (for UI display) func getTimeSinceLastActivity() -> TimeInterval { coreAPI.getTimeSinceLastActivity() @@ -131,7 +133,6 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { func requestPermissions() { coreAPI.requestAccessibilityPermission() - print("ℹ️ Permission request sent. Please grant accessibility permission in System Settings.") } // MARK: - Configuration Access (直接使用CoreConfiguration) @@ -156,22 +157,21 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { func forceCleanup() { // Core API manages its own cleanup - print("🧹 KeyboardLockManager force cleanup completed") syncInitialState() } // MARK: - Private Methods - /// Setup observers for Core configuration changes - private func setupCoreObservers() { - // Check lock status and auto-lock status periodically - Timer.publish(every: 0.5, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - self?.isLocked = self?.coreAPI.isLocked ?? false + /// Setup lock state change callback from Core + private func setupLockStateCallback() { + // Use Core's callback instead of timer-based polling + coreAPI.setLockStateChangeCallback { [weak self] isLocked, _ in + DispatchQueue.main.async { + self?.isLocked = isLocked + // Auto-lock state should also be synced when lock state changes self?.autoLockEnabled = self?.coreAPI.configuration.autoLockDuration.isEnabled ?? false } - .store(in: &cancellables) + } } /// Sync initial state from Core @@ -188,8 +188,4 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration.isEnabled } } - - // MARK: - Combine Support - - private var cancellables = Set() } diff --git a/KeyboardLocker/Sources/Protocols/Protocols.swift b/KeyboardLocker/Sources/Protocols/Protocols.swift index bfda875..8465905 100644 --- a/KeyboardLocker/Sources/Protocols/Protocols.swift +++ b/KeyboardLocker/Sources/Protocols/Protocols.swift @@ -43,7 +43,6 @@ protocol KeyboardLockManaging: AnyObject { func startAutoLock() func stopAutoLock() func toggleAutoLock() - func updateAutoLockSettings() } /// Enhanced protocol with result-based operations for better error handling diff --git a/KeyboardLocker/Sources/Views/AboutView.swift b/KeyboardLocker/Sources/Views/AboutView.swift index 4d527ea..ed4bfc9 100644 --- a/KeyboardLocker/Sources/Views/AboutView.swift +++ b/KeyboardLocker/Sources/Views/AboutView.swift @@ -40,9 +40,9 @@ struct AboutView: View { }) { HStack { Image(systemName: "link.circle.fill") - .foregroundColor(.blue) + .foregroundColor(.accentColor) Text(LocalizationKey.aboutGitHub.localized) - .foregroundColor(.blue) + .foregroundColor(.accentColor) } .font(.body) } @@ -78,7 +78,7 @@ struct FeatureRow: View { var body: some View { HStack(spacing: 6) { Image(systemName: icon) - .foregroundColor(.blue) + .foregroundColor(.accentColor) .frame(width: 16) Text(text) diff --git a/KeyboardLocker/Sources/Views/ContentView.swift b/KeyboardLocker/Sources/Views/ContentView.swift index e0d226b..2f42f92 100644 --- a/KeyboardLocker/Sources/Views/ContentView.swift +++ b/KeyboardLocker/Sources/Views/ContentView.swift @@ -1,309 +1,58 @@ +import Core import SwiftUI struct ContentView: View { - @State private var isKeyboardLocked = false - @State private var lockDurationTimer: Timer? + @StateObject private var viewState = ContentViewState() @EnvironmentObject var permissionManager: PermissionManager @EnvironmentObject var keyboardManager: KeyboardLockManager var body: some View { NavigationStack { if permissionManager.hasAccessibilityPermission { - authorizedView + MainContentView(state: viewState) } else { - unauthorizedView + PermissionRequiredView(permissionManager: permissionManager) } } - .frame(width: 300) + .frame(width: .viewWidth) .background(Color(NSColor.windowBackgroundColor)) - .onAppear { - permissionManager.checkAllPermissions() - isKeyboardLocked = keyboardManager.isLocked - setupLockDurationTimer() - } - .onReceive(keyboardManager.$isLocked) { locked in - isKeyboardLocked = locked - setupLockDurationTimer() - } - .onDisappear { - lockDurationTimer?.invalidate() - } + .onAppear(perform: setupInitialState) + .onReceive(keyboardManager.$isLocked, perform: viewState.handleLockStateChange) + .onDisappear(perform: viewState.cleanup) } - // 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() - } + private func setupInitialState() { + permissionManager.checkAllPermissions() + viewState.configure(with: keyboardManager) } +} - // MARK: - Authorized View (Main Interface) +private struct MainContentView: View { + @ObservedObject var state: ContentViewState + @Environment(\.colorScheme) var colorScheme - private var authorizedView: some View { + var body: some View { VStack(spacing: 16) { - appTitleHeader - - // Main functionality area - VStack(spacing: 16) { - // Lock status indicator - VStack(alignment: .leading, spacing: 4) { - 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() - } - - // Show lock duration when locked - if isKeyboardLocked, let durationString = keyboardManager.getLockDurationString() { - HStack { - Image(systemName: "clock") - .foregroundColor(.secondary) - .font(.caption) - Text(LocalizationKey.lockDurationFormat.localized(durationString)) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - .padding(.leading, 16) // Align with status text - } - - // Show auto-lock status when enabled and not locked - if !isKeyboardLocked, keyboardManager.isAutoLockEnabled { - HStack { - Image(systemName: "timer") - .foregroundColor(.orange) - .font(.caption) - Text(LocalizationKey.autoLockStatus.localized(autoLockStatusText)) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - .padding(.leading, 16) - } - } - - // 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() - } - } + AppTitleHeaderView() - // 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) + if let keyboardManager = state.keyboardManager { + StatusSectionView( + isKeyboardLocked: state.isKeyboardLocked, + keyboardManager: keyboardManager + ) + + LockControlButtonView( + state: state, + keyboardManager: keyboardManager + ) + + QuickActionsView(keyboardManager: keyboardManager) } - .buttonStyle(PlainButtonStyle()) - .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() - } - } - - private func setupLockDurationTimer() { - // Invalidate existing timer - lockDurationTimer?.invalidate() - - // Only start timer when locked - if isKeyboardLocked { - lockDurationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - // Force UI update by triggering objectWillChange - DispatchQueue.main.async { - keyboardManager.objectWillChange.send() - } - } - } - } - - /// Get auto-lock status text for display - private var autoLockStatusText: String { - let duration = keyboardManager.autoLockDuration - if duration == 0 { - return LocalizationKey.autoLockDisabled.localized - } - - // Get time since last activity - let timeSinceActivity = keyboardManager.getTimeSinceLastActivity() - let remainingTime = max(0, TimeInterval(duration * 60) - timeSinceActivity) - - if remainingTime > 0 { - return CountdownFormatter.countdownString(from: remainingTime) - } else { - return LocalizationKey.autoLockReadyToLock.localized + BottomActionsView() } } } - -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/Sources/Views/ContentViewState.swift b/KeyboardLocker/Sources/Views/ContentViewState.swift new file mode 100644 index 0000000..87700a8 --- /dev/null +++ b/KeyboardLocker/Sources/Views/ContentViewState.swift @@ -0,0 +1,75 @@ +import Core +import SwiftUI + +@MainActor +class ContentViewState: ObservableObject { + // MARK: - Published Properties + + @Published var isKeyboardLocked = false + @Published var selectedTimedLockDuration: CoreConfiguration.Duration = .infinite + @Published var showTimedLockOptions = false + @Published var customMinutes: Int = 5 + + // MARK: - Private Properties + + private var lockDurationTimer: Timer? + var keyboardManager: KeyboardLockManager? + + // MARK: - Computed Properties + + var customMinutesString: Binding { + Binding( + get: { String(self.customMinutes) }, + set: { self.customMinutes = Int($0) ?? 5 } + ) + } + + // MARK: - Public Methods + + func configure(with keyboardManager: KeyboardLockManager) { + self.keyboardManager = keyboardManager + isKeyboardLocked = keyboardManager.isLocked + } + + func handleLockStateChange(_ locked: Bool) { + isKeyboardLocked = locked + setupLockDurationTimer() + } + + func startTimedLock() { + lock(with: selectedTimedLockDuration) + } + + func startCustomTimedLock() { + guard customMinutes > 0 else { return } + + let customDuration = CoreConfiguration.Duration.minutes(customMinutes) + lock(with: customDuration) + } + + private func lock(with duration: CoreConfiguration.Duration) { + guard let keyboardManager else { return } + + showTimedLockOptions = false + keyboardManager.lockKeyboard(with: duration) + } + + func cleanup() { + lockDurationTimer?.invalidate() + lockDurationTimer = nil + } + + // MARK: - Private Methods + + private func setupLockDurationTimer() { + cleanup() + guard isKeyboardLocked else { return } + + lockDurationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { + [weak self] _ in + Task { @MainActor [weak self] in + self?.objectWillChange.send() + } + } + } +} diff --git a/KeyboardLocker/Sources/Views/LockControlView.swift b/KeyboardLocker/Sources/Views/LockControlView.swift new file mode 100644 index 0000000..915725b --- /dev/null +++ b/KeyboardLocker/Sources/Views/LockControlView.swift @@ -0,0 +1,72 @@ +import Core +import SwiftUI + +struct LockControlButtonView: View { + @ObservedObject var state: ContentViewState + let keyboardManager: KeyboardLockManager + + var body: some View { + HStack(spacing: 8) { + MainLockButton(state: state, keyboardManager: keyboardManager) + + if !state.isKeyboardLocked { + TimedLockOptionsButton(state: state) + } + } + } +} + +private struct MainLockButton: View { + @ObservedObject var state: ContentViewState + let 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() + } + } +} + +private struct TimedLockOptionsButton: View { + @ObservedObject var state: ContentViewState + + var body: some View { + Button(action: { state.showTimedLockOptions.toggle() }) { + Image(systemName: "info.circle") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.accentColor) + .frame(width: 44, height: 44) + .background(Color.gray.opacity(0.1)) + .cornerRadius(22) + } + .buttonStyle(PlainButtonStyle()) + .popover(isPresented: $state.showTimedLockOptions, arrowEdge: .bottom) { + TimedLockControlsView(state: state) + .frame(width: 280) + .padding() + } + } +} diff --git a/KeyboardLocker/Sources/Views/PermissionView.swift b/KeyboardLocker/Sources/Views/PermissionView.swift new file mode 100644 index 0000000..8df6f37 --- /dev/null +++ b/KeyboardLocker/Sources/Views/PermissionView.swift @@ -0,0 +1,88 @@ +import Core +import SwiftUI + +struct PermissionRequiredView: View { + let permissionManager: PermissionManager + + var body: some View { + VStack(spacing: 20) { + AppTitleHeaderView() + PermissionContent(permissionManager: permissionManager) + Spacer() + QuitButton() + } + } +} + +private struct PermissionContent: View { + let permissionManager: PermissionManager + + var body: some View { + VStack(spacing: 16) { + WarningIcon() + PermissionTexts() + PermissionButton(permissionManager: permissionManager) + } + .padding(.horizontal, 16) + } +} + +private struct WarningIcon: View { + var body: some View { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 48)) + } +} + +private struct PermissionTexts: View { + var body: some View { + 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) + } + } +} + +private struct PermissionButton: View { + let permissionManager: PermissionManager + + var body: some View { + 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) + } +} + +private struct QuitButton: View { + var body: some View { + HStack { + Spacer() + Button(LocalizationKey.actionQuit.localized) { + NSApplication.shared.terminate(nil) + } + .buttonStyle(PlainButtonStyle()) + .foregroundColor(.red) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } +} diff --git a/KeyboardLocker/Sources/Views/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift index 07c1ac6..48c9de7 100644 --- a/KeyboardLocker/Sources/Views/SettingsView.swift +++ b/KeyboardLocker/Sources/Views/SettingsView.swift @@ -4,7 +4,7 @@ import SwiftUI struct SettingsView: View { @ObservedObject private var coreConfig = CoreConfiguration.shared - private typealias AutoLockInterval = CoreConfiguration.AutoLockDuration + private typealias AutoLockInterval = CoreConfiguration.Duration var body: some View { VStack(alignment: .leading, spacing: 20) { diff --git a/KeyboardLocker/Sources/Views/SharedComponents.swift b/KeyboardLocker/Sources/Views/SharedComponents.swift new file mode 100644 index 0000000..763deae --- /dev/null +++ b/KeyboardLocker/Sources/Views/SharedComponents.swift @@ -0,0 +1,124 @@ +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 { + let keyboardManager: KeyboardLockManager + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(LocalizationKey.quickActions.localized) + .font(.headline) + .foregroundColor(.secondary) + + VStack(spacing: 6) { + SettingsNavigation(keyboardManager: keyboardManager) + AboutNavigation() + } + } + } +} + +private struct SettingsNavigation: View { + let keyboardManager: KeyboardLockManager + + var body: some View { + NavigationLink(destination: SettingsView().environmentObject(keyboardManager)) { + SettingRow( + icon: "gear", + title: LocalizationKey.settingsTitle.localized, + subtitle: LocalizationKey.settingsSubtitle.localized + ) + } + .buttonStyle(PlainButtonStyle()) + } +} + +private struct AboutNavigation: View { + var body: some View { + 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..d769674 --- /dev/null +++ b/KeyboardLocker/Sources/Views/StatusView.swift @@ -0,0 +1,120 @@ +import Core +import SwiftUI + +struct StatusSectionView: View { + let isKeyboardLocked: Bool + let keyboardManager: KeyboardLockManager + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + MainStatusRow(isLocked: isKeyboardLocked) + + if isKeyboardLocked { + LockDurationRow(keyboardManager: keyboardManager) + } + + if !isKeyboardLocked, keyboardManager.isAutoLockEnabled { + AutoLockStatusRow(keyboardManager: keyboardManager) + } + } + } +} + +private struct MainStatusRow: View { + let isLocked: Bool + + var body: some View { + HStack { + StatusIndicator(isLocked: isLocked) + StatusText(isLocked: isLocked) + Spacer() + } + } +} + +private struct StatusIndicator: View { + let isLocked: Bool + + var body: some View { + Circle() + .fill(isLocked ? Color.red : Color.green) + .frame(width: 12, height: 12) + } +} + +private struct StatusText: View { + let isLocked: Bool + + var body: some View { + Text(statusText) + .font(.body) + .foregroundColor(.primary) + } + + private var statusText: String { + isLocked + ? LocalizationKey.statusLocked.localized + : LocalizationKey.statusUnlocked.localized + } +} + +private struct LockDurationRow: View { + let keyboardManager: KeyboardLockManager + + var body: some View { + if let durationString = keyboardManager.getLockDurationString() { + let displayText = getLockDurationDisplayText(durationString) + 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 { + durationString.contains(":") + ? LocalizationKey.timedLockRemaining.localized(durationString) + : "" + } +} + +private struct AutoLockStatusRow: View { + let keyboardManager: KeyboardLockManager + + var body: some View { + HStack { + Image(systemName: "timer") + .foregroundColor(.orange) + .font(.caption) + Text(LocalizationKey.autoLockStatus.localized(getAutoLockStatusText())) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.leading, 16) + } + + private func getAutoLockStatusText() -> String { + let duration = keyboardManager.autoLockDuration + if duration == 0 { + return LocalizationKey.autoLockDisabled.localized + } + + let timeSinceActivity = keyboardManager.getTimeSinceLastActivity() + let remainingTime = max(0, TimeInterval(duration * 60) - timeSinceActivity) + + if remainingTime > 0 { + return CountdownFormatter.countdownString(from: remainingTime) + } else { + return LocalizationKey.autoLockReadyToLock.localized + } + } +} diff --git a/KeyboardLocker/Sources/Views/TimedLockView.swift b/KeyboardLocker/Sources/Views/TimedLockView.swift new file mode 100644 index 0000000..4eabea6 --- /dev/null +++ b/KeyboardLocker/Sources/Views/TimedLockView.swift @@ -0,0 +1,128 @@ +import Core +import SwiftUI + +struct TimedLockControlsView: View { + @ObservedObject var state: ContentViewState + + var body: some View { + VStack(spacing: 12) { + TimedLockHeader() + PresetButtonsSection(state: state) + Divider() + CustomDurationSection(state: state) + } + } +} + +private struct TimedLockHeader: View { + var body: some View { + HStack { + Text(LocalizationKey.timedLockTitle.localized) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + Spacer() + } + } +} + +private struct PresetButtonsSection: View { + @ObservedObject var state: ContentViewState + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + ForEach(Array(LockDurationHelper.timedLockPresets.prefix(2)), id: \.self) { duration in + PresetButton(duration: duration, action: state.startTimedLock) + } + } + + HStack(spacing: 8) { + ForEach(Array(LockDurationHelper.timedLockPresets.suffix(2)), id: \.self) { duration in + PresetButton(duration: duration, action: state.startTimedLock) + } + } + } + } +} + +private struct CustomDurationSection: View { + @ObservedObject var state: ContentViewState + + var body: some View { + VStack(spacing: 8) { + CustomDurationHeader() + CustomDurationControls(state: state) + } + } +} + +private struct CustomDurationHeader: View { + var body: some View { + HStack { + Text(LocalizationKey.timedLockCustom.localized) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + } +} + +private struct CustomDurationControls: View { + @ObservedObject var state: ContentViewState + + var body: some View { + HStack(spacing: 8) { + TextField("", text: state.customMinutesString) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(width: 60) + .onSubmit(state.startCustomTimedLock) + + Text(LocalizationKey.timeMinutes.localized) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + CustomLockButton(action: state.startCustomTimedLock) + } + } +} + +private struct CustomLockButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: "timer") + Text(LocalizationKey.timedLockStart.localized) + } + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + } +} + +struct PresetButton: View { + let duration: CoreConfiguration.Duration + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(LockDurationHelper.localizedDisplayString(for: duration)) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(16) + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings index e55f251..c122357 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + "shouldTranslate" : false + }, "about.feature.auto.lock" : { "extractionState" : "manual", "localizations" : { @@ -290,699 +293,767 @@ } } }, - "lock.duration.format" : { + "countdown.finished" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Locked for %@" + "value" : "Finished" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已锁定 %@" + "value" : "已完成" } } } }, - "notification.error" : { + "countdown.hours.format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error" + "value" : "%d:%02d:%02d remaining" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "错误" + "value" : "剩余 %d:%02d:%02d" } } } }, - "notification.keyboard.locked" : { + "countdown.minutes.format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard Locked" + "value" : "%d:%02d remaining" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "键盘已锁定" + "value" : "剩余 %d:%02d" } } } }, - "notification.keyboard.unlocked" : { + "countdown.seconds.format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard Unlocked" + "value" : "%d seconds remaining" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "键盘已解锁" + "value" : "剩余 %d 秒" } } } }, - "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.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard input restored to normal" + "value" : "Auto unlock after %d hours" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "键盘输入已恢复正常" + "value" : "%d小时后自动解锁" } } } }, - "notification.url.command" : { + "duration.hours.minutes" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "URL Command" + "value" : "%d hour(s) %d minute(s)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "URL 命令" + "value" : "%d小时%d分钟" } } } }, - "open.system.preferences" : { + "duration.hours.minutes.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open System Settings" + "value" : "Auto unlock after %d hours %d minutes" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "打开系统设置" + "value" : "%d小时%d分钟后自动解锁" } } } }, - "permission.description" : { + "duration.infinite" : { "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" : "Infinite" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard Locker 需要辅助功能权限来控制您的键盘。这允许应用程序在激活时暂时阻止键盘输入。" + "value" : "无限期" } } } }, - "permission.required" : { + "duration.infinite.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Permission Required" + "value" : "Lock indefinitely" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要权限" + "value" : "无限期锁定" } } } }, - "quick.actions" : { + "duration.minutes" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Quick Actions" + "value" : "%d minute(s)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "快速操作" + "value" : "%d分钟" } } } }, - "settings.auto.lock" : { + "duration.minutes.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Auto Lock" + "value" : "Auto unlock after %d minutes" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "自动锁定" + "value" : "%d分钟后自动解锁" } } } }, - "settings.auto.lock.description" : { + "duration.never" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Set the time to automatically lock keyboard after inactivity" + "value" : "Never" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "设置无操作后自动锁定键盘的时间" + "value" : "从不" } } } }, - "settings.keyboard" : { + "duration.never.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard" + "value" : "Never auto unlock" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "快捷键" + "value" : "永不自动解锁" } } } }, - "settings.keyboard.description" : { + "lock.duration.format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Use shortcut to quickly lock/unlock keyboard" + "value" : "Locked for %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用快捷键快速锁定或解锁键盘" + "value" : "已锁定 %@" } } } }, - "settings.notifications" : { + "notification.error" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notifications" + "value" : "Error" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "通知" + "value" : "错误" } } } }, - "settings.notifications.description" : { + "notification.keyboard.locked" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Display system notifications when locking and unlocking" + "value" : "Keyboard Locked" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "锁定和解锁时显示系统通知" + "value" : "键盘已锁定" } } } }, - "settings.reset" : { + "notification.keyboard.unlocked" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reset Settings" + "value" : "Keyboard Unlocked" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重置设置" + "value" : "键盘已解锁" } } } }, - "settings.show.notifications" : { + "notification.locked.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Show Lock Notifications" + "value" : "Use ⌘+⌥+L to unlock keyboard" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "显示锁定通知" + "value" : "使用 ⌘+⌥+L 解锁键盘" } } } }, - "settings.subtitle" : { + "notification.unlocked.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Configure app preferences" + "value" : "Keyboard input restored to normal" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "配置应用偏好设置" + "value" : "键盘输入已恢复正常" } } } }, - "settings.title" : { + "notification.url.command" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Settings" + "value" : "URL Command" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "设置" + "value" : "URL 命令" } } } }, - "shortcut.hint" : { + "open.system.preferences" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Shortcut: ⌘+⌥+L" + "value" : "Open System Settings" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "快捷键: ⌘+⌥+L" + "value" : "打开系统设置" } } } }, - "status.locked" : { + "permission.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard Locked" + "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 需要辅助功能权限来控制您的键盘。这允许应用程序在激活时暂时阻止键盘输入。" } } } }, - "status.unlocked" : { + "permission.required" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard Unlocked" + "value" : "Permission Required" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "键盘未锁定" + "value" : "需要权限" } } } }, - "time.activity.text" : { + "quick.actions" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Starts counting when you stop typing or using the mouse" + "value" : "Quick Actions" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "当您停止输入或使用鼠标时开始计时" + "value" : "快速操作" } } } }, - "time.auto.lock.duration" : { + "settings.auto.lock" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Auto-lock Duration" + "value" : "Auto Lock" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "自动锁定时长" + "value" : "自动锁定" } } } }, - "time.minutes" : { + "settings.auto.lock.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d minutes" + "value" : "Set the time to automatically lock keyboard after inactivity" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%d分钟" + "value" : "设置无操作后自动锁定键盘的时间" } } } }, - "duration.never" : { + "settings.keyboard" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Never" + "value" : "Keyboard" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "从不" + "value" : "快捷键" } } } }, - "duration.infinite" : { + "settings.keyboard.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Infinite" + "value" : "Use shortcut to quickly lock/unlock keyboard" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无限期" + "value" : "使用快捷键快速锁定或解锁键盘" } } } }, - "duration.minutes" : { + "settings.notifications" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d minute(s)" + "value" : "Notifications" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%d分钟" + "value" : "通知" } } } }, - "duration.hours" : { + "settings.notifications.description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d hour(s)" + "value" : "Display system notifications when locking and unlocking" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%d小时" + "value" : "锁定和解锁时显示系统通知" } } } }, - "duration.hours.minutes" : { + "settings.reset" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d hour(s) %d minute(s)" + "value" : "Reset Settings" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%d小时%d分钟" + "value" : "重置设置" } } } }, - "duration.never.description" : { + "settings.show.notifications" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Never auto unlock" + "value" : "Show Lock Notifications" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "永不自动解锁" + "value" : "显示锁定通知" } } } }, - "duration.infinite.description" : { + "settings.subtitle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Lock indefinitely" + "value" : "Configure app preferences" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无限期锁定" + "value" : "配置应用偏好设置" } } } }, - "duration.minutes.description" : { + "settings.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Auto unlock after %d minutes" + "value" : "Settings" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%d分钟后自动解锁" + "value" : "设置" } } } }, - "duration.hours.description" : { + "shortcut.hint" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Auto unlock after %d hours" + "value" : "Shortcut: ⌘+⌥+L" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%d小时后自动解锁" + "value" : "快捷键: ⌘+⌥+L" } } } }, - "duration.hours.minutes.description" : { + "status.locked" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Auto unlock after %d hours %d minutes" + "value" : "Keyboard Locked" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%d小时%d分钟后自动解锁" + "value" : "键盘已锁定" } } } }, - "countdown.finished" : { + "status.unlocked" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Finished" + "value" : "Keyboard Unlocked" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已完成" + "value" : "键盘未锁定" } } } }, - "countdown.hours.format" : { + "time.activity.text" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d:%02d:%02d remaining" + "value" : "Starts counting when you stop typing or using the mouse" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "剩余 %d:%02d:%02d" + "value" : "当您停止输入或使用鼠标时开始计时" } } } }, - "countdown.minutes.format" : { + "time.auto.lock.duration" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d:%02d remaining" + "value" : "Auto-lock Duration" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "剩余 %d:%02d" + "value" : "自动锁定时长" } } } }, - "countdown.seconds.format" : { + "time.minutes" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d seconds remaining" + "value" : "Minutes" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "剩余 %d 秒" + "value" : "分钟" + } + } + } + }, + "timed.lock.custom" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Duration" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义时长" + } + } + } + }, + "timed.lock.remaining" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remaining time: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余时间:%@" + } + } + } + }, + "timed.lock.start" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始" + } + } + } + }, + "timed.lock.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timed Lock" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "定时锁定" } } } diff --git a/KeyboardLockerTool/main.swift b/KeyboardLockerTool/main.swift index c746fd2..e2bd8a3 100644 --- a/KeyboardLockerTool/main.swift +++ b/KeyboardLockerTool/main.swift @@ -7,4 +7,5 @@ import Foundation -print("Hello, World!") +// TODO: Implement command line interface for KeyboardLocker +// This tool will provide CLI access to keyboard locking functionality From 8df783b03b6af92f1f7f8fd801f4c2b2cd11e3de Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 6 Aug 2025 01:56:40 +0800 Subject: [PATCH 09/21] =?UTF-8?q?=E2=9C=A8=20refactor:=20Simplify=20depend?= =?UTF-8?q?ency=20management=20and=20cleanup.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Core/KeyboardLockCore.swift | 55 ----- Core/Sources/Core/KeyboardLockerAPI.swift | 218 ------------------ .../Sources/Application/AppDelegate.swift | 17 +- .../Application/KeyboardLockerApp.swift | 29 +-- .../Sources/Helpers/AppDependencies.swift | 52 +++++ .../Sources/Helpers/DependencyFactory.swift | 107 --------- .../Sources/Helpers/URLHandler.swift | 27 +-- .../Managers/KeyboardLockManager.swift | 193 +++++++++------- .../Managers/NotificationManager.swift | 9 +- .../Sources/Managers/PermissionManager.swift | 4 +- .../Sources/Protocols/Protocols.swift | 103 --------- .../Sources/Views/ContentView.swift | 3 +- .../Sources/Views/ContentViewState.swift | 69 ++++-- .../Sources/Views/SettingsView.swift | 2 +- 14 files changed, 255 insertions(+), 633 deletions(-) delete mode 100644 Core/Sources/Core/KeyboardLockerAPI.swift create mode 100644 KeyboardLocker/Sources/Helpers/AppDependencies.swift delete mode 100644 KeyboardLocker/Sources/Helpers/DependencyFactory.swift delete mode 100644 KeyboardLocker/Sources/Protocols/Protocols.swift diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift index f01e35c..35a4a48 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -38,8 +38,6 @@ public class KeyboardLockCore { private var runLoopSource: CFRunLoopSource? private var _isLocked = false private var _lockedAt: Date? - private var timedLockTimer: Timer? - private var timedLockDuration: CoreConfiguration.Duration? // Internal access for callback var internalEventTap: CFMachPort? { @@ -115,11 +113,6 @@ public class KeyboardLockCore { destroyEventTap() - // Clean up timed lock resources - timedLockTimer?.invalidate() - timedLockTimer = nil - timedLockDuration = nil - _isLocked = false let wasLockedAt = _lockedAt _lockedAt = nil @@ -146,54 +139,6 @@ public class KeyboardLockCore { } } - /// Lock keyboard with specified duration (timed lock) - /// - Parameter duration: Duration for which to lock the keyboard - /// - Throws: KeyboardLockError if locking fails - public func lockKeyboardWithDuration(_ duration: CoreConfiguration.Duration) throws { - // First lock the keyboard normally - try lockKeyboard() - - // Store the duration - timedLockDuration = duration - - // Set up timer for auto-unlock (only for finite durations) - if case let .minutes(minutes) = duration, minutes > 0 { - let timeInterval = TimeInterval(minutes * 60) - timedLockTimer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { - [weak self] _ in - DispatchQueue.main.async { - self?.unlockKeyboard() - print("⏰ Timed lock completed after \(minutes) minutes") - } - } - print("⏰ Timed lock set for \(minutes) minutes") - } else if case .infinite = duration { - print("♾️ Infinite timed lock started (manual unlock required)") - } - } - - /// Get the current timed lock duration (if any) - public var currentTimedLockDuration: CoreConfiguration.Duration? { - timedLockDuration - } - - /// Get remaining time for timed lock - public func getTimedLockRemainingTime() -> TimeInterval? { - guard let duration = timedLockDuration, - let lockedAt = _lockedAt, - case let .minutes(minutes) = duration, - minutes > 0 - else { - return nil - } - - let totalDuration = TimeInterval(minutes * 60) - let elapsed = Date().timeIntervalSince(lockedAt) - let remaining = max(0, totalDuration - elapsed) - - return remaining > 0 ? remaining : nil - } - // MARK: - Utility Methods /// Get lock duration string diff --git a/Core/Sources/Core/KeyboardLockerAPI.swift b/Core/Sources/Core/KeyboardLockerAPI.swift deleted file mode 100644 index c97f274..0000000 --- a/Core/Sources/Core/KeyboardLockerAPI.swift +++ /dev/null @@ -1,218 +0,0 @@ -import Combine -import Foundation - -/// Simplified Core API for KeyboardLocker -/// This provides a unified interface for both CLI and GUI applications -public class KeyboardLockerAPI: ObservableObject { - // MARK: - Singleton - - public static let shared = KeyboardLockerAPI() - - // MARK: - Core Components - - private let core = KeyboardLockCore.shared - private let activityMonitor = UserActivityMonitor.shared - - /// The shared configuration instance - public var configuration: CoreConfiguration { - CoreConfiguration.shared - } - - // MARK: - Initialization - - private init() { - setupActivityMonitor() - setupConfigurationObserver() - } - - // MARK: - Lock/Unlock Operations - - /// Lock the keyboard - public func lockKeyboard() throws { - try core.lockKeyboard() - } - - /// Unlock the keyboard - public func unlockKeyboard() { - core.unlockKeyboard() - } - - /// Toggle keyboard lock state - public func toggleKeyboardLock() { - core.toggleLock() - } - - /// Lock keyboard with specified duration (timed lock) - public func lockKeyboardWithDuration(_ duration: CoreConfiguration.Duration) throws { - try core.lockKeyboardWithDuration(duration) - } - - /// Get current lock status - public var isLocked: Bool { - core.basicLockInfo.isLocked - } - - /// Get locked at timestamp - public var lockedAt: Date? { - core.basicLockInfo.lockedAt - } - - /// Get lock duration string - public func getLockDurationString() -> String? { - guard let lockedAt else { return nil } - - // Check if this is a timed lock - if let timedDuration = core.currentTimedLockDuration { - if case .infinite = timedDuration { - // For infinite timed lock, show elapsed time - let duration = Date().timeIntervalSince(lockedAt) - let minutes = Int(duration / 60) - let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", minutes, seconds) - } else if let remainingTime = core.getTimedLockRemainingTime() { - // For finite timed lock, show remaining time - let minutes = Int(remainingTime / 60) - let seconds = Int(remainingTime.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", minutes, seconds) - } - } - - // For regular lock, show elapsed time - let duration = Date().timeIntervalSince(lockedAt) - let minutes = Int(duration / 60) - let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", minutes, seconds) - } - - // MARK: - Auto-lock Configuration (Core Logic Only) - - /// Enable auto-lock with specified duration in minutes - public func enableAutoLock(minutes: Int) { - let autoLockSetting: CoreConfiguration.Duration = minutes == 0 ? .never : .minutes(minutes) - - configuration.autoLockDuration = autoLockSetting - activityMonitor.enableAutoLock(seconds: autoLockSetting.seconds) - - // Start activity monitoring if enabled - if autoLockSetting.isEnabled { - activityMonitor.startMonitoring() - } else { - activityMonitor.stopMonitoring() - } - } - - /// Enable auto-lock with specified duration in seconds (for backward compatibility) - public func enableAutoLock(seconds: TimeInterval) { - let minutes = Int(seconds / 60) - enableAutoLock(minutes: minutes) - } - - /// Disable auto-lock - public func disableAutoLock() { - configuration.autoLockDuration = .never - activityMonitor.enableAutoLock(seconds: 0) - activityMonitor.stopMonitoring() - } - - /// Get current auto-lock status - public func isAutoLockEnabled() -> Bool { - configuration.isAutoLockEnabled - } - - /// Get auto-lock duration in seconds - public func getAutoLockDurationSeconds() -> Int { - Int(configuration.autoLockDurationInSeconds) - } - - /// Get auto-lock duration in minutes - public func getAutoLockDuration() -> Int { - configuration.autoLockDuration.minutes - } - - /// Get time since last user activity (for UI display) - public func getTimeSinceLastActivity() -> TimeInterval { - activityMonitor.timeSinceLastActivity - } - - /// Reset user activity timer manually - public func resetUserActivityTimer() { - activityMonitor.resetActivityTimer() - } - - // MARK: - Configuration Management - - /// Export current configuration - public func exportConfiguration() -> [String: Any] { - configuration.exportConfiguration() - } - - /// Import configuration - public func importConfiguration(_ newConfig: [String: Any]) { - configuration.importConfiguration(newConfig) - } - - /// Reset configuration to defaults - public func resetConfiguration() { - configuration.resetToDefaults() - } - - /// Set notification preferences - public func setNotificationsEnabled(_ enabled: Bool) { - configuration.showNotifications = enabled - } - - /// Get notification preferences - public func isNotificationsEnabled() -> Bool { - configuration.showNotifications - } - - // MARK: - State Change Callbacks - - /// Set callback for lock state changes - /// - Parameter callback: Called when lock state changes with (isLocked, lockedAt) - public func setLockStateChangeCallback(_ callback: @escaping (Bool, Date?) -> Void) { - core.onLockStateChanged = callback - } - - /// Set callback for unlock hotkey detection - /// - Parameter callback: Called when unlock hotkey is detected - public func setUnlockHotkeyCallback(_ callback: @escaping () -> Void) { - core.onUnlockHotkeyDetected = callback - } - - // MARK: - Permission Management - - /// Check if accessibility permission is granted - public func hasAccessibilityPermission() -> Bool { - // Basic implementation - could be enhanced with proper permission checking - true - } - - /// Request accessibility permission - public func requestAccessibilityPermission() { - PermissionHelper.requestAccessibilityPermission() - } - - // MARK: - Private Setup Methods - - /// Setup activity monitor with auto-lock callback - private func setupActivityMonitor() { - activityMonitor.onAutoLockTriggered = { [weak self] in - do { - try self?.lockKeyboard() - } catch { - print("❌ Auto-lock failed: \(error.localizedDescription)") - } - } - } - - /// Setup configuration observer to sync auto-lock settings - private func setupConfigurationObserver() { - // Sync initial auto-lock configuration using new enum - let autoLockConfig = configuration.autoLockDuration - if autoLockConfig.isEnabled { - activityMonitor.enableAutoLock(seconds: autoLockConfig.seconds) - activityMonitor.startMonitoring() - } - } -} diff --git a/KeyboardLocker/Sources/Application/AppDelegate.swift b/KeyboardLocker/Sources/Application/AppDelegate.swift index e23969b..e43d256 100644 --- a/KeyboardLocker/Sources/Application/AppDelegate.swift +++ b/KeyboardLocker/Sources/Application/AppDelegate.swift @@ -4,16 +4,12 @@ import SwiftUI /// Custom AppDelegate for handling URL schemes and application lifecycle class AppDelegate: NSObject, NSApplicationDelegate { - var urlHandler: URLCommandHandler = .shared - var keyboardLockManager: KeyboardLockManaging? + var urlHandler: URLCommandHandler? + var keyboardLockManager: KeyboardLockManager? - func configure(_ manager: KeyboardLockManaging) { + func configure(_ manager: KeyboardLockManager, _ handler: URLCommandHandler) { keyboardLockManager = manager - urlHandler.setKeyboardLockManager(manager) - } - - func applicationDidFinishLaunching(_: Notification) { - print("Application did finish launching") + urlHandler = handler } func applicationWillTerminate(_: Notification) { @@ -44,6 +40,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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) diff --git a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift index 236b8a6..a2cce3c 100644 --- a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift +++ b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift @@ -4,27 +4,13 @@ import SwiftUI /// Main app entry point using modern SwiftUI App protocol with AppDelegate @main struct KeyboardLockerApp: App { - // Use dependency factory to create managers with proper dependency injection - @StateObject private var keyboardLockManager: KeyboardLockManager - @StateObject private var permissionManager = DependencyFactory.shared.makePermissionManager() + // Application dependencies container + private let dependencies = appDependencies // Use AppDelegate for URL handling @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - // Create keyboard lock manager safely without force casting - private static func makeKeyboardLockManager() -> KeyboardLockManager { - let manager = DependencyFactory.shared.makeKeyboardLockManager() - if let concreteManager = manager as? KeyboardLockManager { - return concreteManager - } else { - // Fallback: create a new instance directly - return KeyboardLockManager() - } - } - init() { - _keyboardLockManager = StateObject(wrappedValue: Self.makeKeyboardLockManager()) - // Initialize IPC server for external communication IPCManager.shared.startServer() @@ -36,10 +22,10 @@ struct KeyboardLockerApp: App { // Modern MenuBarExtra for native menu bar integration MenuBarExtra(LocalizationKey.appMenuTitle.localized, systemImage: "lock.shield") { ContentView() - .environmentObject(keyboardLockManager) - .environmentObject(permissionManager) + .environmentObject(dependencies.keyboardLockManager) + .environmentObject(dependencies.permissionManager) .onAppear { - appDelegate.configure(keyboardLockManager) + appDelegate.configure(dependencies.keyboardLockManager, dependencies.urlHandler) } } .menuBarExtraStyle(.window) @@ -56,9 +42,8 @@ struct KeyboardLockerApp: App { // Attempt to unlock keyboard before crash - safety measure DispatchQueue.main.async { - // Force unlock by creating a temporary manager - let tempManager = KeyboardLockManager() - tempManager.unlockKeyboard() + // Force unlock using Core directly (emergency safety) + KeyboardLockCore.shared.unlockKeyboard() // Give time for cleanup before exit DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/KeyboardLocker/Sources/Helpers/AppDependencies.swift b/KeyboardLocker/Sources/Helpers/AppDependencies.swift new file mode 100644 index 0000000..44c325b --- /dev/null +++ b/KeyboardLocker/Sources/Helpers/AppDependencies.swift @@ -0,0 +1,52 @@ +import Core +import Foundation + +/// 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 + ) + } +} + +/// Global application dependency instance +/// Created once at app startup and passed to components that need dependencies +let appDependencies = AppDependencies() diff --git a/KeyboardLocker/Sources/Helpers/DependencyFactory.swift b/KeyboardLocker/Sources/Helpers/DependencyFactory.swift deleted file mode 100644 index a485901..0000000 --- a/KeyboardLocker/Sources/Helpers/DependencyFactory.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Core - -/// Factory for creating and managing application dependencies -/// This helps reduce coupling and makes testing easier -class DependencyFactory { - /// Shared instance for application-wide dependency management - static let shared = DependencyFactory() - - private init() {} - - // MARK: - Factory Methods - - /// Create a notification manager instance - /// - Returns: NotificationManaging instance - func makeNotificationManager() -> NotificationManaging { - NotificationManager.shared - } - - /// Create a keyboard lock manager instance - /// - Parameters: - /// - notificationManager: Optional notification manager, uses default if nil - /// - Returns: KeyboardLockManaging instance - func makeKeyboardLockManager( - notificationManager: NotificationManaging? = nil - ) -> KeyboardLockManaging { - let notificationMgr = notificationManager ?? makeNotificationManager() - return KeyboardLockManager(notificationManager: notificationMgr) - } - - /// Create a URL command handler instance - /// - Returns: URLCommandHandler instance - func makeURLCommandHandler() -> URLCommandHandler { - // Since URLCommandHandler uses a singleton pattern, we return the shared instance - // In a more sophisticated dependency injection system, we might create new instances - URLCommandHandler.shared - } - - /// Create a permission manager instance - /// - Parameter notificationManager: Optional notification manager, uses default if nil - /// - Returns: PermissionManager instance - func makePermissionManager( - notificationManager: NotificationManager? = nil - ) -> PermissionManager { - let notificationMgr: NotificationManager = - if let providedManager = notificationManager { - providedManager - } else if let defaultManager = makeNotificationManager() as? NotificationManager { - defaultManager - } else { - // Fallback: create a new instance directly - NotificationManager.shared - } - return PermissionManager(notificationManager: notificationMgr) - } -} - -// MARK: - Testing Support - -#if DEBUG - extension DependencyFactory { - /// Create a mock notification manager for testing - /// - Returns: Mock NotificationManaging instance - func makeMockNotificationManager() -> NotificationManaging { - MockNotificationManager() - } - - /// Create a keyboard lock manager with mock dependencies for testing - /// - Returns: KeyboardLockManaging instance with mock dependencies - func makeMockKeyboardLockManager() -> KeyboardLockManaging { - let mockNotificationManager = makeMockNotificationManager() - return KeyboardLockManager(notificationManager: mockNotificationManager) - } - } - - /// Mock notification manager for testing purposes - class MockNotificationManager: NotificationManaging { - var sentNotifications: [(type: NotificationManager.NotificationType, showNotifications: Bool)] = - [] - var customNotifications: [(title: String, body: String, isError: Bool)] = [] - - // Implement the required protocol property - var isAuthorized: Bool = true - - func sendNotificationIfEnabled( - _ type: NotificationManager.NotificationType, showNotifications: Bool - ) { - sentNotifications.append((type: type, showNotifications: showNotifications)) - print("Mock: Would send notification \(type) with showNotifications=\(showNotifications)") - } - - func sendNotification(title: String, body: String, isError: Bool) { - customNotifications.append((title: title, body: body, isError: isError)) - print( - "Mock: Would send custom notification - Title: \(title), Body: \(body), Error: \(isError)") - } - - func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) { - print("Mock: Would request authorization") - // Simulate success for testing - completion(true, nil) - } - - func checkAuthorizationStatus() { - print("Mock: Would check authorization status") - } - } -#endif diff --git a/KeyboardLocker/Sources/Helpers/URLHandler.swift b/KeyboardLocker/Sources/Helpers/URLHandler.swift index 304b586..24ca4de 100644 --- a/KeyboardLocker/Sources/Helpers/URLHandler.swift +++ b/KeyboardLocker/Sources/Helpers/URLHandler.swift @@ -48,21 +48,18 @@ class URLCommandHandler { } } - static let shared = URLCommandHandler() + private weak var keyboardLockManager: KeyboardLockManager? + private let notificationManager: NotificationManager - private weak var keyboardLockManager: KeyboardLockManaging? - private let notificationManager: NotificationManaging - - private init(notificationManager: NotificationManaging = NotificationManager.shared) { + /// Create URLCommandHandler with dependencies + /// - Parameters: + /// - keyboardLockManager: Manager for keyboard operations + /// - notificationManager: Manager for notifications + init(keyboardLockManager: KeyboardLockManager, notificationManager: NotificationManager) { + self.keyboardLockManager = keyboardLockManager self.notificationManager = notificationManager } - /// Set the keyboard lock manager reference - /// - Parameter manager: The keyboard lock manager instance - func setKeyboardLockManager(_ manager: KeyboardLockManaging) { - keyboardLockManager = manager - } - /// Process incoming URL and execute the appropriate command /// - Parameter url: The URL to process /// - Returns: Command response indicating success or failure @@ -120,7 +117,7 @@ class URLCommandHandler { } /// Execute lock command - private func executeLockCommand(_ manager: KeyboardLockManaging) -> CommandResponse { + private func executeLockCommand(_ manager: KeyboardLockManager) -> CommandResponse { if manager.isLocked { let message = LocalizationKey.statusLocked.localized print("ℹ️ Keyboard already locked") @@ -142,7 +139,7 @@ class URLCommandHandler { } /// Execute unlock command - private func executeUnlockCommand(_ manager: KeyboardLockManaging) -> CommandResponse { + private func executeUnlockCommand(_ manager: KeyboardLockManager) -> CommandResponse { if !manager.isLocked { let message = LocalizationKey.statusUnlocked.localized print("ℹ️ Keyboard already unlocked") @@ -164,7 +161,7 @@ class URLCommandHandler { } /// Execute toggle command - private func executeToggleCommand(_ manager: KeyboardLockManaging) -> CommandResponse { + private func executeToggleCommand(_ manager: KeyboardLockManager) -> CommandResponse { let wasLocked = manager.isLocked if wasLocked { @@ -175,7 +172,7 @@ class URLCommandHandler { } /// Execute status command - private func executeStatusCommand(_ manager: KeyboardLockManaging) -> CommandResponse { + private func executeStatusCommand(_ manager: KeyboardLockManager) -> CommandResponse { let statusText = manager.isLocked ? LocalizationKey.statusLocked.localized diff --git a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift index 8d9797b..42a79e6 100644 --- a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift +++ b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift @@ -1,25 +1,49 @@ +import Combine import Core import SwiftUI -/// UI-focused keyboard lock manager that uses Core Library's unified API -/// This layer handles only UI-specific concerns like notifications -class KeyboardLockManager: ObservableObject, KeyboardLockManaging { +/// UI-focused keyboard lock manager that bridges Core functionality and UI state +/// This layer handles UI state management and integrates with the Core library +class KeyboardLockManager: ObservableObject { + // MARK: - Published UI State + @Published var isLocked = false @Published var autoLockEnabled = false - // Core API - unified interface for all functionality - private let coreAPI = KeyboardLockerAPI.shared + // MARK: - Dependencies + + // Core functionality - injected dependencies + private let core: KeyboardLockCore + private let config: CoreConfiguration + private let activityMonitor: UserActivityMonitor // UI-specific dependencies - private let notificationManager: NotificationManaging + private let notificationManager: NotificationManager + + // MARK: - Combine Subscriptions + + private var cancellables = Set() + + // MARK: - Initialization + /// Create KeyboardLockManager with injected dependencies + /// - Parameters: + /// - core: Core keyboard functionality + /// - config: Configuration management + /// - activityMonitor: User activity monitoring + /// - notificationManager: Notification handling init( - notificationManager: NotificationManaging = NotificationManager.shared + core: KeyboardLockCore, + config: CoreConfiguration, + activityMonitor: UserActivityMonitor, + notificationManager: NotificationManager ) { + self.core = core + self.config = config + self.activityMonitor = activityMonitor self.notificationManager = notificationManager - // Setup state change callback and sync - setupLockStateCallback() + setupStateSubscriptions() syncInitialState() } @@ -29,163 +53,178 @@ class KeyboardLockManager: ObservableObject, KeyboardLockManaging { /// Clean up resources when object is deallocated private func cleanup() { - // Core API handles its own cleanup + cancellables.removeAll() } // MARK: - Public Interface (UI Actions) func lockKeyboard() { do { - try coreAPI.lockKeyboard() - - // Send notification to user (UI concern) - notificationManager.sendNotificationIfEnabled( - .keyboardLocked, // Use locked notification for auto-lock - showNotifications: coreAPI.isNotificationsEnabled() - ) + try core.lockKeyboard() } catch { print("❌ Failed to lock keyboard: \(error.localizedDescription)") } } func unlockKeyboard() { - guard coreAPI.isLocked else { + guard core.isKeyboardLocked else { return } - - coreAPI.unlockKeyboard() - - // Send notification to user (UI concern) - notificationManager.sendNotificationIfEnabled( - .keyboardUnlocked, - showNotifications: coreAPI.isNotificationsEnabled() - ) + core.unlockKeyboard() } func toggleLock() { - coreAPI.toggleKeyboardLock() - - // Send notification based on new state - let notificationType: NotificationManager.NotificationType = coreAPI.isLocked ? .keyboardLocked : .keyboardUnlocked - notificationManager.sendNotificationIfEnabled( - notificationType, - showNotifications: coreAPI.isNotificationsEnabled() - ) + core.toggleLock() } /// Start a timed lock with specified duration func lockKeyboard(with duration: CoreConfiguration.Duration) { do { - try coreAPI.lockKeyboardWithDuration(duration) - - // Send notification to user (UI concern) - notificationManager.sendNotificationIfEnabled( - .keyboardLocked, - showNotifications: coreAPI.isNotificationsEnabled() - ) + try core.lockKeyboard() + // For timed locks, we implement timer logic in the UI layer + // This keeps business logic separate from core functionality + if case let .minutes(minutes) = duration, minutes > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(minutes * 60)) { + self.core.unlockKeyboard() + print("⏰ Timed lock completed after \(minutes) minutes") + } + } else if case .infinite = duration { + print("♾️ Infinite timed lock started (manual unlock required)") + } } catch { print("❌ Failed to start timed lock: \(error.localizedDescription)") } } - // MARK: - Auto-Lock Management (using Core API directly) + // MARK: - Auto-Lock Management func startAutoLock() { - // Use thirtyMinutes as default when enabling auto-lock if currently disabled - if !coreAPI.configuration.autoLockDuration.isEnabled { - coreAPI.configuration.autoLockDuration = .minutes(30) + // Use 30 minutes as default when enabling auto-lock if currently disabled + if !config.autoLockDuration.isEnabled { + config.autoLockDuration = .minutes(30) } + enableAutoLockMonitoring() } func stopAutoLock() { - coreAPI.configuration.autoLockDuration = .never + config.autoLockDuration = .never + activityMonitor.stopMonitoring() } func toggleAutoLock() { - if coreAPI.configuration.autoLockDuration.isEnabled { - coreAPI.configuration.autoLockDuration = .never + if config.autoLockDuration.isEnabled { + config.autoLockDuration = .never + activityMonitor.stopMonitoring() } else { - coreAPI.configuration.autoLockDuration = .minutes(30) + config.autoLockDuration = .minutes(30) + enableAutoLockMonitoring() } - // Update UI state - syncAutoLockConfiguration() } /// Get time since last user activity (for UI display) func getTimeSinceLastActivity() -> TimeInterval { - coreAPI.getTimeSinceLastActivity() + activityMonitor.timeSinceLastActivity } /// Reset user activity timer manually func resetUserActivityTimer() { - coreAPI.resetUserActivityTimer() + activityMonitor.resetActivityTimer() } // MARK: - Status and Information func getLockDurationString() -> String? { - coreAPI.getLockDurationString() + guard let lockedAt = core.keyboardLockedAt else { return nil } + + let duration = Date().timeIntervalSince(lockedAt) + let minutes = Int(duration / 60) + let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", minutes, seconds) } func checkPermissions() -> Bool { - coreAPI.hasAccessibilityPermission() + PermissionHelper.hasAccessibilityPermission() } func requestPermissions() { - coreAPI.requestAccessibilityPermission() + PermissionHelper.requestAccessibilityPermission() } - // MARK: - Configuration Access (直接使用CoreConfiguration) + // MARK: - Configuration Access /// Auto-lock duration in minutes for UI display var autoLockDuration: Int { - CoreConfiguration.shared.autoLockDuration.minutes + config.autoLockDuration.minutes } /// Check if auto-lock is enabled var isAutoLockEnabled: Bool { - CoreConfiguration.shared.autoLockDuration.isEnabled + config.autoLockDuration.isEnabled } - /// Get/set notification preference (using Core directly) + /// Get/set notification preference var showNotifications: Bool { - get { coreAPI.isNotificationsEnabled() } - set { coreAPI.setNotificationsEnabled(newValue) } + get { config.showNotifications } + set { config.showNotifications = newValue } } // MARK: - Utility Methods func forceCleanup() { - // Core API manages its own cleanup + core.forceCleanup() syncInitialState() } - // MARK: - Private Methods + // MARK: - Private Setup Methods - /// Setup lock state change callback from Core - private func setupLockStateCallback() { - // Use Core's callback instead of timer-based polling - coreAPI.setLockStateChangeCallback { [weak self] isLocked, _ in + /// Setup reactive state subscriptions from Core components + private func setupStateSubscriptions() { + // Setup lock state callback + core.onLockStateChanged = { [weak self] isLocked, _ in DispatchQueue.main.async { self?.isLocked = isLocked - // Auto-lock state should also be synced when lock state changes - self?.autoLockEnabled = self?.coreAPI.configuration.autoLockDuration.isEnabled ?? false + + // Send notifications based on state change + let notificationType: NotificationManager.NotificationType = + isLocked ? .keyboardLocked : .keyboardUnlocked + self?.notificationManager.sendNotificationIfEnabled( + notificationType, + showNotifications: self?.config.showNotifications ?? false + ) } } + + // Subscribe to configuration changes for auto-lock state + config.$autoLockDuration + .receive(on: DispatchQueue.main) + .sink { [weak self] duration in + self?.autoLockEnabled = duration.isEnabled + if duration.isEnabled { + self?.enableAutoLockMonitoring() + } else { + self?.activityMonitor.stopMonitoring() + } + } + .store(in: &cancellables) } - /// Sync initial state from Core + /// Sync initial state from Core components private func syncInitialState() { DispatchQueue.main.async { - self.isLocked = self.coreAPI.isLocked - self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration.isEnabled + self.isLocked = self.core.isKeyboardLocked + self.autoLockEnabled = self.config.autoLockDuration.isEnabled } } - /// Sync auto-lock configuration from Core - private func syncAutoLockConfiguration() { - DispatchQueue.main.async { - self.autoLockEnabled = self.coreAPI.configuration.autoLockDuration.isEnabled + /// Enable auto-lock monitoring with current configuration + private func enableAutoLockMonitoring() { + let duration = config.autoLockDuration + if duration.isEnabled { + activityMonitor.enableAutoLock(seconds: duration.seconds) + activityMonitor.onAutoLockTriggered = { [weak self] in + self?.lockKeyboard() + } + activityMonitor.startMonitoring() } } } diff --git a/KeyboardLocker/Sources/Managers/NotificationManager.swift b/KeyboardLocker/Sources/Managers/NotificationManager.swift index 739d4be..c97bebd 100644 --- a/KeyboardLocker/Sources/Managers/NotificationManager.swift +++ b/KeyboardLocker/Sources/Managers/NotificationManager.swift @@ -2,11 +2,7 @@ import Foundation import UserNotifications /// Centralized notification management for the app -class NotificationManager: ObservableObject, NotificationManaging { - // MARK: - Singleton - - static let shared = NotificationManager() - +class NotificationManager: ObservableObject { // MARK: - Published Properties @Published var isAuthorized = false @@ -115,7 +111,8 @@ class NotificationManager: ObservableObject, NotificationManaging { // MARK: - Initialization - private init() { + /// Create a new NotificationManager instance + init() { setupNotificationCategories() checkAuthorizationStatus() } diff --git a/KeyboardLocker/Sources/Managers/PermissionManager.swift b/KeyboardLocker/Sources/Managers/PermissionManager.swift index b3f503f..9246475 100644 --- a/KeyboardLocker/Sources/Managers/PermissionManager.swift +++ b/KeyboardLocker/Sources/Managers/PermissionManager.swift @@ -14,11 +14,11 @@ class PermissionManager: ObservableObject { // MARK: - Private Properties - private let notificationManager: NotificationManaging + let notificationManager: NotificationManager // MARK: - Initialization - init(notificationManager: NotificationManaging = NotificationManager.shared) { + init(notificationManager: NotificationManager) { self.notificationManager = notificationManager checkAllPermissions() setupApplicationFocusMonitoring() diff --git a/KeyboardLocker/Sources/Protocols/Protocols.swift b/KeyboardLocker/Sources/Protocols/Protocols.swift deleted file mode 100644 index 8465905..0000000 --- a/KeyboardLocker/Sources/Protocols/Protocols.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation - -// MARK: - Notification Manager Protocol - -/// Protocol for notification management to reduce coupling -protocol NotificationManaging { - var isAuthorized: Bool { get } - - /// Send a notification if notifications are enabled - /// - Parameters: - /// - type: The type of notification to send - /// - showNotifications: Whether notifications are enabled - func sendNotificationIfEnabled(_ type: NotificationManager.NotificationType, showNotifications: Bool) - - /// 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) - - /// Request authorization for notifications - /// - Parameter completion: Completion handler with success status and optional error - func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) - - /// Check current authorization status - func checkAuthorizationStatus() -} - -// MARK: - Keyboard Lock Managing Protocol - -/// Protocol for keyboard lock management to reduce coupling -protocol KeyboardLockManaging: AnyObject { - var isLocked: Bool { get } - - func lockKeyboard() - func unlockKeyboard() - func toggleLock() - func getLockDurationString() -> String? - func forceCleanup() - - // Auto-lock management - func startAutoLock() - func stopAutoLock() - func toggleAutoLock() -} - -/// Enhanced protocol with result-based operations for better error handling -protocol KeyboardLockManagingAdvanced: KeyboardLockManaging { - func lockKeyboard() -> KeyboardLockResult - func unlockKeyboard() -> KeyboardLockResult -} - -// MARK: - Operation Result - -/// Result type for keyboard operations -enum KeyboardLockResult { - case success - case failure(KeyboardLockerError) - - var isSuccess: Bool { - switch self { - case .success: - true - case .failure: - false - } - } - - var error: KeyboardLockerError? { - switch self { - case .success: - nil - case let .failure(error): - error - } - } -} - -// MARK: - Error Types - -enum KeyboardLockerError: LocalizedError { - case accessibilityPermissionDenied - case eventTapCreationFailed - case runLoopSourceCreationFailed - case invalidEventType - case managerNotAvailable - - var errorDescription: String? { - switch self { - case .accessibilityPermissionDenied: - "Accessibility permission not granted" - case .eventTapCreationFailed: - "Failed to create event tap" - case .runLoopSourceCreationFailed: - "Failed to create run loop source" - case .invalidEventType: - "Invalid event type encountered" - case .managerNotAvailable: - "Keyboard lock manager not available" - } - } -} diff --git a/KeyboardLocker/Sources/Views/ContentView.swift b/KeyboardLocker/Sources/Views/ContentView.swift index 2f42f92..53f7163 100644 --- a/KeyboardLocker/Sources/Views/ContentView.swift +++ b/KeyboardLocker/Sources/Views/ContentView.swift @@ -17,13 +17,12 @@ struct ContentView: View { .frame(width: .viewWidth) .background(Color(NSColor.windowBackgroundColor)) .onAppear(perform: setupInitialState) - .onReceive(keyboardManager.$isLocked, perform: viewState.handleLockStateChange) .onDisappear(perform: viewState.cleanup) } private func setupInitialState() { permissionManager.checkAllPermissions() - viewState.configure(with: keyboardManager) + viewState.setup(with: keyboardManager) } } diff --git a/KeyboardLocker/Sources/Views/ContentViewState.swift b/KeyboardLocker/Sources/Views/ContentViewState.swift index 87700a8..faa6d33 100644 --- a/KeyboardLocker/Sources/Views/ContentViewState.swift +++ b/KeyboardLocker/Sources/Views/ContentViewState.swift @@ -1,3 +1,4 @@ +import Combine import Core import SwiftUI @@ -10,10 +11,14 @@ class ContentViewState: ObservableObject { @Published var showTimedLockOptions = false @Published var customMinutes: Int = 5 - // MARK: - Private Properties + // MARK: - Dependencies - private var lockDurationTimer: Timer? var keyboardManager: KeyboardLockManager? + private var cancellables = Set() + + // MARK: - UI State Timer + + private var uiUpdateTimer: Timer? // MARK: - Computed Properties @@ -24,18 +29,21 @@ class ContentViewState: ObservableObject { ) } - // MARK: - Public Methods + // MARK: - Lifecycle Methods - func configure(with keyboardManager: KeyboardLockManager) { + func setup(with keyboardManager: KeyboardLockManager) { self.keyboardManager = keyboardManager - isKeyboardLocked = keyboardManager.isLocked + setupSubscriptions() + syncInitialState() } - func handleLockStateChange(_ locked: Bool) { - isKeyboardLocked = locked - setupLockDurationTimer() + func cleanup() { + cancellables.removeAll() + stopUIUpdateTimer() } + // MARK: - Public Methods + func startTimedLock() { lock(with: selectedTimedLockDuration) } @@ -47,6 +55,8 @@ class ContentViewState: ObservableObject { lock(with: customDuration) } + // MARK: - Private Methods + private func lock(with duration: CoreConfiguration.Duration) { guard let keyboardManager else { return } @@ -54,22 +64,47 @@ class ContentViewState: ObservableObject { keyboardManager.lockKeyboard(with: duration) } - func cleanup() { - lockDurationTimer?.invalidate() - lockDurationTimer = nil + private func setupSubscriptions() { + guard let keyboardManager else { return } + + // Subscribe to lock state changes + keyboardManager.$isLocked + .receive(on: DispatchQueue.main) + .sink { [weak self] isLocked in + self?.handleLockStateChange(isLocked) + } + .store(in: &cancellables) } - // MARK: - Private Methods + private func syncInitialState() { + handleLockStateChange(keyboardManager?.isLocked ?? false) + } - private func setupLockDurationTimer() { - cleanup() - guard isKeyboardLocked else { return } + private func handleLockStateChange(_ locked: Bool) { + isKeyboardLocked = locked - lockDurationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { - [weak self] _ in + if locked { + startUIUpdateTimer() + } else { + stopUIUpdateTimer() + } + } + + // MARK: - UI Update Timer + + private func startUIUpdateTimer() { + stopUIUpdateTimer() + + // Timer for UI updates (display refresh) + uiUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.objectWillChange.send() } } } + + private func stopUIUpdateTimer() { + uiUpdateTimer?.invalidate() + uiUpdateTimer = nil + } } diff --git a/KeyboardLocker/Sources/Views/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift index 48c9de7..dbd7ef4 100644 --- a/KeyboardLocker/Sources/Views/SettingsView.swift +++ b/KeyboardLocker/Sources/Views/SettingsView.swift @@ -127,6 +127,6 @@ struct SettingsView: View { #Preview { NavigationStack { SettingsView() - .environmentObject(KeyboardLockManager()) + .environmentObject(AppDependencies().keyboardLockManager) } } From d6e2cc01ece3554685555366171e186b834168a0 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 6 Aug 2025 10:42:18 +0800 Subject: [PATCH 10/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Update=20default=20ho?= =?UTF-8?q?tkey=20and=20add=20timed=20lock=20method.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Core/CoreConfiguration.swift | 6 +++--- Core/Sources/Core/KeyboardLockCore.swift | 3 ++- KeyboardLocker/Sources/Views/ContentViewState.swift | 4 ++++ KeyboardLocker/Sources/Views/StatusView.swift | 9 ++++++--- KeyboardLocker/Sources/Views/TimedLockView.swift | 4 ++-- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift index 5ab0d67..1c77a97 100644 --- a/Core/Sources/Core/CoreConfiguration.swift +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -202,12 +202,12 @@ public struct HotkeyConfiguration: Codable, CustomStringConvertible { self.displayString = displayString } - /// Default hotkey: Command+Shift+L + /// Default hotkey: Command+Option+L public static func defaultHotkey() -> HotkeyConfiguration { HotkeyConfiguration( keyCode: CoreConstants.defaultUnlockKeyCode, - modifierFlags: UInt32(cmdKey | shiftKey), - displayString: "⌘⇧L" + modifierFlags: UInt32(cmdKey | optionKey), + displayString: "⌘⌥L" ) } diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift index 35a4a48..e6d53b6 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -212,7 +212,8 @@ public class KeyboardLockCore { let keycode = SafeEventHandler.getKeycode(from: event) let flags = SafeEventHandler.getFlags(from: event) - if keycode == Int64(unlockKeyCode), flags.contains(.maskCommand), + if keycode == Int64(unlockKeyCode), + flags.contains(.maskCommand), flags.contains(.maskAlternate) { print("🔑 Unlock hotkey detected: ⌘+⌥+L") diff --git a/KeyboardLocker/Sources/Views/ContentViewState.swift b/KeyboardLocker/Sources/Views/ContentViewState.swift index faa6d33..48706d6 100644 --- a/KeyboardLocker/Sources/Views/ContentViewState.swift +++ b/KeyboardLocker/Sources/Views/ContentViewState.swift @@ -48,6 +48,10 @@ class ContentViewState: ObservableObject { lock(with: selectedTimedLockDuration) } + func startTimedLock(with duration: CoreConfiguration.Duration) { + lock(with: duration) + } + func startCustomTimedLock() { guard customMinutes > 0 else { return } diff --git a/KeyboardLocker/Sources/Views/StatusView.swift b/KeyboardLocker/Sources/Views/StatusView.swift index d769674..03e2f6b 100644 --- a/KeyboardLocker/Sources/Views/StatusView.swift +++ b/KeyboardLocker/Sources/Views/StatusView.swift @@ -80,9 +80,12 @@ private struct LockDurationRow: View { } private func getLockDurationDisplayText(_ durationString: String) -> String { - durationString.contains(":") - ? LocalizationKey.timedLockRemaining.localized(durationString) - : "" + if durationString.contains(":") { + return LocalizationKey.timedLockRemaining.localized(durationString) + } else { + // Fallback: show a generic message with the duration string + return LocalizationKey.statusLocked.localized() + } } } diff --git a/KeyboardLocker/Sources/Views/TimedLockView.swift b/KeyboardLocker/Sources/Views/TimedLockView.swift index 4eabea6..ca8f0c2 100644 --- a/KeyboardLocker/Sources/Views/TimedLockView.swift +++ b/KeyboardLocker/Sources/Views/TimedLockView.swift @@ -33,13 +33,13 @@ private struct PresetButtonsSection: View { VStack(spacing: 8) { HStack(spacing: 8) { ForEach(Array(LockDurationHelper.timedLockPresets.prefix(2)), id: \.self) { duration in - PresetButton(duration: duration, action: state.startTimedLock) + PresetButton(duration: duration, action: { state.startTimedLock(with: duration) }) } } HStack(spacing: 8) { ForEach(Array(LockDurationHelper.timedLockPresets.suffix(2)), id: \.self) { duration in - PresetButton(duration: duration, action: state.startTimedLock) + PresetButton(duration: duration, action: { state.startTimedLock(with: duration) }) } } } From 5fb29d0331013bf52b6b3b651d5bec209c7a4cde Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 10 Oct 2025 15:21:37 +0800 Subject: [PATCH 11/21] Refactor KeyboardLocker application structure and enhance duration management - Removed initialization code from KeyboardLockerApp for IPC and exception handling. - Introduced new extensions for CGFloat, Duration, and TimeInterval to manage UI dimensions and duration formatting. - Consolidated duration management logic into CoreConfiguration.Duration with preset collections for timed and auto-lock durations. - Deleted redundant CountdownFormatter and LockDurationHelper classes, streamlining duration handling. - Updated NotificationManager to handle keyboard status notifications more efficiently. - Simplified UI components in ContentViewState and TimedLockView, removing unnecessary timers and headers. - Enhanced PermissionView layout and added quit button functionality. - Improved SettingsView and SharedComponents for better navigation and user experience. - Removed unused localization keys related to countdown and duration descriptions. --- Core/Sources/Core/CoreConfiguration.swift | 150 ++++++------- Core/Sources/Core/IPCManager.swift | 151 ++++--------- Core/Sources/Core/KeyboardLockCore.swift | 207 ++++++++---------- Core/Sources/Core/PermissionHelper.swift | 77 ------- Core/Sources/Core/SharedModels.swift | 21 +- Core/Sources/Core/UserActivityMonitor.swift | 43 ++-- KeyboardLocker.xcodeproj/project.pbxproj | 16 +- .../xcschemes/KeyboardLocker.xcscheme | 78 +++++++ .../Sources/Application/AppDelegate.swift | 29 +++ .../Application/KeyboardLockerApp.swift | 29 --- .../{Constants.swift => CGFloat+.swift} | 0 .../Sources/Extensions/Duration+.swift | 56 +++++ .../Sources/Extensions/TimeInterval+.swift | 20 ++ .../Sources/Helpers/AppDependencies.swift | 1 - .../Sources/Helpers/CountdownFormatter.swift | 44 ---- .../Sources/Helpers/LocalizationHelper.swift | 15 -- .../Sources/Helpers/LockDurationHelper.swift | 153 ------------- .../Sources/Helpers/SafeEventHandling.swift | 84 ------- .../Sources/Helpers/URLHandler.swift | 26 +-- .../Managers/KeyboardLockManager.swift | 18 +- .../Managers/NotificationManager.swift | 57 +++-- .../Sources/Views/ContentViewState.swift | 30 --- .../Sources/Views/PermissionView.swift | 93 +++----- .../Sources/Views/SettingsView.swift | 6 +- .../Sources/Views/SharedComponents.swift | 49 ++--- KeyboardLocker/Sources/Views/StatusView.swift | 122 ++++------- .../Sources/Views/TimedLockView.swift | 98 ++++----- KeyboardLocker/i18n/Localizable.xcstrings | 155 +------------ 28 files changed, 614 insertions(+), 1214 deletions(-) create mode 100644 KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme rename KeyboardLocker/Sources/Extensions/{Constants.swift => CGFloat+.swift} (100%) create mode 100644 KeyboardLocker/Sources/Extensions/Duration+.swift create mode 100644 KeyboardLocker/Sources/Extensions/TimeInterval+.swift delete mode 100644 KeyboardLocker/Sources/Helpers/CountdownFormatter.swift delete mode 100644 KeyboardLocker/Sources/Helpers/LockDurationHelper.swift delete mode 100644 KeyboardLocker/Sources/Helpers/SafeEventHandling.swift diff --git a/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift index 1c77a97..dea7b6f 100644 --- a/Core/Sources/Core/CoreConfiguration.swift +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -1,6 +1,4 @@ import Carbon -import Combine -import Foundation import SwiftUI /// Core configuration management for KeyboardLocker @@ -15,21 +13,18 @@ public class CoreConfiguration: ObservableObject { private enum ConfigKeys: String, CaseIterable { case autoLockDuration case showNotifications - case launchAtLogin - case enableSounds case hotkey - case isFirstLaunch case appVersion } - public enum Duration: Codable, Equatable, Hashable, Identifiable { + public enum Duration: Codable, Equatable, Hashable, Identifiable, RawRepresentable { case never case infinite case minutes(Int) // Duration in minutes - // MARK: - Identifiable + // MARK: - RawRepresentable - public var id: String { + public var rawValue: String { switch self { case .never: "never" @@ -40,6 +35,27 @@ public class CoreConfiguration: ObservableObject { } } + 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 { @@ -61,44 +77,27 @@ public class CoreConfiguration: ObservableObject { TimeInterval(minutes * 60) } } - - /// Whether auto-lock is enabled - public var isEnabled: Bool { - self != .never - } } // MARK: - Published Properties with AppStorage - /// Auto-lock configuration using enum instead of raw values - @AppStorage("autoLockDuration") private var storedAutoLockMinutes: Int = 0 - - @Published public var autoLockDuration: Duration = .never { - didSet { - storedAutoLockMinutes = autoLockDuration.minutes - } - } + /// Auto-lock configuration using enum with RawRepresentable + @AppStorage("io.lzhlovesjyq.keyboardlocker.autolockduration") + public var autoLockDuration: Duration = .never /// Whether to show system notifications - @AppStorage("showNotifications") public var showNotifications: Bool = true - - /// Whether to launch app at login - @AppStorage("launchAtLogin") public var launchAtLogin: Bool = false + @AppStorage("io.lzhlovesjyq.keyboardlocker.shownotifications") + public var showNotifications: Bool = true - /// Hotkey configuration (using Carbon key codes) - @Published public var hotkey: HotkeyConfiguration = .defaultHotkey() { - didSet { - if let data = try? JSONEncoder().encode(hotkey) { - UserDefaults.standard.set(data, forKey: ConfigKeys.hotkey.rawValue) - } - } - } + /// Hotkey configuration using RawRepresentable + @AppStorage("io.lzhlovesjyq.keyboardlocker.hotkey") + public var hotkey: HotkeyConfiguration = .defaultHotkey() // MARK: - Computed Properties /// Check if auto-lock is enabled public var isAutoLockEnabled: Bool { - autoLockDuration.isEnabled + autoLockDuration != .never && autoLockDuration != .infinite } /// Auto-lock duration in seconds @@ -106,84 +105,49 @@ public class CoreConfiguration: ObservableObject { autoLockDuration.seconds } - // MARK: - Non-Published Properties with AppStorage - - /// Whether this is the first app launch - @AppStorage("isFirstLaunch") public var isFirstLaunch: Bool = true - - /// Current app version - @AppStorage("appVersion") public var appVersion: String = "1.0.0" - // MARK: - Initialization - private init() { - loadConfiguration() - } + private init() {} // MARK: - Configuration Management - /// Load configuration from UserDefaults - public func loadConfiguration() { - // Load auto-lock duration from stored minutes - autoLockDuration = storedAutoLockMinutes == 0 ? .never : .minutes(storedAutoLockMinutes) - - // Load hotkey configuration - if let data = UserDefaults.standard.data(forKey: ConfigKeys.hotkey.rawValue), - let decodedHotkey = try? JSONDecoder().decode(HotkeyConfiguration.self, from: data) - { - hotkey = decodedHotkey - } else { - hotkey = HotkeyConfiguration.defaultHotkey() - } - } - /// Reset configuration to default values public func resetToDefaults() { autoLockDuration = .never showNotifications = true - launchAtLogin = false hotkey = HotkeyConfiguration.defaultHotkey() - isFirstLaunch = false } /// Export configuration as dictionary - public func exportConfiguration() -> [String: Any] { + public func export(with appVersion: String) -> [String: Any] { [ - ConfigKeys.autoLockDuration.rawValue: autoLockDuration.minutes, + ConfigKeys.autoLockDuration.rawValue: autoLockDuration.rawValue, ConfigKeys.showNotifications.rawValue: showNotifications, - ConfigKeys.launchAtLogin.rawValue: launchAtLogin, - ConfigKeys.hotkey.rawValue: (try? JSONEncoder().encode(hotkey)) ?? Data(), - ConfigKeys.isFirstLaunch.rawValue: isFirstLaunch, + ConfigKeys.hotkey.rawValue: hotkey.rawValue, ConfigKeys.appVersion.rawValue: appVersion, ] } /// Import configuration from dictionary public func importConfiguration(_ config: [String: Any]) { - if let duration = config[ConfigKeys.autoLockDuration.rawValue] as? Int { - autoLockDuration = duration == 0 ? .never : .minutes(duration) + 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 login = config[ConfigKeys.launchAtLogin.rawValue] as? Bool { - launchAtLogin = login - } - - if let hotkeyData = config[ConfigKeys.hotkey.rawValue] as? Data, - let decodedHotkey = try? JSONDecoder().decode(HotkeyConfiguration.self, from: hotkeyData) + if let rawValue = config[ConfigKeys.hotkey.rawValue] as? String, + let hotkeyConfig = HotkeyConfiguration(rawValue: rawValue) { - hotkey = decodedHotkey + hotkey = hotkeyConfig } - if let firstLaunch = config[ConfigKeys.isFirstLaunch.rawValue] as? Bool { - isFirstLaunch = firstLaunch - } - - if let version = config[ConfigKeys.appVersion.rawValue] as? String { - appVersion = version + if let _ = config[ConfigKeys.appVersion.rawValue] as? String { + /// Store app version for compatibility checks } } } @@ -191,7 +155,7 @@ public class CoreConfiguration: ObservableObject { // MARK: - Hotkey Configuration /// Hotkey configuration structure -public struct HotkeyConfiguration: Codable, CustomStringConvertible { +public struct HotkeyConfiguration: Codable, CustomStringConvertible, RawRepresentable { public let keyCode: UInt16 public let modifierFlags: UInt32 public let displayString: String @@ -202,6 +166,26 @@ public struct HotkeyConfiguration: Codable, CustomStringConvertible { 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( diff --git a/Core/Sources/Core/IPCManager.swift b/Core/Sources/Core/IPCManager.swift index 3ba8f9e..7fa2a10 100644 --- a/Core/Sources/Core/IPCManager.swift +++ b/Core/Sources/Core/IPCManager.swift @@ -1,5 +1,4 @@ import AppKit -import Foundation // MARK: - XPC Service Protocol @@ -18,7 +17,10 @@ public class IPCManager: NSObject { private let serviceName = CoreConstants.ipcServiceName private var listener: NSXPCListener? - private var isServerRunning = false + + private var isServerRunning: Bool { + listener != nil + } // MARK: - Initialization @@ -38,7 +40,6 @@ public class IPCManager: NSObject { listener = NSXPCListener(machServiceName: serviceName) listener?.delegate = self listener?.resume() - isServerRunning = true print("🚀 IPC Server started on service: \(serviceName)") } @@ -47,7 +48,6 @@ public class IPCManager: NSObject { public func stopServer() { listener?.invalidate() listener = nil - isServerRunning = false print("🛑 IPC Server stopped") } @@ -57,61 +57,53 @@ public class IPCManager: NSObject { /// - Parameters: /// - command: The command to execute /// - timeout: Timeout in seconds - /// - completion: Completion handler with response + /// - Returns: Response from the main app public func sendCommand( _ command: IPCCommand, - timeout _: TimeInterval = CoreConstants.ipcTimeout, - completion: @escaping (Result) -> Void - ) { - // Check if main app is running first - guard isMainAppRunning() else { - completion(.failure(CoreError.mainAppNotRunning)) - return - } - - let connection = NSXPCConnection(machServiceName: serviceName, options: []) - connection.remoteObjectInterface = NSXPCInterface(with: IPCServiceProtocol.self) + timeout _: TimeInterval = CoreConstants.ipcTimeout + ) async throws -> IPCResponse { + try await withCheckedThrowingContinuation { continuation in + // Check if main app is running first + guard isMainAppRunning() else { + continuation.resume(throwing: CoreError.mainAppNotRunning) + return + } - connection.interruptionHandler = { - completion(.failure(CoreError.ipcConnectionFailed)) - } + let connection = NSXPCConnection(machServiceName: serviceName) + connection.remoteObjectInterface = NSXPCInterface(with: IPCServiceProtocol.self) - connection.invalidationHandler = { - // Connection will be cleaned up automatically - } + connection.interruptionHandler = { + continuation.resume(throwing: CoreError.ipcConnectionFailed) + } - connection.resume() + connection.invalidationHandler = { + // Connection will be cleaned up automatically + } - guard let service = connection.remoteObjectProxy as? IPCServiceProtocol else { - connection.invalidate() - completion(.failure(CoreError.ipcConnectionFailed)) - return - } + connection.resume() - service.executeCommand(command.rawValue) { responseDict in - DispatchQueue.main.async { + guard let service = connection.remoteObjectProxy as? IPCServiceProtocol else { connection.invalidate() - - let response = self.parseResponse(from: responseDict) - completion(.success(response)) + continuation.resume(throwing: CoreError.ipcConnectionFailed) + return } - } - } - /// Simplified async/await version for modern Swift - @available(macOS 10.15, *) - public func sendCommand(_ command: IPCCommand, timeout: TimeInterval = CoreConstants.ipcTimeout) - async throws -> IPCResponse - { - try await withCheckedThrowingContinuation { continuation in - sendCommand(command, timeout: timeout) { result in - continuation.resume(with: result) + service.executeCommand(command.rawValue) { [weak self] responseDict in + defer { connection.invalidate() } + + guard let self else { + continuation.resume(throwing: CoreError.ipcConnectionFailed) + return + } + + let response = parseResponse(from: responseDict) + continuation.resume(returning: response) } } } /// Check if main app is running - public func isMainAppRunning() -> Bool { + private func isMainAppRunning() -> Bool { let runningApps = NSWorkspace.shared.runningApplications return runningApps.contains { app in app.bundleIdentifier == CoreConstants.mainAppBundleID @@ -130,27 +122,25 @@ public class IPCManager: NSObject { } /// Convert IPCResponse to dictionary for XPC - func responseToDict(_ response: IPCResponse) -> [String: Any] { - var dict: [String: Any] = [ + func responseToObject(_ response: IPCResponse) -> [String: Any] { + var obj: [String: Any] = [ "success": response.success, "message": response.message, "timestamp": response.timestamp.timeIntervalSince1970, ] if let data = response.data { - dict["data"] = data + obj["data"] = data } - return dict + return obj } } // MARK: - XPC Listener Delegate extension IPCManager: NSXPCListenerDelegate { - public func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) - -> Bool - { + public func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { newConnection.exportedInterface = NSXPCInterface(with: IPCServiceProtocol.self) newConnection.exportedObject = IPCServiceHandler() newConnection.resume() @@ -165,14 +155,14 @@ public class IPCServiceHandler: NSObject, IPCServiceProtocol { public func executeCommand(_ command: String, withReply reply: @escaping ([String: Any]) -> Void) { guard let ipcCommand = IPCCommand(rawValue: command) else { let response = IPCResponse.error("Unknown command: \(command)") - reply(IPCManager.shared.responseToDict(response)) + reply(IPCManager.shared.responseToObject(response)) return } // Execute command on main queue DispatchQueue.main.async { let response = self.handleCommand(ipcCommand) - reply(IPCManager.shared.responseToDict(response)) + reply(IPCManager.shared.responseToObject(response)) } } @@ -192,31 +182,19 @@ public class IPCServiceHandler: NSObject, IPCServiceProtocol { case .toggle: lockCore.toggleLock() - let coreInfo = lockCore.basicLockInfo - let statusMessage = coreInfo.isLocked ? "locked" : "unlocked" + let statusMessage = lockCore.isLocked ? "locked" : "unlocked" return IPCResponse.success("Keyboard \(statusMessage) successfully") case .status: - let coreInfo = lockCore.basicLockInfo let status = LockStatus( - isLocked: coreInfo.isLocked, - lockedAt: coreInfo.lockedAt, - autoLockEnabled: false, // Auto-lock is now in business layer - autoLockInterval: 0 // Auto-lock is now in business layer + isLocked: lockCore.isLocked, + lockedAt: lockCore.lockedAt ) return IPCResponse.success( "Keyboard is currently \(status.isLocked ? "locked" : "unlocked")", data: status.toDictionary() ) - - case .quit: - // Schedule app termination after sending response - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - NSApplication.shared.terminate(nil) - } - return IPCResponse.success("Quitting application") } - } catch let error as CoreError { return IPCResponse.error(error.localizedDescription) } catch { @@ -224,40 +202,3 @@ public class IPCServiceHandler: NSObject, IPCServiceProtocol { } } } - -// MARK: - Extensions - -public extension IPCManager { - /// Convenience method to get status - func getStatus(completion: @escaping (Result) -> Void) { - sendCommand(.status) { result in - switch result { - case let .success(response): - if response.success, let data = response.data { - let isLocked = data["locked"] == "true" - let autoLockEnabled = data["autoLockEnabled"] == "true" - let autoLockInterval = Int(data["autoLockInterval"] ?? "0") ?? 0 - - var lockedAt: Date? - if let lockedAtString = data["lockedAt"] { - let formatter = ISO8601DateFormatter() - lockedAt = formatter.date(from: lockedAtString) - } - - let status = LockStatus( - isLocked: isLocked, - lockedAt: lockedAt, - autoLockEnabled: autoLockEnabled, - autoLockInterval: autoLockInterval - ) - completion(.success(status)) - } else { - completion(.failure(CoreError.invalidCommand)) - } - - case let .failure(error): - completion(.failure(error)) - } - } - } -} diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift index e6d53b6..8fec9f2 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -1,29 +1,6 @@ import AppKit import ApplicationServices import Carbon -import Foundation - -// MARK: - Event Handling Helpers - -private enum EventTypeFactory { - static func createEventMask() -> CGEventMask { - (1 << CGEventType.keyDown.rawValue) | - (1 << CGEventType.keyUp.rawValue) | - (1 << CGEventType.flagsChanged.rawValue) | - (1 << CGEventType.otherMouseDown.rawValue) | - (1 << CGEventType.otherMouseUp.rawValue) - } -} - -private enum SafeEventHandler { - static func getFlags(from event: CGEvent) -> CGEventFlags { - event.flags - } - - static func getKeycode(from event: CGEvent) -> Int64? { - event.getIntegerValueField(.keyboardEventKeycode) - } -} /// Pure core keyboard locking engine - only handles low-level keyboard interception /// Business logic and UI concerns are handled by upper layers @@ -34,19 +11,20 @@ public class KeyboardLockCore { // MARK: - State Properties (Read-Only) - private var eventTap: CFMachPort? + var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? - private var _isLocked = false - private var _lockedAt: Date? - // Internal access for callback - var internalEventTap: CFMachPort? { - eventTap - } + /// Current keyboard lock state + public private(set) var isLocked = false + + /// When keyboard was locked + public private(set) var lockedAt: Date? + + // MARK: - Hotkey Configuration - // Constants for hotkey detection - private let unlockKeyCode: UInt16 = CoreConstants.defaultUnlockKeyCode - private let unlockModifiers: UInt32 = .init(cmdKey | optionKey) // Cmd+Option + /// Unlock hotkey configuration + public var unlockHotkey: HotkeyConfiguration = .defaultHotkey() // MARK: - Callbacks for UI Layer @@ -56,22 +34,12 @@ public class KeyboardLockCore { /// Callback triggered when unlock hotkey is detected public var onUnlockHotkeyDetected: (() -> Void)? - // MARK: - Public Read-Only Properties - - /// Current keyboard lock state - public var isKeyboardLocked: Bool { - _isLocked - } - - /// When keyboard was locked - public var keyboardLockedAt: Date? { - _lockedAt - } - - /// Current lock status for external systems (simplified for Core layer) - public var basicLockInfo: (isLocked: Bool, lockedAt: Date?) { - (_isLocked, _lockedAt) - } + 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 @@ -81,54 +49,74 @@ public class KeyboardLockCore { forceCleanup() } - // MARK: - Core Locking Methods + // 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 { + guard !isLocked else { throw KeyboardLockError.alreadyLocked } - // Use AX API directly. - let axOptions = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false] - guard AXIsProcessTrustedWithOptions(axOptions as CFDictionary) else { + // Check accessibility permission using PermissionHelper + guard PermissionHelper.checkAccessibilityPermission(promptUser: true) else { throw KeyboardLockError.permissionDenied } try createEventTap() - _isLocked = true - _lockedAt = Date() + isLocked = true + lockedAt = Date() // Notify business layer - onLockStateChanged?(_isLocked, _lockedAt) + onLockStateChanged?(isLocked, lockedAt) } /// Unlock keyboard input public func unlockKeyboard() { - guard _isLocked else { + guard isLocked else { return } destroyEventTap() - _isLocked = false - let wasLockedAt = _lockedAt - _lockedAt = nil + isLocked = false + lockedAt = nil // Notify business layer - onLockStateChanged?(_isLocked, nil) - - if let lockedAt = wasLockedAt { - let duration = Date().timeIntervalSince(lockedAt) - print("🔓 Keyboard unlocked after \(formatDuration(duration))") - } + onLockStateChanged?(isLocked, nil) } /// Toggle lock state public func toggleLock() { - if _isLocked { + if isLocked { unlockKeyboard() } else { do { @@ -139,23 +127,11 @@ public class KeyboardLockCore { } } - // MARK: - Utility Methods - - /// Get lock duration string - public func getLockDurationString() -> String? { - guard let lockedAt = _lockedAt else { return nil } - let duration = Date().timeIntervalSince(lockedAt) - return formatDuration(duration) - } - /// Force cleanup all resources public func forceCleanup() { print("🧹 KeyboardLockCore: Force cleanup initiated") - if _isLocked { - unlockKeyboard() - } - + unlockKeyboard() destroyEventTap() } @@ -163,13 +139,11 @@ public class KeyboardLockCore { /// Create event tap for keyboard monitoring private func createEventTap() throws { - let eventMask = EventTypeFactory.createEventMask() - eventTap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, - eventsOfInterest: eventMask, + eventsOfInterest: Self.eventMasks, callback: globalEventCallback, userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) @@ -205,45 +179,44 @@ public class KeyboardLockCore { } } + /// Check if the given flags match the required unlock modifiers + private func matchesUnlockModifiers(_ flags: CGEventFlags) -> Bool { + // Extract modifier flags from the event + let eventModifiers = flags.rawValue & ( + CGEventFlags.maskCommand.rawValue | + CGEventFlags.maskAlternate.rawValue | + CGEventFlags.maskShift.rawValue | + CGEventFlags.maskControl.rawValue + ) + + // Compare with configured unlock modifiers + return eventModifiers == UInt64(unlockHotkey.modifierFlags) + } + /// Handle keyboard events (internal for callback) func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { - // Check for unlock hotkey combination - if type == .keyDown { - let keycode = SafeEventHandler.getKeycode(from: event) - let flags = SafeEventHandler.getFlags(from: event) - - if keycode == Int64(unlockKeyCode), - flags.contains(.maskCommand), - flags.contains(.maskAlternate) - { - print("🔑 Unlock hotkey detected: ⌘+⌥+L") - - // Notify business layer through callback - DispatchQueue.main.async { - self.onUnlockHotkeyDetected?() - } - - // Don't pass through this event - return nil - } + // Only process keyDown events for unlock hotkey detection + guard type == .keyDown else { + return nil // Block all other events when locked } - // Block all keyboard events when locked - return nil - } + let keycode = event.getIntegerValueField(.keyboardEventKeycode) + let flags = event.flags - // MARK: - Helper Methods + // Check for unlock hotkey combination + guard keycode == Int64(unlockHotkey.keyCode), matchesUnlockModifiers(flags) else { + return nil // Block this event, not the unlock hotkey + } - /// Format duration for display - private func formatDuration(_ duration: TimeInterval) -> String { - let minutes = Int(duration) / 60 - let seconds = Int(duration) % 60 + print("🔑 Unlock hotkey pressed: \(unlockHotkey.displayString)") - if minutes > 0 { - return String(format: "%d:%02d", minutes, seconds) - } else { - return String(format: "%ds", seconds) + // Notify business layer through callback + DispatchQueue.main.async { + self.onUnlockHotkeyDetected?() } + + // Don't pass through this event + return nil } } @@ -265,7 +238,7 @@ private func globalEventCallback( if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { print("⚠️ Event tap disabled by system, attempting to re-enable...") - if let eventTap = core.internalEventTap { + if let eventTap = core.eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) } @@ -273,7 +246,7 @@ private func globalEventCallback( } // Only process events when locked - guard core.isKeyboardLocked else { + guard core.isLocked else { return Unmanaged.passUnretained(event) } diff --git a/Core/Sources/Core/PermissionHelper.swift b/Core/Sources/Core/PermissionHelper.swift index bf68057..81eac76 100644 --- a/Core/Sources/Core/PermissionHelper.swift +++ b/Core/Sources/Core/PermissionHelper.swift @@ -1,6 +1,5 @@ import AppKit import ApplicationServices -import Foundation /// Helper class for checking system permissions required by KeyboardLocker public class PermissionHelper { @@ -29,40 +28,6 @@ public class PermissionHelper { _ = AXIsProcessTrustedWithOptions(options as CFDictionary) } - // MARK: - Screen Recording Permission (if needed for enhanced security) - - /// Check if screen recording permission is granted - /// This might be required for some advanced event monitoring - public static func hasScreenRecordingPermission() -> Bool { - if #available(macOS 10.15, *) { - // For now, we'll assume screen recording permission is not strictly required - // In a real implementation, you might use ScreenCaptureKit or other methods - true - } else { - // Screen recording permission not required on older macOS versions - true - } - } - - // MARK: - Permission Status Summary - - /// Get a summary of all required permissions - /// - Returns: Dictionary with permission names and their status - public static func getPermissionStatus() -> [String: Bool] { - [ - "accessibility": hasAccessibilityPermission(), - "screenRecording": hasScreenRecordingPermission(), - ] - } - - /// Check if all required permissions are granted - /// - Returns: True if all required permissions are available - public static func hasAllRequiredPermissions() -> Bool { - hasAccessibilityPermission() - // Add other required permissions here if needed - // && hasScreenRecordingPermission() - } - // MARK: - System URLs /// Open System Preferences to Security & Privacy > Accessibility @@ -72,46 +37,4 @@ public class PermissionHelper { } NSWorkspace.shared.open(url) } - - /// Open System Preferences to Security & Privacy > Screen Recording - public static func openScreenRecordingSettings() { - if #available(macOS 10.15, *) { - guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { - return - } - NSWorkspace.shared.open(url) - } - } - - // MARK: - Validation Helpers - - /// Validate that the current process can perform keyboard locking operations - /// - Throws: CoreError if required permissions are not available - public static func validatePermissions() throws { - guard hasAccessibilityPermission() else { - throw CoreError.accessibilityPermissionDenied - } - - // Add additional permission checks here if needed - } - - /// Get user-friendly permission status message - /// - Returns: Localized string describing permission status - public static func getPermissionStatusMessage() -> String { - if hasAllRequiredPermissions() { - return "All required permissions are granted" - } else { - var missingPermissions: [String] = [] - - if !hasAccessibilityPermission() { - missingPermissions.append("Accessibility") - } - - if !hasScreenRecordingPermission() { - missingPermissions.append("Screen Recording") - } - - return "Missing permissions: \(missingPermissions.joined(separator: ", "))" - } - } } diff --git a/Core/Sources/Core/SharedModels.swift b/Core/Sources/Core/SharedModels.swift index 0b0909f..dbcfeca 100644 --- a/Core/Sources/Core/SharedModels.swift +++ b/Core/Sources/Core/SharedModels.swift @@ -44,7 +44,6 @@ public enum IPCCommand: String, Codable, CaseIterable { case unlock case toggle case status - case quit public var description: String { switch self { @@ -56,8 +55,6 @@ public enum IPCCommand: String, Codable, CaseIterable { "Toggle keyboard lock status" case .status: "Get current keyboard lock status" - case .quit: - "Quit the main application" } } } @@ -126,10 +123,10 @@ public enum CoreError: Error, LocalizedError { /// Shared constants used across the application public enum CoreConstants { /// IPC service name for XPC communication - public static let ipcServiceName = "com.keyboardlocker.ipc" + public static let ipcServiceName = "io.lzhlovesjyq.keyboardlocker.ipc" /// Main app bundle identifier - public static let mainAppBundleID = "com.keyboardlocker.app" + public static let mainAppBundleID = "io.lzhlovesjyq.KeyboardLocker" /// Default unlock key combination (Cmd + Option + L) public static let defaultUnlockKeyCode: UInt16 = 37 // 'L' key @@ -144,28 +141,18 @@ public enum CoreConstants { public struct LockStatus: Codable { public let isLocked: Bool public let lockedAt: Date? - public let autoLockEnabled: Bool - public let autoLockInterval: Int // minutes, 0 for never public init( isLocked: Bool, - lockedAt: Date? = nil, - autoLockEnabled: Bool = false, - autoLockInterval: Int = 0 + lockedAt: Date? = nil ) { self.isLocked = isLocked self.lockedAt = lockedAt - self.autoLockEnabled = autoLockEnabled - self.autoLockInterval = autoLockInterval } /// Convert to dictionary for IPC data public func toDictionary() -> [String: String] { - var dict: [String: String] = [ - "locked": isLocked ? "true" : "false", - "autoLockEnabled": autoLockEnabled ? "true" : "false", - "autoLockInterval": "\(autoLockInterval)", - ] + var dict: [String: String] = ["locked": isLocked ? "true" : "false"] if let lockedAt { let formatter = ISO8601DateFormatter() diff --git a/Core/Sources/Core/UserActivityMonitor.swift b/Core/Sources/Core/UserActivityMonitor.swift index 1db25a1..a92f530 100644 --- a/Core/Sources/Core/UserActivityMonitor.swift +++ b/Core/Sources/Core/UserActivityMonitor.swift @@ -1,7 +1,6 @@ import AppKit import ApplicationServices import Carbon -import Foundation /// User activity monitor for tracking keyboard and mouse activity /// Used to implement proper auto-lock behavior that only starts counting when user stops activity @@ -21,11 +20,7 @@ public class UserActivityMonitor { public var onAutoLockTriggered: (() -> Void)? /// Current auto-lock duration in seconds (0 = disabled) - public var autoLockDuration: TimeInterval = 0 { - didSet { - updateAutoLockTimer() - } - } + private var autoLockDuration: TimeInterval = 0 /// Whether auto-lock is currently enabled public var isAutoLockEnabled: Bool { @@ -39,13 +34,10 @@ public class UserActivityMonitor { // MARK: - Initialization - private init() { - print("🔍 UserActivityMonitor initialized") - } + private init() {} deinit { stopMonitoring() - print("🔍 UserActivityMonitor deallocated") } // MARK: - Public Methods @@ -83,25 +75,28 @@ public class UserActivityMonitor { /// Enable auto-lock with specified duration /// - Parameter seconds: Duration in seconds (0 to disable) public func enableAutoLock(seconds: TimeInterval) { - autoLockDuration = seconds if seconds > 0 { - print("✅ Auto-lock enabled: \(Int(seconds / 60)) minutes") + 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 - let axOptions = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false] - guard AXIsProcessTrustedWithOptions(axOptions as CFDictionary) else { + // Check accessibility permission using PermissionHelper + guard PermissionHelper.hasAccessibilityPermission() else { throw UserActivityError.permissionDenied } @@ -127,9 +122,12 @@ public class UserActivityMonitor { options: .listenOnly, // Only listen, don't block events eventsOfInterest: CGEventMask(eventMask), callback: { _, _, event, refcon in - let monitor = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() + guard let refcon else { + return Unmanaged.passUnretained(event) + } + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() monitor.handleActivityEvent(event) - return Unmanaged.passRetained(event) + return Unmanaged.passUnretained(event) }, userInfo: Unmanaged.passUnretained(self).toOpaque() ) @@ -172,7 +170,9 @@ public class UserActivityMonitor { private func updateAutoLockTimer() { stopAutoLockTimer() - guard autoLockDuration > 0 else { return } + guard autoLockDuration > 0 else { + return + } // Start new timer autoLockTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in @@ -186,7 +186,10 @@ public class UserActivityMonitor { } private func checkAutoLock() { - guard autoLockDuration > 0 else { return } + guard autoLockDuration > 0 else { + stopAutoLockTimer() + return + } let timeSinceActivity = Date().timeIntervalSince(lastActivityTime) @@ -194,7 +197,7 @@ public class UserActivityMonitor { // Trigger auto-lock stopAutoLockTimer() onAutoLockTriggered?() - print("🔒 Auto-lock triggered after \(Int(autoLockDuration / 60)) minutes of inactivity") + print("🔒 Auto-lock triggered after \(autoLockDuration) seconds of inactivity") } } } diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index b543afa..1a309ca 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -161,7 +161,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2600; TargetAttributes = { 022 = { CreatedOnToolsVersion = 15.0; @@ -232,6 +232,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"; @@ -263,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; @@ -286,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"; }; @@ -296,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"; @@ -327,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; @@ -343,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; @@ -354,13 +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 = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -388,13 +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 = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -421,7 +429,6 @@ CODE_SIGN_ENTITLEMENTS = KeyboardLockerTool/KeyboardLockerTool.entitlements; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; OTHER_CODE_SIGN_FLAGS = "$(inherited) -i $(PRODUCT_BUNDLE_IDENTIFIER)"; @@ -438,7 +445,6 @@ CODE_SIGN_ENTITLEMENTS = KeyboardLockerTool/KeyboardLockerTool.entitlements; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; OTHER_CODE_SIGN_FLAGS = "$(inherited) -i $(PRODUCT_BUNDLE_IDENTIFIER)"; diff --git a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme new file mode 100644 index 0000000..bf18625 --- /dev/null +++ b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KeyboardLocker/Sources/Application/AppDelegate.swift b/KeyboardLocker/Sources/Application/AppDelegate.swift index e43d256..e12408b 100644 --- a/KeyboardLocker/Sources/Application/AppDelegate.swift +++ b/KeyboardLocker/Sources/Application/AppDelegate.swift @@ -12,6 +12,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { urlHandler = handler } + func applicationWillFinishLaunching(_: Notification) { + IPCManager.shared.startServer() + } + + func applicationDidFinishLaunching(_: Notification) { + setupExceptionHandling() + } + func applicationWillTerminate(_: Notification) { print("Application will terminate - cleaning up") // Stop IPC server @@ -25,6 +33,27 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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 diff --git a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift index a2cce3c..d459981 100644 --- a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift +++ b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift @@ -10,14 +10,6 @@ struct KeyboardLockerApp: App { // Use AppDelegate for URL handling @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - init() { - // Initialize IPC server for external communication - IPCManager.shared.startServer() - - // Setup global exception handling for stability - setupExceptionHandling() - } - var body: some Scene { // Modern MenuBarExtra for native menu bar integration MenuBarExtra(LocalizationKey.appMenuTitle.localized, systemImage: "lock.shield") { @@ -31,25 +23,4 @@ struct KeyboardLockerApp: App { .menuBarExtraStyle(.window) .handlesExternalEvents(matching: ["keyboardlocker"]) } - - // 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 - } - } - } - } } diff --git a/KeyboardLocker/Sources/Extensions/Constants.swift b/KeyboardLocker/Sources/Extensions/CGFloat+.swift similarity index 100% rename from KeyboardLocker/Sources/Extensions/Constants.swift rename to KeyboardLocker/Sources/Extensions/CGFloat+.swift diff --git a/KeyboardLocker/Sources/Extensions/Duration+.swift b/KeyboardLocker/Sources/Extensions/Duration+.swift new file mode 100644 index 0000000..e8808db --- /dev/null +++ b/KeyboardLocker/Sources/Extensions/Duration+.swift @@ -0,0 +1,56 @@ +import Core + +/// Business logic helper for lock duration management and display +extension CoreConfiguration.Duration { + // MARK: - Preset Collections + + /// Preset durations for timed lock UI + static let timedLockPresets: [Self] = [ + .infinite, + .minutes(1), + .minutes(5), + .minutes(15), + .minutes(30), + .minutes(60), // 1 hour + .minutes(120), // 2 hours + .minutes(240), // 4 hours + ] + + /// 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 index 44c325b..0220e3c 100644 --- a/KeyboardLocker/Sources/Helpers/AppDependencies.swift +++ b/KeyboardLocker/Sources/Helpers/AppDependencies.swift @@ -1,5 +1,4 @@ import Core -import Foundation /// Application dependency container /// Responsible for creating and managing all dependencies, ensuring single responsibility and clear dependency flow diff --git a/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift b/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift deleted file mode 100644 index 2167ea5..0000000 --- a/KeyboardLocker/Sources/Helpers/CountdownFormatter.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Core -import Foundation - -enum CountdownFormatter { - /// Format remaining time as countdown string - static func countdownString(from timeInterval: TimeInterval) -> String { - let totalSeconds = Int(timeInterval) - - 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) - } - } - - /// Format remaining time as human readable string - static func humanReadableCountdown(from timeInterval: TimeInterval) -> String { - let totalSeconds = Int(timeInterval) - - if totalSeconds <= 0 { - return LocalizationKey.countdownFinished.localized - } - - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - let seconds = totalSeconds % 60 - - if hours > 0 { - return LocalizationKey.countdownHoursFormat.localized(hours, minutes, seconds) - } else if minutes > 0 { - return LocalizationKey.countdownMinutesFormat.localized(minutes, seconds) - } else { - return LocalizationKey.countdownSecondsFormat.localized(seconds) - } - } -} diff --git a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift index a4b20a7..e952aa9 100644 --- a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift @@ -1,4 +1,3 @@ -import Foundation import SwiftUI // MARK: - Bundle Extensions for App Info @@ -107,20 +106,6 @@ enum LocalizationKey { static let durationHours = "duration.hours" // "%d hour(s)" static let durationHoursMinutes = "duration.hours.minutes" // "%d hour(s) %d minute(s)" - // Duration descriptions for settings - static let durationNeverDescription = "duration.never.description" - static let durationInfiniteDescription = "duration.infinite.description" - static let durationMinutesDescription = "duration.minutes.description" - static let durationHoursDescription = "duration.hours.description" - static let durationHoursMinutesDescription = "duration.hours.minutes.description" - static let durationAutoUnlock = "duration.auto.unlock" // "Auto unlock after %@" - - // Countdown formatting - static let countdownFinished = "countdown.finished" - static let countdownHoursFormat = "countdown.hours.format" - static let countdownMinutesFormat = "countdown.minutes.format" - static let countdownSecondsFormat = "countdown.seconds.format" - // Timed Lock static let timedLockTitle = "timed.lock.title" static let timedLockStart = "timed.lock.start" diff --git a/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift b/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift deleted file mode 100644 index 433e63d..0000000 --- a/KeyboardLocker/Sources/Helpers/LockDurationHelper.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Core -import Foundation - -/// Business logic helper for lock duration management and display -class LockDurationHelper { - // MARK: - Preset Collections - - /// Preset durations for timed lock UI - static let timedLockPresets: [CoreConfiguration.Duration] = [ - .infinite, - .minutes(1), - .minutes(5), - .minutes(15), - .minutes(30), - .minutes(60), // 1 hour - .minutes(120), // 2 hours - .minutes(240), // 4 hours - ] - - /// Preset durations for auto-lock UI - static let autoLockPresets: [CoreConfiguration.Duration] = [ - .never, - .minutes(15), - .minutes(30), - .minutes(60), - ] - - /// Quick preset durations for timed lock - static let quickTimedPresets: [CoreConfiguration.Duration] = [ - .infinite, - .minutes(1), - .minutes(5), - .minutes(15), - .minutes(30), - ] - - // MARK: - Display Logic - - /// Get localized display string for UI - static func localizedDisplayString(for duration: CoreConfiguration.Duration) -> String { - switch duration { - case .never: - LocalizationKey.durationNever.localized - case .infinite: - LocalizationKey.durationInfinite.localized - case let .minutes(minutes): - formatMinutes(minutes) - } - } - - /// Get description text for duration settings - static func localizedDescriptionString(for duration: CoreConfiguration.Duration) -> String { - switch duration { - case .never: - return LocalizationKey.durationNeverDescription.localized - case .infinite: - return LocalizationKey.durationInfiniteDescription.localized - case let .minutes(minutes): - if minutes < 60 { - return LocalizationKey.durationMinutesDescription.localized(minutes) - } else { - let hours = minutes / 60 - let remainingMinutes = minutes % 60 - if remainingMinutes == 0 { - return LocalizationKey.durationHoursDescription.localized(hours) - } else { - return LocalizationKey.durationHoursMinutesDescription.localized(hours, remainingMinutes) - } - } - } - } - - // MARK: - Time Formatting Helpers - - private static func formatMinutes(_ minutes: Int) -> String { - if minutes < 60 { - return LocalizationKey.durationMinutes.localized(minutes) - } else { - let hours = minutes / 60 - let remainingMinutes = minutes % 60 - if remainingMinutes == 0 { - return LocalizationKey.durationHours.localized(hours) - } else { - return LocalizationKey.durationHoursMinutes.localized(hours, remainingMinutes) - } - } - } - - // MARK: - Factory Methods - - /// Create duration from seconds with smart conversion - static func durationFromSeconds(_ seconds: TimeInterval) -> CoreConfiguration.Duration { - let totalSeconds = Int(seconds) - - if totalSeconds == 0 { - return .infinite - } else if totalSeconds < 60 { - // For very short durations, round up to 1 minute - return .minutes(1) - } else { - let minutes = totalSeconds / 60 - return .minutes(minutes) - } - } - - /// Create duration from total seconds (exact) - static func durationFromSecondsExact(_ seconds: TimeInterval) -> CoreConfiguration.Duration { - let totalSeconds = Int(seconds) - - if totalSeconds == 0 { - return .infinite - } else { - let minutes = max(1, totalSeconds / 60) // Minimum 1 minute - return .minutes(minutes) - } - } - - // MARK: - Validation - - /// Check if this duration is valid for timed lock - static func isValidForTimedLock(_ duration: CoreConfiguration.Duration) -> Bool { - switch duration { - case .never: - false // Never is not valid for timed lock - case .infinite, .minutes: - true - } - } - - /// Check if this duration is valid for auto-lock - static func isValidForAutoLock(_ duration: CoreConfiguration.Duration) -> Bool { - switch duration { - case .never, .minutes: - true - case .infinite: - false // Infinite is not valid for auto-lock - } - } - - // MARK: - Comparison Helpers - - /// Get sort order for duration comparison - static func sortOrder(for duration: CoreConfiguration.Duration) -> Int { - switch duration { - case .never: - 0 - case .infinite: - Int.max - case let .minutes(minutes): - minutes - } - } -} diff --git a/KeyboardLocker/Sources/Helpers/SafeEventHandling.swift b/KeyboardLocker/Sources/Helpers/SafeEventHandling.swift deleted file mode 100644 index 4b6fc2e..0000000 --- a/KeyboardLocker/Sources/Helpers/SafeEventHandling.swift +++ /dev/null @@ -1,84 +0,0 @@ -import CoreGraphics -import Foundation - -/// Safe factory for creating CGEventType instances and handling system events -enum EventTypeFactory { - /// System-defined event type (NX_SYSDEFINED) - static let systemDefinedEventType: CGEventType? = CGEventType(rawValue: 14) - - /// System-defined event subtype field - static let systemDefinedSubtypeField: CGEventField? = CGEventField(rawValue: 2) - - /// Get all supported event types for keyboard monitoring - /// - Returns: Array of valid CGEventType instances - static func getSupportedEventTypes() -> [CGEventType] { - var eventTypes: [CGEventType] = [ - .keyDown, - .keyUp, - .flagsChanged, - ] - - // Safely add system-defined events if available - if let systemDefined = systemDefinedEventType { - eventTypes.append(systemDefined) - } - - return eventTypes - } - - /// Create event mask from supported event types - /// - Returns: CGEventMask for all supported events - static func createEventMask() -> CGEventMask { - let eventTypes = getSupportedEventTypes() - var eventMask: CGEventMask = 0 - - for eventType in eventTypes { - eventMask |= CGEventMask(1 << eventType.rawValue) - } - - return eventMask - } - - /// Check if an event type is a system-defined event - /// - Parameter eventType: The event type to check - /// - Returns: True if this is a system-defined event - static func isSystemDefinedEvent(_ eventType: CGEventType) -> Bool { - guard let systemDefined = systemDefinedEventType else { return false } - return eventType.rawValue == systemDefined.rawValue - } - - /// Get system-defined event subtype safely - /// - Parameter event: The CGEvent to extract subtype from - /// - Returns: Subtype value if available, nil otherwise - static func getSystemDefinedSubtype(from event: CGEvent) -> Int64? { - guard let subtypeField = systemDefinedSubtypeField else { return nil } - return event.getIntegerValueField(subtypeField) - } -} - -/// Safe wrapper for CGEvent operations -enum SafeEventHandler { - /// Safely get keyboard event keycode - /// - Parameter event: The CGEvent to extract keycode from - /// - Returns: Keycode value if available, nil otherwise - static func getKeycode(from event: CGEvent) -> Int64? { - event.getIntegerValueField(.keyboardEventKeycode) - } - - /// Safely get event flags - /// - Parameter event: The CGEvent to extract flags from - /// - Returns: CGEventFlags for the event - static func getFlags(from event: CGEvent) -> CGEventFlags { - event.flags - } - - /// Check if event has specific modifier flags - /// - Parameters: - /// - event: The CGEvent to check - /// - modifiers: Array of modifier flags to check for - /// - Returns: True if event has any of the specified modifiers - static func hasModifiers(_ event: CGEvent, _ modifiers: [CGEventFlags]) -> Bool { - let flags = getFlags(from: event) - return !flags.intersection(CGEventFlags(modifiers)).isEmpty - } -} diff --git a/KeyboardLocker/Sources/Helpers/URLHandler.swift b/KeyboardLocker/Sources/Helpers/URLHandler.swift index 24ca4de..c41cf57 100644 --- a/KeyboardLocker/Sources/Helpers/URLHandler.swift +++ b/KeyboardLocker/Sources/Helpers/URLHandler.swift @@ -1,5 +1,4 @@ import AppKit -import Foundation /// Handles URL scheme requests for keyboard control operations class URLCommandHandler { @@ -188,34 +187,11 @@ class URLCommandHandler { DispatchQueue.main.async { print("💬 User feedback: \(response.message)") - // Send notification to user about the URL command result - self.sendNotification( + 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) { - notificationManager.sendNotification(title: title, body: body, isError: isError) - } -} - -/// Extension to provide convenience methods for testing -extension URLCommandHandler { - /// Test URL creation helper - static func createTestURL(for command: URLCommand) -> URL? { - URL(string: "keyboardlocker://\(command.rawValue)") - } - - /// Get all supported commands for documentation - static func getSupportedCommands() -> [String] { - URLCommand.allCases.map { "keyboardlocker://\($0.rawValue)" } - } } diff --git a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift index 42a79e6..180ea64 100644 --- a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift +++ b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift @@ -1,4 +1,3 @@ -import Combine import Core import SwiftUI @@ -159,13 +158,7 @@ class KeyboardLockManager: ObservableObject { /// Check if auto-lock is enabled var isAutoLockEnabled: Bool { - config.autoLockDuration.isEnabled - } - - /// Get/set notification preference - var showNotifications: Bool { - get { config.showNotifications } - set { config.showNotifications = newValue } + config.isAutoLockEnabled } // MARK: - Utility Methods @@ -194,6 +187,13 @@ class KeyboardLockManager: ObservableObject { } } + // Setup unlock hotkey callback + core.onUnlockHotkeyDetected = { [weak self] in + DispatchQueue.main.async { + self?.unlockKeyboard() + } + } + // Subscribe to configuration changes for auto-lock state config.$autoLockDuration .receive(on: DispatchQueue.main) @@ -219,7 +219,7 @@ class KeyboardLockManager: ObservableObject { /// Enable auto-lock monitoring with current configuration private func enableAutoLockMonitoring() { let duration = config.autoLockDuration - if duration.isEnabled { + if isAutoLockEnabled { activityMonitor.enableAutoLock(seconds: duration.seconds) activityMonitor.onAutoLockTriggered = { [weak self] in self?.lockKeyboard() diff --git a/KeyboardLocker/Sources/Managers/NotificationManager.swift b/KeyboardLocker/Sources/Managers/NotificationManager.swift index c97bebd..9b23847 100644 --- a/KeyboardLocker/Sources/Managers/NotificationManager.swift +++ b/KeyboardLocker/Sources/Managers/NotificationManager.swift @@ -169,6 +169,14 @@ class NotificationManager: ObservableObject { 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 @@ -215,28 +223,21 @@ class NotificationManager: ObservableObject { /// - 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(\.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(\.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)") } } @@ -281,20 +282,36 @@ class NotificationManager: ObservableObject { } 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 diff --git a/KeyboardLocker/Sources/Views/ContentViewState.swift b/KeyboardLocker/Sources/Views/ContentViewState.swift index 48706d6..ddf7a2b 100644 --- a/KeyboardLocker/Sources/Views/ContentViewState.swift +++ b/KeyboardLocker/Sources/Views/ContentViewState.swift @@ -1,4 +1,3 @@ -import Combine import Core import SwiftUI @@ -16,10 +15,6 @@ class ContentViewState: ObservableObject { var keyboardManager: KeyboardLockManager? private var cancellables = Set() - // MARK: - UI State Timer - - private var uiUpdateTimer: Timer? - // MARK: - Computed Properties var customMinutesString: Binding { @@ -39,7 +34,6 @@ class ContentViewState: ObservableObject { func cleanup() { cancellables.removeAll() - stopUIUpdateTimer() } // MARK: - Public Methods @@ -86,29 +80,5 @@ class ContentViewState: ObservableObject { private func handleLockStateChange(_ locked: Bool) { isKeyboardLocked = locked - - if locked { - startUIUpdateTimer() - } else { - stopUIUpdateTimer() - } - } - - // MARK: - UI Update Timer - - private func startUIUpdateTimer() { - stopUIUpdateTimer() - - // Timer for UI updates (display refresh) - uiUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.objectWillChange.send() - } - } - } - - private func stopUIUpdateTimer() { - uiUpdateTimer?.invalidate() - uiUpdateTimer = nil } } diff --git a/KeyboardLocker/Sources/Views/PermissionView.swift b/KeyboardLocker/Sources/Views/PermissionView.swift index 8df6f37..ce1f4cd 100644 --- a/KeyboardLocker/Sources/Views/PermissionView.swift +++ b/KeyboardLocker/Sources/Views/PermissionView.swift @@ -9,7 +9,17 @@ struct PermissionRequiredView: View { AppTitleHeaderView() PermissionContent(permissionManager: permissionManager) Spacer() - QuitButton() + + HStack { + Spacer() + Button(LocalizationKey.actionQuit.localized) { + NSApplication.shared.terminate(nil) + } + .buttonStyle(PlainButtonStyle()) + .foregroundColor(.red) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) } } } @@ -19,70 +29,37 @@ private struct PermissionContent: View { var body: some View { VStack(spacing: 16) { - WarningIcon() - PermissionTexts() - PermissionButton(permissionManager: permissionManager) - } - .padding(.horizontal, 16) - } -} - -private struct WarningIcon: View { - var body: some View { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - .font(.system(size: 48)) - } -} - -private struct PermissionTexts: View { - var body: some View { - VStack(spacing: 16) { - Text(LocalizationKey.permissionRequired.localized) - .font(.title2) - .fontWeight(.semibold) - .multilineTextAlignment(.center) + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 48)) - Text(LocalizationKey.permissionDescription.localized) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - } -} + VStack(spacing: 16) { + Text(LocalizationKey.permissionRequired.localized) + .font(.title2) + .fontWeight(.semibold) + .multilineTextAlignment(.center) -private struct PermissionButton: View { - let permissionManager: PermissionManager - - var body: some View { - Button(action: permissionManager.requestAccessibilityPermission) { - HStack { - Image(systemName: "gear") - Text(LocalizationKey.openSystemPreferences.localized) + Text(LocalizationKey.permissionDescription.localized) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - .buttonStyle(PlainButtonStyle()) - .padding(.top, 8) - } -} -private struct QuitButton: View { - var body: some View { - HStack { - Spacer() - Button(LocalizationKey.actionQuit.localized) { - NSApplication.shared.terminate(nil) + 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()) - .foregroundColor(.red) + .padding(.top, 8) } .padding(.horizontal, 16) - .padding(.bottom, 16) } } diff --git a/KeyboardLocker/Sources/Views/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift index dbd7ef4..bc1766e 100644 --- a/KeyboardLocker/Sources/Views/SettingsView.swift +++ b/KeyboardLocker/Sources/Views/SettingsView.swift @@ -32,8 +32,8 @@ struct SettingsView: View { Picker( LocalizationKey.timeAutoLockDuration.localized, selection: $coreConfig.autoLockDuration ) { - ForEach(LockDurationHelper.autoLockPresets, id: \.self) { duration in - Text(LockDurationHelper.localizedDisplayString(for: duration)) + ForEach(AutoLockInterval.autoLockPresets, id: \.self) { duration in + Text(duration.localized) .tag(duration) } } @@ -41,7 +41,7 @@ struct SettingsView: View { } // Show current activity status if auto-lock is enabled - if coreConfig.autoLockDuration.isEnabled { + if coreConfig.isAutoLockEnabled { HStack { Image(systemName: "timer") .foregroundColor(.secondary) diff --git a/KeyboardLocker/Sources/Views/SharedComponents.swift b/KeyboardLocker/Sources/Views/SharedComponents.swift index 763deae..7deee80 100644 --- a/KeyboardLocker/Sources/Views/SharedComponents.swift +++ b/KeyboardLocker/Sources/Views/SharedComponents.swift @@ -33,41 +33,30 @@ struct QuickActionsView: View { .foregroundColor(.secondary) VStack(spacing: 6) { - SettingsNavigation(keyboardManager: keyboardManager) - AboutNavigation() + 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()) } } } } -private struct SettingsNavigation: View { - let keyboardManager: KeyboardLockManager - - var body: some View { - NavigationLink(destination: SettingsView().environmentObject(keyboardManager)) { - SettingRow( - icon: "gear", - title: LocalizationKey.settingsTitle.localized, - subtitle: LocalizationKey.settingsSubtitle.localized - ) - } - .buttonStyle(PlainButtonStyle()) - } -} - -private struct AboutNavigation: View { - var body: some View { - NavigationLink(destination: AboutView()) { - SettingRow( - icon: "info.circle", - title: LocalizationKey.aboutTitle.localized, - subtitle: LocalizationKey.aboutSubtitle.localized - ) - } - .buttonStyle(PlainButtonStyle()) - } -} - // MARK: - Bottom Actions struct BottomActionsView: View { diff --git a/KeyboardLocker/Sources/Views/StatusView.swift b/KeyboardLocker/Sources/Views/StatusView.swift index 03e2f6b..d5f438b 100644 --- a/KeyboardLocker/Sources/Views/StatusView.swift +++ b/KeyboardLocker/Sources/Views/StatusView.swift @@ -3,67 +3,62 @@ import SwiftUI struct StatusSectionView: View { let isKeyboardLocked: Bool - let keyboardManager: KeyboardLockManager + @ObservedObject var keyboardManager: KeyboardLockManager - var body: some View { - VStack(alignment: .leading, spacing: 4) { - MainStatusRow(isLocked: isKeyboardLocked) + private var statusText: String { + isKeyboardLocked + ? LocalizationKey.statusLocked.localized + : LocalizationKey.statusUnlocked.localized + } - if isKeyboardLocked { - LockDurationRow(keyboardManager: keyboardManager) - } + private var mainStatusView: some View { + HStack { + Circle() + .fill(isKeyboardLocked ? Color.red : Color.green) + .frame(width: 12, height: 12) - if !isKeyboardLocked, keyboardManager.isAutoLockEnabled { - AutoLockStatusRow(keyboardManager: keyboardManager) - } + Text(statusText) + .font(.body) + .foregroundColor(.primary) + + Spacer() } } -} - -private struct MainStatusRow: View { - let isLocked: Bool - var body: some View { + private var autoLockStatusView: some View { HStack { - StatusIndicator(isLocked: isLocked) - StatusText(isLocked: isLocked) + Image(systemName: "timer") + .foregroundColor(.orange) + .font(.caption) + Text(LocalizationKey.autoLockStatus.localized(keyboardManager.autoLockStatusText)) + .font(.caption) + .foregroundColor(.secondary) Spacer() } + .padding(.leading, 16) } -} - -private struct StatusIndicator: View { - let isLocked: Bool var body: some View { - Circle() - .fill(isLocked ? Color.red : Color.green) - .frame(width: 12, height: 12) - } -} - -private struct StatusText: View { - let isLocked: Bool + VStack(alignment: .leading, spacing: 4) { + mainStatusView - var body: some View { - Text(statusText) - .font(.body) - .foregroundColor(.primary) - } + if isKeyboardLocked { + LockDurationRow(keyboardManager: keyboardManager) + } - private var statusText: String { - isLocked - ? LocalizationKey.statusLocked.localized - : LocalizationKey.statusUnlocked.localized + if !isKeyboardLocked, keyboardManager.isAutoLockEnabled { + autoLockStatusView + } + } } } private struct LockDurationRow: View { - let keyboardManager: KeyboardLockManager + @ObservedObject var keyboardManager: KeyboardLockManager var body: some View { - if let durationString = keyboardManager.getLockDurationString() { - let displayText = getLockDurationDisplayText(durationString) + if let durationText = keyboardManager.lockDurationText { + let displayText = getLockDurationDisplayText(durationText) if !displayText.isEmpty { HStack { Image(systemName: "clock") @@ -81,43 +76,16 @@ private struct LockDurationRow: View { private func getLockDurationDisplayText(_ durationString: String) -> String { if durationString.contains(":") { - return LocalizationKey.timedLockRemaining.localized(durationString) - } else { - // Fallback: show a generic message with the duration string - return LocalizationKey.statusLocked.localized() - } - } -} - -private struct AutoLockStatusRow: View { - let keyboardManager: KeyboardLockManager - - var body: some View { - HStack { - Image(systemName: "timer") - .foregroundColor(.orange) - .font(.caption) - Text(LocalizationKey.autoLockStatus.localized(getAutoLockStatusText())) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - .padding(.leading, 16) - } - - private func getAutoLockStatusText() -> String { - let duration = keyboardManager.autoLockDuration - if duration == 0 { - return LocalizationKey.autoLockDisabled.localized - } - - let timeSinceActivity = keyboardManager.getTimeSinceLastActivity() - let remainingTime = max(0, TimeInterval(duration * 60) - timeSinceActivity) - - if remainingTime > 0 { - return CountdownFormatter.countdownString(from: remainingTime) + // Check if it's a timed lock with remaining time + if keyboardManager.isTimedLock { + LocalizationKey.timedLockRemaining.localized(durationString) + } else { + // Regular lock showing elapsed time + LocalizationKey.lockDurationFormat.localized(durationString) + } } else { - return LocalizationKey.autoLockReadyToLock.localized + // Fallback: show a generic message + LocalizationKey.statusLocked.localized() } } } diff --git a/KeyboardLocker/Sources/Views/TimedLockView.swift b/KeyboardLocker/Sources/Views/TimedLockView.swift index ca8f0c2..55d0839 100644 --- a/KeyboardLocker/Sources/Views/TimedLockView.swift +++ b/KeyboardLocker/Sources/Views/TimedLockView.swift @@ -1,12 +1,20 @@ import Core import SwiftUI +private typealias LockInterval = CoreConfiguration.Duration + struct TimedLockControlsView: View { @ObservedObject var state: ContentViewState var body: some View { VStack(spacing: 12) { - TimedLockHeader() + HStack { + Text(LocalizationKey.timedLockTitle.localized) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + Spacer() + } PresetButtonsSection(state: state) Divider() CustomDurationSection(state: state) @@ -14,33 +22,21 @@ struct TimedLockControlsView: View { } } -private struct TimedLockHeader: View { - var body: some View { - HStack { - Text(LocalizationKey.timedLockTitle.localized) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.primary) - Spacer() - } - } -} - private struct PresetButtonsSection: View { @ObservedObject var state: ContentViewState var body: some View { - VStack(spacing: 8) { - HStack(spacing: 8) { - ForEach(Array(LockDurationHelper.timedLockPresets.prefix(2)), id: \.self) { duration in - PresetButton(duration: duration, action: { state.startTimedLock(with: duration) }) - } - } - - HStack(spacing: 8) { - ForEach(Array(LockDurationHelper.timedLockPresets.suffix(2)), id: \.self) { duration in - PresetButton(duration: duration, action: { state.startTimedLock(with: duration) }) - } + let presets = LockInterval.timedLockPresets + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + ] + + LazyVGrid(columns: columns, spacing: 8) { + ForEach(presets, id: \.self) { duration in + PresetButton(duration: duration, action: { state.startTimedLock(with: duration) }) } } } @@ -51,23 +47,17 @@ private struct CustomDurationSection: View { var body: some View { VStack(spacing: 8) { - CustomDurationHeader() + HStack { + Text(LocalizationKey.timedLockCustom.localized) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } CustomDurationControls(state: state) } } } -private struct CustomDurationHeader: View { - var body: some View { - HStack { - Text(LocalizationKey.timedLockCustom.localized) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - } -} - private struct CustomDurationControls: View { @ObservedObject var state: ContentViewState @@ -84,28 +74,20 @@ private struct CustomDurationControls: View { Spacer() - CustomLockButton(action: state.startCustomTimedLock) - } - } -} - -private struct CustomLockButton: View { - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - Image(systemName: "timer") - Text(LocalizationKey.timedLockStart.localized) + Button(action: state.startCustomTimedLock) { + HStack { + Image(systemName: "timer") + Text(LocalizationKey.timedLockStart.localized) + } + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) } - .font(.caption) - .padding(.horizontal, 12) - .padding(.vertical, 6) .background(Color.orange) - .foregroundColor(.white) .cornerRadius(8) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } @@ -115,14 +97,14 @@ struct PresetButton: View { var body: some View { Button(action: action) { - Text(LockDurationHelper.localizedDisplayString(for: duration)) + Text(duration.localized) .font(.caption) + .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Color.orange) - .foregroundColor(.white) - .cornerRadius(16) } + .background(Color.orange) + .cornerRadius(16) .buttonStyle(PlainButtonStyle()) } } diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings index c122357..064567c 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -293,74 +293,6 @@ } } }, - "countdown.finished" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Finished" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已完成" - } - } - } - }, - "countdown.hours.format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d:%02d:%02d remaining" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "剩余 %d:%02d:%02d" - } - } - } - }, - "countdown.minutes.format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d:%02d remaining" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "剩余 %d:%02d" - } - } - } - }, - "countdown.seconds.format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d seconds remaining" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "剩余 %d 秒" - } - } - } - }, "duration.hours" : { "extractionState" : "manual", "localizations" : { @@ -378,23 +310,6 @@ } } }, - "duration.hours.description" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto unlock after %d hours" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d小时后自动解锁" - } - } - } - }, "duration.hours.minutes" : { "extractionState" : "manual", "localizations" : { @@ -412,23 +327,6 @@ } } }, - "duration.hours.minutes.description" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto unlock after %d hours %d minutes" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d小时%d分钟后自动解锁" - } - } - } - }, "duration.infinite" : { "extractionState" : "manual", "localizations" : { @@ -446,23 +344,6 @@ } } }, - "duration.infinite.description" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lock indefinitely" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无限期锁定" - } - } - } - }, "duration.minutes" : { "extractionState" : "manual", "localizations" : { @@ -480,23 +361,6 @@ } } }, - "duration.minutes.description" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto unlock after %d minutes" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d分钟后自动解锁" - } - } - } - }, "duration.never" : { "extractionState" : "manual", "localizations" : { @@ -514,23 +378,6 @@ } } }, - "duration.never.description" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Never auto unlock" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "永不自动解锁" - } - } - } - }, "lock.duration.format" : { "extractionState" : "manual", "localizations" : { @@ -1162,4 +1009,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file From ed944fc0a0ca397460bba19de8e4d6d87e4395b2 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 10 Oct 2025 16:01:22 +0800 Subject: [PATCH 12/21] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20chore:=20Remove?= =?UTF-8?q?=20IPCManager=20and=20related=20IPC=20structures.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Core/IPCManager.swift | 204 ------------------ Core/Sources/Core/SharedModels.swift | 100 --------- .../Sources/Application/AppDelegate.swift | 6 - 3 files changed, 310 deletions(-) delete mode 100644 Core/Sources/Core/IPCManager.swift diff --git a/Core/Sources/Core/IPCManager.swift b/Core/Sources/Core/IPCManager.swift deleted file mode 100644 index 7fa2a10..0000000 --- a/Core/Sources/Core/IPCManager.swift +++ /dev/null @@ -1,204 +0,0 @@ -import AppKit - -// MARK: - XPC Service Protocol - -/// Protocol for XPC communication between main app and CLI tool -@objc protocol IPCServiceProtocol { - func executeCommand(_ command: String, withReply reply: @escaping ([String: Any]) -> Void) -} - -// MARK: - IPC Manager - -/// Manager for Inter-Process Communication between main app and CLI tool -public class IPCManager: NSObject { - public static let shared = IPCManager() - - // MARK: - Properties - - private let serviceName = CoreConstants.ipcServiceName - private var listener: NSXPCListener? - - private var isServerRunning: Bool { - listener != nil - } - - // MARK: - Initialization - - override private init() { - super.init() - } - - // MARK: - Server Methods (Main App) - - /// Start IPC server in main app - public func startServer() { - guard !isServerRunning else { - print("IPC Server already running") - return - } - - listener = NSXPCListener(machServiceName: serviceName) - listener?.delegate = self - listener?.resume() - - print("🚀 IPC Server started on service: \(serviceName)") - } - - /// Stop IPC server - public func stopServer() { - listener?.invalidate() - listener = nil - print("🛑 IPC Server stopped") - } - - // MARK: - Client Methods (CLI Tool) - - /// Send command to main app from CLI tool - /// - Parameters: - /// - command: The command to execute - /// - timeout: Timeout in seconds - /// - Returns: Response from the main app - public func sendCommand( - _ command: IPCCommand, - timeout _: TimeInterval = CoreConstants.ipcTimeout - ) async throws -> IPCResponse { - try await withCheckedThrowingContinuation { continuation in - // Check if main app is running first - guard isMainAppRunning() else { - continuation.resume(throwing: CoreError.mainAppNotRunning) - return - } - - let connection = NSXPCConnection(machServiceName: serviceName) - connection.remoteObjectInterface = NSXPCInterface(with: IPCServiceProtocol.self) - - connection.interruptionHandler = { - continuation.resume(throwing: CoreError.ipcConnectionFailed) - } - - connection.invalidationHandler = { - // Connection will be cleaned up automatically - } - - connection.resume() - - guard let service = connection.remoteObjectProxy as? IPCServiceProtocol else { - connection.invalidate() - continuation.resume(throwing: CoreError.ipcConnectionFailed) - return - } - - service.executeCommand(command.rawValue) { [weak self] responseDict in - defer { connection.invalidate() } - - guard let self else { - continuation.resume(throwing: CoreError.ipcConnectionFailed) - return - } - - let response = parseResponse(from: responseDict) - continuation.resume(returning: response) - } - } - } - - /// Check if main app is running - private func isMainAppRunning() -> Bool { - let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { app in - app.bundleIdentifier == CoreConstants.mainAppBundleID - } - } - - // MARK: - Helper Methods - - /// Parse response dictionary into IPCResponse - private func parseResponse(from dict: [String: Any]) -> IPCResponse { - let success = dict["success"] as? Bool ?? false - let message = dict["message"] as? String ?? "Unknown response" - let data = dict["data"] as? [String: String] - - return IPCResponse(success: success, message: message, data: data) - } - - /// Convert IPCResponse to dictionary for XPC - func responseToObject(_ response: IPCResponse) -> [String: Any] { - var obj: [String: Any] = [ - "success": response.success, - "message": response.message, - "timestamp": response.timestamp.timeIntervalSince1970, - ] - - if let data = response.data { - obj["data"] = data - } - - return obj - } -} - -// MARK: - XPC Listener Delegate - -extension IPCManager: NSXPCListenerDelegate { - public func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - newConnection.exportedInterface = NSXPCInterface(with: IPCServiceProtocol.self) - newConnection.exportedObject = IPCServiceHandler() - newConnection.resume() - return true - } -} - -// MARK: - IPC Service Handler - -/// Handles incoming IPC commands from CLI tool -public class IPCServiceHandler: NSObject, IPCServiceProtocol { - public func executeCommand(_ command: String, withReply reply: @escaping ([String: Any]) -> Void) { - guard let ipcCommand = IPCCommand(rawValue: command) else { - let response = IPCResponse.error("Unknown command: \(command)") - reply(IPCManager.shared.responseToObject(response)) - return - } - - // Execute command on main queue - DispatchQueue.main.async { - let response = self.handleCommand(ipcCommand) - reply(IPCManager.shared.responseToObject(response)) - } - } - - /// Handle the actual command execution - private func handleCommand(_ command: IPCCommand) -> IPCResponse { - let lockCore = KeyboardLockCore.shared - - do { - switch command { - case .lock: - try lockCore.lockKeyboard() - return IPCResponse.success("Keyboard locked successfully") - - case .unlock: - lockCore.unlockKeyboard() - return IPCResponse.success("Keyboard unlocked successfully") - - case .toggle: - lockCore.toggleLock() - let statusMessage = lockCore.isLocked ? "locked" : "unlocked" - return IPCResponse.success("Keyboard \(statusMessage) successfully") - - case .status: - let status = LockStatus( - isLocked: lockCore.isLocked, - lockedAt: lockCore.lockedAt - ) - return IPCResponse.success( - "Keyboard is currently \(status.isLocked ? "locked" : "unlocked")", - data: status.toDictionary() - ) - } - } catch let error as CoreError { - return IPCResponse.error(error.localizedDescription) - } catch { - return IPCResponse.error("Unexpected error: \(error.localizedDescription)") - } - } -} diff --git a/Core/Sources/Core/SharedModels.swift b/Core/Sources/Core/SharedModels.swift index dbcfeca..1bcc13a 100644 --- a/Core/Sources/Core/SharedModels.swift +++ b/Core/Sources/Core/SharedModels.swift @@ -36,103 +36,15 @@ public enum KeyboardLockError: Error, LocalizedError { } } -// MARK: - IPC Commands - -/// Commands that can be sent from CLI tool to main app -public enum IPCCommand: String, Codable, CaseIterable { - case lock - case unlock - case toggle - case status - - public var description: String { - switch self { - case .lock: - "Lock the keyboard" - case .unlock: - "Unlock the keyboard" - case .toggle: - "Toggle keyboard lock status" - case .status: - "Get current keyboard lock status" - } - } -} - -// MARK: - IPC Response - -/// Response structure for IPC communication -public struct IPCResponse: Codable { - public let success: Bool - public let message: String - public let data: [String: String]? - public let timestamp: Date - - public init(success: Bool, message: String, data: [String: String]? = nil) { - self.success = success - self.message = message - self.data = data - timestamp = Date() - } - - /// Convenience initializer for success responses - public static func success(_ message: String, data: [String: String]? = nil) -> IPCResponse { - IPCResponse(success: true, message: message, data: data) - } - - /// Convenience initializer for error responses - public static func error(_ message: String) -> IPCResponse { - IPCResponse(success: false, message: message, data: nil) - } -} - -// MARK: - Error Types - -/// Errors that can occur in Core operations -public enum CoreError: Error, LocalizedError { - case accessibilityPermissionDenied - case eventTapCreationFailed - case ipcConnectionFailed - case invalidCommand - case mainAppNotRunning - case alreadyLocked - case notLocked - - public var errorDescription: String? { - switch self { - case .accessibilityPermissionDenied: - "Accessibility permission is required to control keyboard input" - case .eventTapCreationFailed: - "Failed to create event tap for keyboard monitoring" - case .ipcConnectionFailed: - "Failed to connect to main application" - case .invalidCommand: - "Invalid command provided" - case .mainAppNotRunning: - "Main application is not running" - case .alreadyLocked: - "Keyboard is already locked" - case .notLocked: - "Keyboard is not currently locked" - } - } -} - // MARK: - Constants /// Shared constants used across the application public enum CoreConstants { - /// IPC service name for XPC communication - public static let ipcServiceName = "io.lzhlovesjyq.keyboardlocker.ipc" - /// 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 - - /// Timeout for IPC connections (in seconds) - public static let ipcTimeout: TimeInterval = 5.0 } // MARK: - Lock Status @@ -149,16 +61,4 @@ public struct LockStatus: Codable { self.isLocked = isLocked self.lockedAt = lockedAt } - - /// Convert to dictionary for IPC data - public func toDictionary() -> [String: String] { - var dict: [String: String] = ["locked": isLocked ? "true" : "false"] - - if let lockedAt { - let formatter = ISO8601DateFormatter() - dict["lockedAt"] = formatter.string(from: lockedAt) - } - - return dict - } } diff --git a/KeyboardLocker/Sources/Application/AppDelegate.swift b/KeyboardLocker/Sources/Application/AppDelegate.swift index e12408b..e76bf24 100644 --- a/KeyboardLocker/Sources/Application/AppDelegate.swift +++ b/KeyboardLocker/Sources/Application/AppDelegate.swift @@ -12,18 +12,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { urlHandler = handler } - func applicationWillFinishLaunching(_: Notification) { - IPCManager.shared.startServer() - } - func applicationDidFinishLaunching(_: Notification) { setupExceptionHandling() } func applicationWillTerminate(_: Notification) { print("Application will terminate - cleaning up") - // Stop IPC server - IPCManager.shared.stopServer() // Ensure keyboard is unlocked before termination keyboardLockManager?.unlockKeyboard() } From 456843485eaf3eaad49dc8919072bee4e065340b Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 10 Oct 2025 16:48:28 +0800 Subject: [PATCH 13/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Migrate=20to=20UserDe?= =?UTF-8?q?faults=20for=20configuration=20persistence.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Core/CoreConfiguration.swift | 47 ++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift index dea7b6f..b588bfe 100644 --- a/Core/Sources/Core/CoreConfiguration.swift +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -1,5 +1,6 @@ import Carbon -import SwiftUI +import Combine +import Foundation /// Core configuration management for KeyboardLocker /// Handles persistent settings and configuration synchronization across all targets @@ -79,19 +80,32 @@ public class CoreConfiguration: ObservableObject { } } - // MARK: - Published Properties with AppStorage + // MARK: - UserDefaults + + private let userDefaults = UserDefaults.standard + + // MARK: - Published Properties /// Auto-lock configuration using enum with RawRepresentable - @AppStorage("io.lzhlovesjyq.keyboardlocker.autolockduration") - public var autoLockDuration: Duration = .never + @Published public var autoLockDuration: Duration = .never { + didSet { + userDefaults.set(autoLockDuration.rawValue, forKey: "io.lzhlovesjyq.keyboardlocker.autolockduration") + } + } /// Whether to show system notifications - @AppStorage("io.lzhlovesjyq.keyboardlocker.shownotifications") - public var showNotifications: Bool = true + @Published public var showNotifications: Bool = true { + didSet { + userDefaults.set(showNotifications, forKey: "io.lzhlovesjyq.keyboardlocker.shownotifications") + } + } /// Hotkey configuration using RawRepresentable - @AppStorage("io.lzhlovesjyq.keyboardlocker.hotkey") - public var hotkey: HotkeyConfiguration = .defaultHotkey() + @Published public var hotkey: HotkeyConfiguration = .defaultHotkey() { + didSet { + userDefaults.set(hotkey.rawValue, forKey: "io.lzhlovesjyq.keyboardlocker.hotkey") + } + } // MARK: - Computed Properties @@ -107,7 +121,22 @@ public class CoreConfiguration: ObservableObject { // MARK: - Initialization - private init() {} + 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 From e952c6d65abcbc42325f8fd69f2afda8e7ddacf8 Mon Sep 17 00:00:00 2001 From: Eden Date: Sat, 11 Oct 2025 15:36:32 +0800 Subject: [PATCH 14/21] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Update=20AppDe?= =?UTF-8?q?pendencies=20initialization.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KeyboardLocker/Sources/Application/KeyboardLockerApp.swift | 2 +- KeyboardLocker/Sources/Helpers/AppDependencies.swift | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift index d459981..9ba17de 100644 --- a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift +++ b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift @@ -5,7 +5,7 @@ import SwiftUI @main struct KeyboardLockerApp: App { // Application dependencies container - private let dependencies = appDependencies + private let dependencies = AppDependencies() // Use AppDelegate for URL handling @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate diff --git a/KeyboardLocker/Sources/Helpers/AppDependencies.swift b/KeyboardLocker/Sources/Helpers/AppDependencies.swift index 0220e3c..91b9704 100644 --- a/KeyboardLocker/Sources/Helpers/AppDependencies.swift +++ b/KeyboardLocker/Sources/Helpers/AppDependencies.swift @@ -45,7 +45,3 @@ final class AppDependencies { ) } } - -/// Global application dependency instance -/// Created once at app startup and passed to components that need dependencies -let appDependencies = AppDependencies() From 23c5aecbaeeb160d1a0c4d91284d553811db3b69 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 13 Oct 2025 10:48:09 +0800 Subject: [PATCH 15/21] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Simplify=20per?= =?UTF-8?q?mission=20management=20and=20UI=20components.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../Managers/NotificationManager.swift | 6 +----- .../Sources/Managers/PermissionManager.swift | 18 ++--------------- .../Sources/Views/ContentView.swift | 20 ++++++------------- .../Sources/Views/LockControlView.swift | 5 ++--- .../Sources/Views/PermissionView.swift | 6 ++---- .../Sources/Views/SettingsView.swift | 12 ++++++++++- .../Sources/Views/SharedComponents.swift | 6 +----- KeyboardLocker/Sources/Views/StatusView.swift | 6 +++--- 9 files changed, 29 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index 52bf5c9..8c81267 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ iOSInjectionProject/ buildServer.json [Ff]eatures.md +[Tt]asks.md diff --git a/KeyboardLocker/Sources/Managers/NotificationManager.swift b/KeyboardLocker/Sources/Managers/NotificationManager.swift index 9b23847..e55995c 100644 --- a/KeyboardLocker/Sources/Managers/NotificationManager.swift +++ b/KeyboardLocker/Sources/Managers/NotificationManager.swift @@ -86,8 +86,6 @@ class NotificationManager: ObservableObject { } } - // MARK: - NotificationManaging Protocol Conformance - /// Send a notification of the specified type if enabled /// - Parameters: /// - type: The type of notification to send @@ -146,9 +144,7 @@ class NotificationManager: ObservableObject { // 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")") } } } diff --git a/KeyboardLocker/Sources/Managers/PermissionManager.swift b/KeyboardLocker/Sources/Managers/PermissionManager.swift index 9246475..cdb0025 100644 --- a/KeyboardLocker/Sources/Managers/PermissionManager.swift +++ b/KeyboardLocker/Sources/Managers/PermissionManager.swift @@ -30,10 +30,9 @@ 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 @@ -52,9 +51,9 @@ class PermissionManager: ObservableObject { } /// Request notification permission using NotificationManager + /// Should only be called when user enables notifications in settings func requestNotificationPermission() { notificationManager.requestAuthorization { [weak self] (_: Bool, error: Error?) in - // The NotificationManager handles state updates if let error { print("Failed to request notification permission: \(error)") } @@ -84,13 +83,10 @@ 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() } @@ -106,16 +102,6 @@ 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() { PermissionHelper.openAccessibilitySettings() print("Opened accessibility settings") diff --git a/KeyboardLocker/Sources/Views/ContentView.swift b/KeyboardLocker/Sources/Views/ContentView.swift index 53f7163..79f8f1e 100644 --- a/KeyboardLocker/Sources/Views/ContentView.swift +++ b/KeyboardLocker/Sources/Views/ContentView.swift @@ -11,7 +11,7 @@ struct ContentView: View { if permissionManager.hasAccessibilityPermission { MainContentView(state: viewState) } else { - PermissionRequiredView(permissionManager: permissionManager) + PermissionRequiredView() } } .frame(width: .viewWidth) @@ -35,19 +35,11 @@ private struct MainContentView: View { AppTitleHeaderView() VStack(spacing: 16) { - if let keyboardManager = state.keyboardManager { - StatusSectionView( - isKeyboardLocked: state.isKeyboardLocked, - keyboardManager: keyboardManager - ) - - LockControlButtonView( - state: state, - keyboardManager: keyboardManager - ) - - QuickActionsView(keyboardManager: keyboardManager) - } + StatusSectionView(isKeyboardLocked: state.isKeyboardLocked) + + LockControlButtonView(state: state) + + QuickActionsView() } .padding(.horizontal, 16) diff --git a/KeyboardLocker/Sources/Views/LockControlView.swift b/KeyboardLocker/Sources/Views/LockControlView.swift index 915725b..9c199af 100644 --- a/KeyboardLocker/Sources/Views/LockControlView.swift +++ b/KeyboardLocker/Sources/Views/LockControlView.swift @@ -3,11 +3,10 @@ import SwiftUI struct LockControlButtonView: View { @ObservedObject var state: ContentViewState - let keyboardManager: KeyboardLockManager var body: some View { HStack(spacing: 8) { - MainLockButton(state: state, keyboardManager: keyboardManager) + MainLockButton(state: state) if !state.isKeyboardLocked { TimedLockOptionsButton(state: state) @@ -18,7 +17,7 @@ struct LockControlButtonView: View { private struct MainLockButton: View { @ObservedObject var state: ContentViewState - let keyboardManager: KeyboardLockManager + @EnvironmentObject private var keyboardManager: KeyboardLockManager var body: some View { Button(action: toggleLock) { diff --git a/KeyboardLocker/Sources/Views/PermissionView.swift b/KeyboardLocker/Sources/Views/PermissionView.swift index ce1f4cd..4110315 100644 --- a/KeyboardLocker/Sources/Views/PermissionView.swift +++ b/KeyboardLocker/Sources/Views/PermissionView.swift @@ -2,12 +2,10 @@ import Core import SwiftUI struct PermissionRequiredView: View { - let permissionManager: PermissionManager - var body: some View { VStack(spacing: 20) { AppTitleHeaderView() - PermissionContent(permissionManager: permissionManager) + PermissionContent() Spacer() HStack { @@ -25,7 +23,7 @@ struct PermissionRequiredView: View { } private struct PermissionContent: View { - let permissionManager: PermissionManager + @EnvironmentObject private var permissionManager: PermissionManager var body: some View { VStack(spacing: 16) { diff --git a/KeyboardLocker/Sources/Views/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift index bc1766e..2428c55 100644 --- a/KeyboardLocker/Sources/Views/SettingsView.swift +++ b/KeyboardLocker/Sources/Views/SettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SettingsView: View { @ObservedObject private var coreConfig = CoreConfiguration.shared + @EnvironmentObject private var permissionManager: PermissionManager private typealias AutoLockInterval = CoreConfiguration.Duration @@ -71,7 +72,16 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { Toggle( LocalizationKey.settingsShowNotifications.localized, - isOn: $coreConfig.showNotifications + 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) diff --git a/KeyboardLocker/Sources/Views/SharedComponents.swift b/KeyboardLocker/Sources/Views/SharedComponents.swift index 7deee80..f1c3ea6 100644 --- a/KeyboardLocker/Sources/Views/SharedComponents.swift +++ b/KeyboardLocker/Sources/Views/SharedComponents.swift @@ -24,8 +24,6 @@ struct AppTitleHeaderView: View { // MARK: - Quick Actions Components struct QuickActionsView: View { - let keyboardManager: KeyboardLockManager - var body: some View { VStack(alignment: .leading, spacing: 12) { Text(LocalizationKey.quickActions.localized) @@ -33,9 +31,7 @@ struct QuickActionsView: View { .foregroundColor(.secondary) VStack(spacing: 6) { - NavigationLink( - destination: SettingsView().environmentObject(keyboardManager) - ) { + NavigationLink(destination: SettingsView()) { SettingRow( icon: "gear", title: LocalizationKey.settingsTitle.localized, diff --git a/KeyboardLocker/Sources/Views/StatusView.swift b/KeyboardLocker/Sources/Views/StatusView.swift index d5f438b..7edb520 100644 --- a/KeyboardLocker/Sources/Views/StatusView.swift +++ b/KeyboardLocker/Sources/Views/StatusView.swift @@ -3,7 +3,7 @@ import SwiftUI struct StatusSectionView: View { let isKeyboardLocked: Bool - @ObservedObject var keyboardManager: KeyboardLockManager + @EnvironmentObject private var keyboardManager: KeyboardLockManager private var statusText: String { isKeyboardLocked @@ -43,7 +43,7 @@ struct StatusSectionView: View { mainStatusView if isKeyboardLocked { - LockDurationRow(keyboardManager: keyboardManager) + LockDurationRow() } if !isKeyboardLocked, keyboardManager.isAutoLockEnabled { @@ -54,7 +54,7 @@ struct StatusSectionView: View { } private struct LockDurationRow: View { - @ObservedObject var keyboardManager: KeyboardLockManager + @EnvironmentObject private var keyboardManager: KeyboardLockManager var body: some View { if let durationText = keyboardManager.lockDurationText { From 1eebc2bb50973a3252f2912814303a89c648c02d Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 13 Oct 2025 20:44:06 +0800 Subject: [PATCH 16/21] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Remove=20backw?= =?UTF-8?q?ard=20compatibility=20aliases=20for=20duration.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KeyboardLocker/Sources/Helpers/LocalizationHelper.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift index e952aa9..496227f 100644 --- a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift @@ -97,10 +97,6 @@ enum LocalizationKey { static let durationNever = "duration.never" static let durationInfinite = "duration.infinite" - // Backward compatibility aliases - static let timeNever = durationNever - static let timeInfinite = durationInfinite - // Duration display - parameterized forms static let durationMinutes = "duration.minutes" // "%d minute(s)" static let durationHours = "duration.hours" // "%d hour(s)" From 6df09a4959c956e18d111f4fbd5e43d719372420 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 29 Oct 2025 00:53:06 +0800 Subject: [PATCH 17/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20auto=20lock=20c?= =?UTF-8?q?ountdown=20format=20localization.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Helpers/LocalizationHelper.swift | 1 + KeyboardLocker/i18n/Localizable.xcstrings | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift index 496227f..2f74f9d 100644 --- a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift @@ -92,6 +92,7 @@ enum LocalizationKey { 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" diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings index 064567c..bbfcb12 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -276,6 +276,23 @@ } } }, + "auto.lock.countdown.format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keyboard will auto lock in %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余%@会自动锁定键盘" + } + } + } + }, "auto.lock.status" : { "extractionState" : "manual", "localizations" : { From f0ea8b5736295766f7dc3d396b26f03afe859fca Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Nov 2025 13:40:45 +0800 Subject: [PATCH 18/21] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Remove=20timed?= =?UTF-8?q?=20lock=20features=20and=20localization.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .../Sources/Extensions/Duration+.swift | 12 -- .../Sources/Helpers/LocalizationHelper.swift | 6 - .../Sources/Views/ContentViewState.swift | 36 ------ .../Sources/Views/LockControlView.swift | 24 ---- .../Sources/Views/TimedLockView.swift | 110 ------------------ KeyboardLocker/i18n/Localizable.xcstrings | 70 +---------- 7 files changed, 4 insertions(+), 257 deletions(-) delete mode 100644 KeyboardLocker/Sources/Views/TimedLockView.swift diff --git a/.gitignore b/.gitignore index 8c81267..5a261c6 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,9 @@ iOSInjectionProject/ # macOS .DS_Store +# xcode-build-server files buildServer.json +.compile + [Ff]eatures.md [Tt]asks.md diff --git a/KeyboardLocker/Sources/Extensions/Duration+.swift b/KeyboardLocker/Sources/Extensions/Duration+.swift index e8808db..fceef06 100644 --- a/KeyboardLocker/Sources/Extensions/Duration+.swift +++ b/KeyboardLocker/Sources/Extensions/Duration+.swift @@ -4,18 +4,6 @@ import Core extension CoreConfiguration.Duration { // MARK: - Preset Collections - /// Preset durations for timed lock UI - static let timedLockPresets: [Self] = [ - .infinite, - .minutes(1), - .minutes(5), - .minutes(15), - .minutes(30), - .minutes(60), // 1 hour - .minutes(120), // 2 hours - .minutes(240), // 4 hours - ] - /// Preset durations for auto-lock UI static let autoLockPresets: [Self] = [ .never, diff --git a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift index 2f74f9d..58e3a61 100644 --- a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift @@ -86,7 +86,6 @@ enum LocalizationKey { static let settingsReset = "settings.reset" // Time durations and duration display - static let timeMinutes = "time.minutes" static let timeActivityText = "time.activity.text" static let timeAutoLockDuration = "time.auto.lock.duration" static let autoLockStatus = "auto.lock.status" @@ -103,11 +102,6 @@ enum LocalizationKey { static let durationHours = "duration.hours" // "%d hour(s)" static let durationHoursMinutes = "duration.hours.minutes" // "%d hour(s) %d minute(s)" - // Timed Lock - static let timedLockTitle = "timed.lock.title" - static let timedLockStart = "timed.lock.start" - static let timedLockCustom = "timed.lock.custom" - // About static let aboutVersionFormat = "about.version.format" static let aboutFeatures = "about.features" diff --git a/KeyboardLocker/Sources/Views/ContentViewState.swift b/KeyboardLocker/Sources/Views/ContentViewState.swift index ddf7a2b..b1acaee 100644 --- a/KeyboardLocker/Sources/Views/ContentViewState.swift +++ b/KeyboardLocker/Sources/Views/ContentViewState.swift @@ -6,24 +6,12 @@ class ContentViewState: ObservableObject { // MARK: - Published Properties @Published var isKeyboardLocked = false - @Published var selectedTimedLockDuration: CoreConfiguration.Duration = .infinite - @Published var showTimedLockOptions = false - @Published var customMinutes: Int = 5 // MARK: - Dependencies var keyboardManager: KeyboardLockManager? private var cancellables = Set() - // MARK: - Computed Properties - - var customMinutesString: Binding { - Binding( - get: { String(self.customMinutes) }, - set: { self.customMinutes = Int($0) ?? 5 } - ) - } - // MARK: - Lifecycle Methods func setup(with keyboardManager: KeyboardLockManager) { @@ -38,30 +26,6 @@ class ContentViewState: ObservableObject { // MARK: - Public Methods - func startTimedLock() { - lock(with: selectedTimedLockDuration) - } - - func startTimedLock(with duration: CoreConfiguration.Duration) { - lock(with: duration) - } - - func startCustomTimedLock() { - guard customMinutes > 0 else { return } - - let customDuration = CoreConfiguration.Duration.minutes(customMinutes) - lock(with: customDuration) - } - - // MARK: - Private Methods - - private func lock(with duration: CoreConfiguration.Duration) { - guard let keyboardManager else { return } - - showTimedLockOptions = false - keyboardManager.lockKeyboard(with: duration) - } - private func setupSubscriptions() { guard let keyboardManager else { return } diff --git a/KeyboardLocker/Sources/Views/LockControlView.swift b/KeyboardLocker/Sources/Views/LockControlView.swift index 9c199af..08c9ed5 100644 --- a/KeyboardLocker/Sources/Views/LockControlView.swift +++ b/KeyboardLocker/Sources/Views/LockControlView.swift @@ -7,10 +7,6 @@ struct LockControlButtonView: View { var body: some View { HStack(spacing: 8) { MainLockButton(state: state) - - if !state.isKeyboardLocked { - TimedLockOptionsButton(state: state) - } } } } @@ -49,23 +45,3 @@ private struct MainLockButton: View { } } -private struct TimedLockOptionsButton: View { - @ObservedObject var state: ContentViewState - - var body: some View { - Button(action: { state.showTimedLockOptions.toggle() }) { - Image(systemName: "info.circle") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.accentColor) - .frame(width: 44, height: 44) - .background(Color.gray.opacity(0.1)) - .cornerRadius(22) - } - .buttonStyle(PlainButtonStyle()) - .popover(isPresented: $state.showTimedLockOptions, arrowEdge: .bottom) { - TimedLockControlsView(state: state) - .frame(width: 280) - .padding() - } - } -} diff --git a/KeyboardLocker/Sources/Views/TimedLockView.swift b/KeyboardLocker/Sources/Views/TimedLockView.swift deleted file mode 100644 index 55d0839..0000000 --- a/KeyboardLocker/Sources/Views/TimedLockView.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Core -import SwiftUI - -private typealias LockInterval = CoreConfiguration.Duration - -struct TimedLockControlsView: View { - @ObservedObject var state: ContentViewState - - var body: some View { - VStack(spacing: 12) { - HStack { - Text(LocalizationKey.timedLockTitle.localized) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.primary) - Spacer() - } - PresetButtonsSection(state: state) - Divider() - CustomDurationSection(state: state) - } - } -} - -private struct PresetButtonsSection: View { - @ObservedObject var state: ContentViewState - - var body: some View { - let presets = LockInterval.timedLockPresets - let columns = [ - GridItem(.flexible()), - GridItem(.flexible()), - GridItem(.flexible()), - GridItem(.flexible()), - ] - - LazyVGrid(columns: columns, spacing: 8) { - ForEach(presets, id: \.self) { duration in - PresetButton(duration: duration, action: { state.startTimedLock(with: duration) }) - } - } - } -} - -private struct CustomDurationSection: View { - @ObservedObject var state: ContentViewState - - var body: some View { - VStack(spacing: 8) { - HStack { - Text(LocalizationKey.timedLockCustom.localized) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - CustomDurationControls(state: state) - } - } -} - -private struct CustomDurationControls: View { - @ObservedObject var state: ContentViewState - - var body: some View { - HStack(spacing: 8) { - TextField("", text: state.customMinutesString) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .frame(width: 60) - .onSubmit(state.startCustomTimedLock) - - Text(LocalizationKey.timeMinutes.localized) - .font(.caption) - .foregroundColor(.secondary) - - Spacer() - - Button(action: state.startCustomTimedLock) { - HStack { - Image(systemName: "timer") - Text(LocalizationKey.timedLockStart.localized) - } - .font(.caption) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - .background(Color.orange) - .cornerRadius(8) - .buttonStyle(PlainButtonStyle()) - } - } -} - -struct PresetButton: View { - let duration: CoreConfiguration.Duration - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(duration.localized) - .font(.caption) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - .background(Color.orange) - .cornerRadius(16) - .buttonStyle(PlainButtonStyle()) - } -} diff --git a/KeyboardLocker/i18n/Localizable.xcstrings b/KeyboardLocker/i18n/Localizable.xcstrings index bbfcb12..bbfcb1e 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -837,40 +837,6 @@ } } }, - "time.minutes" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Minutes" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "分钟" - } - } - } - }, - "timed.lock.custom" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Custom Duration" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "自定义时长" - } - } - } - }, "timed.lock.remaining" : { "extractionState" : "manual", "localizations" : { @@ -888,40 +854,6 @@ } } }, - "timed.lock.start" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "开始" - } - } - } - }, - "timed.lock.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Timed Lock" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "定时锁定" - } - } - } - }, "url.error.invalid.scheme" : { "extractionState" : "manual", "localizations" : { @@ -1026,4 +958,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 3292c2a44df3886a67813c4efe39c1fffcdf37c3 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Nov 2025 15:52:17 +0800 Subject: [PATCH 19/21] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Clean=20up=20c?= =?UTF-8?q?ode=20structure=20and=20remove=20unused=20keys.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Core/KeyboardLockCore.swift | 4 +- KeyboardLocker.xcodeproj/project.pbxproj | 2 +- .../xcschemes/KeyboardLocker.xcscheme | 2 +- .../Resources/KeyboardLocker.entitlements | 2 - .../Sources/Helpers/LocalizationHelper.swift | 1 - .../Managers/KeyboardLockManager.swift | 357 ++++++++++++------ .../Sources/Views/ContentViewState.swift | 5 +- KeyboardLocker/Sources/Views/StatusView.swift | 7 +- KeyboardLocker/i18n/Localizable.xcstrings | 40 +- 9 files changed, 253 insertions(+), 167 deletions(-) diff --git a/Core/Sources/Core/KeyboardLockCore.swift b/Core/Sources/Core/KeyboardLockCore.swift index 8fec9f2..55b5365 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -76,7 +76,9 @@ public class KeyboardLockCore { /// Reset unlock hotkey to default (Cmd+Option+L) public func resetUnlockHotkeyToDefault() { configureUnlockHotkey(.defaultHotkey()) - } // MARK: - Core Locking Methods + } + + // MARK: - Core Locking Methods /// Lock keyboard input /// - Throws: KeyboardLockError if locking fails diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index 1a309ca..68521eb 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -161,7 +161,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2610; TargetAttributes = { 022 = { CreatedOnToolsVersion = 15.0; diff --git a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme index bf18625..0e60521 100644 --- a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme +++ b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme @@ -1,6 +1,6 @@ - com.apple.security.automation.apple-events - com.apple.security.device.audio-input com.apple.security.device.camera diff --git a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift index 58e3a61..cd1e7d2 100644 --- a/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift +++ b/KeyboardLocker/Sources/Helpers/LocalizationHelper.swift @@ -121,7 +121,6 @@ enum LocalizationKey { // Lock Duration static let lockDurationFormat = "lock.duration.format" - static let timedLockRemaining = "timed.lock.remaining" // Permissions static let permissionRequired = "permission.required" diff --git a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift index 180ea64..e47561c 100644 --- a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift +++ b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift @@ -3,27 +3,62 @@ 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 UI State + // MARK: - Nested Types - @Published var isLocked = false - @Published var autoLockEnabled = false + /// Manages periodic UI state updates + private final class UIRefreshScheduler { + private var timer: Timer? + + var isActive: Bool { + timer != nil + } + + func start(interval: TimeInterval = 1.0, onUpdate: @escaping () -> Void) { + stop() + + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + onUpdate() + } + + // Immediate update + onUpdate() + } + + func stop() { + timer?.invalidate() + timer = nil + } + } + + // MARK: - Published State + + @Published private(set) var isLocked = false + @Published private(set) var lockDuration: TimeInterval? + @Published private(set) var autoLockRemainingTime: TimeInterval? // MARK: - Dependencies - // Core functionality - injected dependencies private let core: KeyboardLockCore private let config: CoreConfiguration private let activityMonitor: UserActivityMonitor - - // UI-specific dependencies private let notificationManager: NotificationManager - // MARK: - Combine Subscriptions + // MARK: - Coordinators - private var cancellables = Set() + private let refreshScheduler = UIRefreshScheduler() - // MARK: - Initialization + // MARK: - State + + private var isUserOperation = false + + // MARK: - Lifecycle /// Create KeyboardLockManager with injected dependencies /// - Parameters: @@ -42,81 +77,86 @@ class KeyboardLockManager: ObservableObject { self.activityMonitor = activityMonitor self.notificationManager = notificationManager - setupStateSubscriptions() + configureSubscriptions() syncInitialState() } - deinit { - cleanup() - } + /// Configure reactive state subscriptions from Core components + private func configureSubscriptions() { + core.onLockStateChanged = { [weak self] isLocked, _ in + self?.handleLockStateChange(isLocked) + } - /// Clean up resources when object is deallocated - private func cleanup() { - cancellables.removeAll() + core.onUnlockHotkeyDetected = { [weak self] in + DispatchQueue.main.async { + self?.unlockKeyboard() + } + } + + updateAutoLockState() } - // MARK: - Public Interface (UI Actions) + /// Sync initial state from Core components + private func syncInitialState() { + DispatchQueue.main.async { + self.isLocked = self.core.isLocked + self.updateUIState() - func lockKeyboard() { - do { - try core.lockKeyboard() - } catch { - print("❌ Failed to lock keyboard: \(error.localizedDescription)") + if self.shouldRunUIUpdater { + self.startUIUpdates() + } } } - func unlockKeyboard() { - guard core.isKeyboardLocked else { - return + /// Handle lock state change coming from Core layer + private func handleLockStateChange(_ isLocked: Bool) { + DispatchQueue.main.async { + self.isLocked = isLocked + self.updateUIUpdater() + self.notifyIfNeeded(isLocked: isLocked) } - core.unlockKeyboard() } - func toggleLock() { - core.toggleLock() + /// 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 + ) } - /// Start a timed lock with specified duration - func lockKeyboard(with duration: CoreConfiguration.Duration) { - do { - try core.lockKeyboard() - // For timed locks, we implement timer logic in the UI layer - // This keeps business logic separate from core functionality - if case let .minutes(minutes) = duration, minutes > 0 { - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(minutes * 60)) { - self.core.unlockKeyboard() - print("⏰ Timed lock completed after \(minutes) minutes") - } - } else if case .infinite = duration { - print("♾️ Infinite timed lock started (manual unlock required)") - } - } catch { - print("❌ Failed to start timed lock: \(error.localizedDescription)") - } + deinit { + refreshScheduler.stop() } +} - // MARK: - Auto-Lock Management +// MARK: - Public API - func startAutoLock() { - // Use 30 minutes as default when enabling auto-lock if currently disabled - if !config.autoLockDuration.isEnabled { - config.autoLockDuration = .minutes(30) +extension KeyboardLockManager { + /// Lock the keyboard (user-initiated, no notification) + func lockKeyboard() { + performUserOperation { + try core.lockKeyboard() } - enableAutoLockMonitoring() } - func stopAutoLock() { - config.autoLockDuration = .never - activityMonitor.stopMonitoring() + /// Unlock the keyboard (user-initiated, no notification) + func unlockKeyboard() { + guard core.isLocked else { return } + + performUserOperation { + core.unlockKeyboard() + } } - func toggleAutoLock() { - if config.autoLockDuration.isEnabled { - config.autoLockDuration = .never - activityMonitor.stopMonitoring() - } else { - config.autoLockDuration = .minutes(30) - enableAutoLockMonitoring() + /// Toggle keyboard lock state (user-initiated) + func toggleLock() { + performUserOperation { + core.toggleLock() } } @@ -130,27 +170,26 @@ class KeyboardLockManager: ObservableObject { activityMonitor.resetActivityTimer() } - // MARK: - Status and Information - - func getLockDurationString() -> String? { - guard let lockedAt = core.keyboardLockedAt else { return nil } - - let duration = Date().timeIntervalSince(lockedAt) - let minutes = Int(duration / 60) - let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", minutes, seconds) - } - + /// Check if required permissions are granted func checkPermissions() -> Bool { PermissionHelper.hasAccessibilityPermission() } + /// Request required permissions from the user func requestPermissions() { PermissionHelper.requestAccessibilityPermission() } - // MARK: - Configuration Access + /// 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 @@ -161,70 +200,144 @@ class KeyboardLockManager: ObservableObject { config.isAutoLockEnabled } - // MARK: - Utility Methods + /// Format lock duration as string for UI display + var lockDurationText: String? { + guard let duration = lockDuration else { return nil } - func forceCleanup() { - core.forceCleanup() - syncInitialState() + let minutes = Int(duration / 60) + let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", minutes, seconds) } - // MARK: - Private Setup Methods - - /// Setup reactive state subscriptions from Core components - private func setupStateSubscriptions() { - // Setup lock state callback - core.onLockStateChanged = { [weak self] isLocked, _ in - DispatchQueue.main.async { - self?.isLocked = isLocked - - // Send notifications based on state change - let notificationType: NotificationManager.NotificationType = - isLocked ? .keyboardLocked : .keyboardUnlocked - self?.notificationManager.sendNotificationIfEnabled( - notificationType, - showNotifications: self?.config.showNotifications ?? false - ) - } + /// Format auto-lock remaining time as string for UI display + var autoLockStatusText: String { + let duration = autoLockDuration + if duration == 0 { + return LocalizationKey.autoLockDisabled.localized } - // Setup unlock hotkey callback - core.onUnlockHotkeyDetected = { [weak self] in - DispatchQueue.main.async { - self?.unlockKeyboard() - } + guard let remainingTime = autoLockRemainingTime else { + return LocalizationKey.autoLockReadyToLock.localized } - // Subscribe to configuration changes for auto-lock state - config.$autoLockDuration - .receive(on: DispatchQueue.main) - .sink { [weak self] duration in - self?.autoLockEnabled = duration.isEnabled - if duration.isEnabled { - self?.enableAutoLockMonitoring() - } else { - self?.activityMonitor.stopMonitoring() - } - } - .store(in: &cancellables) + if remainingTime > 0 { + let countdownString = remainingTime.formattedCountdown + return LocalizationKey.autoLockCountdownFormat.localized(countdownString) + } else { + return LocalizationKey.autoLockReadyToLock.localized + } } +} - /// Sync initial state from Core components - private func syncInitialState() { - DispatchQueue.main.async { - self.isLocked = self.core.isKeyboardLocked - self.autoLockEnabled = self.config.autoLockDuration.isEnabled +// MARK: - Auto-Lock Management + +extension KeyboardLockManager { + /// Update auto-lock state based on current configuration + private func updateAutoLockState() { + if isAutoLockEnabled { + enableAutoLockMonitoring() + } else { + activityMonitor.stopMonitoring() } + + updateUIUpdater() } /// Enable auto-lock monitoring with current configuration private func enableAutoLockMonitoring() { let duration = config.autoLockDuration - if isAutoLockEnabled { - activityMonitor.enableAutoLock(seconds: duration.seconds) - activityMonitor.onAutoLockTriggered = { [weak self] in - self?.lockKeyboard() - } - activityMonitor.startMonitoring() + 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: - UI State Management + +extension KeyboardLockManager { + /// Determine whether UI updater should be running + private var shouldRunUIUpdater: Bool { + isLocked || isAutoLockEnabled + } + + /// Start periodic UI state updates + private func startUIUpdates() { + refreshScheduler.start { [weak self] in + self?.updateUIState() + } + } + + /// Stop periodic UI state updates + private func stopUIUpdates() { + refreshScheduler.stop() + lockDuration = nil + autoLockRemainingTime = nil + } + + /// Update UI updater based on current state + private func updateUIUpdater() { + if shouldRunUIUpdater { + startUIUpdates() + } else { + stopUIUpdates() + } + } + + /// Update all UI state values + private func updateUIState() { + lockDuration = calculateLockDuration() + autoLockRemainingTime = calculateAutoLockRemainingTime() + } +} + +// 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/Sources/Views/ContentViewState.swift b/KeyboardLocker/Sources/Views/ContentViewState.swift index b1acaee..25b9fd9 100644 --- a/KeyboardLocker/Sources/Views/ContentViewState.swift +++ b/KeyboardLocker/Sources/Views/ContentViewState.swift @@ -12,7 +12,7 @@ class ContentViewState: ObservableObject { var keyboardManager: KeyboardLockManager? private var cancellables = Set() - // MARK: - Lifecycle Methods + // MARK: - Lifecycle func setup(with keyboardManager: KeyboardLockManager) { self.keyboardManager = keyboardManager @@ -24,12 +24,11 @@ class ContentViewState: ObservableObject { cancellables.removeAll() } - // MARK: - Public Methods + // MARK: - Private Methods private func setupSubscriptions() { guard let keyboardManager else { return } - // Subscribe to lock state changes keyboardManager.$isLocked .receive(on: DispatchQueue.main) .sink { [weak self] isLocked in diff --git a/KeyboardLocker/Sources/Views/StatusView.swift b/KeyboardLocker/Sources/Views/StatusView.swift index 7edb520..2ce51e1 100644 --- a/KeyboardLocker/Sources/Views/StatusView.swift +++ b/KeyboardLocker/Sources/Views/StatusView.swift @@ -77,12 +77,7 @@ private struct LockDurationRow: View { private func getLockDurationDisplayText(_ durationString: String) -> String { if durationString.contains(":") { // Check if it's a timed lock with remaining time - if keyboardManager.isTimedLock { - LocalizationKey.timedLockRemaining.localized(durationString) - } else { - // Regular lock showing elapsed time - LocalizationKey.lockDurationFormat.localized(durationString) - } + 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 bbfcb1e..46fd370 100644 --- a/KeyboardLocker/i18n/Localizable.xcstrings +++ b/KeyboardLocker/i18n/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - "shouldTranslate" : false - }, "about.feature.auto.lock" : { "extractionState" : "manual", "localizations" : { @@ -242,53 +239,53 @@ } } }, - "auto.lock.disabled" : { + "auto.lock.countdown.format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Disabled" + "value" : "Keyboard will auto lock in %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已禁用" + "value" : "剩余%@会自动锁定键盘" } } } }, - "auto.lock.ready.to.lock" : { + "auto.lock.disabled" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ready to lock" + "value" : "Disabled" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "准备锁定" + "value" : "已禁用" } } } }, - "auto.lock.countdown.format" : { + "auto.lock.ready.to.lock" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Keyboard will auto lock in %@" + "value" : "Ready to lock" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "剩余%@会自动锁定键盘" + "value" : "准备锁定" } } } @@ -837,23 +834,6 @@ } } }, - "timed.lock.remaining" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remaining time: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "剩余时间:%@" - } - } - } - }, "url.error.invalid.scheme" : { "extractionState" : "manual", "localizations" : { @@ -958,4 +938,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file From bc1dbf62cb82ac46fafa578c9140d3bbb131b240 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Nov 2025 17:56:41 +0800 Subject: [PATCH 20/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Enhance=20hotkey=20ev?= =?UTF-8?q?ent=20handling=20and=20state=20tracking.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Sources/Core/CoreConfiguration.swift | 23 ++++ Core/Sources/Core/KeyboardLockCore.swift | 74 +++++++----- .../Managers/KeyboardLockManager.swift | 106 ++++-------------- .../Sources/Views/LockControlView.swift | 1 - .../Sources/Views/SettingsView.swift | 2 +- KeyboardLocker/Sources/Views/StatusView.swift | 47 ++++---- 6 files changed, 117 insertions(+), 136 deletions(-) diff --git a/Core/Sources/Core/CoreConfiguration.swift b/Core/Sources/Core/CoreConfiguration.swift index b588bfe..06aaf00 100644 --- a/Core/Sources/Core/CoreConfiguration.swift +++ b/Core/Sources/Core/CoreConfiguration.swift @@ -224,6 +224,29 @@ public struct HotkeyConfiguration: Codable, CustomStringConvertible, RawRepresen ) } + /// 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 index 55b5365..a1cd724 100644 --- a/Core/Sources/Core/KeyboardLockCore.swift +++ b/Core/Sources/Core/KeyboardLockCore.swift @@ -1,5 +1,4 @@ import AppKit -import ApplicationServices import Carbon /// Pure core keyboard locking engine - only handles low-level keyboard interception @@ -34,6 +33,15 @@ public class KeyboardLockCore { /// 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) | @@ -181,44 +189,50 @@ public class KeyboardLockCore { } } - /// Check if the given flags match the required unlock modifiers - private func matchesUnlockModifiers(_ flags: CGEventFlags) -> Bool { - // Extract modifier flags from the event - let eventModifiers = flags.rawValue & ( - CGEventFlags.maskCommand.rawValue | - CGEventFlags.maskAlternate.rawValue | - CGEventFlags.maskShift.rawValue | - CGEventFlags.maskControl.rawValue - ) - - // Compare with configured unlock modifiers - return eventModifiers == UInt64(unlockHotkey.modifierFlags) - } - /// Handle keyboard events (internal for callback) func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { - // Only process keyDown events for unlock hotkey detection - guard type == .keyDown else { - return nil // Block all other events when locked + if shouldTriggerUnlock(for: type, event: event) { + print("🔑 Unlock hotkey pressed: \(unlockHotkey.displayString)") + + DispatchQueue.main.async { + self.onUnlockHotkeyDetected?() + } } - let keycode = event.getIntegerValueField(.keyboardEventKeycode) - let flags = event.flags + // Block all events from propagating while locked + return nil + } - // Check for unlock hotkey combination - guard keycode == Int64(unlockHotkey.keyCode), matchesUnlockModifiers(flags) else { - return nil // Block this event, not the unlock hotkey + private func shouldTriggerUnlock(for type: CGEventType, event: CGEvent) -> Bool { + guard event.flags.intersection(Self.relevantModifierMask) == unlockHotkey.eventModifierFlags else { + return false } - print("🔑 Unlock hotkey pressed: \(unlockHotkey.displayString)") + switch type { + case .keyDown: + let keycodeValue = event.getIntegerValueField(.keyboardEventKeycode) + guard keycodeValue >= 0, keycodeValue <= Int64(UInt16.max) else { + return false + } - // Notify business layer through callback - DispatchQueue.main.async { - self.onUnlockHotkeyDetected?() - } + let eventKeyCode = CGKeyCode(UInt16(keycodeValue)) + guard eventKeyCode == unlockHotkey.keyCode else { + return false + } - // Don't pass through this event - return nil + 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 + } } } diff --git a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift index e47561c..1920d81 100644 --- a/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift +++ b/KeyboardLocker/Sources/Managers/KeyboardLockManager.swift @@ -1,3 +1,4 @@ +import Combine import Core import SwiftUI @@ -10,38 +11,9 @@ import SwiftUI /// - Clear separation between public API and private implementation /// - Follows Single Responsibility Principle class KeyboardLockManager: ObservableObject { - // MARK: - Nested Types - - /// Manages periodic UI state updates - private final class UIRefreshScheduler { - private var timer: Timer? - - var isActive: Bool { - timer != nil - } - - func start(interval: TimeInterval = 1.0, onUpdate: @escaping () -> Void) { - stop() - - timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in - onUpdate() - } - - // Immediate update - onUpdate() - } - - func stop() { - timer?.invalidate() - timer = nil - } - } - // MARK: - Published State @Published private(set) var isLocked = false - @Published private(set) var lockDuration: TimeInterval? - @Published private(set) var autoLockRemainingTime: TimeInterval? // MARK: - Dependencies @@ -50,13 +22,10 @@ class KeyboardLockManager: ObservableObject { private let activityMonitor: UserActivityMonitor private let notificationManager: NotificationManager - // MARK: - Coordinators - - private let refreshScheduler = UIRefreshScheduler() - // MARK: - State private var isUserOperation = false + private var cancellables = Set() // MARK: - Lifecycle @@ -93,6 +62,20 @@ class KeyboardLockManager: ObservableObject { } } + 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() } @@ -100,11 +83,6 @@ class KeyboardLockManager: ObservableObject { private func syncInitialState() { DispatchQueue.main.async { self.isLocked = self.core.isLocked - self.updateUIState() - - if self.shouldRunUIUpdater { - self.startUIUpdates() - } } } @@ -112,7 +90,6 @@ class KeyboardLockManager: ObservableObject { private func handleLockStateChange(_ isLocked: Bool) { DispatchQueue.main.async { self.isLocked = isLocked - self.updateUIUpdater() self.notifyIfNeeded(isLocked: isLocked) } } @@ -130,7 +107,7 @@ class KeyboardLockManager: ObservableObject { } deinit { - refreshScheduler.stop() + cancellables.removeAll() } } @@ -202,7 +179,7 @@ extension KeyboardLockManager { /// Format lock duration as string for UI display var lockDurationText: String? { - guard let duration = lockDuration else { return nil } + guard let duration = calculateLockDuration() else { return nil } let minutes = Int(duration / 60) let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) @@ -216,7 +193,7 @@ extension KeyboardLockManager { return LocalizationKey.autoLockDisabled.localized } - guard let remainingTime = autoLockRemainingTime else { + guard let remainingTime = calculateAutoLockRemainingTime() else { return LocalizationKey.autoLockReadyToLock.localized } @@ -232,6 +209,11 @@ extension KeyboardLockManager { // 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 { @@ -239,8 +221,6 @@ extension KeyboardLockManager { } else { activityMonitor.stopMonitoring() } - - updateUIUpdater() } /// Enable auto-lock monitoring with current configuration @@ -266,44 +246,6 @@ extension KeyboardLockManager { } } -// MARK: - UI State Management - -extension KeyboardLockManager { - /// Determine whether UI updater should be running - private var shouldRunUIUpdater: Bool { - isLocked || isAutoLockEnabled - } - - /// Start periodic UI state updates - private func startUIUpdates() { - refreshScheduler.start { [weak self] in - self?.updateUIState() - } - } - - /// Stop periodic UI state updates - private func stopUIUpdates() { - refreshScheduler.stop() - lockDuration = nil - autoLockRemainingTime = nil - } - - /// Update UI updater based on current state - private func updateUIUpdater() { - if shouldRunUIUpdater { - startUIUpdates() - } else { - stopUIUpdates() - } - } - - /// Update all UI state values - private func updateUIState() { - lockDuration = calculateLockDuration() - autoLockRemainingTime = calculateAutoLockRemainingTime() - } -} - // MARK: - State Calculation extension KeyboardLockManager { diff --git a/KeyboardLocker/Sources/Views/LockControlView.swift b/KeyboardLocker/Sources/Views/LockControlView.swift index 08c9ed5..8756ed1 100644 --- a/KeyboardLocker/Sources/Views/LockControlView.swift +++ b/KeyboardLocker/Sources/Views/LockControlView.swift @@ -44,4 +44,3 @@ private struct MainLockButton: View { } } } - diff --git a/KeyboardLocker/Sources/Views/SettingsView.swift b/KeyboardLocker/Sources/Views/SettingsView.swift index 2428c55..328900d 100644 --- a/KeyboardLocker/Sources/Views/SettingsView.swift +++ b/KeyboardLocker/Sources/Views/SettingsView.swift @@ -77,7 +77,7 @@ struct SettingsView: View { set: { newValue in coreConfig.showNotifications = newValue // Request notification permission when user enables notifications - if newValue && !permissionManager.hasNotificationPermission { + if newValue, !permissionManager.hasNotificationPermission { permissionManager.requestNotificationPermission() } } diff --git a/KeyboardLocker/Sources/Views/StatusView.swift b/KeyboardLocker/Sources/Views/StatusView.swift index 2ce51e1..d461311 100644 --- a/KeyboardLocker/Sources/Views/StatusView.swift +++ b/KeyboardLocker/Sources/Views/StatusView.swift @@ -26,16 +26,18 @@ struct StatusSectionView: View { } private var autoLockStatusView: some View { - HStack { - Image(systemName: "timer") - .foregroundColor(.orange) - .font(.caption) - Text(LocalizationKey.autoLockStatus.localized(keyboardManager.autoLockStatusText)) - .font(.caption) - .foregroundColor(.secondary) - Spacer() + 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) } - .padding(.leading, 16) } var body: some View { @@ -57,26 +59,27 @@ private struct LockDurationRow: View { @EnvironmentObject private var keyboardManager: KeyboardLockManager var body: some View { - 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() + 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) } - .padding(.leading, 16) } } } private func getLockDurationDisplayText(_ durationString: String) -> String { if durationString.contains(":") { - // Check if it's a timed lock with remaining time LocalizationKey.lockDurationFormat.localized(durationString) } else { // Fallback: show a generic message From 676822304911443f5a8b0f92421137eb57d1a504 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Nov 2025 21:41:18 +0800 Subject: [PATCH 21/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Refactor=20MenuBarExt?= =?UTF-8?q?ra=20for=20better=20structure.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/KeyboardLockerApp.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift index 9ba17de..16fa04f 100644 --- a/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift +++ b/KeyboardLocker/Sources/Application/KeyboardLockerApp.swift @@ -12,15 +12,33 @@ struct KeyboardLockerApp: App { var body: some Scene { // Modern MenuBarExtra for native menu bar integration - MenuBarExtra(LocalizationKey.appMenuTitle.localized, systemImage: "lock.shield") { + 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) + } + } +}