diff --git a/Package.swift b/Package.swift index 05ff329..5f89b37 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,10 @@ let package = Package( name: "Capswitch", dependencies: ["CapswitchCore", "CapswitchHID"], path: "Sources/CapswitchApp", - linkerSettings: [.linkedFramework("IOKit")] + linkerSettings: [ + .linkedFramework("IOKit"), + .linkedFramework("ServiceManagement"), + ] ), .target( name: "CapswitchHID", diff --git a/README.md b/README.md index 3b639ef..cf7524b 100644 --- a/README.md +++ b/README.md @@ -2,116 +2,115 @@ Native macOS menu-bar keyboard layout switcher, targeting macOS 14+. -**Caps**: switch to the next enabled input source **on release**, without -macOS's stock Caps delay or input-source HUD. +- Plain physical Caps: switch to the next enabled input source on **release**, + bypassing the stock Caps delay and input-source HUD. +- Caps + Option in either order: toggle the real system Caps Lock state. -**Caps + Option** in either order: toggle the actual system Caps Lock state. +## Architecture -## How it works +The signed Capswitch executable itself uses IOKit to remap physical Caps +(HID usage 0x39) to F19 (usage 0x6E). Its active CoreGraphics event tap +suppresses F19 down/up events and switches through Carbon TIS on F19 release. +The small native C bridge is compiled into the app; there is no external +hidutil process, driver, helper, LaunchAgent or root component. -Capswitch uses **IOKit APIs inside its own process** to remap physical HID -Caps Lock (usage 0x39) to F19 (usage 0x6E). Its active CoreGraphics event tap -intercepts F19 down/up and suppresses them, switching layouts on F19 release. -A small C bridge built into Capswitch contains the IOKit calls required by -Swift. There is **no subprocess**, no hidutil dependency, no external helper, -no driver, and no requirement to give permissions to a system executable. +Apple API reference: https://developer.apple.com/library/archive/technotes/tn2450/ -Apple documents the direct APIs used for remapping in Technical Note TN2450: -https://developer.apple.com/library/archive/technotes/tn2450/ +## Build, install and autostart -## Build on your Mac +Install Apple's Command Line Tools and the Task runner. Full Xcode is not +required. Quit any running Capswitch from its status-bar menu before rebuilding. -Install Command Line Tools and the Task runner. Full Xcode is not needed. +For development: -**Quit any running Capswitch from the menu before rebuilding.** An already -running process won't be replaced merely by opening a new .app file. + git pull origin main + task test + task bundle + open build/Capswitch.app -~~~ -git pull origin main -task test -task bundle -open build/Capswitch.app -~~~ +For daily use, install without sudo into ~/Applications/Capswitch.app: -The bundle is ad-hoc signed with a stable bundle identifier -(dev.local.Capswitch). Keep its installed path stable so macOS can retain -its privacy grants. + git pull origin main + task test + task install -## Permissions - Capswitch only +The Taskfile builds, installs and opens the installed copy. -At first launch Capswitch requests access via macOS: -- **Accessibility**: needed for an active event tap that suppresses F19. -- **Input Monitoring**, if macOS requires it: needed on some macOS setups - for observing keyboard events or interacting with the HID subsystem. +Alternatively, open the development build and choose **Install to +~/Applications...** in the menu. Capswitch copies itself and restarts into the +installed copy. If an older installed Capswitch is still running, quit it +before reinstalling. Only bundles with the same bundle ID can be replaced. -Approve Capswitch.app, *never* /usr/bin/hidutil. macOS requires your own -confirmation in System Settings; applications cannot grant themselves access. +From the installed copy, click **Launch at Login** in the menu. This uses +Apple's SMAppService.mainApp, not an ad-hoc LaunchAgent. If the menu says +approval required, open **General -> Login Items** in System Settings and +approve Capswitch. Click the same menu item to disable auto-start. +Launch at Login is disabled for development builds to avoid registering +a temporary build/ path. -You can check/access those settings through the Capswitch menu. If granting -a permission requires a restart, Quit and reopen Capswitch. +The menu also has **Restart Capswitch**. It restores the Caps HID mapping +before exiting and launches a new copy of the same executable after the +old process is gone. -To avoid competing with the stock shortcut, disable +The app has a stable bundle identifier (dev.local.Capswitch) and is signed +ad-hoc. macOS may require re-approval for Accessibility/Input Monitoring +when moving from build/ to ~/Applications or replacing an ad-hoc signature. + +## Privacy permissions + +On first launch the app requests permissions under its own identity: +- Accessibility to suppress F19. +- Input Monitoring when macOS needs it to observe keyboard events. + +Grant permissions to Capswitch only. No system executable needs extra +permissions. Apple requires the user to approve them in System Settings. + +Disable the stock macOS Caps layout toggle via **System Settings -> Keyboard -> Text Input -> Edit -> -Use Caps Lock to switch to/from ABC**. Leave Caps Lock with its normal -Caps Lock modifier role (not "No Action") in Keyboard Shortcuts -> Modifier Keys. -Capswitch never secretly changes these system preferences. +Use Caps Lock to switch to/from ABC**. Keep the key assigned to its normal +Caps Lock role in Modifier Keys (not No Action). +Capswitch does not secretly change these preferences. -## Status menu +## Menu and diagnostics -The menu shows: -- Current layout -- Native HID keyboard mapping count (e.g. 2/2 keyboards) -- Whether the keyboard event tap is enabled -- Accessibility and Input Monitoring grants -- F19 down/up and input-source switch counters (no typed text is recorded) -- Errors and a manual "Retry Caps -> F19 mapping" action -- Pause (restores Caps) and Quit (restores Caps) +The status-bar menu shows current layout, native HID keyboard service +mapping count, event tap status, permission status and diagnostics. -When testing fast typing, repeatedly tap Caps while typing letters. If F19 -counts increase but the switch counter doesn't, check the enabled TIS sources. -If the F19 counters remain zero, check the event tap, privacy grants, and HID -keyboard service count in the menu. + F19 down/up counts; input source switches -## Native diagnostics +No typed characters are recorded. If F19 counts increase but switches do not, +inspect enabled TIS input sources. If counts remain zero, inspect the +HID mapping and event tap status. Pause/normal Quit restore the physical +Caps key and preserve unrelated keyboard mappings. -~~~ -task doctor -~~~ +Read-only native diagnostic mode: -This executes **Capswitch's own signed executable** in a read-only inspection -mode and reports the number of HID keyboard services and how many have -Caps->F19 configured. It does not launch hidutil and does not change keyboard -configuration. The command requires an existing app bundle. + task doctor -## HID safety +This executes Capswitch's own binary, not hidutil, and does not change keys. -Capswitch reads **each keyboard service's existing mappings**, refuses -conflicting mappings involving Caps or F19, and preserves unrelated mappings. -It remembers which services it modified, storing their registry IDs in the -app's own preferences for crash recovery. +## HID recovery and limitations -During Pause or normal Quit it removes only the exact Caps->F19 rule from -those services. Newly connected keyboards are handled when the app refreshes -its HID state. If Capswitch is forcibly killed, its mapping may remain until -the next successful Capswitch launch/quit, keyboard service removal, or reboot. -If the IOKit service rejects a mapping, Capswitch shows an error rather than -silently claiming success. +Capswitch preserves pre-existing key mappings and records ownership of +its added Caps-to-F19 pair per keyboard service. It removes only owned +pairs when paused or quit. A forcibly terminated app may leave a pair +active until next clean Capswitch launch/quit, keyboard service removal, +or reboot. To recover, reopen Capswitch and Quit normally. -If you need to recover, use the menu's Pause/Quit action after restarting -Capswitch. In an emergency reboot macOS to clear HID remappings. Do not -blindly clear other programs' mapping tables. +If installation or restart fails to restore Caps, the app displays an +error instead of silently quitting. If macOS loses permission, it surfaces +that status in the menu. -## Development +Development tasks: -~~~ -task verify -task lint -task test -task build -task bundle -task doctor -task clean -~~~ + task verify + task lint + task test + task build + task bundle + task install + task doctor + task clean The Linux RSB host can run static checks but has no Apple AppKit/IOKit SDK. -The final Swift build and real keyboard tests must be run on macOS. +The macOS build and real keyboard behavior must be verified on macOS. diff --git a/Resources/Info.plist b/Resources/Info.plist index 30c9d37..79dbafe 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + 0.2.0 CFBundleVersion - 1 + 2 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/Capswitch/LaunchArguments.swift b/Sources/Capswitch/LaunchArguments.swift new file mode 100644 index 0000000..804fed4 --- /dev/null +++ b/Sources/Capswitch/LaunchArguments.swift @@ -0,0 +1,21 @@ +public enum LaunchArguments: Equatable { + case application + case doctor + case install + case waitForParent(Int32) + + public init(arguments: [String]) { + if arguments.contains("--doctor") { + self = .doctor + } else if arguments.contains("--install") { + self = .install + } else if let rawValue = arguments.first(where: { + $0.hasPrefix("--wait-for-pid=") + })?.dropFirst("--wait-for-pid=".count), + let pid = Int32(rawValue), pid > 1 { + self = .waitForParent(pid) + } else { + self = .application + } + } +} diff --git a/Sources/CapswitchApp/AppInstallationManager.swift b/Sources/CapswitchApp/AppInstallationManager.swift new file mode 100644 index 0000000..18cb7f1 --- /dev/null +++ b/Sources/CapswitchApp/AppInstallationManager.swift @@ -0,0 +1,134 @@ +import AppKit +import Foundation + +enum AppInstallationError: LocalizedError { + case invalidSource + case alreadyInstalled + case destinationContainsAnotherApp + case installedCopyRunning + case unsafeDestination + case rollbackFailed(String) + + var errorDescription: String? { + switch self { + case .invalidSource: + return "Capswitch must be launched from a valid .app bundle." + case .alreadyInstalled: + return "This copy of Capswitch is already installed." + case .destinationContainsAnotherApp: + return "The destination contains another application. It will not be overwritten." + case .installedCopyRunning: + return "Quit the installed Capswitch first, then retry installation." + case .unsafeDestination: + return "The installation destination is a symbolic link. No files were changed." + case let .rollbackFailed(backupPath): + return "Could not restore the previous app. The backup is at: \(backupPath)" + } + } +} + +/// Copies the currently running app to the user's Applications folder without +/// admin privileges. Copy to a staging path before replacing an older version. +final class AppInstallationManager { + private let fileManager = FileManager.default + private let expectedBundleID = "dev.local.Capswitch" + + var installedURL: URL { + fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Applications", isDirectory: true) + .appendingPathComponent("Capswitch.app", isDirectory: true) + } + + var isRunningInstalledCopy: Bool { + let current = Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL + return current == installedURL.resolvingSymlinksInPath().standardizedFileURL + } + + func install() throws -> URL { + let source = Bundle.main.bundleURL.standardizedFileURL + let destination = installedURL.standardizedFileURL + + guard source.pathExtension == "app", + Bundle.main.bundleIdentifier == expectedBundleID, + let executable = Bundle.main.executableURL, + fileManager.isExecutableFile(atPath: executable.path) else { + throw AppInstallationError.invalidSource + } + + guard !isRunningInstalledCopy else { + throw AppInstallationError.alreadyInstalled + } + + let parent = destination.deletingLastPathComponent() + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + + // fileExists can follow links, including dangling links. Never + // replace a symlink that points outside the user's Applications dir. + if (try? fileManager.destinationOfSymbolicLink(atPath: destination.path)) != nil { + throw AppInstallationError.unsafeDestination + } + + if fileManager.fileExists(atPath: destination.path) { + guard Bundle(url: destination)?.bundleIdentifier == expectedBundleID else { + throw AppInstallationError.destinationContainsAnotherApp + } + + let runningInstalledCopy = NSRunningApplication.runningApplications( + withBundleIdentifier: expectedBundleID + ).contains { app in + app.bundleURL?.resolvingSymlinksInPath().standardizedFileURL + == destination.resolvingSymlinksInPath().standardizedFileURL + } + if runningInstalledCopy { + throw AppInstallationError.installedCopyRunning + } + } + + let token = UUID().uuidString + let staging = parent.appendingPathComponent( + ".Capswitch-stage-\(token).app", isDirectory: true + ) + let backup = parent.appendingPathComponent( + ".Capswitch-backup-\(token).app", isDirectory: true + ) + + defer { + if fileManager.fileExists(atPath: staging.path) { + try? fileManager.removeItem(at: staging) + } + } + + try fileManager.copyItem(at: source, to: staging) + + guard Bundle(url: staging)?.bundleIdentifier == expectedBundleID, + fileManager.isExecutableFile( + atPath: staging.appendingPathComponent("Contents/MacOS/Capswitch").path + ) else { + throw AppInstallationError.invalidSource + } + + let hasPreviousCopy = fileManager.fileExists(atPath: destination.path) + if hasPreviousCopy { + try fileManager.moveItem(at: destination, to: backup) + } + + do { + try fileManager.moveItem(at: staging, to: destination) + } catch { + if hasPreviousCopy { + do { + try fileManager.moveItem(at: backup, to: destination) + } catch { + throw AppInstallationError.rollbackFailed(backup.path) + } + } + throw error + } + + if hasPreviousCopy { + try? fileManager.removeItem(at: backup) + } + + return destination + } +} diff --git a/Sources/CapswitchApp/ApplicationLifecycleController.swift b/Sources/CapswitchApp/ApplicationLifecycleController.swift new file mode 100644 index 0000000..0db7062 --- /dev/null +++ b/Sources/CapswitchApp/ApplicationLifecycleController.swift @@ -0,0 +1,85 @@ +import Foundation +import ServiceManagement + +enum ApplicationLifecycleError: LocalizedError { + case missingExecutable + case couldNotRestoreCaps(String) + + var errorDescription: String? { + switch self { + case .missingExecutable: + return "Unable to locate the bundled Capswitch executable." + case let .couldNotRestoreCaps(details): + return "Caps Lock was not restored: \(details)" + } + } +} + +/// Owns the app-level transitions. All exit/restart paths first restore Caps +/// via the same HID controller used by Pause and Quit. +final class ApplicationLifecycleController { + private let inputController: MacOSInputController + private let installer = AppInstallationManager() + private let loginItems = LoginItemManager() + + init(inputController: MacOSInputController) { + self.inputController = inputController + } + + var isInstalled: Bool { + installer.isRunningInstalledCopy + } + + var loginItemStatus: SMAppService.Status { + loginItems.status + } + + var loginItemTitle: String { + loginItems.title + } + + func toggleLaunchAtLogin() throws { + try loginItems.toggle(isRunningInstalledCopy: isInstalled) + } + + func install() throws -> URL { + try installer.install() + } + + func restartCurrentCopy() throws { + guard let executableURL = Bundle.main.executableURL else { + throw ApplicationLifecycleError.missingExecutable + } + try relaunch(executableURL: executableURL) + } + + func restartInstalledCopy(at applicationURL: URL) throws { + let executableURL = applicationURL.appendingPathComponent("Contents/MacOS/Capswitch") + try relaunch(executableURL: executableURL) + } + + func stopForQuit() throws { + guard inputController.stop() else { + throw ApplicationLifecycleError.couldNotRestoreCaps( + inputController.lastError ?? "Unknown IOKit error" + ) + } + } + + private func relaunch(executableURL: URL) throws { + try stopForQuit() + do { + try ApplicationRelauncher.spawn(executableURL: executableURL) + } catch { + // Starting the replacement failed: resume the old app instead + // of leaving a remapped keyboard with no active interceptor. + do { + try inputController.start() + } catch { + inputController.lastError = + "Relaunch and input recovery failed: \(error.localizedDescription)" + } + throw error + } + } +} diff --git a/Sources/CapswitchApp/ApplicationRelauncher.swift b/Sources/CapswitchApp/ApplicationRelauncher.swift new file mode 100644 index 0000000..700c612 --- /dev/null +++ b/Sources/CapswitchApp/ApplicationRelauncher.swift @@ -0,0 +1,48 @@ +import Darwin +import Foundation + +enum RelaunchError: LocalizedError { + case missingExecutable + + var errorDescription: String? { + switch self { + case .missingExecutable: + return "The application executable was not found." + } + } +} + +/// Starts the same bundled executable. The child waits for the old PID to +/// disappear before starting AppKit or the HID remapper. +enum ApplicationRelauncher { + static func spawn(executableURL: URL) throws { + guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { + throw RelaunchError.missingExecutable + } + + let child = Process() + child.executableURL = executableURL + child.arguments = [ + "--wait-for-pid=\(ProcessInfo.processInfo.processIdentifier)" + ] + child.standardOutput = FileHandle.nullDevice + child.standardError = FileHandle.nullDevice + try child.run() + } + + static func waitForPreviousProcess(_ parentPID: pid_t) -> Bool { + guard parentPID > 1 else { + return false + } + + // Bounded wait: never start a second keyboard remapper when the + // original application refuses to terminate. + for _ in 0..<200 { + if kill(parentPID, 0) == -1 { + return errno == ESRCH + } + usleep(50_000) + } + return false + } +} diff --git a/Sources/CapswitchApp/HIDRemappingController.swift b/Sources/CapswitchApp/HIDRemappingController.swift index dbc61d5..690084c 100644 --- a/Sources/CapswitchApp/HIDRemappingController.swift +++ b/Sources/CapswitchApp/HIDRemappingController.swift @@ -24,6 +24,8 @@ enum HIDRemapError: LocalizedError { return "A keyboard did not confirm the Caps → F19 mapping." case 8: return "Could not identify a keyboard service safely." + case 9: + return "Cannot persist keyboard remapping recovery data. Capswitch will not accept an untracked remap." default: return "Native IOKit keyboard remapping failed (code \(code))." } diff --git a/Sources/CapswitchApp/InputSourceManager.swift b/Sources/CapswitchApp/InputSourceManager.swift new file mode 100644 index 0000000..3fd2b6d --- /dev/null +++ b/Sources/CapswitchApp/InputSourceManager.swift @@ -0,0 +1,124 @@ +import Carbon +import Foundation + +final class InputSourceManager { + private struct Source { + let inputSource: TISInputSource + let id: String + } + + private var orderedSources: [Source] = [] + + var currentDisplayName: String { + guard let source = currentSource() else { + return "-" + } + return property(source, key: kTISPropertyLocalizedName) + ?? property(source, key: kTISPropertyInputSourceID) + ?? "-" + } + + var currentShortName: String { + let name = currentDisplayName + if let first = name.split(whereSeparator: { $0 == " " || $0 == "-" }).first, + !first.isEmpty { + return String(first).uppercased() + } + return name + } + + func refresh() { + orderedSources = enabledKeyboardSourcesInUserOrder() + } + + @discardableResult + func selectNextInputSource() -> Bool { + let sources = orderedSources + guard sources.count > 1 else { + return false + } + + let currentID = currentSource().flatMap { + property($0, key: kTISPropertyInputSourceID) + } + + let currentIndex = currentID.flatMap { id in + sources.firstIndex { $0.id == id } + } + let nextIndex = ((currentIndex ?? -1) + 1) % sources.count + return TISSelectInputSource(sources[nextIndex].inputSource) == noErr + } + + private func enabledKeyboardSourcesInUserOrder() -> [Source] { + let allSources = TISCreateInputSourceList(nil, false) + .takeRetainedValue() as NSArray as! [TISInputSource] + + let sources = allSources.compactMap { inputSource -> Source? in + guard isEnabled(inputSource), + property(inputSource, key: kTISPropertyInputSourceCategory) + == (kTISCategoryKeyboardInputSource as String), + let id = property(inputSource, key: kTISPropertyInputSourceID) else { + return nil + } + + return Source(inputSource: inputSource, id: id) + } + + var byID: [String: Source] = [:] + for source in sources { + byID[source.id] = source + } + + let configuredIDs = configuredInputSourceIDs() + var usedIDs: Set = [] + var ordered: [Source] = [] + + for id in configuredIDs { + if let source = byID[id], usedIDs.insert(id).inserted { + ordered.append(source) + } + } + + ordered.append(contentsOf: sources.filter { + usedIDs.insert($0.id).inserted + }) + return ordered + } + + private func currentSource() -> TISInputSource? { + TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() + } + + private func configuredInputSourceIDs() -> [String] { + guard let entries = UserDefaults(suiteName: "com.apple.HIToolbox")? + .array(forKey: "AppleEnabledInputSources") as? [[String: Any]] else { + return [] + } + + return entries.compactMap { $0["InputSourceID"] as? String } + } + + private func isEnabled(_ inputSource: TISInputSource) -> Bool { + guard let pointer = TISGetInputSourceProperty( + inputSource, + kTISPropertyInputSourceIsEnabled + ) else { + return false + } + + let value = Unmanaged.fromOpaque(pointer).takeUnretainedValue() + return CFBooleanGetValue(value) + } + + private func property( + _ inputSource: TISInputSource, + key: CFString + ) -> String? { + guard let pointer = TISGetInputSourceProperty(inputSource, key) else { + return nil + } + + return Unmanaged.fromOpaque(pointer) + .takeUnretainedValue() as String + } +} diff --git a/Sources/CapswitchApp/LoginItemManager.swift b/Sources/CapswitchApp/LoginItemManager.swift new file mode 100644 index 0000000..949fd94 --- /dev/null +++ b/Sources/CapswitchApp/LoginItemManager.swift @@ -0,0 +1,52 @@ +import Foundation +import ServiceManagement + +enum LoginItemError: LocalizedError { + case installFirst + + var errorDescription: String? { + switch self { + case .installFirst: + return "Install Capswitch to ~/Applications before enabling launch at login." + } + } +} + +/// Registers the main app, not a helper process or LaunchAgent plist. +struct LoginItemManager { + var status: SMAppService.Status { + SMAppService.mainApp.status + } + + var title: String { + switch status { + case .enabled, .notRegistered: + return "Launch at Login" + case .requiresApproval: + return "Launch at Login - approval required" + case .notFound: + return "Launch at Login - unavailable" + @unknown default: + return "Launch at Login" + } + } + + func toggle(isRunningInstalledCopy: Bool) throws { + guard isRunningInstalledCopy else { + throw LoginItemError.installFirst + } + + let service = SMAppService.mainApp + switch service.status { + case .enabled, .requiresApproval: + try service.unregister() + case .notRegistered, .notFound: + try service.register() + if service.status == .requiresApproval { + SMAppService.openSystemSettingsLoginItems() + } + @unknown default: + SMAppService.openSystemSettingsLoginItems() + } + } +} diff --git a/Sources/CapswitchApp/MacOSInputController.swift b/Sources/CapswitchApp/MacOSInputController.swift index 231e0c2..204b7ab 100644 --- a/Sources/CapswitchApp/MacOSInputController.swift +++ b/Sources/CapswitchApp/MacOSInputController.swift @@ -1,8 +1,7 @@ import AppKit +import ApplicationServices import CapswitchCore import CapswitchHID -import ApplicationServices -import Carbon import CoreGraphics import Foundation @@ -23,128 +22,6 @@ enum InputControllerError: LocalizedError { } } -final class InputSourceManager { - private struct Source { - let inputSource: TISInputSource - let id: String - } - - private var orderedSources: [Source] = [] - - var currentDisplayName: String { - guard let source = currentSource() else { - return "-" - } - return property(source, key: kTISPropertyLocalizedName) - ?? property(source, key: kTISPropertyInputSourceID) - ?? "-" - } - - var currentShortName: String { - let name = currentDisplayName - if let first = name.split(whereSeparator: { $0 == " " || $0 == "-" }).first, - !first.isEmpty { - return String(first).uppercased() - } - return name - } - - func refresh() { - orderedSources = enabledKeyboardSourcesInUserOrder() - } - - @discardableResult - func selectNextInputSource() -> Bool { - let sources = orderedSources - guard sources.count > 1 else { - return false - } - - let currentID = currentSource().flatMap { - property($0, key: kTISPropertyInputSourceID) - } - - let currentIndex = currentID.flatMap { id in - sources.firstIndex { $0.id == id } - } - let nextIndex = ((currentIndex ?? -1) + 1) % sources.count - return TISSelectInputSource(sources[nextIndex].inputSource) == noErr - } - - private func enabledKeyboardSourcesInUserOrder() -> [Source] { - let allSources = TISCreateInputSourceList(nil, false) - .takeRetainedValue() as NSArray as! [TISInputSource] - - let sources = allSources.compactMap { inputSource -> Source? in - guard isEnabled(inputSource), - property(inputSource, key: kTISPropertyInputSourceCategory) - == (kTISCategoryKeyboardInputSource as String), - let id = property(inputSource, key: kTISPropertyInputSourceID) else { - return nil - } - - return Source(inputSource: inputSource, id: id) - } - - var byID: [String: Source] = [:] - for source in sources { - byID[source.id] = source - } - - let configuredIDs = configuredInputSourceIDs() - var usedIDs: Set = [] - var ordered: [Source] = [] - - for id in configuredIDs { - if let source = byID[id], usedIDs.insert(id).inserted { - ordered.append(source) - } - } - - ordered.append(contentsOf: sources.filter { - usedIDs.insert($0.id).inserted - }) - return ordered - } - - private func currentSource() -> TISInputSource? { - TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() - } - - private func configuredInputSourceIDs() -> [String] { - guard let entries = UserDefaults(suiteName: "com.apple.HIToolbox")? - .array(forKey: "AppleEnabledInputSources") as? [[String: Any]] else { - return [] - } - - return entries.compactMap { $0["InputSourceID"] as? String } - } - - private func isEnabled(_ inputSource: TISInputSource) -> Bool { - guard let pointer = TISGetInputSourceProperty( - inputSource, - kTISPropertyInputSourceIsEnabled - ) else { - return false - } - - let value = Unmanaged.fromOpaque(pointer).takeUnretainedValue() - return CFBooleanGetValue(value) - } - - private func property( - _ inputSource: TISInputSource, - key: CFString - ) -> String? { - guard let pointer = TISGetInputSourceProperty(inputSource, key) else { - return nil - } - - return Unmanaged.fromOpaque(pointer) - .takeUnretainedValue() as String - } -} - final class MacOSInputController { private let inputSources = InputSourceManager() private let remapper = HIDRemappingController() diff --git a/Sources/CapswitchApp/StatusBarController.swift b/Sources/CapswitchApp/StatusBarController.swift index c2895c0..6f7e065 100644 --- a/Sources/CapswitchApp/StatusBarController.swift +++ b/Sources/CapswitchApp/StatusBarController.swift @@ -1,8 +1,10 @@ import AppKit import Foundation +import ServiceManagement final class StatusBarController: NSObject, NSMenuDelegate { private let inputController: MacOSInputController + private let lifecycle: ApplicationLifecycleController private let statusItem: NSStatusItem private let menu = NSMenu() @@ -56,6 +58,26 @@ final class StatusBarController: NSObject, NSMenuDelegate { action: #selector(togglePaused), keyEquivalent: "" ) + private let installItem = NSMenuItem( + title: "Install to ~/Applications…", + action: #selector(installApplication), + keyEquivalent: "" + ) + private let launchAtLoginItem = NSMenuItem( + title: "Launch at Login", + action: #selector(toggleLaunchAtLogin), + keyEquivalent: "" + ) + private let loginSettingsItem = NSMenuItem( + title: "Open Login Items Settings…", + action: #selector(openLoginItemsSettings), + keyEquivalent: "" + ) + private let restartItem = NSMenuItem( + title: "Restart Capswitch", + action: #selector(restartApplication), + keyEquivalent: "" + ) private var refreshTimer: Timer? private var nextStartAttempt = Date.distantPast @@ -63,6 +85,7 @@ final class StatusBarController: NSObject, NSMenuDelegate { init(inputController: MacOSInputController = MacOSInputController()) { self.inputController = inputController + self.lifecycle = ApplicationLifecycleController(inputController: inputController) self.statusItem = NSStatusBar.system.statusItem( withLength: NSStatusItem.variableLength ) @@ -113,6 +136,11 @@ final class StatusBarController: NSObject, NSMenuDelegate { menu.addItem(inputMonitoringItem) menu.addItem(systemSwitchItem) menu.addItem(NSMenuItem.separator()) + menu.addItem(installItem) + menu.addItem(launchAtLoginItem) + menu.addItem(loginSettingsItem) + menu.addItem(restartItem) + menu.addItem(NSMenuItem.separator()) let quitItem = NSMenuItem( title: "Quit Capswitch", @@ -127,6 +155,10 @@ final class StatusBarController: NSObject, NSMenuDelegate { inputMonitoringItem, systemSwitchItem, reapplyMappingItem, + installItem, + launchAtLoginItem, + loginSettingsItem, + restartItem, quitItem, ] { item.target = self @@ -217,6 +249,23 @@ final class StatusBarController: NSObject, NSMenuDelegate { systemSwitchItem.toolTip = "Keyboard → Text Input → Edit → disable 'Use Caps Lock to switch input sources'." + installItem.title = lifecycle.isInstalled + ? "Installed in ~/Applications" + : "Install to ~/Applications…" + installItem.isEnabled = !lifecycle.isInstalled + + launchAtLoginItem.title = lifecycle.loginItemTitle + launchAtLoginItem.isEnabled = lifecycle.isInstalled + switch lifecycle.loginItemStatus { + case .enabled: + launchAtLoginItem.state = .on + case .requiresApproval: + launchAtLoginItem.state = .mixed + default: + launchAtLoginItem.state = .off + } + loginSettingsItem.isHidden = lifecycle.loginItemStatus != .requiresApproval + for item in [ currentLayoutItem, permissionItem, @@ -259,17 +308,74 @@ final class StatusBarController: NSObject, NSMenuDelegate { inputController.reapplyHIDMapping() } - @objc private func quit() { - if inputController.stop() { + @objc private func installApplication() { + let alert = NSAlert() + alert.messageText = "Install Capswitch?" + alert.informativeText = + "Copy this version to ~/Applications/Capswitch.app and launch it from there. " + + "No administrator privileges are required." + alert.addButton(withTitle: "Install and Launch") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + + let installedURL: URL + do { + installedURL = try lifecycle.install() + } catch { + showError("Installation failed", message: error.localizedDescription) + return + } + + do { + try lifecycle.restartInstalledCopy(at: installedURL) NSApp.terminate(nil) - } else { - update() - let alert = NSAlert() - alert.messageText = "Caps Lock could not be restored" - alert.informativeText = inputController.lastError - ?? "The native HID service rejected the restoration." - alert.addButton(withTitle: "Keep Capswitch Running") - alert.runModal() + } catch { + showError( + "Installed, but could not relaunch", + message: "Capswitch was installed to ~/Applications. " + + error.localizedDescription + ) + } + } + + @objc private func restartApplication() { + do { + try lifecycle.restartCurrentCopy() + NSApp.terminate(nil) + } catch { + showError("Restart failed", message: error.localizedDescription) + } + } + + @objc private func toggleLaunchAtLogin() { + do { + try lifecycle.toggleLaunchAtLogin() + } catch { + showError("Launch at Login", message: error.localizedDescription) + } + update() + } + + @objc private func openLoginItemsSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + private func showError(_ title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } + + @objc private func quit() { + do { + try lifecycle.stopForQuit() + NSApp.terminate(nil) + } catch { + showError("Caps Lock could not be restored", message: error.localizedDescription) } } } diff --git a/Sources/CapswitchApp/main.swift b/Sources/CapswitchApp/main.swift index 3daca20..b3353ae 100644 --- a/Sources/CapswitchApp/main.swift +++ b/Sources/CapswitchApp/main.swift @@ -1,10 +1,13 @@ import AppKit +import CapswitchCore import Darwin import Foundation -if CommandLine.arguments.contains("--doctor") { - // Read-only inspection uses the same native IOKit code as the GUI. - // This command never changes mappings or starts an event tap. +let arguments = LaunchArguments(arguments: Array(CommandLine.arguments.dropFirst())) + +switch arguments { +case .doctor: + // Uses the same read-only native IOKit inspector as the running app. let remapper = HIDRemappingController() do { try remapper.inspect() @@ -21,6 +24,25 @@ if CommandLine.arguments.contains("--doctor") { fputs("Capswitch HID check: \(error.localizedDescription)\n", stderr) exit(1) } + +case .install: + do { + let location = try AppInstallationManager().install() + print("Installed Capswitch: \(location.path)") + exit(0) + } catch { + fputs("Capswitch install: \(error.localizedDescription)\n", stderr) + exit(1) + } + +case let .waitForParent(pid): + guard ApplicationRelauncher.waitForPreviousProcess(pid) else { + fputs("Capswitch restart aborted: previous instance did not exit.\n", stderr) + exit(1) + } + +case .application: + break } let application = NSApplication.shared diff --git a/Sources/CapswitchHID/CapswitchRemapping.c b/Sources/CapswitchHID/CapswitchRemapping.c index d31861c..557ad44 100644 --- a/Sources/CapswitchHID/CapswitchRemapping.c +++ b/Sources/CapswitchHID/CapswitchRemapping.c @@ -19,12 +19,12 @@ typedef struct { CFMutableSetRef ownedServices; } CapswitchRemapper; -static void PersistOwnedServices(CapswitchRemapper *manager) { +static bool PersistOwnedServices(CapswitchRemapper *manager) { CFIndex count = CFSetGetCount(manager->ownedServices); const void **ids = count > 0 ? calloc((size_t)count, sizeof(void *)) : NULL; if (count > 0 && ids == NULL) { - return; + return false; } if (count > 0) { @@ -36,13 +36,16 @@ static void PersistOwnedServices(CapswitchRemapper *manager) { ); free(ids); - if (array != NULL) { - CFPreferencesSetAppValue( - OWNED_SERVICES_KEY, array, kCFPreferencesCurrentApplication - ); - CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication); - CFRelease(array); + if (array == NULL) { + return false; } + + CFPreferencesSetAppValue( + OWNED_SERVICES_KEY, array, kCFPreferencesCurrentApplication + ); + bool persisted = CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication); + CFRelease(array); + return persisted; } CapswitchRemapperRef CapswitchRemapperCreate(void) { @@ -273,7 +276,12 @@ static int32_t InstallService( // Persist ownership before writing so a crash immediately after writing // does not leave behind a remap the next launch cannot recognize. CFSetAddValue(manager->ownedServices, id); - PersistOwnedServices(manager); + if (!PersistOwnedServices(manager)) { + // No keyboard remapping has been performed yet; fail closed. + CFSetRemoveValue(manager->ownedServices, id); + CFRelease(desired); + return CapswitchRemapPersistenceFailed; + } bool set = IOHIDServiceClientSetProperty(service, MAPPING_KEY, desired); CFRelease(desired); @@ -484,7 +492,10 @@ int32_t CapswitchRemapperRestore(CapswitchRemapperRef reference) { if (result != CapswitchRemapOK) break; CFSetRemoveValue(manager->ownedServices, id); - PersistOwnedServices(manager); + if (!PersistOwnedServices(manager)) { + result = CapswitchRemapPersistenceFailed; + break; + } } // IDs for disconnected services can be discarded; macOS discards their @@ -502,7 +513,9 @@ int32_t CapswitchRemapperRestore(CapswitchRemapperRef reference) { } } free(ids); - PersistOwnedServices(manager); + if (!PersistOwnedServices(manager)) { + result = CapswitchRemapPersistenceFailed; + } } } diff --git a/Sources/CapswitchHID/include/CapswitchHID.h b/Sources/CapswitchHID/include/CapswitchHID.h index 8eac284..b677e2b 100644 --- a/Sources/CapswitchHID/include/CapswitchHID.h +++ b/Sources/CapswitchHID/include/CapswitchHID.h @@ -26,7 +26,8 @@ typedef enum { CapswitchRemapConflict = 5, CapswitchRemapSetFailed = 6, CapswitchRemapVerificationFailed = 7, - CapswitchRemapMissingRegistryID = 8 + CapswitchRemapMissingRegistryID = 8, + CapswitchRemapPersistenceFailed = 9 } CapswitchRemapResult; CapswitchRemapperRef CapswitchRemapperCreate(void); diff --git a/Taskfile.yml b/Taskfile.yml index 52418a7..3860226 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -49,6 +49,18 @@ tasks: cp Resources/Info.plist "{{.APP_DIR}}/Contents/Info.plist" codesign --force --sign - "{{.APP_DIR}}" + install: + desc: Install Capswitch into ~/Applications and open it (no sudo) + deps: [bundle] + cmds: + - | + if [ "$(uname -s)" != "Darwin" ]; then + echo "SKIP: installation requires macOS." + exit 0 + fi + "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" --install + open "$HOME/Applications/{{.APP_NAME}}.app" + lint: desc: Run host-independent source and bundle checks cmds: @@ -63,10 +75,18 @@ tasks: - grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift - test -f Sources/CapswitchApp/HIDRemappingController.swift + - test -f Sources/CapswitchApp/InputSourceManager.swift + - test -f Sources/CapswitchApp/ApplicationRelauncher.swift + - test -f Sources/CapswitchApp/ApplicationLifecycleController.swift + - test -f Sources/CapswitchApp/LoginItemManager.swift + - test -f Sources/CapswitchApp/AppInstallationManager.swift + - grep -q "SMAppService.mainApp" Sources/CapswitchApp/LoginItemManager.swift + - test -f Tests/CapswitchCoreTests/LaunchArgumentsTests.swift - test -f Sources/CapswitchHID/CapswitchHID.c - test -f Sources/CapswitchHID/CapswitchRemapping.c - grep -q "IOHIDServiceClientSetProperty" Sources/CapswitchHID/CapswitchRemapping.c - - '! grep -R -E "Process\(|/usr/bin/hidutil" Sources' + - '! grep -R -F "/usr/bin/hidutil" Sources' + - grep -q "child.executableURL = executableURL" Sources/CapswitchApp/ApplicationRelauncher.swift - test -f Tests/CapswitchCoreTests/CapsInputRouterTests.swift - grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift @@ -75,7 +95,7 @@ tasks: - grep -q "CGEvent.tapIsEnabled" Sources/CapswitchApp/MacOSInputController.swift - test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 - test "$(grep -c "CGEventTapEnable" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 - - grep -q "TISSelectInputSource" Sources/CapswitchApp/MacOSInputController.swift + - grep -q "TISSelectInputSource" Sources/CapswitchApp/InputSourceManager.swift - grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift doctor: diff --git a/Tests/CapswitchCoreTests/LaunchArgumentsTests.swift b/Tests/CapswitchCoreTests/LaunchArgumentsTests.swift new file mode 100644 index 0000000..ab7f54c --- /dev/null +++ b/Tests/CapswitchCoreTests/LaunchArgumentsTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import CapswitchCore + +final class LaunchArgumentsTests: XCTestCase { + func testDefaultStartsApplication() { + XCTAssertEqual(LaunchArguments(arguments: []), .application) + XCTAssertEqual(LaunchArguments(arguments: ["--unknown"]), .application) + } + + func testDoctorAndInstallerDoNotStartGUI() { + XCTAssertEqual(LaunchArguments(arguments: ["--doctor"]), .doctor) + XCTAssertEqual(LaunchArguments(arguments: ["--install"]), .install) + } + + func testRelaunchWaitRequiresPositiveParentPID() { + XCTAssertEqual(LaunchArguments(arguments: ["--wait-for-pid=4321"]), .waitForParent(4321)) + XCTAssertEqual(LaunchArguments(arguments: ["--wait-for-pid=-1"]), .application) + XCTAssertEqual(LaunchArguments(arguments: ["--wait-for-pid=0"]), .application) + XCTAssertEqual(LaunchArguments(arguments: ["--wait-for-pid=foo"]), .application) + } +}