diff --git a/README.md b/README.md index 0faa930..5dc775e 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,39 @@ This update was tested on macOS 27.0; older macOS versions have not been reteste strafe switch left|right # switch once and exit strafe status # print accessibility / tap status strafe speed [preset] # show or set transition speed - strafe hotkeys [on|off] # show or set Space-switch hotkeys + strafe hotkeys [on|off] # show or set all Space-switch hotkeys + strafe control-arrows [on|off] # show or set additional Control-arrow pair strafe # start the menu-bar app ``` +### Optional Control + arrow shortcuts + +The standard macOS `ctrl`+`←` / `ctrl`+`→` shortcuts normally keep Apple's +animation: strafe's gesture tap does not receive keyboard events. +To route those combinations through strafe as well: + +1. In **System Settings > Keyboard > Keyboard Shortcuts > Mission Control**, + turn off **Move left a space** and **Move right a space**. +2. In strafe's menu, enable **Also use Control + arrows…** (or run + `strafe control-arrows on`). Keep **Space-switch hotkeys** enabled. +3. Press Control + Left/Right to switch with your selected transition speed. + Control + Option + Left/Right and trackpad swipes still work. + +This option defaults to off. It adds two Carbon hotkey registrations; it does +not install a keyboard event tap or change macOS preferences. A successful +registration does not prove that macOS will deliver the shortcut: native +Space bindings or another app can still own the combination. If switching +still uses Apple's animation, check the two Mission Control settings above. + +**When strafe quits, crashes, or has hotkeys disabled, these Control-arrow +shortcuts will not switch Spaces while the native bindings are off.** Turn +**Move left a space** and **Move right a space** back on to restore native +behavior, including before uninstalling. Deleting strafe's preferences does +not restore those system settings. Switching also requires Accessibility. + +`strafe control-arrows off` removes only the additional pair. `strafe hotkeys +off` disables both pairs. Both settings update a running app without restarting. + ## Permissions strafe needs **Accessibility** permission, and only that. macOS requires it to @@ -199,7 +228,8 @@ strafe off (or remove it from the list). ## Uninstall -1. Quit strafe from its menu-bar menu. +1. If you disabled macOS **Move left a space** / **Move right a space**, turn + them back on in Keyboard Shortcuts > Mission Control. Quit strafe. 2. Delete `strafe.app`. 3. Remove its entry from **System Settings › Privacy & Security › Accessibility**. diff --git a/SECURITY.md b/SECURITY.md index 95a7626..564bdea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -150,14 +150,16 @@ Each of these is verifiable with a single grep over `Sources/`. (`grep -rniE 'Process\(\)|/usr/bin|/bin/|tccutil' Sources/` — no spawns). - **Persistence is limited to menu settings.** strafe stores no databases and no - caches. Its own code writes two `UserDefaults` values: `transitionSpeed`, an integer + caches. Its own code writes three `UserDefaults` values: `transitionSpeed`, an integer 0–2 recording which **Transition speed** preset you picked in the menu - (`TransitionSpeed`, `Sources/strafe/TransitionSpeed.swift` line 101); and - `spaceHotkeysEnabled`, a bool recording whether the Ctrl+Option+Left/Right - **Space-switch hotkeys** toggle is on (`HotkeyManager`, - `Sources/strafe/HotkeyManager.swift`). Neither has any effect on what the + (`TransitionSpeed`, `Sources/strafe/TransitionSpeed.swift` line 101); + `spaceHotkeysEnabled`, a bool recording whether all + **Space-switch hotkeys** are on; and `controlArrowHotkeysEnabled`, a bool + defaulting to false that adds Control+Left/Right alongside + Control+Option+Left/Right (`HotkeyManager`, + `Sources/strafe/HotkeyManager.swift`). None affects what the gesture tap sees — the first changes the shape of the gesture strafe - *posts*, the second only registers/unregisters a Carbon global hotkey (a + *posts*, the two hotkey settings only register/unregister Carbon global hotkeys (a separate mechanism from the tap, added so the hotkeys can be turned off independently if they conflict with a third-party shortcut bound to the same chord). @@ -167,7 +169,7 @@ Each of these is verifiable with a single grep over `Sources/`. different plists: ``` - grep -rn 'Preferences.store' Sources/ # two keys, plus cache synchronization + grep -rn 'Preferences.store' Sources/ # three keys, plus cache synchronization grep -rn 'UserDefaults(' Sources/ # one hit: the suite in Preferences.swift ``` @@ -182,6 +184,13 @@ Each of these is verifiable with a single grep over `Sources/`. commands or settings from notification data. This adds no network access or permissions. + The optional Control-arrow pair uses the same Carbon mechanism, with distinct + IDs for each direction. It does not receive unrelated keystrokes or widen the + gesture tap mask. strafe neither reads nor writes native symbolic-hotkey + preferences. Users must disable the two conflicting macOS Space shortcuts + themselves, and re-enable them to restore native behavior after quitting or + uninstalling strafe. Deleting strafe's plist does not restore macOS settings. + No usage data, no history, no coordinates are stored. Deleting `strafe.app` leaves behind only that plist, which `defaults delete com.rileycx.strafe` removes (see README → Uninstall). @@ -224,7 +233,7 @@ grep -rniE 'Process\(\)|tccutil|/usr/bin|/bin/' Sources/ # 4. Confirm the tap mask excludes keystrokes, and that there is only one mask. grep -rn 'strafe_tap_event_mask' Sources/ -# 5. Confirm the one stored setting. +# 5. Confirm the stored settings. grep -rn 'Preferences.store' Sources/ ``` diff --git a/Sources/strafe/HotkeyManager.swift b/Sources/strafe/HotkeyManager.swift index 0b71b75..1b577a9 100644 --- a/Sources/strafe/HotkeyManager.swift +++ b/Sources/strafe/HotkeyManager.swift @@ -10,7 +10,7 @@ import Foundation /// /// **Toggleable.** Ctrl+Option+Left/Right is also a common chord for /// third-party window-tiling tools (and macOS's own tiling shortcuts), and -/// Carbon's `RegisterEventHotKey` grabs it system-wide ahead of them. Since +/// Carbon registrations can conflict with other owners of the same shortcut. Since /// this is a separate mechanism from the gesture tap (SPEC §2), it can be /// switched off independently via `HotkeyManager.enabled` / the menu-bar /// "Space-switch hotkeys" item / `strafe hotkeys off` — leaving the swipe @@ -22,6 +22,12 @@ final class HotkeyManager { private var eventHandler: EventHandlerRef? private var leftHotKey: EventHotKeyRef? private var rightHotKey: EventHotKeyRef? + private var controlLeftHotKey: EventHotKeyRef? + private var controlRightHotKey: EventHotKeyRef? + + /// Registration is not proof of delivery: native Space shortcuts must be disabled. + var controlArrowsRegistered: Bool { controlLeftHotKey != nil && controlRightHotKey != nil } + private var settingsObserver: (any NSObjectProtocol)? nonisolated private static let settingsChanged = Notification.Name( @@ -36,6 +42,8 @@ final class HotkeyManager { }() private static let leftID: UInt32 = 1 private static let rightID: UInt32 = 2 + private static let controlLeftID: UInt32 = 3 + private static let controlRightID: UInt32 = 4 init(engine: SwitchEngine) { self.engine = engine @@ -65,6 +73,7 @@ final class HotkeyManager { /// Install the Carbon event handler and register both hotkeys. func register() { installHandlerIfNeeded() + guard eventHandler != nil else { return } let ctrlOpt = UInt32(controlKey | optionKey) if leftHotKey == nil { @@ -75,8 +84,27 @@ final class HotkeyManager { } } + private func registerControlArrows() { + guard !controlArrowsRegistered else { return } + controlLeftHotKey = registerHotKey( + keyCode: UInt32(kVK_LeftArrow), id: Self.controlLeftID, modifiers: UInt32(controlKey)) + controlRightHotKey = registerHotKey( + keyCode: UInt32(kVK_RightArrow), id: Self.controlRightID, modifiers: UInt32(controlKey)) + // A failed pair must not leave only one direction captured. Applying the + // setting again retries, while the original Ctrl+Option pair stays intact. + if !controlArrowsRegistered { unregisterControlArrows() } + } + + private func unregisterControlArrows() { + if let controlLeftHotKey { UnregisterEventHotKey(controlLeftHotKey) } + if let controlRightHotKey { UnregisterEventHotKey(controlRightHotKey) } + controlLeftHotKey = nil + controlRightHotKey = nil + } + /// Unregister hotkeys and remove the handler. func unregister() { + unregisterControlArrows() if let leftHotKey { UnregisterEventHotKey(leftHotKey) } if let rightHotKey { UnregisterEventHotKey(rightHotKey) } leftHotKey = nil @@ -95,6 +123,11 @@ final class HotkeyManager { Preferences.store.synchronize() if HotkeyManager.enabled { register() + if Self.controlArrowsEnabled && eventHandler != nil { + registerControlArrows() + } else { + unregisterControlArrows() + } } else { unregister() } @@ -117,8 +150,38 @@ final class HotkeyManager { Preferences.store.object(forKey: enabledStorageKey) as? Bool ?? true } + nonisolated static let controlArrowsStorageKey = "controlArrowHotkeysEnabled" + + /// Opt-in: native macOS Space shortcuts otherwise own these combinations. + nonisolated static var controlArrowsEnabled: Bool { + Preferences.store.bool(forKey: controlArrowsStorageKey) + } + + nonisolated static let controlArrowSetup = """ + In System Settings > Keyboard > Keyboard Shortcuts > Mission Control, \ + turn off “Move left a space” and “Move right a space”. strafe does not \ + change these macOS settings for you. + + Control-arrow switching requires strafe to be running, Accessibility \ + permission, and Space-switch hotkeys to be enabled. Control+Option+arrows \ + remain available too. + + If you quit, disable, or uninstall strafe, turn those two macOS shortcuts \ + back on to restore native Control-arrow switching. Removing strafe’s \ + preferences does not restore macOS shortcuts. + """ + + nonisolated static func persist(controlArrowsEnabled: Bool) { + Preferences.store.set(controlArrowsEnabled, forKey: controlArrowsStorageKey) + notifySettingsChanged() + } + nonisolated static func persist(enabled: Bool) { Preferences.store.set(enabled, forKey: enabledStorageKey) + notifySettingsChanged() + } + + nonisolated private static func notifySettingsChanged() { // Flush before notifying so a resident app cannot read the previous value. Preferences.store.synchronize() DistributedNotificationCenter.default().postNotificationName( @@ -138,7 +201,7 @@ final class HotkeyManager { let userInfo = Unmanaged.passUnretained(self).toOpaque() - InstallEventHandler( + let status = InstallEventHandler( GetApplicationEventTarget(), { _, event, userInfo -> OSStatus in guard let userInfo, let event else { return OSStatus(eventNotHandledErr) } @@ -155,6 +218,7 @@ final class HotkeyManager { &hotKeyID ) guard status == noErr else { return status } + guard hotKeyID.signature == HotkeyManager.signature else { return OSStatus(eventNotHandledErr) } // Carbon calls back on the main thread; hop to the main actor. MainActor.assumeIsolated { @@ -167,6 +231,11 @@ final class HotkeyManager { userInfo, &eventHandler ) + if status != noErr { + eventHandler = nil + FileHandle.standardError.write( + Data("[HotkeyManager] InstallEventHandler failed (status \(status))\n".utf8)) + } } private func registerHotKey(keyCode: UInt32, id: UInt32, modifiers: UInt32) -> EventHotKeyRef? { @@ -192,8 +261,8 @@ final class HotkeyManager { private func handle(id: UInt32) { let direction: SwitchDirection switch id { - case Self.leftID: direction = .left - case Self.rightID: direction = .right + case Self.leftID, Self.controlLeftID: direction = .left + case Self.rightID, Self.controlRightID: direction = .right default: return } do { diff --git a/Sources/strafe/StatusItem.swift b/Sources/strafe/StatusItem.swift index e7c3716..cfb28a1 100644 --- a/Sources/strafe/StatusItem.swift +++ b/Sources/strafe/StatusItem.swift @@ -20,6 +20,10 @@ final class StatusItemController: NSObject, NSMenuDelegate { private let hotkeysItem = NSMenuItem( title: "Space-switch hotkeys (⌃⌥←/→)", action: #selector(toggleHotkeys), keyEquivalent: "" ) + private let controlArrowsItem = NSMenuItem( + title: "Also use Control + arrows…", action: #selector(toggleControlArrows), keyEquivalent: "" + ) + private let controlArrowsStatusItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") private let accessibilityItem = NSMenuItem( title: "Accessibility granted: —", action: nil, keyEquivalent: "" ) @@ -63,6 +67,11 @@ final class StatusItemController: NSObject, NSMenuDelegate { if hotkeys != nil { hotkeysItem.target = self menu.addItem(hotkeysItem) + controlArrowsItem.target = self + controlArrowsItem.toolTip = HotkeyManager.controlArrowSetup + menu.addItem(controlArrowsItem) + controlArrowsStatusItem.isEnabled = false + menu.addItem(controlArrowsStatusItem) } menu.addItem(accessibilityItem) @@ -160,6 +169,21 @@ final class StatusItemController: NSObject, NSMenuDelegate { refresh() } + @objc private func toggleControlArrows() { + guard let hotkeys else { return } + let newValue = !HotkeyManager.controlArrowsEnabled + let alert = NSAlert() + alert.messageText = newValue ? "Enable Control + arrows?" : "Disable Control + arrows?" + alert.informativeText = HotkeyManager.controlArrowSetup + alert.addButton(withTitle: newValue ? "Enable" : "Disable") + alert.addButton(withTitle: "Cancel") + NSApp.activate() + guard alert.runModal() == .alertFirstButtonReturn else { return } + HotkeyManager.persist(controlArrowsEnabled: newValue) + hotkeys.applyStoredState() + refresh() + } + // AppKit saves visibility; initialization resets it on the next launch. @objc private func hideFromMenuBar() { let alert = NSAlert() @@ -195,6 +219,17 @@ final class StatusItemController: NSObject, NSMenuDelegate { } let granted = Permissions.isAccessibilityGranted accessibilityItem.title = "Accessibility granted: \(granted ? "yes" : "no")" + controlArrowsItem.state = HotkeyManager.controlArrowsEnabled ? .on : .off + controlArrowsStatusItem.isHidden = !HotkeyManager.controlArrowsEnabled + if !HotkeyManager.enabled { + controlArrowsStatusItem.title = "Control + arrows paused: hotkeys are off" + } else if hotkeys?.controlArrowsRegistered != true { + controlArrowsStatusItem.title = "Control + arrows registration failed; toggle to retry" + } else { + controlArrowsStatusItem.title = "Control + arrows requires macOS shortcuts off" + } + hotkeysItem.title = HotkeyManager.controlArrowsEnabled + ? "Space-switch hotkeys (⌃⌥←/→ and ⌃←/→)" : "Space-switch hotkeys (⌃⌥←/→)" hotkeysItem.state = HotkeyManager.enabled ? .on : .off } } diff --git a/Sources/strafe/main.swift b/Sources/strafe/main.swift index 33fac28..c098e03 100644 --- a/Sources/strafe/main.swift +++ b/Sources/strafe/main.swift @@ -84,6 +84,23 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { print("transition speed: \(speed.title)") return 0 + case "control-arrows": + guard args.count <= 2 else { + FileHandle.standardError.write(Data("usage: strafe control-arrows [on|off]\n".utf8)) + return 2 + } + if args.count == 2 { + guard args[1] == "on" || args[1] == "off" else { + FileHandle.standardError.write(Data("usage: strafe control-arrows [on|off]\n".utf8)) + return 2 + } + HotkeyManager.persist(controlArrowsEnabled: args[1] == "on") + } + print("Control-arrow preference: \(HotkeyManager.controlArrowsEnabled ? "on" : "off")") + print(HotkeyManager.controlArrowSetup) + if !HotkeyManager.enabled { print("Space-switch hotkeys are off; enable them with strafe hotkeys on.") } + return 0 + case "hotkeys": // Persist the setting and notify any running menu-bar app to apply it. guard args.count >= 2 else { @@ -112,7 +129,8 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { strafe switch left|right switch space once and exit strafe status print accessibility / tap status strafe speed [preset] show or set the swipe transition speed - strafe hotkeys [on|off] show or set the ctrl+opt+arrow hotkeys + strafe hotkeys [on|off] show or set all Space-switch hotkeys + strafe control-arrows [on|off] show or set optional ctrl+arrow hotkeys """.utf8)) return 2 diff --git a/Tests/HotkeyManagerTests.swift b/Tests/HotkeyManagerTests.swift index 49f9c9b..1657faf 100644 --- a/Tests/HotkeyManagerTests.swift +++ b/Tests/HotkeyManagerTests.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon.HIToolbox // Compile the production manager with an isolated preferences domain and // replacement Carbon registration functions. No real shortcuts are captured. @@ -6,16 +7,20 @@ enum Preferences { static let domain = CommandLine.arguments[1] nonisolated(unsafe) static let store = UserDefaults(suiteName: domain)! } -enum SwitchDirection { case left, right } +enum SwitchDirection: Equatable { case left, right } protocol SwitchEngine { func switchSpace(_ direction: SwitchDirection) throws } -struct TestEngine: SwitchEngine { +final class TestEngine: SwitchEngine { + var directions: [SwitchDirection] = [] func switchSpace(_ direction: SwitchDirection) throws { - preconditionFailure("No keyboard events should be delivered during these tests") + directions.append(direction) } } @_silgen_name("test_active_hotkeys") private func activeHotkeys() -> UInt32 @_silgen_name("test_registration_count") private func registrationCount() -> UInt32 +@_silgen_name("test_fail_registration") private func failRegistration(_ id: UInt32) +@_silgen_name("test_fire_hotkey") private func fireHotkey(_ id: UInt32, _ signature: UInt32) -> Int32 + @main struct HotkeyManagerTests { @MainActor static func main() { let mode = CommandLine.arguments[2] @@ -23,8 +28,13 @@ struct TestEngine: SwitchEngine { HotkeyManager.persist(enabled: mode == "on") return } + if mode == "control-on" || mode == "control-off" { + HotkeyManager.persist(controlArrowsEnabled: mode == "control-on") + return + } NSApplication.shared.setActivationPolicy(.accessory) - let manager = HotkeyManager(engine: TestEngine()) + let engine = TestEngine() + let manager = HotkeyManager(engine: engine) manager.start() defer { manager.stop() } if mode == "selftest" { @@ -45,7 +55,45 @@ struct TestEngine: SwitchEngine { precondition(activeHotkeys() == 0) manager.start() precondition(activeHotkeys() == 2) - print("PASS: default, repeated enable/disable, and restart") + precondition(!HotkeyManager.controlArrowsEnabled && !manager.controlArrowsRegistered) + HotkeyManager.persist(controlArrowsEnabled: true) + manager.applyStoredState() + precondition(activeHotkeys() == 4 && manager.controlArrowsRegistered) + let count = registrationCount() + manager.applyStoredState() + manager.start() + precondition(activeHotkeys() == 4 && registrationCount() == count) + for id: UInt32 in 1...4 { precondition(fireHotkey(id, 0x534E4150) == noErr) } + precondition(engine.directions == [.left, .right, .left, .right]) + precondition(fireHotkey(1, 0xBAD) == eventNotHandledErr) + _ = fireHotkey(99, 0x534E4150) + precondition(engine.directions.count == 4) + HotkeyManager.persist(enabled: false) + manager.applyStoredState() + precondition(activeHotkeys() == 0 && !manager.controlArrowsRegistered) + HotkeyManager.persist(enabled: true) + manager.applyStoredState() + precondition(activeHotkeys() == 4) + manager.stop() + precondition(activeHotkeys() == 0) + manager.start() + precondition(activeHotkeys() == 4) + for failedID: UInt32 in [3, 4] { + HotkeyManager.persist(controlArrowsEnabled: false) + manager.applyStoredState() + precondition(activeHotkeys() == 2) + failRegistration(failedID) + HotkeyManager.persist(controlArrowsEnabled: true) + manager.applyStoredState() + precondition(activeHotkeys() == 2 && !manager.controlArrowsRegistered) + failRegistration(0) + manager.applyStoredState() + precondition(activeHotkeys() == 4 && manager.controlArrowsRegistered) + } + HotkeyManager.persist(controlArrowsEnabled: false) + manager.applyStoredState() + precondition(activeHotkeys() == 2 && !manager.controlArrowsRegistered) + print("PASS: defaults, opt-in, routing, repeat, restart, rollback, and retry") return } precondition(mode == "listen") diff --git a/Tests/HotkeyRegistrationStub.c b/Tests/HotkeyRegistrationStub.c index fefc475..e185741 100644 --- a/Tests/HotkeyRegistrationStub.c +++ b/Tests/HotkeyRegistrationStub.c @@ -4,13 +4,16 @@ static unsigned activeCount; static unsigned registrationCount; +static unsigned failingID; // Keep tests from claiming the user's real keyboard shortcuts. OSStatus RegisterEventHotKey(UInt32 key, UInt32 modifiers, EventHotKeyID id, EventTargetRef target, OptionBits options, EventHotKeyRef *out) { assert(key == kVK_LeftArrow || key == kVK_RightArrow); - assert(modifiers == (controlKey | optionKey)); - assert(id.id == 1 || id.id == 2); + assert(id.id >= 1 && id.id <= 4); + assert(key == ((id.id % 2) ? kVK_LeftArrow : kVK_RightArrow)); + assert(modifiers == ((id.id <= 2) ? (controlKey | optionKey) : controlKey)); + if (id.id == failingID) { *out = NULL; return eventHotKeyExistsErr; } assert(target != NULL && options == 0); *out = (EventHotKeyRef)malloc(1); assert(*out != NULL); @@ -28,3 +31,16 @@ OSStatus UnregisterEventHotKey(EventHotKeyRef ref) { unsigned test_active_hotkeys(void) { return activeCount; } unsigned test_registration_count(void) { return registrationCount; } + +void test_fail_registration(unsigned id) { failingID = id; } +OSStatus test_fire_hotkey(unsigned id, unsigned signature) { + EventRef event = NULL; + assert(CreateEvent(NULL, kEventClassKeyboard, kEventHotKeyPressed, + GetCurrentEventTime(), 0, &event) == noErr); + EventHotKeyID hotkey = {signature, id}; + assert(SetEventParameter(event, kEventParamDirectObject, typeEventHotKeyID, + sizeof(hotkey), &hotkey) == noErr); + OSStatus status = SendEventToEventTarget(event, GetApplicationEventTarget()); + ReleaseEvent(event); + return status; +} diff --git a/Tests/hotkeys.sh b/Tests/hotkeys.sh index 9b0632b..c831669 100644 --- a/Tests/hotkeys.sh +++ b/Tests/hotkeys.sh @@ -26,7 +26,7 @@ try: actual = listener.stdout.readline().strip() assert actual == line, (line, actual) expect('ACTIVE 2') - for value, expected in [('off', 0), ('on', 2), ('off', 0), ('on', 2)]: + for value, expected in [('control-on', 4), ('off', 0), ('on', 4), ('control-off', 2), ('off', 0), ('on', 2)]: subprocess.run([binary, domain, value], check=True) expect('ACTIVE ' + str(expected)) print('PASS: separate processes apply on/off changes without restarting')