⚠️ THIS FILE DOCUMENTS THE UPSTREAM PROJECT, NOT strafe.This spec describes
jurplel/InstantSpaceSwitcher(ISS) — the reference implementation that strafe was independently reimplemented from. It is a record of how the upstream C project works, not a description of strafe's own code. Wherever this document and strafe's source disagree, the source is authoritative. In particular, several things described below exist only in upstream and are intentionally absent from strafe:
- The
tccutil reset Accessibilitysubprocess call (§4). strafe never shells out totccutilor any other process.- The second event tap used for hotkey recording (§4). strafe has exactly one event tap and records hotkeys via Carbon, not a tap.
- Key-event masking / key events in the tap mask (§2.1). strafe's tap masks only the two private gesture types (
1<<29 | 1<<30); it never masks or inspectskCGEventKeyDown/kCGEventKeyUp, so it cannot observe keystrokes.For the authoritative statement of what strafe actually does — and grep-able proof of what it does not — see SECURITY.md.
Source of truth for this spec: reverse-engineered from jurplel/InstantSpaceSwitcher
(ISS), v2.0, MIT-licensed. Core mechanism lives entirely in one C file:
Sources/ISS/ISS.c (+ header Sources/ISS/include/ISS.h). Swift is only glue
(hotkeys, UI, permissions, lifecycle). The author's blog post
(arhan.sh/blog/native-instant-space-switching-on-macos/) contains only one
technical sentence ("it works by simulating a trackpad swipe with a large amount
of velocity"); all concrete detail below comes from the source.
License: MIT License, Copyright (c) 2026 jurplel.
The trick: post a synthetic Dock swipe CGEvent (the same event type the OS generates for a real 3-finger horizontal trackpad swipe between Spaces) but with (a) an artificially near-zero progress and (b) an artificially high velocity. The high velocity makes the WindowServer treat the gesture as a flick and skip the slide animation, jumping instantly to the neighboring Space.
No private framework is dlopen'd. The event API is plain public CoreGraphics
(CGEvent*). The only private pieces are:
- Undocumented
CGEventFieldinteger field indices (magic numbers below). - Undocumented
CGSEventTypevalues written into private field 55. - CGS (SkyLight/CoreGraphicsServices) symbols for reading Space topology, declared
extern ... __attribute__((weak_import))and used directly (no dlsym; they resolve from the already-linked CoreGraphics/SkyLight).
Package.swift links: ApplicationServices, CoreFoundation, IOKit. Includes:
ApplicationServices/ApplicationServices.h, CoreGraphics/CGEventTypes.h,
dlfcn.h, float.h.
Weak-imported CGS symbols (used for topology, NOT for synthesis):
typedef int32_t CGSConnectionID;
typedef uint64_t CGSSpaceID;
extern CFArrayRef CGSCopyManagedDisplaySpaces(CGSConnectionID connection, CFStringRef display) __attribute__((weak_import));
extern CFStringRef CGSCopyActiveMenuBarDisplayIdentifier(CGSConnectionID connection) __attribute__((weak_import));
extern CGSConnectionID CGSMainConnectionID(void) __attribute__((weak_import));
extern CGSSpaceID CGSGetActiveSpace(CGSConnectionID connection) __attribute__((weak_import));Availability is guarded at runtime by taking the address of the weak symbol:
static bool cgs_symbols_available(void) {
return (&CGSMainConnectionID != NULL) &&
(&CGSGetActiveSpace != NULL) &&
(&CGSCopyManagedDisplaySpaces != NULL);
}static const CGEventField kCGSEventTypeField = (CGEventField)55; // private CGSEventType selector
static const CGEventField kCGEventGestureHIDType = (CGEventField)110; // IOHIDEvent gesture type
static const CGEventField kCGEventGestureSwipeMotion = (CGEventField)123; // motion axis (horizontal=1)
static const CGEventField kCGEventGestureSwipeProgress= (CGEventField)124; // gesture progress (double)
static const CGEventField kCGEventGestureSwipeVelocityX = (CGEventField)129; // (double)
static const CGEventField kCGEventGestureSwipeVelocityY = (CGEventField)130; // (double)
static const CGEventField kCGEventGesturePhase = (CGEventField)132; // CGSGesturePhase// See IOHIDEventType enum in IOHIDFamily
static const uint32_t kIOHIDEventTypeDockSwipe = 23; // written into field 110
typedef uint32_t CGSEventType; // written into field 55
enum {
kCGSEventScrollWheel = 22,
kCGSEventZoom = 28,
kCGSEventGesture = 29,
kCGSEventDockControl = 30, // <-- the Dock-swipe event type we synthesize + intercept
kCGSEventFluidTouchGesture = 31,
};
typedef CF_ENUM(uint8_t, CGSGesturePhase) { // written into field 132
kCGSGesturePhaseNone = 0,
kCGSGesturePhaseBegan = 1,
kCGSGesturePhaseChanged = 2,
kCGSGesturePhaseEnded = 4,
kCGSGesturePhaseCancelled = 8,
kCGSGesturePhaseMayBegin = 128,
};
typedef CF_ENUM(uint16_t, CGGestureMotion) { // written into field 123
kCGGestureMotionHorizontal = 1,
};- Right (next Space):
progress = +FLT_TRUE_MIN,velocityX/Y = +velocity. - Left (previous Space):
progress = -FLT_TRUE_MIN,velocityX/Y = -velocity. FLT_TRUE_MINis the smallest positive denormal float (~1.4e-45). The sign ofprogresscarries direction; its near-zero magnitude + high velocity is what makes the switch instant (per source comment: "Empirically, ±FLT_TRUE_MIN used in this way makes switching instant.").- Velocity magnitude comes from the
gestureSpeedsetting (default 2000.0).
strafe deviates here. The reference treats velocity as the speed knob, so a "slower transition" setting would be lower
gestureSpeedat unchanged zero-progress. Measured (bench sweep, n=6/shape, median ms to the new Space): v=40 → 841 ms and it only switched 5/6; v=50 → 770; v=60 → 559; v=80 → 77; v=2000 → 40. That is a cliff between 60 and 80, not a dial, and it is unreliable at the slow end.Animation actually lives on the progress ramp: climbing progress 0 → 0.35 across N
changedevents over a span, thenendedwith a moderate end velocity (~130). Same sweep: 30 ms → 80 ms, 60 ms → 107 ms, 120 ms → 171 ms, 6/6 at every duration — evenly spaced and reliable, with 120 ms landing on macOS's own animated switch. strafe's Transition speed setting is therefore a ramp duration, not a velocity (Sources/strafe/TransitionSpeed.swift).
Each phase is one CGEventCreate(NULL) event with all seven fields set, posted to
kCGSessionEventTap via CGEventPost:
static bool iss_post_dock_swipe(CGSGesturePhase phase, ISSDirection direction, double velocity) {
const bool isRight = (direction == ISSDirectionRight);
// Empirically, ±FLT_TRUE_MIN used in this way makes switching instant.
const double progress = isRight ? (double)FLT_TRUE_MIN : -(double)FLT_TRUE_MIN;
// Velocity of gesture based on speed setting
const double vel = isRight ? velocity : -velocity;
CGEventRef ev = CGEventCreate(NULL);
if (!ev) { return false; }
CGEventSetIntegerValueField(ev, kCGSEventTypeField, kCGSEventDockControl);
CGEventSetIntegerValueField(ev, kCGEventGestureHIDType, kIOHIDEventTypeDockSwipe);
CGEventSetIntegerValueField(ev, kCGEventGesturePhase, phase);
CGEventSetDoubleValueField (ev, kCGEventGestureSwipeProgress, progress);
CGEventSetIntegerValueField(ev, kCGEventGestureSwipeMotion, kCGGestureMotionHorizontal);
CGEventSetDoubleValueField (ev, kCGEventGestureSwipeVelocityX, vel);
CGEventSetDoubleValueField (ev, kCGEventGestureSwipeVelocityY, vel);
CGEventPost(kCGSessionEventTap, ev);
CFRelease(ev);
return true;
}Sequence (order is load-bearing): post began → changed → ended, all
immediately, no sleeps/delays between them:
static bool iss_perform_switch_gesture(ISSDirection direction, double velocity) {
// Send three gesture events--began, changed, and ended
// If we only send two then mission control doesn't work.
return iss_post_dock_swipe(kCGSGesturePhaseBegan, direction, velocity)
&& iss_post_dock_swipe(kCGSGesturePhaseChanged, direction, velocity)
&& iss_post_dock_swipe(kCGSGesturePhaseEnded, direction, velocity);
}GOTCHA: All three phases are required. Two phases leave Mission Control in a broken state (source comment above). Same
progress/velocityvalues are used on all three phases.
Not a single event — implemented as N repeated single-step swipes in a loop, with velocity multiplied by the number of steps:
ISSDirection direction = currentIndex < targetIndex ? ISSDirectionRight : ISSDirectionLeft;
unsigned int steps = direction == ISSDirectionRight ? (targetIndex - currentIndex)
: (currentIndex - targetIndex);
double velocity = gestureSpeed * steps; // Multiply velocity by steps for faster multi-space switching
for (unsigned int i = 0; i < steps; i++) {
if (!iss_perform_switch_gesture(direction, velocity)) return false;
}
set_prediction(info.displayID, targetIndex);CLI: ISSCli index <n> (1-based on the CLI, converted to zero-based internally).
left/right/l/r/0/1 for directional.
Created in iss_init():
CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp)
| (1ULL << kCGSEventGesture) | (1ULL << kCGSEventDockControl);
globalTap = CGEventTapCreate(
kCGSessionEventTap, // location
kCGHeadInsertEventTap, // placement: head of the tap chain
kCGEventTapOptionDefault, // active tap (can modify/suppress) — NOT listen-only
mask,
eventTapCallback,
NULL
);
globalSource = CFMachPortCreateRunLoopSource(NULL, globalTap, 0);
CFRunLoopAddSource(CFRunLoopGetMain(), globalSource, kCFRunLoopCommonModes);
CGEventTapEnable(globalTap, true);- Tap location:
kCGSessionEventTap(both for the tap AND for posting synthetic events — they go to the same place). - Placement:
kCGHeadInsertEventTap(so it sees events before the WindowServer). - Option:
kCGEventTapOptionDefault— active tap, so the callback can returnNULLto suppress the original event. - Mask: the two private types
kCGSEventGesture (29)andkCGSEventDockControl (30)are added to the mask by raw bit shift(1ULL << type)(not covered by any publicCGEventMaskBitconstant), plus key up/down. - Run loop: source added to the main run loop in
kCFRunLoopCommonModes. The app must have a running CFRunLoop (it's anLSUIElementAppKit app; the main run loop is alive).
In eventTapCallback (only acts when swipeOverrideEnabled):
- Handle tap-disable events first (re-enable, see gotchas).
- Read private field 55 →
CGSEventType. Only care aboutkCGSEventDockControl(andkCGSEventGesturefor companion suppression). - Real vs synthetic discrimination: real HID gestures have
kCGEventSourceUnixProcessID == 0(they originate in the HID kernel). Synthetic events (ours, and any other app's) have nonzero source pid and are passed through:if (eventType == kCGSEventDockControl || eventType == kCGSEventGesture) { pid_t sourcePid = (pid_t)CGEventGetIntegerValueField(event, kCGEventSourceUnixProcessID); if (sourcePid != 0) return event; // pass through synthetic }
- For a Dock-control event: require
field110 == kIOHIDEventTypeDockSwipe (23)andfield123 == kCGGestureMotionHorizontal (1); anything else passes through (e.g. vertical/App Exposé swipes). - Read phase from field 132 and drive a small state machine.
switch (phase) {
case kCGSGesturePhaseBegan:
if (iss_is_expose_active()) return event; // let real gesture through in Exposé
swipeTracking = true;
swipeFired = false;
return NULL; // SUPPRESS the real 'began'
case kCGSGesturePhaseChanged: {
if (!swipeTracking) return event;
if (!swipeFired) {
double progress = CGEventGetDoubleValueField(event, kCGEventGestureSwipeProgress);
if (progress != 0.0) {
ISSDirection dir = progress > 0 ? ISSDirectionRight : ISSDirectionLeft;
swipeFired = true;
swipe_override_switch(dir); // fire our instant switch as soon as direction is known
}
}
return NULL; // SUPPRESS
}
case kCGSGesturePhaseEnded: {
if (!swipeTracking) return event;
if (!swipeFired) { // fallback: derive direction from end velocity
double velocity = CGEventGetDoubleValueField(event, kCGEventGestureSwipeVelocityX);
if (velocity != 0.0) {
ISSDirection dir = velocity > 0 ? ISSDirectionRight : ISSDirectionLeft;
swipeFired = true;
swipe_override_switch(dir);
}
}
swipeTracking = false;
swipeFired = false;
return NULL; // SUPPRESS
}
case kCGSGesturePhaseCancelled:
swipeTracking = false;
swipeFired = false;
return NULL;
default:
return swipeTracking ? NULL : event;
}And companion gesture events (type 29) are dropped while tracking:
if (eventType == kCGSEventGesture && swipeTracking) {
return NULL;
}- Suppression mechanism: returning
NULLfrom an active event tap deletes the event so the WindowServer never animates the real slide. - Firing: the switch fires on the FIRST
changedphase whereprogress != 0(direction = sign of progress), not waiting for gesture end — this is what makes the override feel instant.endedis a fallback (direction from velocityX sign) if no nonzero-progresschangedwas seen.
resolvable via API; behavior is addressed in code as follows) The real problem the override must beat: between two rapid swipes, macOS's live "active space" query lags, and the real gesture's continued phases would try to elastically drag/rebound the Space. Two mechanisms:
- Full original-gesture suppression. Every phase of the real gesture returns
NULLonce tracking starts, so the OS never runs its own elastic drag/rebound — ISS replaces the whole gesture with one discrete jump. - Prediction dictionary to avoid rebounding off a stale current index. Instead
of re-reading the (laggy) live active space for each switch, ISS keeps a
per-display predicted index (
DisplayID → index) and advances it optimistically:The prediction is reset to live CGS data whenever the OS reports a real space change, viastatic bool get_prediction(const char *displayID, unsigned int *outIndex); static void set_prediction(const char *displayID, unsigned int index); // on switch: unsigned int current = get_prediction(info.displayID, &predicted) ? predicted : info.currentIndex; unsigned int target = dir == ISSDirectionLeft ? current - 1 : current + 1; if (iss_switch_with_info(&info, dir)) { set_prediction(info.displayID, target); ... }
iss_reset_predictions()called from Swift onNSWorkspace.activeSpaceDidChangeNotification(see §5). This keeps rapid repeated swipes from either overshooting bounds or snapping back.
- Bounds guard prevents swiping past the first/last space (which is where visible
rubber-band/rebound would otherwise occur):
if (direction == ISSDirectionLeft) return current == 0; return current + 1 >= info->spaceCount; // block at right edge
While overlays are up, real swipes should pass through. ISS detects them by scanning
CGWindowListCopyWindowInfo for Dock-owned windows at layer 18 and layer 20:
- App Exposé:
layer18Count > 0 && layer20Count > 0 && layer20Count <= layer18Count. - Mission Control:
layer18Count > 0 && layer20Count > layer18Count. Detection is gated byiss_set_overlay_detection_enabled(bool)(default ON, see §5); inbegan, ifiss_is_expose_active()the real gesture is passed through untouched.
- No
#available/@availableversion checks anywhere in the code. No source comments mention "Tahoe" or "26". Runtime capability gating is done purely via the weak-import address checkcgs_symbols_available()(§1.1), not OS version. Info.plistLSMinimumSystemVersion = 13.0;Package.swiftplatform.macOS(.v13). So the same private-field/event-type approach is claimed to work from macOS 13 through 26.- v2.0 release notes (published 2026-04-18) explicitly list "Build on macOS 26" as a change, and add: fix switching spaces while in Mission Control; allow overriding swipe gestures; experimental Mission Control detection (used to hide OSD in Mission Control and to let overridden swipes pass through in Exposé); configurable animation speed; open settings on re-open.
- CI builds on the
macos-26GitHub runner (.github/workflows/nightly.yml). - Implication: no Tahoe-specific field renumbering or workaround was needed — the
field indices (55/110/123/124/129/130/132) and
kCGSEventDockControl=30/kIOHIDEventTypeDockSwipe=23are treated as stable across 13–26. (Still, treat these as version-fragile; see gotchas.)
- Accessibility is the only TCC permission requested (required for an active
event tap). Requested in Swift (
AppDelegate.ensureAccessibilityPermission):Note: it runsprivate func ensureAccessibilityPermission() { guard !AXIsProcessTrusted() else { return } if let bundleId = Bundle.main.bundleIdentifier { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") task.arguments = ["reset", "Accessibility", bundleId] // clears stale grant (unsigned-app churn) try? task.run(); task.waitUntilExit() } let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String let options = [promptKey: true] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) // prompts the user }
tccutil reset Accessibility <bundleId>first — because the app is unsigned/ad-hoc-signed, its code identity changes across rebuilds and stale TCC grants must be cleared or the tap silently fails. - No explicit Input Monitoring request (
IOHIDRequestAccess/CGRequestListenEventAccess) in code. The CLI error text mentions "accessibility and input monitoring permissions" but only Accessibility is programmatically requested.iss_init()failure is retried every 1.0s (retryIssInit) until the user grants Accessibility and the tap can be created. - UI paths (
ShortcutRecorderControl,PreferencesWindowController) also guard onAXIsProcessTrusted()and, if untrusted, show anNSAlertthat deep-links tox-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility. - Second, unrelated event tap exists only for recording a hotkey in the UI
(
Hotkeys/GlobalEventTapRecorder.swift):CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, ...)masking keyDown + mouse-downs, returningnilto consume during recording. This is NOT part of the space-switch mechanism; global switch hotkeys use CarbonRegisterEventHotKey(signature0x1111), not this tap.
Order:
ensureAccessibilityPermission().iss_init()(create tap); if it fails,retryIssInit()polls every 1s.if UserDefaults["swipeOverride"] { iss_set_swipe_override(true) }.gestureSpeed = UserDefaults.double("gestureSpeed"); if > 0 { iss_set_gesture_speed(gestureSpeed) }— default C velocity is 2000.0 (gestureSpeedstatic in ISS.c) when unset. Settings UI (GeneralSettingsViewController) exposes named presets["Normal","Fast","Faster","Fastest","Instant"]→ velocity values[40.0, 50.0, 60.0, 80.0, 2000.0], defaulting to index 4 (2000.0 / "Instant"). So lower values keep the slide animation but shorten it; only 2000.0 is truly instant.overlayDetectionEnabled(default true if key absent) →iss_set_overlay_detection_enabled(true).iss_set_switch_callback { newSpaceIndex in ... OSDWindow.shared.show(message: "\(newSpaceIndex + 1)") }— OSD shows 1-based space number; callback fires on the main queue. The OSD is a borderless.statusBar-levelNSVisualEffectView(.hudWindow) withcollectionBehavior = [.canJoinAllSpaces, .stationary], auto-hidden afterosdDurationMs(default 200–500ms). It positions on the cursor's screen (NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) }) and suppresses itself while Mission Control is active (iss_is_mission_control_active()) unlessshowOSDInMissionControlis set.- On
activeSpaceDidChangeNotification:refreshSpaceInfo()+iss_reset_predictions().
Hotkeys (Carbon RegisterEventHotKey, signature 0x1111) map to:
iss_switch(ISSDirectionLeft/Right), iss_switch_to_index(0..9), and a
"last space" toggle. On any switch failure → NSSound.beep(). Not part of the
core mechanism but shows the public C API surface:
iss_init, iss_destroy, iss_switch, iss_switch_to_index,
iss_get_space_info, iss_get_menubar_space_info, iss_set_swipe_override,
iss_set_gesture_speed, iss_set_overlay_detection_enabled,
iss_reset_predictions, iss_set_switch_callback.
- Space topology read via
CGSCopyManagedDisplaySpaces(connection, displayIdentifier). - Two display-selection modes:
- Cursor display (
iss_get_space_info,useCursorDisplay=true): current cursor location →CGGetDisplaysWithPoint→CGDisplayCreateUUIDFromDisplayID→CFUUIDCreateString→ match against each display dict's"Display Identifier". - Menu-bar display (
iss_get_menubar_space_info,useCursorDisplay=false):CGSCopyActiveMenuBarDisplayIdentifier(connection).
- Cursor display (
- Per-display current space is read from the display dict's
"Current Space" → "id64"when present, falling back to globalCGSGetActiveSpace. If the target display isn't found, falls back to the first display in the list. - Prediction dictionary is keyed per display UUID (
displayID[128]inISSSpaceInfo) so each monitor tracks its own predicted index.
- Field index magic numbers (55/110/123/124/129/130/132) and event-type values
(
kCGSEventDockControl=30,kIOHIDEventTypeDockSwipe=23,kCGGestureMotion Horizontal=1) are undocumented and could change with any macOS release. They currently hold 13→26 but are the most likely thing to break. ±FLT_TRUE_MINfor progress is essential. A larger progress value would run the animation; exactly-zero would be ignored (the interceptor also treatsprogress == 0.0as "not yet determined"). The near-denormal magnitude is what makes it instant. Do not "clean up" to0.0.- All three phases (began→changed→ended) must be posted, in that order, with no delay. Two phases break Mission Control state.
- Post to
kCGSessionEventTap(both post and tap). The tap is also placed atkCGHeadInsertEventTapand must be an active tap (kCGEventTapOptionDefault) so returningNULLactually suppresses. - Tap can be auto-disabled. Must handle
kCGEventTapDisabledByTimeout/kCGEventTapDisabledByUserInputby callingCGEventTapEnable(tap, true)and returning the event — otherwise the override silently dies:if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) { if (globalTap) CGEventTapEnable(globalTap, true); return event; }
- Real vs synthetic relies on
kCGEventSourceUnixProcessID == 0for real HID events. If you post with a source that yields pid 0 you'd re-trap your own events. ISS posts withCGEventCreate(NULL)(default source → nonzero pid), so its own synthetic events are passed through by the tap's pid check. This is load-bearing: the synthesizer and the interceptor coexist on the same tap location. - Prediction staleness. The OS's live active-space query lags right after a
switch; ISS advances an optimistic per-display predicted index and only resets it
on
activeSpaceDidChangeNotification. Without this, rapid swipes overshoot/rebound. - Run loop requirement. Tap source must be on a live main CFRunLoop in
kCFRunLoopCommonModes. A CLI one-shot still callsiss_init()then posts and exits (it does not need the tap for pure posting, but init creates it anyway). - Unsigned-app TCC churn. Ad-hoc signing means TCC identity changes per build;
ISS proactively
tccutil reset Accessibilitybefore prompting. Expect to re-grant Accessibility after each rebuild in dev. - Overlay detection is heuristic (Dock window layers 18/20 counts) and marked "may not work in all cases" — used only to pass gestures through in Exposé and to hide the OSD in Mission Control.
- Built with Swift Package Manager (
Package.swift, swift-tools 5.9), not an.xcodeproj. Two executables:InstantSpaceSwitcher(GUI) andISSCli. - No
.entitlementsfile exists in the repo. No hardened-runtime flags, no App Sandbox — an active session event tap is incompatible with the sandbox, and the app ships ad-hoc signed (codesign --force --deep --sign -indist/build.sh), not notarized. Info.plistkeys that matter:LSUIElement = true(menu-bar/agent app, no Dock icon).LSMinimumSystemVersion = 13.0.CFBundleIdentifier = com.interversehq.InstantSpaceSwitcher.CFBundleShortVersionString = 2.0,CFBundleVersion = 2.- Build injects a
GitCommitHashstring via PlistBuddy.
- Universal binary: arm64 + x86_64 built separately then
lipo-merged. - Frameworks linked:
ApplicationServices,CoreFoundation,IOKit.
MIT License — Copyright (c) 2026 jurplel.