From 0d23329f98c8cc9a3d1e46b71120fd06714d405b Mon Sep 17 00:00:00 2001 From: Maroun Najjar <60901592+thecolormaroun@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:59:56 -0700 Subject: [PATCH 1/3] Make the Ctrl+Option+Left/Right Space-switch hotkeys toggleable Those hotkeys are registered system-wide via Carbon's RegisterEventHotKey, which can silently override a third-party window-tiling shortcut bound to the same chord (e.g. "snap window right" tools commonly use Ctrl+Option+Arrow too). This is a separate mechanism from the gesture tap, so it can be disabled independently without touching Accessibility or the swipe speedup itself. Adds a persisted `spaceHotkeysEnabled` setting (default true, so existing behavior is unchanged), a "Space-switch hotkeys" menu-bar checkbox, and a `strafe hotkeys [on|off]` CLI command, following the same conventions as the existing `TransitionSpeed` setting. Updates SECURITY.md's persistence section to describe both stored keys. Co-Authored-By: Claude Sonnet 5 --- SECURITY.md | 24 +++++++++++------- Sources/strafe/HotkeyManager.swift | 40 ++++++++++++++++++++++++++++++ Sources/strafe/StatusItem.swift | 23 ++++++++++++++++- Sources/strafe/main.swift | 27 ++++++++++++++++++-- 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 3c2a480..fcfc34f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -135,24 +135,30 @@ Each of these is verifiable with a single grep over `Sources/`. art, it does **not** shell out to `tccutil` or anything else (`grep -rniE 'Process\(\)|/usr/bin|/bin/|tccutil' Sources/` — no spawns). -- **No persistence beyond one menu setting.** strafe stores no databases and no - caches. It writes exactly one `UserDefaults` value — `transitionSpeed`, an - integer 0–2 recording which **Transition speed** preset you picked in the menu - (`TransitionSpeed`, `Sources/strafe/TransitionSpeed.swift` line 101). It - changes the shape of the gesture strafe *posts*; it has no effect on what the - tap sees. +- **No persistence beyond two menu settings.** strafe stores no databases and no + caches. It writes two `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 + gesture tap sees — the first changes the shape of the gesture strafe + *posts*, the second only registers/unregisters a Carbon global hotkey (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). Reads and writes go through one accessor, so the two launch modes (`strafe.app` and the bare CLI, which has no bundle id) cannot land in different plists: ``` - grep -rn 'Preferences.store' Sources/ # two hits, one key + grep -rn 'Preferences.store' Sources/ # four hits, two keys grep -rn 'UserDefaults(' Sources/ # one hit: the suite in Preferences.swift ``` - No usage data, no history, no coordinates — the plist holds one integer. - Deleting `strafe.app` leaves behind only that plist, which + No usage data, no history, no coordinates — the plist holds two small + values. Deleting `strafe.app` leaves behind only that plist, which `defaults delete com.rileycx.strafe` removes (see README → Uninstall). --- diff --git a/Sources/strafe/HotkeyManager.swift b/Sources/strafe/HotkeyManager.swift index 37a0df3..d2e93db 100644 --- a/Sources/strafe/HotkeyManager.swift +++ b/Sources/strafe/HotkeyManager.swift @@ -7,6 +7,14 @@ import Foundation /// This is a working implementation (not stubbed). Carbon hotkeys are still the /// simplest reliable way to grab a system-wide key combo without a full event /// tap, and they do not require Accessibility permission. +/// +/// **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 +/// 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 +/// speedup itself untouched. @MainActor final class HotkeyManager { private let engine: SwitchEngine @@ -49,6 +57,38 @@ final class HotkeyManager { } } + /// Register or unregister to match the persisted setting. Safe to call + /// repeatedly (both `register`/`unregister` are no-ops in the direction + /// that's already satisfied, aside from a redundant handler install check). + func applyStoredState() { + if HotkeyManager.enabled { + register() + } else { + unregister() + } + } + + // MARK: - Persistence + + /// `nonisolated` so the CLI (`strafe hotkeys [on|off]`, no run loop, no + /// main actor) can read/write this without hopping actors. + + /// The one `UserDefaults` key this setting uses, following the same + /// convention as `TransitionSpeed.storageKey`. + nonisolated static let enabledStorageKey = "spaceHotkeysEnabled" + + /// The persisted setting. An absent key — a fresh install — means `true`, + /// so strafe's out-of-the-box behaviour is unchanged by this feature. + /// `object(forKey:)` rather than `bool(forKey:)` so "never set" is + /// distinguishable from a stored `false`. + nonisolated static var enabled: Bool { + Preferences.store.object(forKey: enabledStorageKey) as? Bool ?? true + } + + nonisolated static func persist(enabled: Bool) { + Preferences.store.set(enabled, forKey: enabledStorageKey) + } + // MARK: - Internals private func installHandlerIfNeeded() { diff --git a/Sources/strafe/StatusItem.swift b/Sources/strafe/StatusItem.swift index 74531da..41e321f 100644 --- a/Sources/strafe/StatusItem.swift +++ b/Sources/strafe/StatusItem.swift @@ -8,6 +8,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { private let statusItem: NSStatusItem private let interceptor: SwipeInterceptor private let engine: GestureSwitchEngine? + private let hotkeys: HotkeyManager? private let toggleItem = NSMenuItem( title: "Enable", action: #selector(toggleEnabled), keyEquivalent: "" @@ -16,6 +17,9 @@ final class StatusItemController: NSObject, NSMenuDelegate { title: "Transition speed", action: nil, keyEquivalent: "" ) private var speedItems: [NSMenuItem] = [] + private let hotkeysItem = NSMenuItem( + title: "Space-switch hotkeys (⌃⌥←/→)", action: #selector(toggleHotkeys), keyEquivalent: "" + ) private let accessibilityItem = NSMenuItem( title: "Accessibility granted: —", action: nil, keyEquivalent: "" ) @@ -28,9 +32,10 @@ final class StatusItemController: NSObject, NSMenuDelegate { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" } - init(interceptor: SwipeInterceptor, engine: GestureSwitchEngine? = nil) { + init(interceptor: SwipeInterceptor, engine: GestureSwitchEngine? = nil, hotkeys: HotkeyManager? = nil) { self.interceptor = interceptor self.engine = engine + self.hotkeys = hotkeys self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) super.init() @@ -52,6 +57,10 @@ final class StatusItemController: NSObject, NSMenuDelegate { menu.addItem(toggleItem) buildSpeedSubmenu(into: menu) + if hotkeys != nil { + hotkeysItem.target = self + menu.addItem(hotkeysItem) + } menu.addItem(accessibilityItem) // Update story, stated rather than performed. strafe cannot reach the @@ -127,6 +136,17 @@ final class StatusItemController: NSObject, NSMenuDelegate { refresh() } + /// Toggle the Ctrl+Option+Left/Right global hotkeys, independent of the + /// gesture tap (`toggleEnabled`). This is the mechanism that can conflict + /// with third-party window-tiling shortcuts bound to the same chord. + @objc private func toggleHotkeys() { + guard let hotkeys else { return } + let newValue = !HotkeyManager.enabled + HotkeyManager.persist(enabled: newValue) + hotkeys.applyStoredState() + refresh() + } + @objc private func quit() { interceptor.teardown() NSApp.terminate(nil) @@ -143,5 +163,6 @@ final class StatusItemController: NSObject, NSMenuDelegate { } let granted = Permissions.isAccessibilityGranted accessibilityItem.title = "Accessibility granted: \(granted ? "yes" : "no")" + hotkeysItem.state = HotkeyManager.enabled ? .on : .off } } diff --git a/Sources/strafe/main.swift b/Sources/strafe/main.swift index 9cb3922..cb27091 100644 --- a/Sources/strafe/main.swift +++ b/Sources/strafe/main.swift @@ -84,6 +84,27 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { print("transition speed: \(speed.title)") return 0 + case "hotkeys": + // Same setting the menu-bar "Space-switch hotkeys" item writes; a + // running menu-bar app picks it up immediately (it re-applies on + // toggle), the CLI just reports/writes the stored value. + guard args.count >= 2 else { + print("space-switch hotkeys: \(HotkeyManager.enabled ? "on" : "off")") + return 0 + } + let enabled: Bool + switch args[1] { + case "on": enabled = true + case "off": enabled = false + default: + FileHandle.standardError.write(Data( + "unknown value '\(args[1])' (expected on|off)\n".utf8)) + return 2 + } + HotkeyManager.persist(enabled: enabled) + print("space-switch hotkeys: \(enabled ? "on" : "off")") + return 0 + default: FileHandle.standardError.write(Data(""" strafe — near-instant macOS Spaces switching @@ -93,6 +114,7 @@ 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 """.utf8)) return 2 @@ -130,10 +152,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Permissions.checkAccessibility(prompt: true) interceptor = SwipeInterceptor(engine: engine) - statusItem = StatusItemController(interceptor: interceptor, engine: engine) hotkeys = HotkeyManager(engine: engine) - hotkeys.register() + hotkeys.applyStoredState() + + statusItem = StatusItemController(interceptor: interceptor, engine: engine, hotkeys: hotkeys) // SPEC §2.4 / §5: reset the prediction dictionary to live CGS data // whenever the OS reports a real space change, so rapid repeated swipes From b0512b5bcd3b019f70a8d4fef3aca48d9bcef340 Mon Sep 17 00:00:00 2001 From: rileycx Date: Thu, 17 Sep 2026 13:08:51 -0500 Subject: [PATCH 2/3] Apply hotkey preferences to the running app and retain registrations --- .github/workflows/ci.yml | 3 ++ README.md | 6 ++- SECURITY.md | 9 +++- Sources/strafe/HotkeyManager.swift | 41 +++++++++++++++++- Sources/strafe/main.swift | 8 ++-- Tests/HotkeyManagerTests.swift | 67 ++++++++++++++++++++++++++++++ Tests/HotkeyRegistrationStub.c | 30 +++++++++++++ Tests/hotkeys.sh | 38 +++++++++++++++++ 8 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 Tests/HotkeyManagerTests.swift create mode 100644 Tests/HotkeyRegistrationStub.c create mode 100644 Tests/hotkeys.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 551317c..544001c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - name: Test Space boundaries and gesture completion run: swift test -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors + - name: Test hotkey settings across processes (no shortcuts captured) + run: bash Tests/hotkeys.sh + - name: Bundle strafe.app (ad-hoc signed in CI) run: ./Scripts/bundle.sh diff --git a/README.md b/README.md index f8f77f5..b4b5552 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,9 @@ This update was tested on macOS 27.0; older macOS versions have not been reteste - **3-finger swipe** — just works once strafe is running and has Accessibility. Swipe left/right between Spaces and the switch is instant. - **Keyboard** — `ctrl`+`opt`+`←` and `ctrl`+`opt`+`→` switch Spaces. + Turn off **Space-switch hotkeys** in the menu if these conflict with another + app. Swipes keep working. `strafe hotkeys off` and `strafe hotkeys on` also + update a running copy without restarting it. - **Menu bar** — click the strafe icon to enable/disable interception, check whether Accessibility has been granted, and see which version you're running and where to get a newer one. @@ -173,6 +176,7 @@ 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 # start the menu-bar app ``` @@ -185,7 +189,7 @@ swipe and replace it with the instant one. The tap sees only trackpad gesture and dock-control events. It does **not** see keystrokes: the event mask excludes key events entirely, and strafe has no network, telemetry, file access, or subprocess code. It saves your transition -speed preference; AppKit also saves menu-bar icon visibility, which strafe resets +speed and hotkey preferences; AppKit also saves menu-bar icon visibility, which strafe resets on launch. See [SECURITY.md](SECURITY.md) for the exact file and line pointers. diff --git a/SECURITY.md b/SECURITY.md index 0d438df..95a7626 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -167,7 +167,7 @@ Each of these is verifiable with a single grep over `Sources/`. different plists: ``` - grep -rn 'Preferences.store' Sources/ # four hits, two keys + grep -rn 'Preferences.store' Sources/ # two keys, plus cache synchronization grep -rn 'UserDefaults(' Sources/ # one hit: the suite in Preferences.swift ``` @@ -175,6 +175,13 @@ Each of these is verifiable with a single grep over `Sources/`. hidden or shown. strafe resets visibility on every fresh launch, so hiding the icon only lasts until the app is reopened or restarted. + Changing the hotkey setting flushes the shared preference and posts a local + `DistributedNotificationCenter` notification in the same login session. + It carries no payload. A running strafe rereads its own preference and + updates only its existing Carbon shortcut registrations; it does not accept + commands or settings from notification data. This adds no network access or + permissions. + 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). diff --git a/Sources/strafe/HotkeyManager.swift b/Sources/strafe/HotkeyManager.swift index d2e93db..0b71b75 100644 --- a/Sources/strafe/HotkeyManager.swift +++ b/Sources/strafe/HotkeyManager.swift @@ -22,6 +22,11 @@ final class HotkeyManager { private var eventHandler: EventHandlerRef? private var leftHotKey: EventHotKeyRef? private var rightHotKey: EventHotKeyRef? + private var settingsObserver: (any NSObjectProtocol)? + + nonisolated private static let settingsChanged = Notification.Name( + "com.rileycx.strafe.hotkeysChanged" + ) // Distinct ids so the handler knows which combo fired. private static let signature: OSType = { @@ -36,13 +41,38 @@ final class HotkeyManager { self.engine = engine } + func start() { + guard settingsObserver == nil else { return } + settingsObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.settingsChanged, object: Preferences.domain, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.settingsObserver != nil else { return } + self.applyStoredState() + } + } + applyStoredState() + } + + func stop() { + if let settingsObserver { + DistributedNotificationCenter.default().removeObserver(settingsObserver) + self.settingsObserver = nil + } + unregister() + } + /// Install the Carbon event handler and register both hotkeys. func register() { installHandlerIfNeeded() let ctrlOpt = UInt32(controlKey | optionKey) - leftHotKey = registerHotKey(keyCode: UInt32(kVK_LeftArrow), id: Self.leftID, modifiers: ctrlOpt) - rightHotKey = registerHotKey(keyCode: UInt32(kVK_RightArrow), id: Self.rightID, modifiers: ctrlOpt) + if leftHotKey == nil { + leftHotKey = registerHotKey(keyCode: UInt32(kVK_LeftArrow), id: Self.leftID, modifiers: ctrlOpt) + } + if rightHotKey == nil { + rightHotKey = registerHotKey(keyCode: UInt32(kVK_RightArrow), id: Self.rightID, modifiers: ctrlOpt) + } } /// Unregister hotkeys and remove the handler. @@ -61,6 +91,8 @@ final class HotkeyManager { /// repeatedly (both `register`/`unregister` are no-ops in the direction /// that's already satisfied, aside from a redundant handler install check). func applyStoredState() { + // Refresh the cache after another process changes the shared preference. + Preferences.store.synchronize() if HotkeyManager.enabled { register() } else { @@ -87,6 +119,11 @@ final class HotkeyManager { nonisolated static func persist(enabled: Bool) { Preferences.store.set(enabled, forKey: enabledStorageKey) + // Flush before notifying so a resident app cannot read the previous value. + Preferences.store.synchronize() + DistributedNotificationCenter.default().postNotificationName( + settingsChanged, object: Preferences.domain, userInfo: nil, deliverImmediately: true + ) } // MARK: - Internals diff --git a/Sources/strafe/main.swift b/Sources/strafe/main.swift index 6f8abe2..33fac28 100644 --- a/Sources/strafe/main.swift +++ b/Sources/strafe/main.swift @@ -85,9 +85,7 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { return 0 case "hotkeys": - // Same setting the menu-bar "Space-switch hotkeys" item writes; a - // running menu-bar app picks it up immediately (it re-applies on - // toggle), the CLI just reports/writes the stored value. + // Persist the setting and notify any running menu-bar app to apply it. guard args.count >= 2 else { print("space-switch hotkeys: \(HotkeyManager.enabled ? "on" : "off")") return 0 @@ -154,7 +152,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { interceptor = SwipeInterceptor(engine: engine) hotkeys = HotkeyManager(engine: engine) - hotkeys.applyStoredState() + hotkeys.start() statusItem = StatusItemController(interceptor: interceptor, engine: engine, hotkeys: hotkeys) @@ -180,7 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { interceptor?.teardown() - hotkeys?.unregister() + hotkeys?.stop() NSWorkspace.shared.notificationCenter.removeObserver(self) } } diff --git a/Tests/HotkeyManagerTests.swift b/Tests/HotkeyManagerTests.swift new file mode 100644 index 0000000..49f9c9b --- /dev/null +++ b/Tests/HotkeyManagerTests.swift @@ -0,0 +1,67 @@ +import AppKit + +// Compile the production manager with an isolated preferences domain and +// replacement Carbon registration functions. No real shortcuts are captured. +enum Preferences { + static let domain = CommandLine.arguments[1] + nonisolated(unsafe) static let store = UserDefaults(suiteName: domain)! +} +enum SwitchDirection { case left, right } +protocol SwitchEngine { func switchSpace(_ direction: SwitchDirection) throws } +struct TestEngine: SwitchEngine { + func switchSpace(_ direction: SwitchDirection) throws { + preconditionFailure("No keyboard events should be delivered during these tests") + } +} +@_silgen_name("test_active_hotkeys") private func activeHotkeys() -> UInt32 +@_silgen_name("test_registration_count") private func registrationCount() -> UInt32 + +@main struct HotkeyManagerTests { + @MainActor static func main() { + let mode = CommandLine.arguments[2] + if mode == "on" || mode == "off" { + HotkeyManager.persist(enabled: mode == "on") + return + } + NSApplication.shared.setActivationPolicy(.accessory) + let manager = HotkeyManager(engine: TestEngine()) + manager.start() + defer { manager.stop() } + if mode == "selftest" { + precondition(HotkeyManager.enabled && activeHotkeys() == 2) + manager.start() + manager.applyStoredState() + manager.applyStoredState() + precondition(activeHotkeys() == 2 && registrationCount() == 2) + HotkeyManager.persist(enabled: false) + manager.applyStoredState() + precondition(activeHotkeys() == 0) + manager.applyStoredState() + precondition(activeHotkeys() == 0) + HotkeyManager.persist(enabled: true) + manager.applyStoredState() + precondition(activeHotkeys() == 2 && registrationCount() == 4) + manager.stop() + precondition(activeHotkeys() == 0) + manager.start() + precondition(activeHotkeys() == 2) + print("PASS: default, repeated enable/disable, and restart") + return + } + precondition(mode == "listen") + var previous = activeHotkeys() + print("ACTIVE \(previous)"); fflush(stdout) + let deadline = Date(timeIntervalSinceNow: 12) + let timer = Timer(timeInterval: 0.02, repeats: true) { _ in } + RunLoop.main.add(timer, forMode: .default) + defer { timer.invalidate() } + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + let current = activeHotkeys() + if current != previous { + print("ACTIVE \(current)"); fflush(stdout) + previous = current + } + } + } +} diff --git a/Tests/HotkeyRegistrationStub.c b/Tests/HotkeyRegistrationStub.c new file mode 100644 index 0000000..fefc475 --- /dev/null +++ b/Tests/HotkeyRegistrationStub.c @@ -0,0 +1,30 @@ +#include +#include +#include + +static unsigned activeCount; +static unsigned registrationCount; + +// 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(target != NULL && options == 0); + *out = (EventHotKeyRef)malloc(1); + assert(*out != NULL); + activeCount++; + registrationCount++; + return noErr; +} + +OSStatus UnregisterEventHotKey(EventHotKeyRef ref) { + assert(ref != NULL && activeCount > 0); + free(ref); + activeCount--; + return noErr; +} + +unsigned test_active_hotkeys(void) { return activeCount; } +unsigned test_registration_count(void) { return registrationCount; } diff --git a/Tests/hotkeys.sh b/Tests/hotkeys.sh new file mode 100644 index 0000000..9b0632b --- /dev/null +++ b/Tests/hotkeys.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +test_dir="$(mktemp -d -t strafe-hotkey-tests)" +trap 'rm -rf "$test_dir"' EXIT +clang -target "$(uname -m)-apple-macosx15.0" -Wall -Wextra -Werror \ + -c Tests/HotkeyRegistrationStub.c -o "$test_dir/registration.o" +swiftc -swift-version 6 -target "$(uname -m)-apple-macosx15.0" \ + -strict-concurrency=complete -warnings-as-errors \ + Sources/strafe/HotkeyManager.swift Tests/HotkeyManagerTests.swift \ + "$test_dir/registration.o" -o "$test_dir/hotkeys" +python3 - "$test_dir/hotkeys" <<'PY' +import select +import subprocess +import sys +import uuid + +binary = sys.argv[1] +domain = 'com.rileycx.strafe.hotkey-tests.' + uuid.uuid4().hex +listener = None +try: + subprocess.run([binary, domain, 'selftest'], check=True) + listener = subprocess.Popen([binary, domain, 'listen'], stdout=subprocess.PIPE, text=True) + def expect(line): + assert select.select([listener.stdout], [], [], 4)[0], 'Notification delivery timed out' + 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)]: + subprocess.run([binary, domain, value], check=True) + expect('ACTIVE ' + str(expected)) + print('PASS: separate processes apply on/off changes without restarting') +finally: + if listener is not None: + listener.terminate() + listener.wait(timeout=5) + subprocess.run(['defaults', 'delete', domain], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +PY From 20186c31171980ca900b0ed4569abdc7022bd61c Mon Sep 17 00:00:00 2001 From: rileycx Date: Thu, 17 Sep 2026 13:08:51 -0500 Subject: [PATCH 3/3] Record security review of live hotkey updates --- docs/reviews/2026-09-17-contributions.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/reviews/2026-09-17-contributions.md b/docs/reviews/2026-09-17-contributions.md index 583af29..5e0ea4a 100644 --- a/docs/reviews/2026-09-17-contributions.md +++ b/docs/reviews/2026-09-17-contributions.md @@ -72,3 +72,24 @@ Strict Swift compilation, the four Swift tests, C tests with undefined-behavior sanitization, release bundling, and signature verification passed locally. Earlier macOS releases were not runtime-tested. These checks do not constitute a comprehensive penetration test or a certification of the whole application. + +## PR #4 follow-up + +The hotkey toggle was updated to notify running copies after a preference write +and to preserve registration handles across repeated enable requests. The +original contribution remains authored by Maroun Najjar; the integration and +follow-up changes are maintainer commits. + +The added `DistributedNotificationCenter` notification stays in the same login +session and carries no payload. The receiver ignores notification data, reloads +its own saved preference, and only updates the existing Carbon registrations. +Observers are removed on shutdown. No network, subprocess, credential access, +additional input mask, or permissions were added to the app. + +The new test script compiles the production manager with an isolated preference +domain and replacement Carbon registration functions. Tests cover default-on +behavior, repeated enable/disable, stop/start, and live updates from a separate +process without taking over real keyboard shortcuts. These tests, the existing +Swift and C tests, strict compilation, bundling, and signature verification all +passed locally. CI now runs the same hotkey tests with its existing read-only +permissions and without introducing dependencies or secrets.