refactor: isolate app lifecycle and add install restart login item
This commit is contained in:
parent
4c47f2cf08
commit
a0bf759abf
17 changed files with 766 additions and 238 deletions
|
|
@ -16,7 +16,10 @@ let package = Package(
|
||||||
name: "Capswitch",
|
name: "Capswitch",
|
||||||
dependencies: ["CapswitchCore", "CapswitchHID"],
|
dependencies: ["CapswitchCore", "CapswitchHID"],
|
||||||
path: "Sources/CapswitchApp",
|
path: "Sources/CapswitchApp",
|
||||||
linkerSettings: [.linkedFramework("IOKit")]
|
linkerSettings: [
|
||||||
|
.linkedFramework("IOKit"),
|
||||||
|
.linkedFramework("ServiceManagement"),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "CapswitchHID",
|
name: "CapswitchHID",
|
||||||
|
|
|
||||||
167
README.md
167
README.md
|
|
@ -2,116 +2,115 @@
|
||||||
|
|
||||||
Native macOS menu-bar keyboard layout switcher, targeting macOS 14+.
|
Native macOS menu-bar keyboard layout switcher, targeting macOS 14+.
|
||||||
|
|
||||||
**Caps**: switch to the next enabled input source **on release**, without
|
- Plain physical Caps: switch to the next enabled input source on **release**,
|
||||||
macOS's stock Caps delay or input-source HUD.
|
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
|
Apple API reference: https://developer.apple.com/library/archive/technotes/tn2450/
|
||||||
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 documents the direct APIs used for remapping in Technical Note TN2450:
|
## Build, install and autostart
|
||||||
https://developer.apple.com/library/archive/technotes/tn2450/
|
|
||||||
|
|
||||||
## 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
|
git pull origin main
|
||||||
running process won't be replaced merely by opening a new .app file.
|
task test
|
||||||
|
task bundle
|
||||||
|
open build/Capswitch.app
|
||||||
|
|
||||||
~~~
|
For daily use, install without sudo into ~/Applications/Capswitch.app:
|
||||||
git pull origin main
|
|
||||||
task test
|
|
||||||
task bundle
|
|
||||||
open build/Capswitch.app
|
|
||||||
~~~
|
|
||||||
|
|
||||||
The bundle is ad-hoc signed with a stable bundle identifier
|
git pull origin main
|
||||||
(dev.local.Capswitch). Keep its installed path stable so macOS can retain
|
task test
|
||||||
its privacy grants.
|
task install
|
||||||
|
|
||||||
## Permissions - Capswitch only
|
The Taskfile builds, installs and opens the installed copy.
|
||||||
|
|
||||||
At first launch Capswitch requests access via macOS:
|
Alternatively, open the development build and choose **Install to
|
||||||
- **Accessibility**: needed for an active event tap that suppresses F19.
|
~/Applications...** in the menu. Capswitch copies itself and restarts into the
|
||||||
- **Input Monitoring**, if macOS requires it: needed on some macOS setups
|
installed copy. If an older installed Capswitch is still running, quit it
|
||||||
for observing keyboard events or interacting with the HID subsystem.
|
before reinstalling. Only bundles with the same bundle ID can be replaced.
|
||||||
|
|
||||||
Approve Capswitch.app, *never* /usr/bin/hidutil. macOS requires your own
|
From the installed copy, click **Launch at Login** in the menu. This uses
|
||||||
confirmation in System Settings; applications cannot grant themselves access.
|
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
|
The menu also has **Restart Capswitch**. It restores the Caps HID mapping
|
||||||
a permission requires a restart, Quit and reopen Capswitch.
|
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 ->
|
**System Settings -> Keyboard -> Text Input -> Edit ->
|
||||||
Use Caps Lock to switch to/from ABC**. Leave Caps Lock with its normal
|
Use Caps Lock to switch to/from ABC**. Keep the key assigned to its normal
|
||||||
Caps Lock modifier role (not "No Action") in Keyboard Shortcuts -> Modifier Keys.
|
Caps Lock role in Modifier Keys (not No Action).
|
||||||
Capswitch never secretly changes these system preferences.
|
Capswitch does not secretly change these preferences.
|
||||||
|
|
||||||
## Status menu
|
## Menu and diagnostics
|
||||||
|
|
||||||
The menu shows:
|
The status-bar menu shows current layout, native HID keyboard service
|
||||||
- Current layout
|
mapping count, event tap status, permission status and diagnostics.
|
||||||
- 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)
|
|
||||||
|
|
||||||
When testing fast typing, repeatedly tap Caps while typing letters. If F19
|
F19 down/up counts; input source switches
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
task doctor
|
||||||
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.
|
|
||||||
|
|
||||||
## HID safety
|
This executes Capswitch's own binary, not hidutil, and does not change keys.
|
||||||
|
|
||||||
Capswitch reads **each keyboard service's existing mappings**, refuses
|
## HID recovery and limitations
|
||||||
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.
|
|
||||||
|
|
||||||
During Pause or normal Quit it removes only the exact Caps->F19 rule from
|
Capswitch preserves pre-existing key mappings and records ownership of
|
||||||
those services. Newly connected keyboards are handled when the app refreshes
|
its added Caps-to-F19 pair per keyboard service. It removes only owned
|
||||||
its HID state. If Capswitch is forcibly killed, its mapping may remain until
|
pairs when paused or quit. A forcibly terminated app may leave a pair
|
||||||
the next successful Capswitch launch/quit, keyboard service removal, or reboot.
|
active until next clean Capswitch launch/quit, keyboard service removal,
|
||||||
If the IOKit service rejects a mapping, Capswitch shows an error rather than
|
or reboot. To recover, reopen Capswitch and Quit normally.
|
||||||
silently claiming success.
|
|
||||||
|
|
||||||
If you need to recover, use the menu's Pause/Quit action after restarting
|
If installation or restart fails to restore Caps, the app displays an
|
||||||
Capswitch. In an emergency reboot macOS to clear HID remappings. Do not
|
error instead of silently quitting. If macOS loses permission, it surfaces
|
||||||
blindly clear other programs' mapping tables.
|
that status in the menu.
|
||||||
|
|
||||||
## Development
|
Development tasks:
|
||||||
|
|
||||||
~~~
|
task verify
|
||||||
task verify
|
task lint
|
||||||
task lint
|
task test
|
||||||
task test
|
task build
|
||||||
task build
|
task bundle
|
||||||
task bundle
|
task install
|
||||||
task doctor
|
task doctor
|
||||||
task clean
|
task clean
|
||||||
~~~
|
|
||||||
|
|
||||||
The Linux RSB host can run static checks but has no Apple AppKit/IOKit SDK.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>0.2.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>14.0</string>
|
<string>14.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|
|
||||||
21
Sources/Capswitch/LaunchArguments.swift
Normal file
21
Sources/Capswitch/LaunchArguments.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Sources/CapswitchApp/AppInstallationManager.swift
Normal file
134
Sources/CapswitchApp/AppInstallationManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Sources/CapswitchApp/ApplicationLifecycleController.swift
Normal file
85
Sources/CapswitchApp/ApplicationLifecycleController.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Sources/CapswitchApp/ApplicationRelauncher.swift
Normal file
48
Sources/CapswitchApp/ApplicationRelauncher.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,8 @@ enum HIDRemapError: LocalizedError {
|
||||||
return "A keyboard did not confirm the Caps → F19 mapping."
|
return "A keyboard did not confirm the Caps → F19 mapping."
|
||||||
case 8:
|
case 8:
|
||||||
return "Could not identify a keyboard service safely."
|
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:
|
default:
|
||||||
return "Native IOKit keyboard remapping failed (code \(code))."
|
return "Native IOKit keyboard remapping failed (code \(code))."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
124
Sources/CapswitchApp/InputSourceManager.swift
Normal file
124
Sources/CapswitchApp/InputSourceManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Sources/CapswitchApp/LoginItemManager.swift
Normal file
52
Sources/CapswitchApp/LoginItemManager.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
import CapswitchCore
|
import CapswitchCore
|
||||||
import CapswitchHID
|
import CapswitchHID
|
||||||
import ApplicationServices
|
|
||||||
import Carbon
|
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import Foundation
|
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 {
|
final class MacOSInputController {
|
||||||
private let inputSources = InputSourceManager()
|
private let inputSources = InputSourceManager()
|
||||||
private let remapper = HIDRemappingController()
|
private let remapper = HIDRemappingController()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
final class StatusBarController: NSObject, NSMenuDelegate {
|
final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
private let inputController: MacOSInputController
|
private let inputController: MacOSInputController
|
||||||
|
private let lifecycle: ApplicationLifecycleController
|
||||||
private let statusItem: NSStatusItem
|
private let statusItem: NSStatusItem
|
||||||
private let menu = NSMenu()
|
private let menu = NSMenu()
|
||||||
|
|
||||||
|
|
@ -56,6 +58,26 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
action: #selector(togglePaused),
|
action: #selector(togglePaused),
|
||||||
keyEquivalent: ""
|
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 refreshTimer: Timer?
|
||||||
private var nextStartAttempt = Date.distantPast
|
private var nextStartAttempt = Date.distantPast
|
||||||
|
|
@ -63,6 +85,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
|
|
||||||
init(inputController: MacOSInputController = MacOSInputController()) {
|
init(inputController: MacOSInputController = MacOSInputController()) {
|
||||||
self.inputController = inputController
|
self.inputController = inputController
|
||||||
|
self.lifecycle = ApplicationLifecycleController(inputController: inputController)
|
||||||
self.statusItem = NSStatusBar.system.statusItem(
|
self.statusItem = NSStatusBar.system.statusItem(
|
||||||
withLength: NSStatusItem.variableLength
|
withLength: NSStatusItem.variableLength
|
||||||
)
|
)
|
||||||
|
|
@ -113,6 +136,11 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
menu.addItem(inputMonitoringItem)
|
menu.addItem(inputMonitoringItem)
|
||||||
menu.addItem(systemSwitchItem)
|
menu.addItem(systemSwitchItem)
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
menu.addItem(installItem)
|
||||||
|
menu.addItem(launchAtLoginItem)
|
||||||
|
menu.addItem(loginSettingsItem)
|
||||||
|
menu.addItem(restartItem)
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
let quitItem = NSMenuItem(
|
let quitItem = NSMenuItem(
|
||||||
title: "Quit Capswitch",
|
title: "Quit Capswitch",
|
||||||
|
|
@ -127,6 +155,10 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
inputMonitoringItem,
|
inputMonitoringItem,
|
||||||
systemSwitchItem,
|
systemSwitchItem,
|
||||||
reapplyMappingItem,
|
reapplyMappingItem,
|
||||||
|
installItem,
|
||||||
|
launchAtLoginItem,
|
||||||
|
loginSettingsItem,
|
||||||
|
restartItem,
|
||||||
quitItem,
|
quitItem,
|
||||||
] {
|
] {
|
||||||
item.target = self
|
item.target = self
|
||||||
|
|
@ -217,6 +249,23 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
systemSwitchItem.toolTip =
|
systemSwitchItem.toolTip =
|
||||||
"Keyboard → Text Input → Edit → disable 'Use Caps Lock to switch input sources'."
|
"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 [
|
for item in [
|
||||||
currentLayoutItem,
|
currentLayoutItem,
|
||||||
permissionItem,
|
permissionItem,
|
||||||
|
|
@ -259,17 +308,74 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
inputController.reapplyHIDMapping()
|
inputController.reapplyHIDMapping()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quit() {
|
@objc private func installApplication() {
|
||||||
if inputController.stop() {
|
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)
|
NSApp.terminate(nil)
|
||||||
} else {
|
} catch {
|
||||||
update()
|
showError(
|
||||||
let alert = NSAlert()
|
"Installed, but could not relaunch",
|
||||||
alert.messageText = "Caps Lock could not be restored"
|
message: "Capswitch was installed to ~/Applications. " +
|
||||||
alert.informativeText = inputController.lastError
|
error.localizedDescription
|
||||||
?? "The native HID service rejected the restoration."
|
)
|
||||||
alert.addButton(withTitle: "Keep Capswitch Running")
|
}
|
||||||
alert.runModal()
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import CapswitchCore
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
if CommandLine.arguments.contains("--doctor") {
|
let arguments = LaunchArguments(arguments: Array(CommandLine.arguments.dropFirst()))
|
||||||
// Read-only inspection uses the same native IOKit code as the GUI.
|
|
||||||
// This command never changes mappings or starts an event tap.
|
switch arguments {
|
||||||
|
case .doctor:
|
||||||
|
// Uses the same read-only native IOKit inspector as the running app.
|
||||||
let remapper = HIDRemappingController()
|
let remapper = HIDRemappingController()
|
||||||
do {
|
do {
|
||||||
try remapper.inspect()
|
try remapper.inspect()
|
||||||
|
|
@ -21,6 +24,25 @@ if CommandLine.arguments.contains("--doctor") {
|
||||||
fputs("Capswitch HID check: \(error.localizedDescription)\n", stderr)
|
fputs("Capswitch HID check: \(error.localizedDescription)\n", stderr)
|
||||||
exit(1)
|
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
|
let application = NSApplication.shared
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@ typedef struct {
|
||||||
CFMutableSetRef ownedServices;
|
CFMutableSetRef ownedServices;
|
||||||
} CapswitchRemapper;
|
} CapswitchRemapper;
|
||||||
|
|
||||||
static void PersistOwnedServices(CapswitchRemapper *manager) {
|
static bool PersistOwnedServices(CapswitchRemapper *manager) {
|
||||||
CFIndex count = CFSetGetCount(manager->ownedServices);
|
CFIndex count = CFSetGetCount(manager->ownedServices);
|
||||||
const void **ids = count > 0 ? calloc((size_t)count, sizeof(void *)) : NULL;
|
const void **ids = count > 0 ? calloc((size_t)count, sizeof(void *)) : NULL;
|
||||||
|
|
||||||
if (count > 0 && ids == NULL) {
|
if (count > 0 && ids == NULL) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
|
|
@ -36,13 +36,16 @@ static void PersistOwnedServices(CapswitchRemapper *manager) {
|
||||||
);
|
);
|
||||||
free(ids);
|
free(ids);
|
||||||
|
|
||||||
if (array != NULL) {
|
if (array == NULL) {
|
||||||
CFPreferencesSetAppValue(
|
return false;
|
||||||
OWNED_SERVICES_KEY, array, kCFPreferencesCurrentApplication
|
|
||||||
);
|
|
||||||
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
|
|
||||||
CFRelease(array);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CFPreferencesSetAppValue(
|
||||||
|
OWNED_SERVICES_KEY, array, kCFPreferencesCurrentApplication
|
||||||
|
);
|
||||||
|
bool persisted = CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
|
||||||
|
CFRelease(array);
|
||||||
|
return persisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
CapswitchRemapperRef CapswitchRemapperCreate(void) {
|
CapswitchRemapperRef CapswitchRemapperCreate(void) {
|
||||||
|
|
@ -273,7 +276,12 @@ static int32_t InstallService(
|
||||||
// Persist ownership before writing so a crash immediately after writing
|
// Persist ownership before writing so a crash immediately after writing
|
||||||
// does not leave behind a remap the next launch cannot recognize.
|
// does not leave behind a remap the next launch cannot recognize.
|
||||||
CFSetAddValue(manager->ownedServices, id);
|
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);
|
bool set = IOHIDServiceClientSetProperty(service, MAPPING_KEY, desired);
|
||||||
CFRelease(desired);
|
CFRelease(desired);
|
||||||
|
|
@ -484,7 +492,10 @@ int32_t CapswitchRemapperRestore(CapswitchRemapperRef reference) {
|
||||||
if (result != CapswitchRemapOK) break;
|
if (result != CapswitchRemapOK) break;
|
||||||
|
|
||||||
CFSetRemoveValue(manager->ownedServices, id);
|
CFSetRemoveValue(manager->ownedServices, id);
|
||||||
PersistOwnedServices(manager);
|
if (!PersistOwnedServices(manager)) {
|
||||||
|
result = CapswitchRemapPersistenceFailed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDs for disconnected services can be discarded; macOS discards their
|
// IDs for disconnected services can be discarded; macOS discards their
|
||||||
|
|
@ -502,7 +513,9 @@ int32_t CapswitchRemapperRestore(CapswitchRemapperRef reference) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
free(ids);
|
free(ids);
|
||||||
PersistOwnedServices(manager);
|
if (!PersistOwnedServices(manager)) {
|
||||||
|
result = CapswitchRemapPersistenceFailed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ typedef enum {
|
||||||
CapswitchRemapConflict = 5,
|
CapswitchRemapConflict = 5,
|
||||||
CapswitchRemapSetFailed = 6,
|
CapswitchRemapSetFailed = 6,
|
||||||
CapswitchRemapVerificationFailed = 7,
|
CapswitchRemapVerificationFailed = 7,
|
||||||
CapswitchRemapMissingRegistryID = 8
|
CapswitchRemapMissingRegistryID = 8,
|
||||||
|
CapswitchRemapPersistenceFailed = 9
|
||||||
} CapswitchRemapResult;
|
} CapswitchRemapResult;
|
||||||
|
|
||||||
CapswitchRemapperRef CapswitchRemapperCreate(void);
|
CapswitchRemapperRef CapswitchRemapperCreate(void);
|
||||||
|
|
|
||||||
24
Taskfile.yml
24
Taskfile.yml
|
|
@ -49,6 +49,18 @@ tasks:
|
||||||
cp Resources/Info.plist "{{.APP_DIR}}/Contents/Info.plist"
|
cp Resources/Info.plist "{{.APP_DIR}}/Contents/Info.plist"
|
||||||
codesign --force --sign - "{{.APP_DIR}}"
|
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:
|
lint:
|
||||||
desc: Run host-independent source and bundle checks
|
desc: Run host-independent source and bundle checks
|
||||||
cmds:
|
cmds:
|
||||||
|
|
@ -63,10 +75,18 @@ tasks:
|
||||||
- grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- test -f Sources/CapswitchApp/HIDRemappingController.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/CapswitchHID.c
|
||||||
- test -f Sources/CapswitchHID/CapswitchRemapping.c
|
- test -f Sources/CapswitchHID/CapswitchRemapping.c
|
||||||
- grep -q "IOHIDServiceClientSetProperty" 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
|
- test -f Tests/CapswitchCoreTests/CapsInputRouterTests.swift
|
||||||
- grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- grep -q "CGEvent.tapEnable" 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
|
- grep -q "CGEvent.tapIsEnabled" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0
|
- test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0
|
||||||
- test "$(grep -c "CGEventTapEnable" 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
|
- grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift
|
||||||
|
|
||||||
doctor:
|
doctor:
|
||||||
|
|
|
||||||
21
Tests/CapswitchCoreTests/LaunchArgumentsTests.swift
Normal file
21
Tests/CapswitchCoreTests/LaunchArgumentsTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue