Skip to content

v2.0: the great cleanup + the progressive-slowdown fixes - #2

Draft
amitayks wants to merge 5 commits into
mainfrom
remove-local-ai
Draft

v2.0: the great cleanup + the progressive-slowdown fixes#2
amitayks wants to merge 5 commits into
mainfrom
remove-local-ai

Conversation

@amitayks

Copy link
Copy Markdown
Owner

Summary

  • Breaking refocus (feat!): removes the on-device AI stack, the Files band, and the iPhone↔Mac device link. The app is the switcher, the launcher (+ clipboard history), Dock previews, and keyboard language. v1.0.0 / the v1 branch preserve the full-featured app.
  • Progressive-slowdown fix, sweep 1 (2ae9b15): five compounding leaks — the vendored multitouch framework orphaning a trackpad listener per sleep/wake (the smoking gun), non-idempotent observer starts, the wizard's hidden animation clocks, stacked preview sweeps, unbounded caches — plus App Nap opt-out, a 0.5 s Accessibility timeout, and tap watchdogs.
  • Sweep 2 (b8ea550): a /usr/bin/defaults fork on the gesture path, two latent crashes (CGWindowID(windowNumber) on the closed Hub; as! on AX data), the sleep-with-fingers-down scroll-eating stuck state, the stale Mission Control latch, tokened deferred actions, and the Swift 6 isolation warnings.

Full record: openspec/changes/fix-progressive-cpu-degradation/ (proposal / design / spec deltas / tasks). README + CLAUDE.md guardrails updated.

Release

Tag v2.0.0 points at this branch's tip and has been pushed — release.yml builds, signs, notarizes, and publishes the DMG. Merge with a merge commit or fast-forward (not squash) so the tagged commit lands on main's history.

Verification

  • swift build clean (no new warnings in touched files)
  • swift test: 783/783
  • Still to confirm on a stable-signed build: idle CPU flat across sleep/wake cycles; trigger instant under a CPU-loaded background app; two-finger scroll works after sleeping mid-gesture.

🤖 Generated with Claude Code

amitayks and others added 5 commits August 20, 2026 00:49
Refocus the app on what actually gets used: the window switcher, the
four-finger launcher, and clipboard history. The full-featured app is
preserved on the v1 branch and the v1.0.0 release (notarized DMG).

Removed outright:
- The entire on-device AI platform: the GemmaRuntime (MLX) target and
  its package deps, Sources/ThreeFingerSwitcher/AI/ (executor, agent
  loop, tool routing, memory, skills, media, fleet, voice/computer-use,
  background autonomy + audit, parked sessions), the AI command canvas,
  the notch home zone + timeline, the region picker, the Hub AI pages,
  ModelManagementView, the .aiCommand item kind, the speakLastResponse
  action, and 46 AI test suites.
- The Files band: Sources/ThreeFingerSwitcher/Files/, FilesBandView /
  FilesDwellArming / BubbleMorph, HubFilesPage, the recognizer's
  Files-drill sub-state, the .fileEntry kind, and 11 Files test suites.
- 15 AI/Files openspec capability folders + 16 active ai-* change
  folders (the archive stays as design history).

Surgical edits: AppCoordinator (~1,100 lines of wiring out),
GestureRecognizer (canvas-resolve / notch-flick / files-drill out),
the launcher stack, the Hub, onboarding, AppSettings (45 keys out),
GestureBindings (switcher-only), Package.swift/main.swift (no MLX),
build-app.sh, release.yml (Xcode pin was MLX-only), Info.plist
(5 AI usage strings out), README + CLAUDE.md.

Migration: Favorites schema v3 — bands decode items lossily
(ContextBand.FailableItem) so records still carrying retired kinds
drop those items instead of failing wholesale and being reseeded;
the empty seeded "AI" band is removed by sentinel id.

swift build clean; swift test: 805 tests, 0 failures. The whole app
now builds under plain swift build (no MLX/xcodebuild-only target).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idge)

The great cleanup, part two. remove-local-ai kept the device link; on
reflection it goes too — remote device syncing is the same drift from
the app's intent (switcher · launcher · clipboard history) as the AI
stack was, and its iOS companion never shipped. The v1 branch /
v1.0.0 release preserve the full-featured app.

Removed:
- Sources/ThreeFingerSwitcher/DeviceLink/ (service, NW/sealing
  transports, handshake, ReceiveHUD, the QR-pairing suite)
