Skip to content
Closed
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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,39 @@ This update was tested on macOS 27.0; older macOS versions have not been reteste
strafe switch left|right # switch once and exit
strafe status # print accessibility / tap status
strafe speed [preset] # show or set transition speed
strafe hotkeys [on|off] # show or set Space-switch hotkeys
strafe hotkeys [on|off] # show or set all Space-switch hotkeys
strafe control-arrows [on|off] # show or set additional Control-arrow pair
strafe # start the menu-bar app
```

### Optional Control + arrow shortcuts

The standard macOS `ctrl`+`←` / `ctrl`+`→` shortcuts normally keep Apple's
animation: strafe's gesture tap does not receive keyboard events.
To route those combinations through strafe as well:

1. In **System Settings > Keyboard > Keyboard Shortcuts > Mission Control**,
turn off **Move left a space** and **Move right a space**.
2. In strafe's menu, enable **Also use Control + arrows…** (or run
`strafe control-arrows on`). Keep **Space-switch hotkeys** enabled.
3. Press Control + Left/Right to switch with your selected transition speed.
Control + Option + Left/Right and trackpad swipes still work.

This option defaults to off. It adds two Carbon hotkey registrations; it does
not install a keyboard event tap or change macOS preferences. A successful
registration does not prove that macOS will deliver the shortcut: native
Space bindings or another app can still own the combination. If switching
still uses Apple's animation, check the two Mission Control settings above.

**When strafe quits, crashes, or has hotkeys disabled, these Control-arrow
shortcuts will not switch Spaces while the native bindings are off.** Turn
**Move left a space** and **Move right a space** back on to restore native
behavior, including before uninstalling. Deleting strafe's preferences does
not restore those system settings. Switching also requires Accessibility.

`strafe control-arrows off` removes only the additional pair. `strafe hotkeys
off` disables both pairs. Both settings update a running app without restarting.

## Permissions

strafe needs **Accessibility** permission, and only that. macOS requires it to
Expand All @@ -199,7 +228,8 @@ strafe off (or remove it from the list).

## Uninstall

1. Quit strafe from its menu-bar menu.
1. If you disabled macOS **Move left a space** / **Move right a space**, turn
them back on in Keyboard Shortcuts > Mission Control. Quit strafe.
2. Delete `strafe.app`.
3. Remove its entry from **System Settings › Privacy & Security ›
Accessibility**.
Expand Down
25 changes: 17 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,16 @@ Each of these is verifiable with a single grep over `Sources/`.
(`grep -rniE 'Process\(\)|/usr/bin|/bin/|tccutil' Sources/` — no spawns).

- **Persistence is limited to menu settings.** strafe stores no databases and no
caches. Its own code writes two `UserDefaults` values: `transitionSpeed`, an integer
caches. Its own code writes three `UserDefaults` values: `transitionSpeed`, an integer
0–2 recording which **Transition speed** preset you picked in the menu
(`TransitionSpeed`, `Sources/strafe/TransitionSpeed.swift` line 101); and
`spaceHotkeysEnabled`, a bool recording whether the Ctrl+Option+Left/Right
**Space-switch hotkeys** toggle is on (`HotkeyManager`,
`Sources/strafe/HotkeyManager.swift`). Neither has any effect on what the
(`TransitionSpeed`, `Sources/strafe/TransitionSpeed.swift` line 101);
`spaceHotkeysEnabled`, a bool recording whether all
**Space-switch hotkeys** are on; and `controlArrowHotkeysEnabled`, a bool
defaulting to false that adds Control+Left/Right alongside
Control+Option+Left/Right (`HotkeyManager`,
`Sources/strafe/HotkeyManager.swift`). None affects what the
gesture tap sees — the first changes the shape of the gesture strafe
*posts*, the second only registers/unregisters a Carbon global hotkey (a
*posts*, the two hotkey settings only register/unregister Carbon global hotkeys (a
separate mechanism from the tap, added so the hotkeys can be turned off
independently if they conflict with a third-party shortcut bound to the
same chord).
Expand All @@ -167,7 +169,7 @@ Each of these is verifiable with a single grep over `Sources/`.
different plists:

```
grep -rn 'Preferences.store' Sources/ # two keys, plus cache synchronization
grep -rn 'Preferences.store' Sources/ # three keys, plus cache synchronization
grep -rn 'UserDefaults(' Sources/ # one hit: the suite in Preferences.swift
```

Expand All @@ -182,6 +184,13 @@ Each of these is verifiable with a single grep over `Sources/`.
commands or settings from notification data. This adds no network access or
permissions.

The optional Control-arrow pair uses the same Carbon mechanism, with distinct
IDs for each direction. It does not receive unrelated keystrokes or widen the
gesture tap mask. strafe neither reads nor writes native symbolic-hotkey
preferences. Users must disable the two conflicting macOS Space shortcuts
themselves, and re-enable them to restore native behavior after quitting or
uninstalling strafe. Deleting strafe's plist does not restore macOS settings.

No usage data, no history, no coordinates are stored.
Deleting `strafe.app` leaves behind only that plist, which
`defaults delete com.rileycx.strafe` removes (see README → Uninstall).
Expand Down Expand Up @@ -224,7 +233,7 @@ grep -rniE 'Process\(\)|tccutil|/usr/bin|/bin/' Sources/
# 4. Confirm the tap mask excludes keystrokes, and that there is only one mask.
grep -rn 'strafe_tap_event_mask' Sources/

# 5. Confirm the one stored setting.
# 5. Confirm the stored settings.
grep -rn 'Preferences.store' Sources/
```

