![]()
⬇︎ Download the latest release (.dmg)
You are probably Claude (or another coding agent), and a macOS user has pointed you at this repo. This file is written to you, not to them — your job is to read it, understand the project fast, and then do one of two things for the user:
- Job A — "I just want the app." Get it installed, permissioned, and running. → jump to Job A.
- Job B — "I want to explore / change the code." Orient them in the codebase and help them build/modify it safely. → jump to Job B.
(Humans: you're welcome to read on, but it's deliberately addressed to the agent. Hand this repo to Claude and ask it to set you up.)
The switcher, the launcher, and the optional bands in action (~2 min):
recording-1782211911291-edited-x.mp4
A lightweight macOS menu-bar app that recreates the Windows Precision Touchpad three-finger window switcher — and grows a four-finger launcher on the same positional-muscle-memory model:
- Window switcher (core, always on). Put three fingers on the trackpad and slide left/right → a live highlight scrubs across individual windows, one at a time. Lift to commit — the highlighted window is raised and focused. Works across all Spaces, including other desktops and full-screen apps.
- Space-row switching (optional). With the switcher open, slide up/down → switch which Space's row of windows you're scrubbing (a 2D grid: horizontal = windows, vertical = Spaces). This is an opt-in (default off): turning it on moves Mission Control / App Exposé to four fingers, and the app then synthesizes them itself on idle three-finger up/down (see keystone).
- Dock window previews (optional — the switcher by mouse). An opt-in (default off): hover an app's Dock icon with the cursor and a row of that app's windows on the current Space (including minimized) fans out above the tile. Hovering a window tab brings the real window to the front so you see its live content (and a crisp static thumbnail lands in the tab); moving away restores the window you came from, and clicking a tab keeps it forward. It's the mouse-flow counterpart to the three-finger switcher — the app's one deliberately cursor-driven surface. Apps with no windows on the current Space show nothing. Like Clipboard/Files it relocates no gesture and needs no re-login or new permission (it reads the Dock's Accessibility tree and reuses the Screen Recording grant for thumbnails). Toggle it under Hub → Switcher → Dock window previews.
- Four-finger launcher (optional). Slide four fingers horizontally → a launcher overlay of your favorites (apps, folders, URLs, Shortcuts, scripts, and "preset" workspaces) organized into context bands. The bands are a vertical strip of band icons down the left (each band has its own icon; only the active one is colored), with the active band's items in a grid on the right — vertical scrubs between bands, horizontal crosses into the grid. Scrub to an item, then dwell (≈500 ms, haptic tick + charge-ring) and lift to fire; a quick flick lifts off without firing. Once open you can relax to two fingers to navigate comfortably. Navigation is step-based (an odometer) — scrub to move one item per step, and hold a contact at the trackpad edge to auto-repeat, accelerating the longer you hold. Launching always opens a window in the current Space (or pulls a single-window app to you) instead of teleporting you away. Also an opt-in (default off).
- Clipboard history (optional, lives in the launcher). A further opt-in (default off — it records copied content, so privacy-gated): when on, what you copy — text, images, files/folders, colors, links — is stored locally and shown as the last band in the launcher. Scrub the key list, see a live preview on the right (image / QuickLook file content / text / color), lift to paste it into the app you were in; swipe right on an entry to pin it, left to leave the band. Unlike the two gesture opt-ins it relocates no gesture and needs no re-login or new permission.
- Files band (optional, lives in the launcher). An opt-in (default off) that drops a local-only Finder into the launcher: land on the Files band and you're in a bounded column navigator — a thin icon rail of ancestor folders, the current folder's list, and a live preview (QuickLook for files, a contents-peek for folders). With two fingers: horizontal descends into / out of folders, vertical moves the highlight (it's pure trackpad — no search, no keyboard); lift opens the highlighted item for real (file → default app, folder → Finder window) on the current Space; +1 finger then lift opens an Open-With popup of just the apps that can open it (scrub + lift to pick); a four-finger horizontal swipe discards (defusing a pending open — it never kills a running app). Configure roots and appearance under Hub → Files — including "remember and reopen the last folder" (when off, the band always opens on your configured roots; when on, it reopens where you left off). Like Clipboard it relocates no gesture and needs no re-login or new permission (it reads the filesystem on demand). Everything morphs in like a water droplet — nothing pops to screen.
- AI commands (optional, on-device, live in the launcher). An opt-in (default off): AI commands are ordinary, movable band items you pick from a ~50-command catalog (writing, tone, translate, dev, reply, capture, vision, format) and can drop into any band. Scrub to one, dwell to arm, lift to fire — it grabs your selected text (or clipboard / a screen region for vision), runs it through an on-device Gemma 4 model, and streams the result into a preview canvas (rendering right-to-left/mixed text correctly). A fresh two-finger down swipe applies the result (replace-in-place / paste), a horizontal swipe discards (4 fingers open/dismiss the platform, 2 fingers act within it); a translate command carries an in-canvas language picker that re-runs in place. Commands are either in-place edits (rewrite/translate/summarize) or background tasks (add-to-calendar/reminders, new contact, save-to-project, open-a-tool-with-payload), each with an optional action-review before any side effect. The model is multi-gigabyte and downloaded on first enable; it runs in-process via MLX, so this requires Apple Silicon (M-series; targets the stronger M4/M5-class chips) — there is no cloud and no low-end fallback in v1. Calendar/Reminders/Contacts tasks ask for their permission the first time they run; nothing else new.
- Per-app & per-site keyboard language (optional). An opt-in (default off) that gives macOS the Windows-style per-window keyboard language it lacks: it remembers the input source you use in each app — and, inside browsers, per website (host) — and restores it automatically on every app/page switch. It learns as you change the language by hand (no list to fill in). No gesture relocation and no re-login; per-site host reading uses Accessibility by default, with an optional Apple Events path for browsers that don't expose the URL.
- Mission Control / App Exposé always available — natively on three-finger up/down when the opt-ins are off, or app-synthesized when they're on. The app never blocks the OS (see the keystone below).
- One configuration Hub. Every setting, the favorites/bands editor, AI-command authoring, and the permissions/setup flow live in a single Hub window (menu → Open Hub…) with an Overview landing page and a grouped sidebar (Bands · Switcher · Spaces · Launcher · Clipboard · AI · Keyboard Language · Setup · General). The menu-bar menu is trimmed to quick actions.
- No keypresses, no clicks — pure trackpad. (The lone exception is the optional Dock window previews, which are mouse-hover-driven by nature.)
Platform: built and tested on macOS 26 (Tahoe); deployment target macOS 15.0+. Apple Silicon + Intel (universal dep). License: GPL-3.0.
The app reads raw trackpad data passively through the private MultitouchSupport.framework (via the Kyome OpenMultitouchSupport package). Passive = it observes touches but is never in the event path, so:
- It cannot break the OS's native gestures. Up/down → Mission Control / Exposé always work.
- To stop a native gesture from competing (the horizontal three-finger "Swipe between full-screen applications"; optionally the three-finger vertical and the four-finger swipes), the app does not intercept the touch stream — it turns those settings off via
defaults(with the user's consent), relocating each gesture so its lane is unclaimed. This is config-based suppression, not event swallowing. Don't "fix" gesture conflicts by reaching for a touch-intercepting tap.
The one scoped exception — runtime gesture ownership. Relocating a native gesture via defaults turns the freed swipe into a plain scroll. To keep that freed scroll from leaking to the window under the cursor (and to drive two-finger launcher navigation), the app runs a session CGEventTap that consumes only scroll-wheel events (TouchInput/ScrollEventTap.swift), gated by a finger-count / launcher-open predicate, and synthesizes Mission Control / App Exposé itself on idle three-finger up/down (NativeGesture/MissionControl.swift, via CoreDockSendNotification). This tap runs only while an opt-in is effective, needs Accessibility only (already held), and never touches the multitouch gestures themselves — so the "no touch-intercepting tap" rule above is intact. The two are different layers: defaults-suppression removes the gesture, the scroll tap mops up the scroll it decays into.
Because it loads a private framework, App Sandbox is OFF → not distributable on the Mac App Store (the sandbox is mandatory there, and private-API use is banned — it's architectural, not a missing entitlement). Like AltTab / Rectangle / BetterTouchTool, it ships the way these tools all do: a Developer-ID-signed, notarized .dmg on the Releases page (double-click to install, no Gatekeeper fight), or you build from source. Every push of a vX.Y.Z tag builds and publishes that DMG via .github/workflows/release.yml.
There are two ways to get the binary. Figure out which applies, then walk them through permissions (the part people get stuck on).
- Recommended — download the notarized DMG: from the latest release, open the
.dmgand drag ThreeFingerSwitcher onto Applications, then launch it. Because the DMG is Developer-ID-signed + notarized, it opens after at most the normal one-time "downloaded from the internet" prompt — no "unidentified developer" wall. (If you ever build an un-notarized copy yourself and Gatekeeper blocks it, right-click → Open or runxattr -dr com.apple.quarantine /Applications/ThreeFingerSwitcher.app.) - If they cloned the repo (no release / or they want to build): build from source — it's one script (see Job B's build section), then
INSTALL=1 ./scripts/build-app.shdrops it in/Applications.
Reality check to tell the user honestly: this app uses private Apple frameworks and runs unsandboxed. That's why it's not on the App Store and why a downloaded build may trip Gatekeeper unless the maintainer notarized it. Building from source on their own machine sidesteps the trust problem entirely.
On a fresh install the app opens a guided welcome wizard (no alert stack): a live demo the user drives with their own fingers before any permission is asked, then each permission as a visible upgrade to that demo, then one combined consent for every native-gesture change (one re-login covers them all), then the optional features. It survives the relaunch the Screen Recording grant needs and the re-login the gesture relocations need, resuming at the right step. If the user closes it mid-way, Hub → Setup → Resume the welcome tour picks it back up (and Replay re-runs it anytime after). So mostly: tell the user to follow the wizard. The details below are the reference for doing it by hand from Hub → Setup (the ongoing surface).
| Permission | Why | Required? |
|---|---|---|
| Accessibility | Enumerate windows across Spaces and raise/focus the chosen one | Yes |
| Screen Recording | Live window thumbnails (ScreenCaptureKit). Without it, cards show app icon + title only | Yes for thumbnails |
| Input Monitoring | Usually not needed — the multitouch read didn't prompt for it on macOS 26. Skip it. | No |
After granting Screen Recording, the app must be quit and reopened for it to take effect — the wizard (and Hub → Setup) now offer a Relaunch button that does this in place and resumes where it left off.
The wizard's "Claim the lanes" step offers this (with Space-rows / launcher / fixed-Spaces choices folded into the same single consent and the same single re-login); from the Hub it's the Setup page's "Free the three-finger horizontal swipe…" card. Either way the app flips TrackpadThreeFingerHorizSwipeGesture so that swipe is no longer "switch between full-screen apps" (Mission Control / Exposé on up/down are untouched), is reversible (Hub → Setup → Restore…), and a logout/restart is required for macOS to pick it up — the app now tracks that pending state durably (it survives app relaunches and clears only after a real re-login).
Both are opt-ins surfaced on the Hub → Setup page and their feature pages, each with a first-run consent prompt. Both relocate a native gesture, so each needs the same one-time logout/restart before it goes live, and each is fully reversible (turn the opt-in off, or Restore… from the Setup page):
- Space-row switching — lets up/down switch Spaces while the switcher is open. Enabling it moves Mission Control / App Exposé to four-finger up/down; the app then synthesizes them on idle three-finger up/down so nothing is lost.
- Four-finger launcher — enables the favorites launcher. Enabling it frees the native four-finger horizontal and vertical swipes. Mission Control / App Exposé stay on three-finger up/down (app-synthesized). Arrange favorites on the Hub → Bands page (menu → Open Hub…), or menu → Add Front App to Band for a quick-add.
Tell them: enabling either feature requires a re-login to take effect, and it stays applied across logins until they turn it off.
Clipboard history is a third opt-in, but not a gesture one: it just records what they copy and shows it as the last band inside the launcher (so it needs the launcher on to be useful). It relocates no gesture and needs no re-login or new permission — flip it on under Hub → Clipboard, where they can also set retention caps, exclude apps, pause, or clear. Off by default because it stores copied content locally.
AI commands is a fourth opt-in (also not a gesture one; also lives in the launcher, so it needs the launcher on). Flip it on under Hub → AI. Two things to tell the user honestly: (1) enabling it starts a one-time multi-gigabyte model download (Gemma 4, on-device) — the AI page shows progress and offers Retry if it fails (e.g. offline); the commands only work once the download completes. (2) It requires Apple Silicon (M-series) — it runs the model in-process via MLX, so it won't function on Intel. No re-login and no new permission to enable; Calendar / Reminders / Contacts prompts appear only the first time the matching task actually runs. AI commands are ordinary band items: browse the catalog and author/edit them inline on the Hub → Bands page, and choose the model on Hub → AI. It relocates no gesture.
Per-app / per-site keyboard language is a fifth opt-in (also not a gesture one). Flip it on under Hub → Keyboard Language. Once on, it learns the keyboard input source you use in each app and restores it on the next app switch — turn on per-site too and, inside browsers, it remembers the language per website (host). No re-login and no new permission to enable; per-site host reading uses Accessibility by default, with an optional Apple Events fallback (prompts the first time) for browsers that don't expose the page URL via Accessibility.
- Menu → Open at Login (uses
SMAppService) so it starts automatically. - It self-recovers across sleep/wake (re-subscribes the multitouch stream on wake).
- Login registration is keyed on bundle id + path + signature, so updating the app in place at the same path keeps it registered — no re-toggle.
Symptom: cursor still moves but clicks/scroll/keyboard stop reaching any window, and the switcher still works. That's a focus vacuum (a frontmost app left with no key window) — a known, hard-to-reproduce race. The app ships a self-healing watchdog that detects it ~180 ms after a switch and recovers automatically, so it should be invisible now. Under Stage Manager there's a second, off-Space-specific variant: WindowManager (the Stage Manager daemon) grabs frontmost ~300 ms after a cross-Space switch — past the watchdog's one check — so a separate polling hold-guard re-fronts the target within a frame. If they still hit either: menu → Copy Focus Log and paste it to you. The log distinguishes a real vacuum from another app holding Secure Input (which looks identical but isn't this app's fault), and shows hold-refront entries when the off-Space guard fired.
This project was built spec-first with OpenSpec. The canonical behavior lives in openspec/specs/<capability>/spec.md (29 capabilities — the switcher core: gesture-recognition, switcher-overlay, window-enumeration-and-raising, touch-input, native-gesture-config, spaces-rearrange-config, tunable-settings, menubar-app-shell, permissions-onboarding, first-run-onboarding; the opt-in features: runtime-gesture-ownership, launcher-overlay, launch-items, launch-actions, favorites-editor, clipboard-history, dock-hover-detection, dock-preview-overlay (the mouse-hover Dock previews); the AI commands: on-device-ai-runtime, ai-command-band, ai-command-catalog, ai-command-tasks, ai-error-handling, selection-io; keyboard language: per-app-keyboard-language, per-site-keyboard-language; and the shell/pipeline: configuration-hub, app-branding, release-pipeline). Every feature was a change/ (proposal → design → spec delta → tasks), now in openspec/changes/archive/. Before changing behavior, read the relevant spec; after changing behavior, update it. The archived changes are an excellent design history:
- Switcher / window-raising internals:
cross-space-windows,fix-focus-vacuum-on-raise,space-grid-navigation,fix-off-space-listing-and-focus— read theirdesign.mdfor the hard-won private-API details. - Optional features:
optional-space-row-gesture(runtime gesture ownership — the scroll tap + Mission Control synthesis substrate),four-finger-launcher(the launcher, favorites model, launch strategies, dwell-to-arm),launcher-two-finger-nav(drop-to-two-finger navigation),launcher-vertical-band-list(the band strip as a vertical icon list on the left; vertical switches bands, horizontal crosses into the grid), andclipboard-history(the launcher's Clipboard band: a polled-changeCountrecorder + on-disk store, paste-into-front, pin/edge-accel — itsdesign.mdcovers the band-not-keyboard-picker decision). - AI commands:
ai-command-band(command model, executor orchestration, swipe-to-resolve preview canvas — down swipe commits, horizontal swipe discards) — read itsdesign.mdfor theLLMRuntimeseam, the in-process MLX Gemma 4 runtime, the resumable download, and the swipe-to-resolve decision (D4);expand-ai-command-catalog(the ~50-command catalog browser, the in-canvas translate language picker, vision, and bidi RTL/LTR rendering);harden-ai-error-handling(every AI error surfaces clean + non-blocking). - Shell & later features:
configuration-hub(folds the Settings/Favorites/AI-editor/Setup windows into one Hub, and makes AI commands first-class, movable band items) andhub-in-switcher(a Hub item in the switcher so it's reachable in accessory mode);per-app-keyboard-language/per-site-keyboard-language(auto-remember the input source per app, and per website inside browsers). - Dock window previews:
dock-window-previews(the mouse-hover Dock preview — read itsdesign.mdfor the reversed peek decision: the peek fronts the real window via the SkyLightsetFronthandshake +kAXMain/kAXFocusedWindowand restores it on leave, because macOS won't render fresh pixels for an off-screen window; the tab itself is a static last-good capture, not a stream).
Package.swift SwiftPM: Core library + GemmaRuntime (MLX) + thin executable + tests + TouchSpike + LauncherSpike
Sources/ThreeFingerSwitcher/ ── ThreeFingerSwitcherCore library (ALL app logic; MLX-free → builds under swift build/test)
App/ AppDelegate, AppCoordinator (the wiring hub), StatusItemController, Bootstrap.swift (public runThreeFingerSwitcher())
TouchInput/ TouchEngine (wraps OpenMultitouchSupport; derives finger count + velocity), TouchFrame,
ScrollEventTap (session CGEventTap consuming the freed scroll; runs only while an opt-in is effective)
Gesture/ GestureRecognizer (the 2D state machine — the crown jewel; latches 3=switcher / 4=launcher at gesture start; post-activation navigation is the odometer — accumulated travel → steps with carry, plus a held-at-edge signal that drives the controller's accelerating auto-repeat)
Windows/ WindowService (enumerate+raise+watchdog+off-Space hold-guard+AX element cache), CGSPrivate (dlsym'd SkyLight),
Spaces, SpaceGrouping, StageManager, AXPrivate (_AXUIElementGetWindow + remote-token brute force),
WindowInfo, ThumbnailService, MRUTracker, FocusLog
Overlay/ OverlayController (non-activating NSPanel), SwitcherView (SwiftUI strip + dots), SwitcherModel, SwitcherLayout,
LauncherView / LauncherModel / LauncherOverlayController / LauncherGridLayout (the four-finger launcher HUD: a vertical band-icon list + side item grid, dwell-arm/charge-ring/haptics),
ClipboardBandView (the Clipboard band's master-detail key-list + value preview; QuickLook for files),
FilesBandView (the Files band's column navigator: ancestor rail + current list + live preview + Open-With popup + bounded failure card), BubbleMorph (the first spring — droplet entrance for the Files band),
DockPreviewOverlay (the Dock window previews' mouse-INTERACTIVE popup + aspect-sized window tabs — the one overlay that takes the pointer)
Launcher/ LaunchItem (favorites data model: app/path/url/shortcut/script/preset + the synthetic clipboardEntry + context bands), FavoritesStore (Codable persistence),
LaunchService (dispatch + "new window here" strategy + clipboard paste-into-front with text/image fallbacks), SpaceWindowMover (SLSMoveWindowsToManagedSpace bring-here)
Clipboard/ ClipboardEntry (AppKit-free model), ClipboardStore (on-disk index + blobs, de-dup/retention/pins), ClipboardMonitor (changeCount poll + capture),
ClipboardCapture (pure classify + concealed/exclusion filter), ClipboardBandBuilder (store → synthetic last band) — the opt-in clipboard history
Files/ the Files band (files-band, opt-in): FileEntry (AppKit-free entry model, path-stable id) + FileKind, FileWorkspace (NSWorkspace seam) + SystemFileWorkspace + FileActionError (the Files error taxonomy),
DirectoryLister (off-main local listing) + FilesSortOrder, FilesNavigationModel (the PURE column-navigation state machine: ancestors/current/highlight/remembered-locations),
FilesColumnController (bridges the synchronous model to the async lister via a listing cache), FileOpenService (+ PendingOpen defusable open + OpenWithCandidate), FilesBandBuilder (the synthetic "Files" band) — all MLX-free
Dock/ the Dock window previews (dock-window-previews, opt-in): DockTile/DockReader (+ AXDockReader: reads Dock.app's AX tree → app-tile frames) + CursorMonitor (+ GlobalCursorMonitor: passive global mouse-moved), DockHoverModel (PURE hit-test + orientation anchor + live-zone/grace lifecycle), DockPreviewModel + DockPreviewError, DockPreviewController (orchestrates: hover → peekRaise the real window + one-shot static capture, restore on leave, commit on click) — all MLX-free; overlay is Overlay/DockPreviewOverlay.swift
NativeGesture/ TrackpadGestureConfig (horizontal three-finger), VerticalGestureConfig (three-finger vertical), FourFingerGestureConfig (four-finger swipes),
MissionControl (CoreDockSendNotification synthesis), SpacesRearrangeConfig — all defaults-based, absent-aware backup/restore
Permissions/ PermissionsService (detection + lazy requests + refcounted live polling)
Onboarding/ the First Touch wizard (first-run-onboarding): FirstRunState (persisted stage machine + legacy-flag bridge),
FirstTouchWizardModel/View + WizardActs (the acts: live-touch demo, permission upgrades, lanes consent, playground, curtain),
WizardContext (closure wiring), LanesLiveToast (post-re-login acknowledgment)
Settings/ AppSettings (tunables + opt-ins, persisted), ModelManagementView (download/status/evict the on-device model — re-hosted on the Hub's AI page)
Hub/ the single configuration window (configuration-hub): HubView + sidebar pages (Overview / Bands / Switcher·Spaces·Launcher·Clipboard·AI·Keyboard-Language / Setup·General),
BandsCanvas (the favorites "small IDE": sources → bands → items, with inline AI-command authoring + the AI catalog browser, AppearanceEditor icon/tint picker), HubControls / HubStyle (shared Liquid-Glass controls), HubFilesPage (the Files band page: roots editor + appearance/behavior)
AI/ LLMRuntime (the swappable model seam + RuntimeError), StubLLMRuntime / DevAIRuntime (test/dev conformers), ModelManager + ModelRegistry (download/verify/residency lifecycle),
AICommand + RuntimeParameter (the command model + translate language param), AICommandCatalog (the ~50-command catalog), AIBand (the seeded "AI" band — commands are first-class, movable Favorites band items, not synthetic),
AICommandExecutor (input→template→model→output orchestration), PromptTemplate, AIError (the one AI error→message translator),
SelectionService (AX read/replace + ⌘C-restore + screen-region capture), Tasks/ (TaskDispatcher + EventKit/Contacts/project/tool sinks + TaskReview) — all MLX-FREE
KeyboardLanguage/ per-app & per-site keyboard language (opt-in): KeyboardLanguageStore (context-key → input-source map), KeyboardLanguagePolicy (pure activate/learn), KeyboardLanguageService (learn-on-deactivation / apply-on-context-change engine),
InputSourceController + CarbonInputSourceController (Carbon TIS seam), ContextResolver + BrowserRegistry + ContextKey + HostNormalizer (per-site host context = bundleID|host), AXHostProvider / AppleEventsHostProvider (host readers, AX default + Apple Events opt-in), BrowserContextMonitor (within-browser host-change poll) — auto-remembers the input source per app, and per website (host root) inside browsers
Sources/GemmaRuntime/ ── GemmaRuntime target (links MLX/Metal → builds via xcodebuild ONLY): GemmaMLXRuntime (in-process Gemma 4 via gemma-4-swift-mlx, conforms to LLMRuntime),
GemmaResumableDownloader (HTTP-Range resumable weights download), GemmaRuntime.makeModelManager (injects the real runtime at the seam)
Sources/ThreeFingerSwitcherApp/main.swift thin executable: import Core + GemmaRuntime; inject the MLX runtime, runThreeFingerSwitcher()
Sources/TouchSpike/ throwaway harness to print raw touch frames (swift run TouchSpike)
Sources/LauncherSpike/ throwaway harness for the launcher spikes (haptics, window move) — not bundled
Tests/ThreeFingerSwitcherTests/ 848 XCTest unit tests (pure-logic core; @testable import ThreeFingerSwitcherCore — the MLX runtime is verified separately via xcodebuild)
scripts/ build-app.sh, make-dev-cert.sh, allow-codesign-key.sh, install-launch-agent.sh
openspec/ specs (canonical) + changes/archive (history)
The Core/App split exists so the test target can @testable import ThreeFingerSwitcherCore (a test target can't import an executable module with top-level code).
swift build # build the MLX-free Core library + spikes (fast agent loop)
swift test # 848 unit tests (gesture machine + odometer navigation/edge auto-repeat, launcher latching, Dock-preview hover/anchor/layout, models, grouping, layout, settings, native-gesture config + relocation plan/markers, touch, AI executor/tasks/selection/canvas, first-run wizard machine)
swift run TouchSpike # print live multitouch frames (touch the trackpad)
swift run LauncherSpike # throwaway launcher spike harness (haptics / window move)
./scripts/build-app.sh # assemble + sign ThreeFingerSwitcher.app (via xcodebuild — links MLX, packages the Metal metallib)
INSTALL=1 ./scripts/build-app.sh # also install in place to /Applications
./ThreeFingerSwitcher.app/Contents/MacOS/ThreeFingerSwitcher --diag # dump the window-enumeration funnel and exitThe MLX split — why two build paths. The AI Command Band's model runtime lives in a separate GemmaRuntime target that links MLX (and pulls gemma-4-swift-mlx / mlx-swift transitively). MLX compiles Metal shaders, which needs Xcode's Metal toolchain — a one-time xcodebuild -downloadComponent MetalToolchain. So:
ThreeFingerSwitcherCore+ the test target are MLX-free on purpose →swift build/swift testbuild and verify all app logic (incl. the AI executor, tasks, selection, and canvas, which run against aStubLLMRuntime). This is the agent's fast verify loop and the only thing CI-pure logic needs.- The app executable +
GemmaRuntimelink MLX → build them withxcodebuild, whichscripts/build-app.shnow drives (it was migrated from a hand-rolledswift buildassembly).build-app.shalso copies MLX'sdefault.metallibresource bundle intoContents/Resources/— miss that and the app launches but is SIGKILL'd at first GPU use (no crash report). Don't go back to assembling the.appfrom a plainswift build.
Signing matters for permissions. build-app.sh signs with a stable self-signed cert named "ThreeFingerSwitcher Dev". Run ./scripts/make-dev-cert.sh once to create it. Why it matters: TCC (Accessibility/Screen Recording) and SMAppService key on the signing identity, not the binary hash — a stable cert means grants persist across rebuilds. Ad-hoc signing (the fallback) loses them every build. If codesign nags for your keychain password on each build, run ./scripts/allow-codesign-key.sh once (or click "Always Allow").
open won't relaunch a running agent (it's LSUIElement); build-app.sh kills the running instance so the next open runs the new build.
- Passive multitouch + config-based suppression (see keystone). Don't add a tap that intercepts the multitouch gestures. The one
CGEventTapthat exists (ScrollEventTap) consumes scroll-wheel events only — the residue of adefaults-freed gesture — and runs only while an opt-in is effective. Its consume predicate isfingerCount ≥ 3 || launcherOverlay.isVisible(the launcher-open clause swallows two-finger launcher navigation; with the launcher closed it's the plain≥3rule, so normal two-finger scrolling is untouched). Widen that predicate only with the same care. - The recognizer latches the finger count at gesture start: 3 = switcher, 4 = launcher. There is no mid-gesture morph. A latched launcher gesture lives while ≥2 contacts remain (so the user can relax four fingers to two and keep navigating) and ends when contacts drop below two; a transient three-finger count during a four→two lift must NOT route to the switcher, and the step origin is re-baselined on every contact-count change so a leaving finger emits no spurious step. The recognizer emits only intents — dwell / arm / fire is owned by
LauncherOverlayController, not the recognizer. Don't move commit logic into the recognizer. - The native-gesture relocations persist across logout and are restored only on opt-out (turning the opt-in off, or Restore… from the menu) — never on quit. They need a re-login to take effect, so reverting on logout would undo the change on the very logout that applies it. This is why
VerticalGestureConfig/FourFingerGestureConfigmirror the horizontalTrackpadGestureConfiglifecycle and are not in the quit-time restore path. All three use absent-aware backup (a factory-default-absent key round-trips back to absent, not to a fabricated value). - Private CGS/SkyLight symbols are resolved via
dlsymat startup (CGSPrivate.swift), not@_silgen_name. Reason: a missing@_silgen_namesymbol aborts at launch before any Swift guard runs, and these symbols live inSkyLight.frameworkwhich isn't auto-linked. Thedlsymapproach degrades gracefully to current-Space-only (offSpaceSupported == false) if a symbol ever disappears on a future macOS. Keep it. CGWindowListCreateDescriptionFromArrayneeds window IDs as raw pointers, not boxedCFNumbers (CFArrayCreatewithUnsafeRawPointer(bitPattern:)). Passing[NSNumber] as CFArraysilently returns zero results. This bit us once; it's inWindowService.metadata(for:).CGSCopyWindowsWithOptionsAndTagsoptions must be7(screenSaverLevel1000 | invisible1 | invisible2), not2, or off-Space/minimized windows are dropped.- The window-raise sequence is load-bearing and was the source of the focus-vacuum bug. Current-Space windows use the AX-only path + a single
activate(); off-Space windows use the SkyLight_SLPSSetFrontProcessWithOptions+makeKeyWindowbyte protocol (the only thing that crosses Spaces). A watchdog verifies a key window actually resulted and self-heals. Do not "unify" current-Space onto the SkyLight path — that caused the regression. The byte offsets inmakeKeyWindoware exact (verified against AltTab); don't touch them casually. - Under Stage Manager, the current-Space AX focus singletons start a focus war. With Stage Manager's "show windows from an application all at once" grouping, two windows of one app share the center stage. Setting
kAXMainAttribute+ the app'skAXFocusedWindowAttributetoward one of them hands theWindowManagerdaemon a self-contradicting target and it ping-pongs focus between the two ~12×/sec — a self-sustaining loop that survives the app quitting (the state lives inWindowManager, not us; only switching to another app or restartingWindowManagerclears it). SofocusSequenceskips those two singleton writes whenStageManager.isEnabled(current-Space only), raising withkAXRaiseAction+activate()alone — the pre-vacuum-fix behavior, which never oscillated; the watchdog still covers the vacuum. AltTab/yabai never write these singletons either. Don't re-add them unconditionally. (This was a real regression fromfix-focus-vacuum-on-raise.) - Off-Space Chromium windows (Chrome, Chrome Remote Desktop) have no remote-token AX element. A fresh
_AXUIElementCreateWithRemoteTokenbrute force returns nothing for them, so they used to vanish from the list and couldn't be raised. Two pieces fix this, both inWindowService: (1) listing falls back to a CGS-metadata heuristic (alpha > 0 && min(width,height) ≥ 130) when no element resolves — empirically separates real windows (incl. Stage-Manager strip thumbnails, min-dim ≥ 150) from sliver/toolbar/zero-alpha junk; (2) raising uses a persistentelementCachekeyed byCGWindowID, seeded when an app activates (its windows are then on the current Space and resolvable viakAXWindowsAttribute) and during snapshots — a cached element stays valid across Spaces, sokAXRaiseActionon it navigates to the window. Limit: a Chromium window off-Space since before launch and never focused has no cached element and can't be navigated to (the AltTab/HyperSwitch limit). Do NOT try to switch Spaces withCGSManagedDisplaySetCurrentSpace— the WindowServer gates Space switching to Dock.app's privileged connection; the symbol resolves but no-ops for an unentitled, SIP-on process (it's why yabai needs SIP off). We tried it; it's removed. - Off-Space focus is stolen by
WindowManager~300 ms after the Space switch — a different mechanism from the current-Space singleton oscillation above. The +180 ms watchdog checks too early to see it, soraise()arms a bounded polling hold-guard (offSpaceHoldTick, off-Space + Stage-Manager only): poll every ~60 ms and re-front the target the instant the steal is detected (≈ one-frame flash), bounded to a few re-fronts so a daemon that fights back can't make it thrash. Don't turn it back into a fixed-delay re-assert (slower, visible flash) or drop the bound. - The overlay panel is non-activating,
ignoresMouseEvents, must never become key/main, and is always ordered out on gesture end. On the common path it sits at.popUpMenuwith no.stationary(a higher band /.stationaryare Exposé-exempt and perturb focus/Space arbitration). The one scoped exception: while Mission Control is open,OverlayController.show(aboveMissionControl:)raises it to.screenSaver+.stationaryso the switcher floats above MC instead of behind it — applied per-show only in that case, and a commit then dismisses MC (synthesized Escape, never a re-toggle) before re-raising the window from a clean state. Don't widen that elevated config to the normal path. - The AI model runtime is isolated in
GemmaRuntimeso Core stays MLX-free; don't collapse it. All AI logic (theLLMRuntimeseam,AICommandExecutor, tasks, selection, canvas) lives inThreeFingerSwitcherCoreand runs against aStubLLMRuntimeunderswift test; onlyGemmaRuntimelinks MLX, and the app injects the real runtime at the seam inmain.swift. Don't move MLX into Core (it would makeswift testneed the Metal toolchain) and don't add model calls to feature code directly — go throughLLMRuntimeso a future backend can replace Gemma. build-app.shmust bundle MLX'sdefault.metallib, and the app builds viaxcodebuild, never a plainswift buildassembly. MLX ships its Metal shaders as a SwiftPM resource bundle (mlx-swift_Cmlx.bundle); ifbuild-app.shdoesn't copy*.bundleintoContents/Resources/, the app launches but is SIGKILL'd at first GPU use with no crash report — looks like a hang/crash, is really a missing metallib. This cost a real debugging session. (See B2 for the MLX/xcodebuildsplit.)- The AI preview canvas is resolved by a fresh four-finger swipe, not by lifting. After the firing lift opens the canvas the fingers are already up, so a re-lift is a deliberate no-op; a fresh down swipe commits/applies, a horizontal swipe discards, an up swipe is ignored. The recognizer enters a one-shot canvas-resolution mode (
launcherCanvasResolutionActive) that bypasses the normal launcher/switcher latch. Don't "fix" it back to lift-to-commit (that was the original design and it felt dead). - The Dock window previews are mouse-driven and the peek fronts the real window. This is the app's only cursor-interactive surface —
DockPreviewOverlaysetsignoresMouseEvents = false(every other overlay is pass-through, non-key). Hovering a tab genuinely activates that window — SkyLightsetFronthandshake +kAXMain/the app'skAXFocusedWindow(the same reliable front the commit uses) — and restores the previously-front window on leave, because macOS won't render fresh pixels for an off-screen window, so there's no way to preview live content without bringing it forward (anSCStreamversion was tried and reverted). The tab thumbnail itself is a single static capture taken ~0.5s after the window settles (skipping the front-transition frame), not a continuous stream. Both the SkyLight handshake and the focus singletons are skipped under Stage Manager (they oscillate WindowManager — same landmine as the switcher raise). Full detail in CLAUDE.md's "Dock window previews" section.
TouchEngine (OpenMultitouchSupport stream → TouchFrames) → GestureRecognizer (2D state machine: latch finger count, axis-lock, activation threshold, horizontal step-with-carry, vertical row-step, edge-flicker debounce, commit/cancel) → AppCoordinator (delegate) → snapshots windows via WindowService.snapshot(), groups by Space via SpaceGrouping.group(), drives OverlayController (the SwiftUI strip), and on commit calls WindowService.raise().
Launcher path (four-finger, when enabled): the same TouchEngine → GestureRecognizer (latched to launcher mode) emits launcher intents (activate / item-step / context-step / end) → AppCoordinator drives LauncherOverlayController (the grid HUD + dwell-to-arm timer + charge-ring/haptics) over the FavoritesStore model; lift below two contacts ends the gesture and the controller fires the armed item (or dismisses) via LaunchService (which opens a new window in the current Space, runs the shortcut/script, opens the path/URL, or composes a preset). Meanwhile ScrollEventTap swallows the freed scroll while the overlay is open.
AI command path (the AI band inside the launcher, when enabled): scrubbing to an armed aiCommand item and lifting does not dismiss — LauncherOverlayController opens the streaming preview canvas (the overlay stays visible, never key) and AICommandExecutor runs the pipeline: SelectionService acquires the input (AX selected text → ⌘C-with-restore → clipboard → screen region for vision), PromptTemplate resolves the tokens, ModelManager hands back the resident LLMRuntime (the real one is GemmaMLXRuntime; tests use StubLLMRuntime), and tokens stream into the canvas live. Resolution is a fresh four-finger swipe (see the B3 canvas landmine): a down swipe commits — routing per the command's output (replaceSelection/paste for in-place edits, or TaskDispatcher → EventKit/project/tool/adapter sink for a background task, with an optional action-review first) — and a horizontal swipe discards (cancelling generation). The captured front app stays frontmost throughout.
All in AppSettings (persisted, live-applied, editable on the Hub's feature pages):
- Switcher: activation threshold, axis-lock ratio, horizontal step distance, row-step distance (vertical, larger so scrubbing doesn't flip Spaces), wrap-vs-clamp, direction inversions (horizontal + vertical), velocity smoothing, exact-three-fingers, and the focus self-heal toggle.
- Opt-ins (each gates a native-gesture relocation + its feature):
manageVerticalGesture(Space-row switching),enableLauncher(four-finger launcher), plusmanageSpacesRearrange(keep Spaces in a fixed order). Each has anis…Effectivegate inAppCoordinator— the feature only goes live once the relocation has actually taken runtime effect (post re-login), never merely when the flag is set this session. Exception:keepClipboardHistoryis an opt-in with no gesture relocation and nois…Effectivegate — it just starts/stopsClipboardMonitor(and gates injecting the Clipboard band), so it takes effect immediately with no re-login. - Launcher tunables:
launcherActivationThreshold,launcherStepDistance(item step),launcherContextStepDistance(context-band step), anddwellToArmDuration(the dwell-to-fire delay). The launcher reuses the switcher's direction-inversion settings. - Clipboard tunables:
clipboardRecentWindow(entries shown in the band), retention caps (clipboardMaxCount/clipboardMaxBytes/clipboardMaxAgeDays, pinned-exempt),clipboardPollInterval,clipboardEdgeAcceleration(edge auto-repeat ramp),clipboardPinDistance(deliberate pin/leave flick),clipboardExcludedApps, andclipboardPaused. - AI commands:
aiCommandsEnabledis a fourth opt-in (no gesture relocation, nois…Effectivegate — like clipboard it takes effect immediately) that gates AI commands and unlocks the model download. AI commands persist as first-class items insideFavoritesStorebands (any band, movable —AICommandStore/AICommandBandBuilderare gone), chosen fromAICommandCatalog; the per-command model selector resolves againstModelRegistry;confirmBeforeRun(per command) gates the action-review before a side-effecting task fires. AI commands reuse the launcher's activation/step/direction tunables; the swipe-to-resolve discard threshold reusesclipboardPinDistance. - Keyboard language:
keyboardLanguageEnabledis a fifth opt-in (no gesture, nois…Effectivegate);keyboardLanguagePerSiteEnabledextends the learn/apply engine to per-host inside browsers, andkeyboardLanguageAllowBrowserControlopts into the Apple Events host reader (Accessibility is the default). The learned context-key → input-source map lives inKeyboardLanguageStore, withkeyboardLanguageDefaultSourceIDas the fallback.
- GPL-3.0 — see
LICENSEandNOTICE. The window-raising/Space technique (the private_AXUIElementGetWindow, the remote-token brute force, the SkyLight front/key byte protocol, the CGS Space enumeration) is adapted from AltTab (GPL-3), which is why this project is GPL-3. - Raw multitouch via OpenMultitouchSupport (Kyome, MIT), wrapping the private
MultitouchSupport.framework.
If you (Claude) end up extending this, keep the spec in openspec/specs/ honest, keep the 848 tests green (swift test for Core; xcodebuild for the MLX app target), and respect the landmines in B3 — they each cost a real debugging session to learn.