- The vendored DeviceLinkKit package + its Package.swift products
- Clipboard Link{Inbound,Outbound}Adapter + the coordinator's
  receive/auto-paste/send-latest paths
- The Hub Devices page (.devices destination, rail button, seams)
- The enableDeviceLink setting; NSLocalNetworkUsageDescription +
  NSBonjourServices from Info.plist
- ClipboardOrigin/.peer provenance + the band's ProvenanceChip
  (legacy indexes with an origin key still decode — keys ignored)
- ClipboardMonitor.suppressSelfWrite (only caller was the receive path)
- 9 spec capabilities (device-link-* + mac-qr-pairing); provenance
  requirements in clipboard-history replaced with legacy-compat

swift build green; 777 tests pass (was 805 — deleted suites were
device-link-only). Change folder: openspec/changes/remove-device-link.

Story-origin: the second cut of the great cleanup — realizing the
device link was the same scope drift the AI stack was, just wearing
a hardware accessory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app degraded with use — CPU creeping up, switcher previews refreshing
slower, the gesture trigger developing a "needs to wake up" lag — clearing
only on restart. A four-way audit (repeating-work leaks, event-source
lifecycle, unbounded growth, thread/QoS) found independent accumulation bugs
plus a scheduling posture that let macOS starve the app under load.

Accumulation (the "more use = slower" half):
- Vendored OpenMTManager registers its OWN sleep/wake observers; its wake
  handler double-starts multitouch devices, orphaning one running,
  callback-registered device per sleep/wake cycle — N wakes meant every touch
  frame processed N+1 times through one serial queue. TouchEngine now strips
  those observers at init via the ObjC runtime (fails safe to a no-op);
  AppCoordinator is the sole owner of sleep/wake restart.
- MRUTracker.start()/KeyboardLanguageService.start() were not idempotent and
  the $enabled sink could ping-pong unbounded in the no-trackpad case
  (@published emits on every WRITE; enable() re-writes settings.enabled) —
  each lap stacked a permanent NSWorkspace activation observer. Guards +
  .removeDuplicates() on both sinks.
- Closing the first-run wizard only suspend()ed its model, leaving the
  retained NSHostingView's ungated 15-20 Hz TimelineView breathers ticking
  forever (the postmortem-idle-cpu-spin loop, reachable from the red button).
  willClose now destroys window + model + hosting tree; attract timer drops
  its strong self.
- Preview sweeps stacked: each 0.8 s tick spawned an uncancelled Task, and
  once a sweep overran the tick each overlap paid a full SCShareableContent
  enumeration. Sweeps are now single-slot (skip-if-busy) and cancelled on
  overlay hide.
- ThumbnailService cache was FIFO and never dropped closed windows (~1 MB
  pinned per entry, two 64-slot instances): now true LRU + pruned to the
  live cross-Space id set each switcher open.
- The 1 Hz permissions poll (six TCC/XPC round-trips per tick) could strand
  forever when the Hub closed on the Setup page (.onDisappear never fires in
  a retained window): the tick now no-ops with no regular window visible.

Load sensitivity (the "can't compete with other apps" half):
- Process-global AXUIElementSetMessagingTimeout(0.5 s): AX calls default to
  a 6 s per-call timeout serviced by the TARGET app's main thread, and the
  snapshot makes several per window synchronously inline in the gesture.
- App Nap opt-out (NSAppSleepDisabled + beginActivity with
  .userInitiatedAllowingIdleSystemSleep — deliberately NOT .userInitiated,
  which would block system idle sleep).
- Event taps get a 2 s tapIsEnabled watchdog (the in-band re-enable only
  runs when the NEXT event limps through, dropping the first post-stall
  gesture) + CFMachPortInvalidate on stop.
- snapshot()'s per-app off-Space brute-force sweeps get one 250 ms aggregate
  deadline (was: unbounded 100 ms x app count, inline in the trigger).
- Dock AX walk throttled to an 80 ms TTL cache (was: full Dock.app AX tree
  walk per mouse-move at 60-125 Hz near any screen edge); the dock defaults
  handle is reused; the snap monitor stops installing per-move monitors it
  never consumed.
- The launcher rebuilt its whole SwiftUI graph on EVERY open: the panel
  stays disposable (ghost-on-Space-switch fix untouched) but the
  NSHostingView is now built once and re-parented.