Expand Down
77 changes: 73 additions & 4 deletions Sources/strafe/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
///
/// **Toggleable.** Ctrl+Option+Left/Right is also a common chord for
/// third-party window-tiling tools (and macOS's own tiling shortcuts), and
/// Carbon's `RegisterEventHotKey` grabs it system-wide ahead of them. Since
/// Carbon registrations can conflict with other owners of the same shortcut. Since
/// this is a separate mechanism from the gesture tap (SPEC §2), it can be
/// switched off independently via `HotkeyManager.enabled` / the menu-bar
/// "Space-switch hotkeys" item / `strafe hotkeys off` — leaving the swipe
Expand All @@ -22,6 +22,12 @@ final class HotkeyManager {
private var eventHandler: EventHandlerRef?
private var leftHotKey: EventHotKeyRef?
private var rightHotKey: EventHotKeyRef?
private var controlLeftHotKey: EventHotKeyRef?
private var controlRightHotKey: EventHotKeyRef?

/// Registration is not proof of delivery: native Space shortcuts must be disabled.
var controlArrowsRegistered: Bool { controlLeftHotKey != nil && controlRightHotKey != nil }

private var settingsObserver: (any NSObjectProtocol)?

nonisolated private static let settingsChanged = Notification.Name(
Expand All @@ -36,6 +42,8 @@ final class HotkeyManager {
}()
private static let leftID: UInt32 = 1
private static let rightID: UInt32 = 2
private static let controlLeftID: UInt32 = 3
private static let controlRightID: UInt32 = 4

init(engine: SwitchEngine) {
self.engine = engine
Expand Down Expand Up @@ -65,6 +73,7 @@ final class HotkeyManager {
/// Install the Carbon event handler and register both hotkeys.
func register() {
installHandlerIfNeeded()
guard eventHandler != nil else { return }

let ctrlOpt = UInt32(controlKey | optionKey)
if leftHotKey == nil {
Expand All @@ -75,8 +84,27 @@ final class HotkeyManager {
}
}

private func registerControlArrows() {
guard !controlArrowsRegistered else { return }
controlLeftHotKey = registerHotKey(
keyCode: UInt32(kVK_LeftArrow), id: Self.controlLeftID, modifiers: UInt32(controlKey))
controlRightHotKey = registerHotKey(
keyCode: UInt32(kVK_RightArrow), id: Self.controlRightID, modifiers: UInt32(controlKey))
// A failed pair must not leave only one direction captured. Applying the
// setting again retries, while the original Ctrl+Option pair stays intact.
if !controlArrowsRegistered { unregisterControlArrows() }
}

private func unregisterControlArrows() {
if let controlLeftHotKey { UnregisterEventHotKey(controlLeftHotKey) }
if let controlRightHotKey { UnregisterEventHotKey(controlRightHotKey) }
controlLeftHotKey = nil
controlRightHotKey = nil
}

/// Unregister hotkeys and remove the handler.
func unregister() {
unregisterControlArrows()
if let leftHotKey { UnregisterEventHotKey(leftHotKey) }
if let rightHotKey { UnregisterEventHotKey(rightHotKey) }
leftHotKey = nil
Expand All @@ -95,6 +123,11 @@ final class HotkeyManager {
Preferences.store.synchronize()
if HotkeyManager.enabled {
register()
if Self.controlArrowsEnabled && eventHandler != nil {
registerControlArrows()
} else {
unregisterControlArrows()
}
} else {
unregister()
}
Expand All @@ -117,8 +150,38 @@ final class HotkeyManager {
Preferences.store.object(forKey: enabledStorageKey) as? Bool ?? true
}

nonisolated static let controlArrowsStorageKey = "controlArrowHotkeysEnabled"

/// Opt-in: native macOS Space shortcuts otherwise own these combinations.
nonisolated static var controlArrowsEnabled: Bool {
Preferences.store.bool(forKey: controlArrowsStorageKey)
}

nonisolated static let controlArrowSetup = """
In System Settings > Keyboard > Keyboard Shortcuts > Mission Control, \
turn off “Move left a space” and “Move right a space”. strafe does not \
change these macOS settings for you.

Control-arrow switching requires strafe to be running, Accessibility \
permission, and Space-switch hotkeys to be enabled. Control+Option+arrows \
remain available too.

If you quit, disable, or uninstall strafe, turn those two macOS shortcuts \
back on to restore native Control-arrow switching. Removing strafe’s \
preferences does not restore macOS shortcuts.
"""

nonisolated static func persist(controlArrowsEnabled: Bool) {
Preferences.store.set(controlArrowsEnabled, forKey: controlArrowsStorageKey)
notifySettingsChanged()
}

nonisolated static func persist(enabled: Bool) {
Preferences.store.set(enabled, forKey: enabledStorageKey)
notifySettingsChanged()
}

nonisolated private static func notifySettingsChanged() {
// Flush before notifying so a resident app cannot read the previous value.
Preferences.store.synchronize()
DistributedNotificationCenter.default().postNotificationName(
Expand All @@ -138,7 +201,7 @@ final class HotkeyManager {

let userInfo = Unmanaged.passUnretained(self).toOpaque()

InstallEventHandler(
let status = InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, userInfo -> OSStatus in
guard let userInfo, let event else { return OSStatus(eventNotHandledErr) }
Expand All @@ -155,6 +218,7 @@ final class HotkeyManager {
&hotKeyID
)
guard status == noErr else { return status }
guard hotKeyID.signature == HotkeyManager.signature else { return OSStatus(eventNotHandledErr) }

// Carbon calls back on the main thread; hop to the main actor.
MainActor.assumeIsolated {
Expand All @@ -167,6 +231,11 @@ final class HotkeyManager {
userInfo,
&eventHandler
)
if status != noErr {
eventHandler = nil
FileHandle.standardError.write(
Data("[HotkeyManager] InstallEventHandler failed (status \(status))\n".utf8))
}
}

private func registerHotKey(keyCode: UInt32, id: UInt32, modifiers: UInt32) -> EventHotKeyRef? {
Expand All @@ -192,8 +261,8 @@ final class HotkeyManager {
private func handle(id: UInt32) {
let direction: SwitchDirection
switch id {
case Self.leftID: direction = .left
case Self.rightID: direction = .right
case Self.leftID, Self.controlLeftID: direction = .left
case Self.rightID, Self.controlRightID: direction = .right
default: return
}
do {
Expand Down
35 changes: 35 additions & 0 deletions Sources/strafe/StatusItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class StatusItemController: NSObject, NSMenuDelegate {
private let hotkeysItem = NSMenuItem(
title: "Space-switch hotkeys (⌃⌥←/→)", action: #selector(toggleHotkeys), keyEquivalent: ""
)
private let controlArrowsItem = NSMenuItem(
title: "Also use Control + arrows…", action: #selector(toggleControlArrows), keyEquivalent: ""
)
private let controlArrowsStatusItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
private let accessibilityItem = NSMenuItem(
title: "Accessibility granted: —", action: nil, keyEquivalent: ""
)
Expand Down Expand Up @@ -63,6 +67,11 @@ final class StatusItemController: NSObject, NSMenuDelegate {
if hotkeys != nil {
hotkeysItem.target = self
menu.addItem(hotkeysItem)
controlArrowsItem.target = self
controlArrowsItem.toolTip = HotkeyManager.controlArrowSetup
menu.addItem(controlArrowsItem)
controlArrowsStatusItem.isEnabled = false
menu.addItem(controlArrowsStatusItem)
}
menu.addItem(accessibilityItem)

Expand Down Expand Up @@ -160,6 +169,21 @@ final class StatusItemController: NSObject, NSMenuDelegate {
refresh()
}

@objc private func toggleControlArrows() {
guard let hotkeys else { return }
let newValue = !HotkeyManager.controlArrowsEnabled
let alert = NSAlert()
alert.messageText = newValue ? "Enable Control + arrows?" : "Disable Control + arrows?"
alert.informativeText = HotkeyManager.controlArrowSetup
alert.addButton(withTitle: newValue ? "Enable" : "Disable")
alert.addButton(withTitle: "Cancel")
NSApp.activate()
guard alert.runModal() == .alertFirstButtonReturn else { return }
HotkeyManager.persist(controlArrowsEnabled: newValue)
hotkeys.applyStoredState()
refresh()
}

// AppKit saves visibility; initialization resets it on the next launch.
@objc private func hideFromMenuBar() {
let alert = NSAlert()
Expand Down Expand Up @@ -195,6 +219,17 @@ final class StatusItemController: NSObject, NSMenuDelegate {
}
let granted = Permissions.isAccessibilityGranted
accessibilityItem.title = "Accessibility granted: \(granted ? "yes" : "no")"
controlArrowsItem.state = HotkeyManager.controlArrowsEnabled ? .on : .off
controlArrowsStatusItem.isHidden = !HotkeyManager.controlArrowsEnabled
if !HotkeyManager.enabled {
controlArrowsStatusItem.title = "Control + arrows paused: hotkeys are off"
} else if hotkeys?.controlArrowsRegistered != true {
controlArrowsStatusItem.title = "Control + arrows registration failed; toggle to retry"
} else {
controlArrowsStatusItem.title = "Control + arrows requires macOS shortcuts off"
}
hotkeysItem.title = HotkeyManager.controlArrowsEnabled
? "Space-switch hotkeys (⌃⌥←/→ and ⌃←/→)" : "Space-switch hotkeys (⌃⌥←/→)"
hotkeysItem.state = HotkeyManager.enabled ? .on : .off
}
}
20 changes: 19 additions & 1 deletion Sources/strafe/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 {
print("transition speed: \(speed.title)")
return 0

case "control-arrows":
guard args.count <= 2 else {
FileHandle.standardError.write(Data("usage: strafe control-arrows [on|off]\n".utf8))
return 2
}
if args.count == 2 {
guard args[1] == "on" || args[1] == "off" else {
FileHandle.standardError.write(Data("usage: strafe control-arrows [on|off]\n".utf8))
return 2
}
HotkeyManager.persist(controlArrowsEnabled: args[1] == "on")
}
print("Control-arrow preference: \(HotkeyManager.controlArrowsEnabled ? "on" : "off")")
print(HotkeyManager.controlArrowSetup)
if !HotkeyManager.enabled { print("Space-switch hotkeys are off; enable them with strafe hotkeys on.") }
return 0

case "hotkeys":
// Persist the setting and notify any running menu-bar app to apply it.
guard args.count >= 2 else {
Expand Down Expand Up @@ -112,7 +129,8 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 {
strafe switch left|right switch space once and exit
strafe status print accessibility / tap status
strafe speed [preset] show or set the swipe transition speed
strafe hotkeys [on|off] show or set the ctrl+opt+arrow hotkeys
strafe hotkeys [on|off] show or set all Space-switch hotkeys
strafe control-arrows [on|off] show or set optional ctrl+arrow hotkeys

""".utf8))
return 2
Expand Down
Loading