diff --git a/README.md b/README.md index 0faa930..0854a9e 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,9 @@ Space switching instant. Steps: 2. Run ./Scripts/bundle.sh and move build/strafe.app to /Applications. 3. Launch it, then open System Settings > Privacy & Security > Accessibility so I can grant it permission. Remove any stale strafe entries first. -4. Wait for me to confirm I granted it, then quit strafe from the menu-bar - icon and relaunch it — the event tap is only created at launch, so the - grant does nothing until the app restarts. +4. Wait for me to confirm I granted it, then check strafe's menu reports + "Swipe interception is active". It retries automatically when permission + becomes available; a stale grant after rebuilding may still need a relaunch. 5. Have me test a 3-finger swipe between Spaces. It should be instant. ``` @@ -107,9 +107,10 @@ git clone https://github.com/rileycx/strafe strafe && cd strafe ``` `install.sh` builds `strafe.app`, copies it to `/Applications`, launches it, and -opens the Accessibility pane. Grant permission there, then quit strafe from its -menu-bar icon and launch it again — the event tap is created at launch, so the -grant does nothing until the app restarts. +opens the Accessibility pane. Grant permission there, then check the menu for +**Swipe interception is active**. strafe checks permission and tap health once +a second and retries failed creation automatically. After replacing an ad-hoc +signed build, macOS may still require a fresh permission grant and relaunch. Read the script first if you like; it's about 90 lines and does nothing privileged. @@ -147,14 +148,21 @@ 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. +- **Keyboard** — `ctrl`+`opt`+`←` and `ctrl`+`opt`+`→` switch Spaces by default. + Open **Settings…** from the menu-bar icon to record a shortcut for each + direction. Use Command, Control, or Option with a key, or a function key; + Escape cancels recording. Changes apply immediately and survive a restart. + Shortcuts can be cleared or restored to defaults. Duplicate shortcuts and + registration conflicts are reported without replacing the previous binding. + **Space-switch hotkeys**, `strafe hotkeys off`, and `strafe hotkeys on` toggle + shortcuts independently of swipe interception. +- **Settings…** — configure keyboard shortcuts and transition speed in a native + window. Command-comma opens settings while strafe is active. - **Menu bar** — click the strafe icon to enable/disable interception, check - whether Accessibility has been granted, and see which version you're running + whether swipe interception is actually active, and see which version you're running and where to get a newer one. -- **Transition speed** *(menu bar › Transition speed)* — if instant is too +- **Transition speed** *(Settings › Space transitions, or menu bar › Transition speed)* + — applies to both trackpad swipes and keyboard shortcuts. If instant is too abrupt, you can trade some of it back for animation: | preset | measured | what it is | @@ -175,6 +183,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 settings # open settings for the running app, or start it strafe status # print accessibility / tap status strafe speed [preset] # show or set transition speed strafe hotkeys [on|off] # show or set Space-switch hotkeys @@ -188,8 +197,9 @@ create an *active* event tap — the kind that can suppress the slow animated 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 +keystrokes: the event mask excludes key events entirely. The settings recorder +receives key combinations only in strafe's focused window while recording. +strafe has no network, telemetry, direct file access, or subprocess code. It saves your transition 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 95a7626..2fd2930 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,17 +47,25 @@ do so. The tap's event mask is defined in exactly one place, and it covers removed. The tap now wakes only on real space-swipe gestures. - **The tap is installed here:** `Sources/strafe/SwipeInterceptor.swift`, - `SwipeInterceptor.start()` (line 39; the `tapCreate` call itself is at - line 54), using + `SystemSwipeEventTap.make`, called by `SwipeInterceptor.recoverIfNeeded`, using `CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: mask, ...)` where `mask` comes straight from `strafe_tap_event_mask()` above. -**Because keystrokes are not in the mask, strafe cannot observe what you type.** + A once-per-second timer checks Accessibility trust and the existing tap's + validity and enabled state. It retries failed creation, recovers disabled + taps, and releases invalid taps before replacing them. The timer reads no + input events and never widens the mask. Disabling or tearing down the + interceptor stops the timer. + +**The system-wide gesture tap cannot observe what you type.** A key event fails the `cgsType == dockControl || cgsType == gesture` guard (`SwipeInterceptor.handle`, line 144) and is passed straight through, but in practice a key event is never even delivered to the callback because it is not -in the tap's mask. +in the tap's mask. The settings shortcut recorder receives key events only +while recording in strafe's own focused window. It saves the chosen key code +and modifier flags, not a history of input, and installs no global keyboard +monitor or additional event tap. ### Exactly what event data strafe touches @@ -149,25 +157,21 @@ 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). -- **Persistence is limited to menu settings.** strafe stores no databases and no - caches. Its own code 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). +- **Persistence is limited to settings.** strafe stores no databases and no + caches. Its `UserDefaults` values include `transitionSpeed` (the selected + transition preset), `spaceHotkeysEnabled` (the keyboard-shortcut toggle), and + `spaceShortcut.left` / `spaceShortcut.right` (a key code and modifier flags, + or a cleared shortcut). See `TransitionSpeed.swift`, `HotkeyManager.swift`, + and `KeyboardShortcut.swift`. These never widen the gesture tap's mask. + Keyboard shortcuts use Carbon `RegisterEventHotKey`, a separate mechanism + that delivers only registered shortcut activations. 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 keys, plus cache synchronization + grep -rn 'Preferences.store' Sources/ # settings and cache synchronization grep -rn 'UserDefaults(' Sources/ # one hit: the suite in Preferences.swift ``` @@ -182,6 +186,9 @@ Each of these is verifiable with a single grep over `Sources/`. commands or settings from notification data. This adds no network access or permissions. + `strafe settings` sends a separate payload-free local notification to open + the resident app's settings window. It cannot change settings 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/Scripts/install.sh b/Scripts/install.sh index 24a1476..aa1d01e 100755 --- a/Scripts/install.sh +++ b/Scripts/install.sh @@ -44,7 +44,7 @@ fi # --- Replace any previous install ------------------------------------------- # Quit a running copy first: the bundle cannot be replaced underneath a live -# process, and the event tap is only created at launch anyway. +# process. if pgrep -x "$APP_NAME" >/dev/null 2>&1; then echo "==> Quitting the running ${APP_NAME}…" osascript -e "quit app \"$APP_NAME\"" >/dev/null 2>&1 || pkill -x "$APP_NAME" || true @@ -85,8 +85,9 @@ Grant Accessibility to strafe in the pane that just opened, then: 1. Delete any older/stale "strafe" rows in that list first. This build is ad-hoc signed, so its identity changes on every rebuild and macOS may show a previous build as a separate entry. - 2. Quit strafe from its menu-bar icon and launch it again. The event tap is - created at launch, so the grant does nothing until strafe restarts. + 2. Check that strafe's menu says "Swipe interception is active". It retries + automatically when permission becomes available. If macOS still holds an + old build's permission identity, re-grant access and quit/relaunch strafe. 3. Three-finger swipe between Spaces. It should be instant. EOF diff --git a/Sources/strafe/HotkeyManager.swift b/Sources/strafe/HotkeyManager.swift index 0b71b75..e8c4554 100644 --- a/Sources/strafe/HotkeyManager.swift +++ b/Sources/strafe/HotkeyManager.swift @@ -23,6 +23,10 @@ final class HotkeyManager { private var leftHotKey: EventHotKeyRef? private var rightHotKey: EventHotKeyRef? private var settingsObserver: (any NSObjectProtocol)? + private var registeredShortcuts: [ShortcutAction: KeyboardShortcut] = [:] + private(set) var registrationError: String? + private var isRecording = false + var onStateChanged: (() -> Void)? nonisolated private static let settingsChanged = Notification.Name( "com.rileycx.strafe.hotkeysChanged" @@ -64,15 +68,26 @@ final class HotkeyManager { /// Install the Carbon event handler and register both hotkeys. func register() { - installHandlerIfNeeded() - - let ctrlOpt = UInt32(controlKey | optionKey) - if leftHotKey == nil { - leftHotKey = registerHotKey(keyCode: UInt32(kVK_LeftArrow), id: Self.leftID, modifiers: ctrlOpt) + let desired = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.compactMap { action in + action.storedShortcut.map { (action, $0) } + }) + if desired == registeredShortcuts, registrationError == nil { return } + unregister() + registrationError = nil + if let left = desired[.left], left == desired[.right] { + registrationError = "Previous Space and Next Space must use different shortcuts." + return + } + guard !desired.isEmpty else { return } + guard installHandlerIfNeeded() else { return } + if let shortcut = desired[.left] { + leftHotKey = registerHotKey(shortcut: shortcut, action: .left, id: Self.leftID) } - if rightHotKey == nil { - rightHotKey = registerHotKey(keyCode: UInt32(kVK_RightArrow), id: Self.rightID, modifiers: ctrlOpt) + if registrationError == nil, let shortcut = desired[.right] { + rightHotKey = registerHotKey(shortcut: shortcut, action: .right, id: Self.rightID) } + if registrationError != nil { unregister() } + else { registeredShortcuts = desired } } /// Unregister hotkeys and remove the handler. @@ -81,6 +96,7 @@ final class HotkeyManager { if let rightHotKey { UnregisterEventHotKey(rightHotKey) } leftHotKey = nil rightHotKey = nil + registeredShortcuts = [:] if let eventHandler { RemoveEventHandler(eventHandler) self.eventHandler = nil @@ -93,11 +109,55 @@ final class HotkeyManager { func applyStoredState() { // Refresh the cache after another process changes the shared preference. Preferences.store.synchronize() - if HotkeyManager.enabled { + if HotkeyManager.enabled && !isRecording { register() } else { unregister() + registrationError = nil + } + onStateChanged?() + } + + /// Suspend our Carbon registrations so the local recorder can see even an + /// existing strafe shortcut. No event tap or global keyboard monitor is used. + func setRecording(_ recording: Bool) { + isRecording = recording + applyStoredState() + } + + /// Changes are transactional: a chord owned by another app is rejected and + /// the previous setting/registrations are restored before returning. + func updateShortcut(_ shortcut: KeyboardShortcut?, for action: ShortcutAction) -> String? { + if let error = shortcut?.validationError { return error } + let other: ShortcutAction = action == .left ? .right : .left + if let shortcut, shortcut == other.storedShortcut { + return "That shortcut is already assigned to \(other.title)." } + let previous = action.storedShortcut + action.persist(shortcut) + isRecording = false + applyStoredState() + if let error = registrationError { + action.persist(previous) + applyStoredState() + return error + } + Self.notifySettingsChanged() + return nil + } + + func restoreDefaultShortcuts() -> String? { + let previous = ShortcutAction.allCases.map { ($0, $0.storedShortcut) } + for action in ShortcutAction.allCases { action.persist(action.defaultShortcut) } + isRecording = false + applyStoredState() + if let error = registrationError { + for (action, shortcut) in previous { action.persist(shortcut) } + applyStoredState() + return error + } + Self.notifySettingsChanged() + return nil } // MARK: - Persistence @@ -119,6 +179,10 @@ final class HotkeyManager { 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( @@ -128,8 +192,8 @@ final class HotkeyManager { // MARK: - Internals - private func installHandlerIfNeeded() { - guard eventHandler == nil else { return } + private func installHandlerIfNeeded() -> Bool { + guard eventHandler == nil else { return true } var spec = EventTypeSpec( eventClass: OSType(kEventClassKeyboard), @@ -138,7 +202,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) } @@ -167,20 +231,25 @@ final class HotkeyManager { userInfo, &eventHandler ) + if status != noErr { + registrationError = "Could not install the shortcut handler (macOS error \(status))." + } + return status == noErr } - private func registerHotKey(keyCode: UInt32, id: UInt32, modifiers: UInt32) -> EventHotKeyRef? { + private func registerHotKey(shortcut: KeyboardShortcut, action: ShortcutAction, id: UInt32) -> EventHotKeyRef? { let hotKeyID = EventHotKeyID(signature: Self.signature, id: id) var ref: EventHotKeyRef? let status = RegisterEventHotKey( - keyCode, - modifiers, + shortcut.keyCode, + shortcut.modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref ) guard status == noErr else { + registrationError = "\(action.title): \(shortcut.displayName) could not be registered (macOS error \(status)). It may be in use by another app or macOS. Choose another shortcut or release it there." FileHandle.standardError.write( Data("[HotkeyManager] RegisterEventHotKey failed (status \(status)) for id \(id)\n".utf8) ) diff --git a/Sources/strafe/KeyboardShortcut.swift b/Sources/strafe/KeyboardShortcut.swift new file mode 100644 index 0000000..c8a6b37 --- /dev/null +++ b/Sources/strafe/KeyboardShortcut.swift @@ -0,0 +1,109 @@ +import AppKit +import Carbon.HIToolbox + +/// A physical key plus Carbon modifiers. No keyboard events are persisted. +struct KeyboardShortcut: Codable, Equatable, Sendable { + let keyCode: UInt32 + let modifiers: UInt32 + + static let allowedModifiers = UInt32(cmdKey | controlKey | optionKey | shiftKey) + + init(keyCode: UInt32, modifiers: UInt32) { + self.keyCode = keyCode + self.modifiers = modifiers & Self.allowedModifiers + } + + init(event: NSEvent) { + var modifiers: UInt32 = 0 + if event.modifierFlags.contains(.command) { modifiers |= UInt32(cmdKey) } + if event.modifierFlags.contains(.control) { modifiers |= UInt32(controlKey) } + if event.modifierFlags.contains(.option) { modifiers |= UInt32(optionKey) } + if event.modifierFlags.contains(.shift) { modifiers |= UInt32(shiftKey) } + self.init(keyCode: UInt32(event.keyCode), modifiers: modifiers) + } + + var validationError: String? { + guard keyCode < 128, modifiers & ~Self.allowedModifiers == 0, + ![54, 55, 56, 57, 58, 59, 60, 61, 62, 63].contains(keyCode) else { + return "Choose a key together with its modifiers." + } + let functionKeys: Set = [122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, 105, 107, 113, 106, 64, 79, 80, 90] + guard modifiers & UInt32(cmdKey | controlKey | optionKey) != 0 || functionKeys.contains(keyCode) else { + return "Include Command, Control, or Option, or choose a function key." + } + // These chords belong to the system or to editing/closing this window. + if modifiers == UInt32(cmdKey), [12, 13, 43, 48, 49].contains(keyCode) { + return "That shortcut is reserved. Add another modifier or choose a different key." + } + if modifiers == UInt32(cmdKey | optionKey), keyCode == 53 { + return "Force Quit is reserved by macOS. Choose a different shortcut." + } + return nil + } + + var displayName: String { + var result = "" + if modifiers & UInt32(controlKey) != 0 { result += "⌃" } + if modifiers & UInt32(optionKey) != 0 { result += "⌥" } + if modifiers & UInt32(shiftKey) != 0 { result += "⇧" } + if modifiers & UInt32(cmdKey) != 0 { result += "⌘" } + return result + keyName + } + + private var keyName: String { + let names: [UInt32: String] = [ + 36: "Return", 48: "Tab", 49: "Space", 51: "Delete", 53: "Esc", + 65: ".", 67: "*", 69: "+", 71: "Clear", 75: "/", 76: "Enter", 78: "−", 81: "=", + 82: "0", 83: "1", 84: "2", 85: "3", 86: "4", 87: "5", 88: "6", 89: "7", 91: "8", 92: "9", + 122: "F1", 120: "F2", 99: "F3", 118: "F4", 96: "F5", 97: "F6", + 98: "F7", 100: "F8", 101: "F9", 109: "F10", 103: "F11", 111: "F12", + 105: "F13", 107: "F14", 113: "F15", 106: "F16", 64: "F17", 79: "F18", 80: "F19", 90: "F20", + 114: "Help", 115: "Home", 116: "Page Up", 117: "Forward Delete", 119: "End", 121: "Page Down", + 123: "←", 124: "→", 125: "↓", 126: "↑" + ] + if let name = names[keyCode] { return name } + // Translate against the current keyboard layout, rather than assuming US QWERTY. + let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource().takeRetainedValue() + if let pointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) { + let data = Unmanaged.fromOpaque(pointer).takeUnretainedValue() + let layout = UnsafeRawPointer(CFDataGetBytePtr(data)).assumingMemoryBound(to: UCKeyboardLayout.self) + var deadKey: UInt32 = 0 + var length = 0 + var characters = [UniChar](repeating: 0, count: 8) + let status = UCKeyTranslate(layout, UInt16(keyCode), UInt16(kUCKeyActionDisplay), 0, + UInt32(LMGetKbdType()), OptionBits(kUCKeyTranslateNoDeadKeysBit), + &deadKey, characters.count, &length, &characters) + if status == noErr, length > 0 { + return String(utf16CodeUnits: characters, count: length).uppercased() + } + } + return "Key \(keyCode)" + } +} + +enum ShortcutAction: String, CaseIterable, Sendable { + case left, right + + var title: String { self == .left ? "Previous Space" : "Next Space" } + var storageKey: String { "spaceShortcut.\(rawValue)" } + var defaultShortcut: KeyboardShortcut { + KeyboardShortcut(keyCode: UInt32(self == .left ? kVK_LeftArrow : kVK_RightArrow), + modifiers: UInt32(controlKey | optionKey)) + } + + var storedShortcut: KeyboardShortcut? { + guard let data = Preferences.store.data(forKey: storageKey) else { return defaultShortcut } + // A stored wrapper with no shortcut represents Clear; corrupt data uses the default. + guard let decoded = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { return defaultShortcut } + guard let shortcut = decoded.shortcut else { return nil } + return shortcut.validationError == nil ? shortcut : defaultShortcut + } + + func persist(_ shortcut: KeyboardShortcut?) { + if let data = try? JSONEncoder().encode(StoredShortcut(shortcut: shortcut)) { + Preferences.store.set(data, forKey: storageKey) + } + } + + private struct StoredShortcut: Codable { let shortcut: KeyboardShortcut? } +} diff --git a/Sources/strafe/Permissions.swift b/Sources/strafe/Permissions.swift index c118fba..6e17df0 100644 --- a/Sources/strafe/Permissions.swift +++ b/Sources/strafe/Permissions.swift @@ -22,16 +22,15 @@ enum Permissions { } /// Print a human-readable status readout for `strafe status`. - /// `tapRunning` is supplied by the caller since tap state lives on the - /// interceptor instance. `cgsAvailable` reports whether the private CGS - /// topology symbols resolved (SPEC §1.1 capability check). - static func printStatus(tapRunning: Bool, cgsAvailable: Bool) { + /// The CLI process has no tap, so it must not report its own state as if it + /// described the separately running menu-bar app. `cgsAvailable` reports + /// whether the private CGS topology symbols resolved. + static func printStatus(cgsAvailable: Bool) { let ax = isAccessibilityGranted ? "yes" : "no" - let tap = tapRunning ? "yes" : "no" let cgs = cgsAvailable ? "yes" : "no" print("strafe status") print(" Accessibility granted: \(ax)") - print(" Event tap running: \(tap)") + print(" Event tap: see menu/settings (CLI creates no tap)") print(" CGS symbols resolved: \(cgs)") print(" Transition speed: \(TransitionSpeed.stored.title)") } diff --git a/Sources/strafe/SettingsWindow.swift b/Sources/strafe/SettingsWindow.swift new file mode 100644 index 0000000..bcb3174 --- /dev/null +++ b/Sources/strafe/SettingsWindow.swift @@ -0,0 +1,240 @@ +import AppKit + +/// Captures keys only as the first responder of strafe's visible settings window. +/// No global monitor (or keyboard event tap) is installed. +@MainActor +private final class ShortcutRecorder: NSButton { + var recording = false + var onRecord: ((NSEvent) -> Void)? + var onCancel: (() -> Void)? + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + guard recording else { super.keyDown(with: event); return } + if event.keyCode == 53, event.modifierFlags.intersection(.deviceIndependentFlagsMask).isEmpty { + onCancel?() + } else if !event.isARepeat { + onRecord?(event) + } + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard recording, window?.firstResponder === self else { return super.performKeyEquivalent(with: event) } + keyDown(with: event) + return true + } +} + +@MainActor +final class SettingsWindowController: NSWindowController, NSWindowDelegate { + private let hotkeys: HotkeyManager + private let engine: GestureSwitchEngine? + private let interceptor: SwipeInterceptor + private var recorders: [ShortcutAction: ShortcutRecorder] = [:] + private var recordingAction: ShortcutAction? + private let enableShortcuts = NSButton(checkboxWithTitle: "Enable keyboard shortcuts", target: nil, action: nil) + private let speed = NSPopUpButton(frame: .zero, pullsDown: false) + private let message = NSTextField(wrappingLabelWithString: "") + private let swipeStatus = NSTextField(wrappingLabelWithString: "") + private var statusTimer: Timer? + + init(hotkeys: HotkeyManager, engine: GestureSwitchEngine?, interceptor: SwipeInterceptor) { + self.hotkeys = hotkeys + self.engine = engine + self.interceptor = interceptor + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 550, height: 600), + styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false) + window.title = "strafe Settings" + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("strafe.settings") + super.init(window: window) + window.delegate = self + window.center() + buildContent() + hotkeys.onStateChanged = { [weak self] in self?.refresh() } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func showSettings() { + refresh() + showWindow(nil) + NSApp.activate() + window?.makeKeyAndOrderFront(nil) + statusTimer?.invalidate() + statusTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshSwipeStatus() } + } + } + + private func buildContent() { + guard let content = window?.contentView else { return } + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 14 + stack.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28), + stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24) + ]) + + let heading = NSTextField(labelWithString: "Keyboard shortcuts") + heading.font = .systemFont(ofSize: 16, weight: .semibold) + stack.addArrangedSubview(heading) + enableShortcuts.target = self + enableShortcuts.action = #selector(toggleShortcuts) + stack.addArrangedSubview(enableShortcuts) + + for action in ShortcutAction.allCases { + let label = NSTextField(labelWithString: action.title) + label.widthAnchor.constraint(equalToConstant: 135).isActive = true + let recorder = ShortcutRecorder(title: "", target: self, action: #selector(beginRecording(_:))) + recorder.bezelStyle = .rounded + recorder.identifier = NSUserInterfaceItemIdentifier(action.rawValue) + recorder.widthAnchor.constraint(equalToConstant: 210).isActive = true + recorder.setAccessibilityLabel("\(action.title) shortcut") + recorder.onRecord = { [weak self] event in self?.record(event, for: action) } + recorder.onCancel = { [weak self] in self?.cancelRecording() } + recorders[action] = recorder + let clear = NSButton(title: "Clear", target: self, action: #selector(clearShortcut(_:))) + clear.bezelStyle = .rounded + clear.identifier = NSUserInterfaceItemIdentifier(action.rawValue) + let row = NSStackView(views: [label, recorder, clear]) + row.spacing = 10 + stack.addArrangedSubview(row) + } + let help = NSTextField(wrappingLabelWithString: "Click a shortcut, then press a key with ⌘, ⌃, or ⌥ (or a function key). Press Esc to cancel. Changes apply immediately.") + help.textColor = .secondaryLabelColor + help.font = .systemFont(ofSize: 12) + stack.addArrangedSubview(help) + help.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + let reset = NSButton(title: "Restore Default Shortcuts", target: self, action: #selector(restoreDefaults)) + reset.bezelStyle = .rounded + stack.addArrangedSubview(reset) + + message.font = .systemFont(ofSize: 12) + message.textColor = .systemRed + stack.addArrangedSubview(message) + message.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + message.heightAnchor.constraint(greaterThanOrEqualToConstant: 34).isActive = true + + let divider = NSBox() + divider.boxType = .separator + stack.addArrangedSubview(divider) + divider.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + let transitions = NSTextField(labelWithString: "Space transitions") + transitions.font = .systemFont(ofSize: 16, weight: .semibold) + stack.addArrangedSubview(transitions) + speed.addItems(withTitles: TransitionSpeed.allCases.map(\.title)) + speed.target = self + speed.action = #selector(changeSpeed) + speed.isEnabled = engine != nil + speed.setAccessibilityLabel("Transition speed for swipes and keyboard shortcuts") + stack.addArrangedSubview(NSStackView(views: [NSTextField(labelWithString: "Transition speed"), speed])) + let speedHelp = NSTextField(wrappingLabelWithString: "Applies to both trackpad swipes and keyboard shortcuts.") + speedHelp.font = .systemFont(ofSize: 12) + speedHelp.textColor = .secondaryLabelColor + stack.addArrangedSubview(speedHelp) + speedHelp.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + + let gestureDivider = NSBox() + gestureDivider.boxType = .separator + stack.addArrangedSubview(gestureDivider) + gestureDivider.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + let gestures = NSTextField(labelWithString: "Trackpad gestures") + gestures.font = .systemFont(ofSize: 16, weight: .semibold) + stack.addArrangedSubview(gestures) + swipeStatus.font = .systemFont(ofSize: 12) + swipeStatus.textColor = .secondaryLabelColor + stack.addArrangedSubview(swipeStatus) + swipeStatus.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24).isActive = true + window?.setContentSize(NSSize(width: 550, height: 600)) + } + + private func refresh() { + enableShortcuts.state = HotkeyManager.enabled ? .on : .off + for action in ShortcutAction.allCases { + let recorder = recorders[action]! + recorder.recording = recordingAction == action + recorder.title = recorder.recording ? "Type shortcut… (Esc cancels)" : (action.storedShortcut?.displayName ?? "Record Shortcut…") + } + if let error = hotkeys.registrationError { message.stringValue = error } + speed.selectItem(at: (engine?.transitionSpeed ?? TransitionSpeed.stored).rawValue) + refreshSwipeStatus() + } + + private func refreshSwipeStatus() { + swipeStatus.stringValue = interceptor.statusDescription + } + + @objc private func beginRecording(_ sender: NSButton) { + guard let value = sender.identifier?.rawValue, let action = ShortcutAction(rawValue: value) else { return } + recordingAction = action + message.stringValue = "" + hotkeys.setRecording(true) + refresh() + window?.makeFirstResponder(sender) + } + + private func record(_ event: NSEvent, for action: ShortcutAction) { + let shortcut = KeyboardShortcut(event: event) + if let error = shortcut.validationError { + message.stringValue = error + return + } + recordingAction = nil + let error = hotkeys.updateShortcut(shortcut, for: action) + // Duplicate validation happens before updateShortcut resumes registration. + hotkeys.setRecording(false) + refresh() + message.stringValue = error ?? "" + } + + private func cancelRecording() { + recordingAction = nil + hotkeys.setRecording(false) + refresh() + } + + @objc private func clearShortcut(_ sender: NSButton) { + guard let value = sender.identifier?.rawValue, let action = ShortcutAction(rawValue: value) else { return } + cancelRecording() + let error = hotkeys.updateShortcut(nil, for: action) + refresh() + message.stringValue = error ?? "" + } + + @objc private func restoreDefaults() { + cancelRecording() + let error = hotkeys.restoreDefaultShortcuts() + refresh() + message.stringValue = error ?? "" + } + + @objc private func toggleShortcuts() { + let enabled = enableShortcuts.state == .on + cancelRecording() + HotkeyManager.persist(enabled: enabled) + hotkeys.applyStoredState() + message.stringValue = hotkeys.registrationError ?? "" + refresh() + } + + @objc private func changeSpeed() { + let selected = TransitionSpeed.from(rawValue: speed.indexOfSelectedItem) + selected.persist() + engine?.setTransitionSpeed(selected) + } + + func windowDidResignKey(_ notification: Notification) { cancelRecording() } + func windowWillClose(_ notification: Notification) { + cancelRecording() + statusTimer?.invalidate() + statusTimer = nil + } +} diff --git a/Sources/strafe/StatusItem.swift b/Sources/strafe/StatusItem.swift index e7c3716..9435a52 100644 --- a/Sources/strafe/StatusItem.swift +++ b/Sources/strafe/StatusItem.swift @@ -9,6 +9,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { private let interceptor: SwipeInterceptor private let engine: GestureSwitchEngine? private let hotkeys: HotkeyManager? + private var settings: SettingsWindowController? private let toggleItem = NSMenuItem( title: "Enable", action: #selector(toggleEnabled), keyEquivalent: "" @@ -18,7 +19,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { ) private var speedItems: [NSMenuItem] = [] private let hotkeysItem = NSMenuItem( - title: "Space-switch hotkeys (⌃⌥←/→)", action: #selector(toggleHotkeys), keyEquivalent: "" + title: "Space-switch hotkeys", action: #selector(toggleHotkeys), keyEquivalent: "" ) private let accessibilityItem = NSMenuItem( title: "Accessibility granted: —", action: nil, keyEquivalent: "" @@ -38,6 +39,9 @@ final class StatusItemController: NSObject, NSMenuDelegate { self.hotkeys = hotkeys self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) super.init() + if let hotkeys { + settings = SettingsWindowController(hotkeys: hotkeys, engine: engine, interceptor: interceptor) + } // AppKit restores the previous visibility; every new launch starts visible. statusItem.isVisible = true @@ -65,6 +69,12 @@ final class StatusItemController: NSObject, NSMenuDelegate { menu.addItem(hotkeysItem) } menu.addItem(accessibilityItem) + if settings != nil { + menu.addItem(.separator()) + let item = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",") + item.target = self + menu.addItem(item) + } // Update story, stated rather than performed. strafe cannot reach the // internet, so it cannot check for a new version; instead of a @@ -100,6 +110,10 @@ final class StatusItemController: NSObject, NSMenuDelegate { statusItem.isVisible = true } + @objc func showSettings() { + settings?.showSettings() + } + /// The "Transition speed" submenu: one checkable item per preset. /// /// Hidden entirely when there is no real engine (stub engine / no @@ -193,8 +207,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { speedItem.title = "Transition speed: \(current.title)" for item in speedItems { item.state = item.tag == current.rawValue ? .on : .off } } - let granted = Permissions.isAccessibilityGranted - accessibilityItem.title = "Accessibility granted: \(granted ? "yes" : "no")" + accessibilityItem.title = interceptor.statusDescription hotkeysItem.state = HotkeyManager.enabled ? .on : .off } } diff --git a/Sources/strafe/SwipeInterceptor.swift b/Sources/strafe/SwipeInterceptor.swift index 431cc4d..3050245 100644 --- a/Sources/strafe/SwipeInterceptor.swift +++ b/Sources/strafe/SwipeInterceptor.swift @@ -8,19 +8,29 @@ import CStrafe /// /// Concurrency: the tap source is installed on the **main** run loop in /// `kCFRunLoopCommonModes` (SPEC §2.1), so `eventTapCallback` always runs on -/// the main thread. All mutable state (`swipeTracking`, `swipeFired`, -/// `isRunning`) is therefore touched only from that single run loop and needs +/// the main thread. All mutable gesture and tap lifecycle state is +/// therefore touched only from that single run loop and needs /// no locking. The class is `@unchecked Sendable` because the C callback /// reaches it through an opaque pointer; that confinement invariant is what /// makes the unchecked conformance sound. final class SwipeInterceptor: @unchecked Sendable { private let engine: SwitchEngine private let isExposeActive: () -> Bool - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? + private var eventTap: (any SwipeEventTap)? + private let accessibilityGranted: () -> Bool + private let makeTap: (CGEventTapCallBack, UnsafeMutableRawPointer) -> (any SwipeEventTap)? + private var recoveryTimer: Timer? + private var wantsRunning = false + private var reportedCreationFailure = false /// Whether the tap is currently created and enabled. - private(set) var isRunning: Bool = false + var isRunning: Bool { eventTap?.isEnabled ?? false } + + var statusDescription: String { + if !overrideEnabled { return "Swipe interception is paused" } + if !accessibilityGranted() { return "Accessibility permission required" } + return isRunning ? "Swipe interception is active" : "Waiting for gesture access — retrying automatically" + } /// Whether interception is active. When false the callback passes every /// event through untouched (SPEC §2.2: "only acts when swipeOverrideEnabled"). @@ -31,82 +41,105 @@ final class SwipeInterceptor: @unchecked Sendable { private var swipeFired = false private var swipePosted = false - init(engine: SwitchEngine, isExposeActive: @escaping () -> Bool = { strafe_is_expose_active() }) { + init(engine: SwitchEngine, + isExposeActive: @escaping () -> Bool = { strafe_is_expose_active() }, + accessibilityGranted: @escaping () -> Bool = { Permissions.isAccessibilityGranted }, + makeTap: @escaping (CGEventTapCallBack, UnsafeMutableRawPointer) -> (any SwipeEventTap)? = SystemSwipeEventTap.make) { self.engine = engine self.isExposeActive = isExposeActive + self.accessibilityGranted = accessibilityGranted + self.makeTap = makeTap + } + + deinit { + recoveryTimer?.invalidate() + eventTap?.invalidate() } // MARK: - Lifecycle - /// Create the tap and add it to the main run loop. No-op if already running. + /// Keep trying until the tap exists, including when permission is granted + /// after launch. The timer only checks trust/tap health; it reads no input. func start() { - guard eventTap == nil else { - enable() - return + wantsRunning = true + if recoveryTimer == nil { + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + self?.recoverIfNeeded() + } + recoveryTimer = timer + RunLoop.main.add(timer, forMode: .common) } + recoverIfNeeded() + } - // Gesture (1<<29) | dock-control (1<<30) only, sourced from C so the raw - // private type bits are single-sourced with the synthesizer. Key events - // are intentionally excluded (they were never acted on and only added - // per-keystroke latency) — see the determination comment in CStrafe.c. - let mask = CGEventMask(strafe_tap_event_mask()) + /// Reconcile permission and tap health without creating duplicate taps. + func recoverIfNeeded() { + guard wantsRunning else { return } + guard accessibilityGranted() else { + eventTap?.invalidate() + eventTap = nil + resetGesture() + return + } + if let eventTap { + if !eventTap.isEnabled { + resetGesture() + eventTap.enable() + // A revoked/invalid tap may no longer be re-enableable. + if !eventTap.isEnabled { + eventTap.invalidate() + self.eventTap = nil + } + } + if self.eventTap != nil { return } + } // Trampoline `self` through the tap's userInfo pointer. let userInfo = Unmanaged.passUnretained(self).toOpaque() - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, // SPEC §2.1: same location as posting - place: .headInsertEventTap, // head of the chain: see events before WindowServer - options: .defaultTap, // active tap: returning nil suppresses - eventsOfInterest: mask, - callback: { _, type, event, refcon in + guard let tap = makeTap( + { _, type, event, refcon in guard let refcon else { return Unmanaged.passUnretained(event) } let interceptor = Unmanaged .fromOpaque(refcon).takeUnretainedValue() return interceptor.handle(type: type, event: event) }, - userInfo: userInfo + userInfo ) else { - FileHandle.standardError.write( - Data("[SwipeInterceptor] failed to create event tap (accessibility not granted?)\n".utf8) - ) + if !reportedCreationFailure { + FileHandle.standardError.write(Data( + "[SwipeInterceptor] gesture tap unavailable; retrying automatically\n".utf8)) + reportedCreationFailure = true + } return } - - // SPEC §2.1: source added to the MAIN run loop in common modes. - let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - self.eventTap = tap - self.runLoopSource = source - enable() + reportedCreationFailure = false + tap.enable() } - /// Enable the tap if it exists. + /// Enable the tap, creating it or retrying if permission is pending. func enable() { - guard let eventTap else { return } - CGEvent.tapEnable(tap: eventTap, enable: true) - isRunning = true + start() } /// Disable the tap without tearing it down (can be re-enabled cheaply). func disable() { - guard let eventTap else { return } - CGEvent.tapEnable(tap: eventTap, enable: false) - isRunning = false + wantsRunning = false + recoveryTimer?.invalidate() + recoveryTimer = nil + eventTap?.disable() + resetGesture() } /// Fully remove the tap from the main run loop and release it. func teardown() { - if let runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) - } - if let eventTap { - CGEvent.tapEnable(tap: eventTap, enable: false) - } - runLoopSource = nil + disable() + eventTap?.invalidate() eventTap = nil - isRunning = false + } + + private func resetGesture() { swipeTracking = false swipeFired = false swipePosted = false @@ -130,10 +163,8 @@ final class SwipeInterceptor: @unchecked Sendable { // A disable can swallow a gesture's `ended`/`cancelled`, leaving the // state machine mid-track. Reset before re-enabling so a dropped // gesture-end can't leave us stuck suppressing companion events. - swipeTracking = false - swipeFired = false - swipePosted = false - if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) } + resetGesture() + if wantsRunning { eventTap?.enable() } return passthrough } @@ -252,3 +283,49 @@ final class SwipeInterceptor: @unchecked Sendable { } } } + +/// The narrow lifecycle seam lets permission recovery be tested without +/// installing a system event tap or requesting test-runner Accessibility. +protocol SwipeEventTap: AnyObject { + var isEnabled: Bool { get } + func enable() + func disable() + func invalidate() +} + +private final class SystemSwipeEventTap: SwipeEventTap { + private let port: CFMachPort + private let source: CFRunLoopSource + + var isEnabled: Bool { + CFMachPortIsValid(port) && CGEvent.tapIsEnabled(tap: port) + } + + static func make(callback: CGEventTapCallBack, userInfo: UnsafeMutableRawPointer) -> (any SwipeEventTap)? { + // Gesture (29) and dock-control (30) only. Never keyboard events. + guard let port = CGEvent.tapCreate( + tap: .cgSessionEventTap, place: .headInsertEventTap, + options: .defaultTap, eventsOfInterest: CGEventMask(strafe_tap_event_mask()), + callback: callback, userInfo: userInfo + ) else { return nil } + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0) else { + CFMachPortInvalidate(port) + return nil + } + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + return SystemSwipeEventTap(port: port, source: source) + } + + private init(port: CFMachPort, source: CFRunLoopSource) { + self.port = port + self.source = source + } + + func enable() { CGEvent.tapEnable(tap: port, enable: true) } + func disable() { CGEvent.tapEnable(tap: port, enable: false) } + func invalidate() { + disable() + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + CFMachPortInvalidate(port) + } +} diff --git a/Sources/strafe/main.swift b/Sources/strafe/main.swift index 33fac28..457d46f 100644 --- a/Sources/strafe/main.swift +++ b/Sources/strafe/main.swift @@ -11,8 +11,15 @@ let engine = GestureSwitchEngine() let args = Array(CommandLine.arguments.dropFirst()) -if args.isEmpty { - runMenuBarApp(engine: engine) +if args.isEmpty || args == ["settings"] { + if args == ["settings"], NSRunningApplication.runningApplications(withBundleIdentifier: Preferences.domain) + .contains(where: { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }) { + DistributedNotificationCenter.default().postNotificationName( + Notification.Name("com.rileycx.strafe.showSettings"), object: Preferences.domain, + userInfo: nil, deliverImmediately: true) + } else { + runMenuBarApp(engine: engine, openSettings: args == ["settings"]) + } } else { exit(runCLI(args, engine: engine)) } @@ -55,9 +62,9 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { } case "status": - // No live tap in CLI mode, so report tap as not running. CGS symbol + // This process cannot report the resident app's event tap. CGS symbol // resolution is the capability check per SPEC §1.1 / §6. - Permissions.printStatus(tapRunning: false, cgsAvailable: engine.cgsAvailable) + Permissions.printStatus(cgsAvailable: engine.cgsAvailable) return 0 case "speed": @@ -109,10 +116,11 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { usage: strafe start the menu-bar app + strafe settings open the settings window 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 keyboard shortcut enablement """.utf8)) return 2 @@ -122,13 +130,13 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { // MARK: - Menu-bar app mode @MainActor -func runMenuBarApp(engine: GestureSwitchEngine) { +func runMenuBarApp(engine: GestureSwitchEngine, openSettings: Bool = false) { let app = NSApplication.shared // LSUIElement is also set in Info.plist; set it here so running the raw // binary (unbundled) still behaves as an accessory with no dock icon. app.setActivationPolicy(.accessory) - let delegate = AppDelegate(engine: engine) + let delegate = AppDelegate(engine: engine, openSettings: openSettings) app.delegate = delegate app.run() } @@ -139,9 +147,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var interceptor: SwipeInterceptor! private var hotkeys: HotkeyManager! private var statusItem: StatusItemController! + private let openSettingsOnLaunch: Bool + private var settingsObserver: (any NSObjectProtocol)? - init(engine: GestureSwitchEngine) { + init(engine: GestureSwitchEngine, openSettings: Bool = false) { self.engine = engine + self.openSettingsOnLaunch = openSettings super.init() } @@ -155,6 +166,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { hotkeys.start() statusItem = StatusItemController(interceptor: interceptor, engine: engine, hotkeys: hotkeys) + let appMenu = NSMenu() + let rootItem = NSMenuItem() + let submenu = NSMenu() + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(StatusItemController.showSettings), keyEquivalent: ",") + settingsItem.target = statusItem + submenu.addItem(settingsItem) + submenu.addItem(.separator()) + submenu.addItem(NSMenuItem(title: "Quit strafe", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + rootItem.submenu = submenu + appMenu.addItem(rootItem) + NSApp.mainMenu = appMenu + settingsObserver = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("com.rileycx.strafe.showSettings"), object: Preferences.domain, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.statusItem?.showSettings() } + } // SPEC §2.4 / §5: reset the prediction dictionary to live CGS data // whenever the OS reports a real space change, so rapid repeated swipes @@ -168,6 +195,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } interceptor.start() + if openSettingsOnLaunch { statusItem.showSettings() } } // Sent when the app is opened while already running. This how you unhide the menubar icon. @@ -179,6 +207,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { interceptor?.teardown() hotkeys?.stop() + if let settingsObserver { DistributedNotificationCenter.default().removeObserver(settingsObserver) } NSWorkspace.shared.notificationCenter.removeObserver(self) } } diff --git a/Tests/HotkeyManagerTests.swift b/Tests/HotkeyManagerTests.swift index 49f9c9b..4474eaa 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. @@ -15,6 +16,7 @@ struct TestEngine: SwitchEngine { } @_silgen_name("test_active_hotkeys") private func activeHotkeys() -> UInt32 @_silgen_name("test_registration_count") private func registrationCount() -> UInt32 +@_silgen_name("test_reject_key") private func rejectKey(_ key: UInt32) @main struct HotkeyManagerTests { @MainActor static func main() { @@ -45,7 +47,33 @@ struct TestEngine: SwitchEngine { precondition(activeHotkeys() == 0) manager.start() precondition(activeHotkeys() == 2) + let custom = KeyboardShortcut(keyCode: UInt32(kVK_ANSI_J), modifiers: UInt32(cmdKey | shiftKey)) + precondition(manager.updateShortcut(custom, for: .left) == nil) + precondition(ShortcutAction.left.storedShortcut == custom && activeHotkeys() == 2) + precondition(manager.updateShortcut(custom, for: .right) != nil) + precondition(ShortcutAction.right.storedShortcut == ShortcutAction.right.defaultShortcut) + precondition(manager.updateShortcut(KeyboardShortcut(keyCode: 0, modifiers: 0), for: .left) != nil) + precondition(manager.updateShortcut(KeyboardShortcut(keyCode: UInt32(kVK_ANSI_Q), modifiers: UInt32(cmdKey)), for: .left) != nil) + precondition(custom.displayName.hasPrefix("⇧⌘")) + manager.setRecording(true) + precondition(activeHotkeys() == 0) + manager.setRecording(false) + precondition(activeHotkeys() == 2) + rejectKey(UInt32(kVK_ANSI_K)) + let conflicting = KeyboardShortcut(keyCode: UInt32(kVK_ANSI_K), modifiers: UInt32(controlKey)) + precondition(manager.updateShortcut(conflicting, for: .left) != nil) + precondition(ShortcutAction.left.storedShortcut == custom && activeHotkeys() == 2) + precondition(manager.registrationError == nil) + rejectKey(UInt32.max) + precondition(manager.updateShortcut(nil, for: .right) == nil) + precondition(ShortcutAction.right.storedShortcut == nil && activeHotkeys() == 1) + manager.stop() + manager.start() + precondition(activeHotkeys() == 1 && ShortcutAction.left.storedShortcut == custom) + precondition(manager.restoreDefaultShortcuts() == nil && activeHotkeys() == 2) + precondition(ShortcutAction.left.storedShortcut == ShortcutAction.left.defaultShortcut) print("PASS: default, repeated enable/disable, and restart") + print("PASS: customization, persistence, duplicate/reserved validation, recorder suspension, conflict rollback, clear, restore") return } precondition(mode == "listen") diff --git a/Tests/HotkeyRegistrationStub.c b/Tests/HotkeyRegistrationStub.c index fefc475..403995f 100644 --- a/Tests/HotkeyRegistrationStub.c +++ b/Tests/HotkeyRegistrationStub.c @@ -4,14 +4,16 @@ static unsigned activeCount; static unsigned registrationCount; +static UInt32 rejectedKey = UINT32_MAX; // 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(key < 128); + assert(modifiers != 0); assert(id.id == 1 || id.id == 2); assert(target != NULL && options == 0); + if (key == rejectedKey) { *out = NULL; return eventHotKeyExistsErr; } *out = (EventHotKeyRef)malloc(1); assert(*out != NULL); activeCount++; @@ -28,3 +30,4 @@ OSStatus UnregisterEventHotKey(EventHotKeyRef ref) { unsigned test_active_hotkeys(void) { return activeCount; } unsigned test_registration_count(void) { return registrationCount; } +void test_reject_key(UInt32 key) { rejectedKey = key; } diff --git a/Tests/hotkeys.sh b/Tests/hotkeys.sh index 9b0632b..5642352 100644 --- a/Tests/hotkeys.sh +++ b/Tests/hotkeys.sh @@ -7,7 +7,7 @@ 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 \ + Sources/strafe/KeyboardShortcut.swift Sources/strafe/HotkeyManager.swift Tests/HotkeyManagerTests.swift \ "$test_dir/registration.o" -o "$test_dir/hotkeys" python3 - "$test_dir/hotkeys" <<'PY' import select diff --git a/Tests/strafeTests/SwipeRecoveryTests.swift b/Tests/strafeTests/SwipeRecoveryTests.swift new file mode 100644 index 0000000..7fc0293 --- /dev/null +++ b/Tests/strafeTests/SwipeRecoveryTests.swift @@ -0,0 +1,132 @@ +import CoreGraphics +import CStrafe +import XCTest +@testable import strafe + +final class SwipeRecoveryTests: XCTestCase { + func testPermissionGrantedAfterLaunchStartsTapWithoutRestart() { + var granted = false + var attempts = 0 + let tap = FakeSwipeEventTap() + let interceptor = SwipeInterceptor(engine: StubSwitchEngine(), accessibilityGranted: { granted }, makeTap: { _, _ in + attempts += 1 + return tap + }) + defer { interceptor.teardown() } + interceptor.start() + XCTAssertEqual(attempts, 0) + XCTAssertFalse(interceptor.isRunning) + XCTAssertEqual(interceptor.statusDescription, "Accessibility permission required") + + granted = true + interceptor.recoverIfNeeded() + XCTAssertEqual(attempts, 1) + XCTAssertTrue(interceptor.isRunning) + interceptor.recoverIfNeeded() + XCTAssertEqual(attempts, 1, "Healthy taps must not be recreated every second") + } + + func testCreationFailureRetriesEvenWhenPermissionAlreadyGranted() { + var attempts = 0 + let tap = FakeSwipeEventTap() + let interceptor = SwipeInterceptor(engine: StubSwitchEngine(), accessibilityGranted: { true }, makeTap: { _, _ in + attempts += 1 + return attempts == 1 ? nil : tap + }) + defer { interceptor.teardown() } + interceptor.start() + XCTAssertFalse(interceptor.isRunning) + XCTAssertTrue(interceptor.statusDescription.contains("retrying automatically")) + interceptor.recoverIfNeeded() + XCTAssertEqual(attempts, 2) + XCTAssertTrue(interceptor.isRunning) + } + + func testScheduledRecoveryRetriesWithoutUserAction() { + var granted = false + let tap = FakeSwipeEventTap() + let interceptor = SwipeInterceptor(engine: StubSwitchEngine(), accessibilityGranted: { granted }, makeTap: { _, _ in tap }) + defer { interceptor.teardown() } + interceptor.start() + granted = true + RunLoop.main.run(until: Date().addingTimeInterval(1.1)) + XCTAssertTrue(interceptor.isRunning, "Granting permission must recover without an app restart or menu action") + } + + func testInvalidTapIsRecreatedWhenReenablingFails() { + var taps: [FakeSwipeEventTap] = [] + let interceptor = SwipeInterceptor(engine: StubSwitchEngine(), accessibilityGranted: { true }, makeTap: { _, _ in + let tap = FakeSwipeEventTap() + taps.append(tap) + return tap + }) + defer { interceptor.teardown() } + interceptor.start() + taps[0].invalidate() + interceptor.recoverIfNeeded() + XCTAssertTrue(interceptor.isRunning) + XCTAssertEqual(taps.count, 2) + } + + func testRevocationAndRegrantRecreateTap() { + var granted = true + var taps: [FakeSwipeEventTap] = [] + let interceptor = SwipeInterceptor(engine: StubSwitchEngine(), accessibilityGranted: { granted }, makeTap: { _, _ in + let tap = FakeSwipeEventTap() + taps.append(tap) + return tap + }) + defer { interceptor.teardown() } + interceptor.start() + granted = false + interceptor.recoverIfNeeded() + XCTAssertFalse(interceptor.isRunning) + XCTAssertTrue(taps[0].invalidated) + granted = true + interceptor.recoverIfNeeded() + XCTAssertTrue(interceptor.isRunning) + XCTAssertEqual(taps.count, 2) + } + + func testDisabledOrTornDownInterceptorDoesNotRestart() { + var attempts = 0 + let interceptor = SwipeInterceptor(engine: StubSwitchEngine(), accessibilityGranted: { true }, makeTap: { _, _ in + attempts += 1 + return FakeSwipeEventTap() + }) + interceptor.start() + interceptor.disable() + interceptor.recoverIfNeeded() + XCTAssertFalse(interceptor.isRunning) + XCTAssertEqual(attempts, 1) + interceptor.enable() + XCTAssertTrue(interceptor.isRunning) + interceptor.teardown() + interceptor.recoverIfNeeded() + XCTAssertFalse(interceptor.isRunning) + XCTAssertEqual(attempts, 1) + } + + func testActualTapDisabledStateIsReportedAndRecovers() { + let tap = FakeSwipeEventTap() + let interceptor = SwipeInterceptor(engine: StubSwitchEngine(), accessibilityGranted: { true }, makeTap: { _, _ in tap }) + defer { interceptor.teardown() } + interceptor.start() + tap.disable() + XCTAssertFalse(interceptor.isRunning) + interceptor.recoverIfNeeded() + XCTAssertTrue(interceptor.isRunning) + } + + func testGestureOnlyEventMaskIsUnchanged() { + XCTAssertEqual(strafe_tap_event_mask(), (UInt64(1) << 29) | (UInt64(1) << 30)) + } +} + +private final class FakeSwipeEventTap: SwipeEventTap { + private(set) var isEnabled = false + private(set) var invalidated = false + func enable() { if !invalidated { isEnabled = true } } + func disable() { isEnabled = false } + func invalidate() { disable(); invalidated = true } +}