Skip to content
Open
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
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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.
Expand Down
41 changes: 24 additions & 17 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
```

Expand All @@ -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).
Expand Down
7 changes: 4 additions & 3 deletions Scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
97 changes: 83 additions & 14 deletions Sources/strafe/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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),
Expand All @@ -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) }
Expand Down Expand Up @@ -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)
)
Expand Down
Loading