refactor: isolate app lifecycle and add install restart login item

This commit is contained in:
casona 2026-10-25 23:04:48 +03:00
parent 4c47f2cf08
commit a0bf759abf
17 changed files with 766 additions and 238 deletions

View file

@ -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",

147
README.md
View file

@ -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
~~~
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.
For daily use, install without sudo into ~/Applications/Capswitch.app:
## Permissions - Capswitch only
git pull origin main
task test
task install
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.
The Taskfile builds, installs and opens the installed copy.
Approve Capswitch.app, *never* /usr/bin/hidutil. macOS requires your own
confirmation in System Settings; applications cannot grant themselves access.
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.
You can check/access those settings through the Capswitch menu. If granting
a permission requires a restart, Quit and reopen Capswitch.
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.
To avoid competing with the stock shortcut, disable
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.
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.
Read-only native diagnostic mode:
~~~
task doctor
~~~
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.
This executes Capswitch's own binary, not hidutil, and does not change keys.
## HID safety
## HID recovery and limitations
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.
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.
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.
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.
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.
Development tasks:
## Development
~~~
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.

View file

@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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))."
}

View file

@ -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<String> = []
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<CFBoolean>.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<CFString>.fromOpaque(pointer)
.takeUnretainedValue() as String
}
}

View file

@ -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()
}
}
}

View file

@ -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<String> = []
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<CFBoolean>.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<CFString>.fromOpaque(pointer)
.takeUnretainedValue() as String
}
}
final class MacOSInputController {
private let inputSources = InputSourceManager()
private let remapper = HIDRemappingController()

View file

@ -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() {
NSApp.terminate(nil)
} else {
update()
@objc private func installApplication() {
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.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)
} 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)
}
}
}

View file

@ -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

View file

@ -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) {
if (array == NULL) {
return false;
}
CFPreferencesSetAppValue(
OWNED_SERVICES_KEY, array, kCFPreferencesCurrentApplication
);
CFPreferencesAppSynchronize(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;
}
}
}

View file

@ -26,7 +26,8 @@ typedef enum {
CapswitchRemapConflict = 5,
CapswitchRemapSetFailed = 6,
CapswitchRemapVerificationFailed = 7,
CapswitchRemapMissingRegistryID = 8
CapswitchRemapMissingRegistryID = 8,
CapswitchRemapPersistenceFailed = 9
} CapswitchRemapResult;
CapswitchRemapperRef CapswitchRemapperCreate(void);

View file

@ -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:

View file

@ -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)
}
}