Spec-first: openspec/changes/fix-progressive-cpu-degradation/ (proposal,
design with rejected alternatives + deferred follow-ups, spec deltas for
touch-input and menubar-app-shell). CLAUDE.md gains a guardrails section so
the odd-looking-but-load-bearing bits (runtime removeObserver, the activity
option choice) don't get "cleaned up".

Verification: swift build clean, swift test 777/777 green. Real-build checks
that need the user's stable-signed install (single-processing across a real
sleep/wake, trigger latency under CPU load) are listed as task 3.3.

Story-origin: the app aged like milk and the villain was plural — five separate leaks all compounding; the smoking gun was a vendored framework double-starting trackpad devices on every wake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…system-wide stuck state

Five fresh-lens audits (retain cycles, main-thread I/O, SwiftUI render
storms, retry/poll correctness, crash/safety) over the post-first-sweep
tree. Everything confirmed is fixed; the full itemized list is in
openspec/changes/fix-progressive-cpu-degradation/tasks.md §3.

The headline finds:

- With Space-row switching on, EVERY switcher open shelled out to
  /usr/bin/defaults on the main thread (fork + waitpid, 30-100 ms) via
  isSpaceRowSwitchingEffective — at exactly the moment the overlay should
  appear. The `&&` short-circuited it away on default installs, which is
  why it hid. openSwitcher now reads the recognizer's cached gate.
- CGWindowID(NSWindow.windowNumber) traps on the <= 0 number of the
  retained-but-closed Hub — on every switcher commit and preview tick
  after the Hub had been opened once. One hubWindowID chokepoint.
- Unguarded `as! AXUIElement` / `as! AXValue` on cross-process AX data
  (traps when a misbehaving app's AX server answers with another CF type)
  → typed axElement/axValue helpers.
- currentFingerCount was never reset when the touch stream stopped: a
  sleep with three fingers down left the scroll tap swallowing EVERY
  scroll in every app until quit. Reset on disable/sleep/wake-restart
  plus a 0.5 s staleness guard in the consume predicate.
- missionControlOpen latched true when MC was closed any way but ours,
  so every later commit posted a stray Escape into the user's app and
  every open floated at screen-saver level. Cleared on regular-app
  activation, Space change, sleep, hideOverlay.

Main-thread cost: clipboard capture no longer walks the whole payload
through Data's iterator nor fully decodes every image (bounded sample
hash + ImageIO header dims); the browser AX tree-walk re-validates the
remembered address field instead of re-walking at 2 Hz; StageManager's
cfprefsd round-trip is TTL-cached; launcher/Hub icon lookups go through
a process-wide IconCache (stable NSImage identity also stops SwiftUI
re-rasterizing every cell per step); FavoritesStore coalesces its
per-keystroke full-tree JSON save; blob-backed clipboard previews are
memoized; FirstRunStore mirrors its stage in memory (it was a defaults
read per touch frame); emptyTrash and script runs no longer hold a
thread (terminationHandler, not waitUntilExit).

Deferred actions are tokened (Space-settle poll, de-minimize raise,
focus recovery, MC-dismiss commit, new-window single-flight, seed
retries); WindowFocusTracker's AX source is in .commonModes with add,
teardown and deinit agreeing (a mode mismatch there leaves a source
pointing at freed memory); the TouchEngine consumer is generation-
tagged; observer tokens are held and removed in deinit; the two Swift-6
isolation warnings (KeepAwakeController statics, CursorMonitor
conformance) are fixed at the root.

Verification: swift build clean; swift test 783/783 (6 new
ResourceBoundsTests pin the LRU/prune + MRU eviction). README and
CLAUDE.md guardrails updated; spec deltas extended (scroll consumption
never outlives the touch stream; no subprocess/disk I/O on the gesture
path).

Story-origin: asked "what did we miss?" and the answer was a fork() in the gesture path, a crash that waited for you to open Settings once, and a sleep that could eat every scroll on the Mac.
Story-thread: continues the progressive-degradation arc — the first sweep found the leaks, this one found the cliffs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
-t open against the .app (or the DMG) reports 'rejected · Insufficient
Context' on current macOS even for a notarized artifact — the wrong
assessment type, not a broken release. Verified against the published
v2.0.0 DMG: -t exec → accepted, source=Notarized Developer ID.

Story-origin: routine — a verify recipe that cried wolf on a good release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant