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",
|
||||
dependencies: ["CapswitchCore", "CapswitchHID"],
|
||||
path: "Sources/CapswitchApp",
|
||||
linkerSettings: [.linkedFramework("IOKit")]
|
||||
linkerSettings: [
|
||||
.linkedFramework("IOKit"),
|
||||
.linkedFramework("ServiceManagement"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "CapswitchHID",
|
||||
|
|
|
|||
167
README.md
167
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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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."
|
||||
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))."
|
||||
}
|
||||
|
|
|
|||
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 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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ typedef enum {
|
|||
CapswitchRemapConflict = 5,
|
||||
CapswitchRemapSetFailed = 6,
|
||||
CapswitchRemapVerificationFailed = 7,
|
||||
CapswitchRemapMissingRegistryID = 8
|
||||
CapswitchRemapMissingRegistryID = 8,
|
||||
CapswitchRemapPersistenceFailed = 9
|
||||
} CapswitchRemapResult;
|
||||
|
||||
CapswitchRemapperRef CapswitchRemapperCreate(void);
|
||||
|
|
|
|||
24
Taskfile.yml
24
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:
|
||||
|
|
|
|||
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