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 21180b2..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..2ce346d
--- /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..3298b74
--- /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 cf38d88..3b8c218 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 1242acb..cca03f3 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 c5f6daf..55ad961 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)
+ }
+}
diff --git a/docs/reviews/2026-10-25-code-review.md b/docs/reviews/2026-10-25-code-review.md
new file mode 100644
index 0000000..db6508e
--- /dev/null
+++ b/docs/reviews/2026-10-25-code-review.md
@@ -0,0 +1,55 @@
+# Capswitch code review — 2026-10-25
+
+## Scope
+
+Review of the already-working Caps->F19 HID remapping, Quartz event tap,
+TIS input source switching, menu bar lifecycle, installer and login setup.
+
+## Findings and changes
+
+1. High: no stable installation path. Launching a bundle in build/ makes
+ privacy permissions and login registration fragile. Added a user-scoped
+ installer to ~/Applications with staged copying, identity validation and
+ safeguards against replacing a different or running application.
+2. High: unsafe restart could strand a remapped Caps. Restart restores the
+ physical key first, refuses to exit if restoration fails, launches a new
+ copy of the same executable and waits for the previous PID to disappear
+ before creating AppKit, the event tap or HID manager.
+3. Medium: no native login management. SMAppService.mainApp now provides a
+ user-toggleable Launch at Login status with approval guidance in Settings.
+4. Medium: InputSourceManager and HID event-tap concerns were co-located.
+ Extracted the input source adapter without changing the proven event path.
+5. Medium: startup argument parsing embedded in main. Added an independent
+ LaunchArguments core type and tests for doctor, install and wait-for-PID.
+6. Medium: distribution required manual copying. Taskfile now provides an
+ install task that packages and starts the installed copy without sudo.
+
+## Protected behavior
+
+The existing HID IOHIDServiceClientSetProperty and CoreGraphics CGEventTap
+approach is hardware-verified on macOS 14.8.9. The normal Caps->F19 event
+path and Option+Caps gesture state machine are deliberately unchanged.
+
+## Remaining verification
+
+- SMAppService status/registration for ad-hoc signed apps and user Applications
+ on this particular macOS Sonoma machine.
+- Restart of a copy launched via its signed bundle executable must be checked
+ for macOS privacy identity continuity.
+- Staged app replacement rolls back ordinary filesystem errors but a power
+ interruption between filesystem moves can leave a backup copy.
+- HID ownership persistence failure handling needs independent runtime tests.
+- Enabled input source ordering uses HIToolbox preferences. Preserve the
+ current working behavior unless a future acceptance test demonstrates a bug.
+
+## On-Mac acceptance
+
+1. Quit the old app and run task test, task install.
+2. Confirm the launched bundle resides in ~/Applications.
+3. Verify the rapid Caps typing test still passes.
+4. Restart via menu; only one status item and running process remains.
+5. Verify Caps+Option and Pause/Quit restore normal Caps.
+6. Toggle Launch at Login on, approve when required, log out/in to verify.
+7. Toggle Launch at Login off, log out/in to verify it stays off.
+8. Reinstall an updated development build after closing the installed app.
+9. Confirm other pre-existing HID mappings survive every path.