Skip to content

amitayks/ThreeFingerSwitcher

Repository files navigation

ThreeFingerSwitcher
⬇︎ Download the latest release (.dmg)

ThreeFingerSwitcher — a README for Claude

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.)

Demo

The switcher, the launcher, and the optional bands in action (~2 min):

recording-1782211911291-edited-x.mp4

What this is (30-second brief)

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 architectural keystone (internalize this before anything else)

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:

  1. It cannot break the OS's native gestures. Up/down → Mission Control / Exposé always work.
  2. 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 OFFnot 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.


Job A — help a user install & run

There are two ways to get the binary. Figure out which applies, then walk them through permissions (the part people get stuck on).

A1. Getting the app

  • Recommended — download the notarized DMG: from the latest release, open the .dmg and 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 run xattr -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.sh drops 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.

A2. First launch — the First Touch wizard does this for them

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).

A2b. Permissions (the wizard asks in this order; the Hub → Setup page hosts them afterwards)

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.

A3. Free the horizontal gesture (required for the switcher)

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).

A3b. The two optional gestures (off by default — enable only if they want them)

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.

A4. Make it permanent (optional but recommended)

  • 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.

A5. If the user reports the "everything froze" bug

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.


Job B — help someone work on the code

B0. Source of truth: read openspec/specs/ first

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 their design.md for 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), and clipboard-history (the launcher's Clipboard band: a polled-changeCount recorder + on-disk store, paste-into-front, pin/edge-accel — its design.md covers 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 its design.md for the LLMRuntime seam, 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) and hub-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 its design.md for the reversed peek decision: the peek fronts the real window via the SkyLight setFront handshake + kAXMain/kAXFocusedWindow and 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).

B1. Repo map

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).

B2. Build, run, test

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 exit

The 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 purposeswift build / swift test build and verify all app logic (incl. the AI executor, tasks, selection, and canvas, which run against a StubLLMRuntime). This is the agent's fast verify loop and the only thing CI-pure logic needs.
  • The app executable + GemmaRuntime link MLX → build them with xcodebuild, which scripts/build-app.sh now drives (it was migrated from a hand-rolled swift build assembly). build-app.sh also copies MLX's default.metallib resource bundle into Contents/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 .app from a plain swift 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.

B3. Landmines — things that look wrong but are deliberate (do NOT "fix" these blindly)

  • Passive multitouch + config-based suppression (see keystone). Don't add a tap that intercepts the multitouch gestures. The one CGEventTap that exists (ScrollEventTap) consumes scroll-wheel events only — the residue of a defaults-freed gesture — and runs only while an opt-in is effective. Its consume predicate is fingerCount ≥ 3 || launcherOverlay.isVisible (the launcher-open clause swallows two-finger launcher navigation; with the launcher closed it's the plain ≥3 rule, 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 intentsdwell / 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 / FourFingerGestureConfig mirror the horizontal TrackpadGestureConfig lifecycle 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 dlsym at startup (CGSPrivate.swift), not @_silgen_name. Reason: a missing @_silgen_name symbol aborts at launch before any Swift guard runs, and these symbols live in SkyLight.framework which isn't auto-linked. The dlsym approach degrades gracefully to current-Space-only (offSpaceSupported == false) if a symbol ever disappears on a future macOS. Keep it.
  • CGWindowListCreateDescriptionFromArray needs window IDs as raw pointers, not boxed CFNumbers (CFArrayCreate with UnsafeRawPointer(bitPattern:)). Passing [NSNumber] as CFArray silently returns zero results. This bit us once; it's in WindowService.metadata(for:).
  • CGSCopyWindowsWithOptionsAndTags options must be 7 (screenSaverLevel1000 | invisible1 | invisible2), not 2, 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 + makeKeyWindow byte 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 in makeKeyWindow are 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's kAXFocusedWindowAttribute toward one of them hands the WindowManager daemon 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 in WindowManager, not us; only switching to another app or restarting WindowManager clears it). So focusSequence skips those two singleton writes when StageManager.isEnabled (current-Space only), raising with kAXRaiseAction + 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 from fix-focus-vacuum-on-raise.)
  • Off-Space Chromium windows (Chrome, Chrome Remote Desktop) have no remote-token AX element. A fresh _AXUIElementCreateWithRemoteToken brute force returns nothing for them, so they used to vanish from the list and couldn't be raised. Two pieces fix this, both in WindowService: (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 persistent elementCache keyed by CGWindowID, seeded when an app activates (its windows are then on the current Space and resolvable via kAXWindowsAttribute) and during snapshots — a cached element stays valid across Spaces, so kAXRaiseAction on 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 with CGSManagedDisplaySetCurrentSpace — 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, so raise() 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 .popUpMenu with no .stationary (a higher band / .stationary are Exposé-exempt and perturb focus/Space arbitration). The one scoped exception: while Mission Control is open, OverlayController.show(aboveMissionControl:) raises it to .screenSaver + .stationary so 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 GemmaRuntime so Core stays MLX-free; don't collapse it. All AI logic (the LLMRuntime seam, AICommandExecutor, tasks, selection, canvas) lives in ThreeFingerSwitcherCore and runs against a StubLLMRuntime under swift test; only GemmaRuntime links MLX, and the app injects the real runtime at the seam in main.swift. Don't move MLX into Core (it would make swift test need the Metal toolchain) and don't add model calls to feature code directly — go through LLMRuntime so a future backend can replace Gemma.
  • build-app.sh must bundle MLX's default.metallib, and the app builds via xcodebuild, never a plain swift build assembly. MLX ships its Metal shaders as a SwiftPM resource bundle (mlx-swift_Cmlx.bundle); if build-app.sh doesn't copy *.bundle into Contents/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/xcodebuild split.)
  • 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 surfaceDockPreviewOverlay sets ignoresMouseEvents = false (every other overlay is pass-through, non-key). Hovering a tab genuinely activates that window — SkyLight setFront handshake + kAXMain/the app's kAXFocusedWindow (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 (an SCStream version 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.

B4. How a gesture flows (mental model)

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 TouchEngineGestureRecognizer (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.

B5. Tunables

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), plus manageSpacesRearrange (keep Spaces in a fixed order). Each has an is…Effective gate in AppCoordinator — 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: keepClipboardHistory is an opt-in with no gesture relocation and no is…Effective gate — it just starts/stops ClipboardMonitor (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), and dwellToArmDuration (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, and clipboardPaused.
  • AI commands: aiCommandsEnabled is a fourth opt-in (no gesture relocation, no is…Effective gate — like clipboard it takes effect immediately) that gates AI commands and unlocks the model download. AI commands persist as first-class items inside FavoritesStore bands (any band, movable — AICommandStore/AICommandBandBuilder are gone), chosen from AICommandCatalog; the per-command model selector resolves against ModelRegistry; 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 reuses clipboardPinDistance.
  • Keyboard language: keyboardLanguageEnabled is a fifth opt-in (no gesture, no is…Effective gate); keyboardLanguagePerSiteEnabled extends the learn/apply engine to per-host inside browsers, and keyboardLanguageAllowBrowserControl opts into the Apple Events host reader (Accessibility is the default). The learned context-key → input-source map lives in KeyboardLanguageStore, with keyboardLanguageDefaultSourceID as the fallback.

Credits & license

  • GPL-3.0 — see LICENSE and NOTICE. 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.

About

Trackpad-native window switcher + launcher for macOS, with on-device AI commands. Three-finger swipe between windows; four-finger launcher. Menu-bar app.

Topics

Resources

License

Stars

7 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors