Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```

Expand All @@ -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.

Expand Down
25 changes: 19 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,25 +150,38 @@ 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 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.
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).

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/ # two keys, plus cache synchronization
grep -rn 'UserDefaults(' Sources/ # one hit: the suite in Preferences.swift
```

AppKit also saves menu-bar item visibility automatically when the icon is
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).
Expand Down
81 changes: 79 additions & 2 deletions Sources/strafe/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,26 @@ 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

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 = {
Expand All @@ -28,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.
Expand All @@ -49,6 +87,45 @@ 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() {
// Refresh the cache after another process changes the shared preference.
Preferences.store.synchronize()
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)
// 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

private func installHandlerIfNeeded() {
Expand Down
23 changes: 22 additions & 1 deletion Sources/strafe/StatusItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand All @@ -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: ""
)
Expand All @@ -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()

Expand All @@ -55,6 +60,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
Expand Down Expand Up @@ -140,6 +149,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()
}

// AppKit saves visibility; initialization resets it on the next launch.
@objc private func hideFromMenuBar() {
let alert = NSAlert()
Expand Down Expand Up @@ -175,5 +195,6 @@ final class StatusItemController: NSObject, NSMenuDelegate {
}
let granted = Permissions.isAccessibilityGranted
accessibilityItem.title = "Accessibility granted: \(granted ? "yes" : "no")"
hotkeysItem.state = HotkeyManager.enabled ? .on : .off
}
}
27 changes: 24 additions & 3 deletions Sources/strafe/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 {
print("transition speed: \(speed.title)")
return 0

case "hotkeys":
// 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
}
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
Expand All @@ -93,6 +112,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
Expand Down Expand Up @@ -130,10 +150,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.start()

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
Expand All @@ -157,7 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

func applicationWillTerminate(_ notification: Notification) {
interceptor?.teardown()
hotkeys?.unregister()
hotkeys?.stop()
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
}
67 changes: 67 additions & 0 deletions Tests/HotkeyManagerTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Loading
Loading