Hovercraft · Release Notes

What’s shipping.

Every shipping change to Hovercraft, in version order. The most recent build is at the top. If you’re running an older build, the in-app updater (0.9.52+) will offer the latest the next time you launch.

All notable user-facing changes to Hovercraft. Engineering scaffolding (Day 1–3 plumbing, repo seeding, build pipeline) is summarised rather than enumerated.

[Unreleased]

[v1.0.9.1] — 2026-05-08

Fixed: trial users now get a working download link from the Update Available sheet

When a user on the 7-day trial clicked Download in the "Hovercraft X.X is available" sheet, the app was sending them to the license-keyed download URL with an empty key, producing a "License key required" error. Trial users have no Keygen key — their license is local to the app. The fix: the server manifest now includes a trial_download_url field pointing at the public trial-download endpoint, and the update checker uses it when no license key is on file. Paid users are unaffected.

No camera-helper changes in this release. CameraExtension stays at build 24.

[v1.0.9] — 2026-05-08

Fixed: camera image stretched horizontally when switching away from Hovercraft in Zoom or Teams

When a second app (Zoom, Teams, FaceTime) claimed the same camera Hovercraft was already using, macOS renegotiated the shared format to one both apps could accept. That negotiated format is typically the camera's native aspect ratio — 4:3 for most built-in FaceTime HD modules and for iPhone Continuity Camera (whose sensor is natively 4:3). Hovercraft's compositor was stretching whatever it received to fill the 16:9 canvas, so the incoming 4:3 frames were squashed wide.

Fix: the camera quad now UV-crops to aspect-fill rather than stretch. If the incoming frame is wider or taller than 16:9, the excess is trimmed symmetrically from the center so the canvas always fills without distortion. When the frame is already 16:9 (the normal case) the behavior is identical to before.

Reported by Dan with an iPhone Continuity Camera. Affects any camera whose native format is not 16:9 when shared with a second app.

Fixed: window-capture content preview thumbnail now matches the picker's vertical-flip setting

The window picker has a per-app flip button (hover over any row to reveal it) that corrects thumbnails that ScreenCaptureKit delivers upside-down. That preference now also applies to the small content preview thumbnail in the main chrome — the one showing your currently-shared window next to the source name. Previously the flip only affected the picker row; the chrome thumbnail always showed whatever orientation the live stream delivered.

As a side-effect, Keynote's Presenter Display window — which ScreenCaptureKit delivers in a flipped orientation — now also displays correctly in the chrome thumbnail. The broadcast feed was already correct; only the chrome preview was wrong.

Improved: eye/hand/lock buttons more legible in their inactive state

The three view-control buttons (hide slide, hand gestures, input lock) in the row below the camera preview were hard to read in their inactive state against the smoked-glass window material. Icons are now slightly brighter (55% white vs. 35%) and each button has a faint 1pt border defining its shape, matching the visual language of the capsule badges elsewhere in the app. Alert states (yellow/orange/red solid fill) are unchanged.

No camera-helper changes in this release. CameraExtension stays at build 24.

[v1.0.8.2] — 2026-05-08

Fixed: black camera on Macs with older built-in FaceTime HD modules

Reported by Sooper and Josue within hours of v1.0.8 going live: on certain Macs (typically MacBooks with pre-1080p built-in FaceTime HD modules and some 720p-only Continuity configurations), Hovercraft would show as a black camera in PhotoBooth, QuickTime, FaceTime, and other consumers — even though the same camera worked fine when those apps used it directly.

Root cause was a long-latent bug in the AVCaptureSession setup path. When picking a session preset, the code was asking the session whether it could handle 1080p (AVCaptureSession.canSetSessionPreset(_:)) instead of asking the device whether it supported 1080p (AVCaptureDevice.supportsSessionPreset(_:)). The session check returns true even when no device is attached, so we'd lock in a preset the camera couldn't satisfy, and the input was rejected at attach time. Sooper's diagnostic line showed the rejection verbatim: "device does not support AVCaptureSessionPreset1920x1080." Josue's was the symmetric "session rejected device input for FaceTime HD Camera."

Fix queries AVCaptureDevice.supportsSessionPreset(_:) for 1080p and 720p before trying either; if neither is supported the session falls back to .high, which AVFoundation auto-negotiates to whatever the device can produce. Belt-and-suspenders retry on .high if canAddInput(_:) fails for any other reason. Camera switching at runtime also gets the new fallback path so a swap from a 1080p USB cam to a 720p built-in doesn't strand the session inputless.

The bug predates v1.0.8 (camera-switcher refactor) but only became visible at scale on launch day; rolling the fix here so anyone who installs Hovercraft after the Daring Fireball link reaches a working camera on the first try.

[v1.0.8.1] — 2026-05-08

Camera-helper auto-upgrade no longer loops on launch

Any user whose installed CameraExtension was older than v1.0.8's bundled build 24 could land in a "Restart Hovercraft to finish updating" loop where every restart showed the same dialog. Reported by Sven Schwitter within an hour of v1.0.8 going live, with a clean diagnostic that pinpointed the cause.

The bug was a race on launch: FramePump's CMIO scan would bind the OLD camera-helper daemon at +1ms while the async system-extension properties query landed at ~+120ms. By the time the upgrade check fired, FramePump's binding was already live, the deferral path tripped to avoid orphaning the CMIO DAL, and the same race repeated on every subsequent launch. The deferral was correct in principle (replacing the helper while a stream is bound does break the rest of the session) but the ordering meant the upgrade path was never reachable.

Fix: hold FramePump back from scanning until the launch-time upgrade check resolves. If no upgrade is needed, FramePump starts immediately (the wait is a few hundred ms, imperceptible). If an upgrade is needed, the OS finishes the same-bundle-id daemon swap first, then FramePump starts and binds cleanly to the new build. Belt-and-suspenders 10-second safety timeout releases the gate if anything in the OS path hangs. The "Updating camera helper…" status string surfaces during the swap so a slow daemon transition reads as progress, not as a hang.

No CameraExtension binary changes in this release — the fix is entirely in the host app's launch ordering.

[v1.0.8] — 2026-05-08

Menu cleanup (Gruber pre-link review)

John Gruber caught three menu-bar items that didn't belong on a single-window virtual-camera app and one cluster that didn't belong in the public surface. All four are addressed for v1.0.8:

  • File → New Window (and the default ⌘N shortcut) is gone. SwiftUI auto-injects a "New Window" item for any WindowGroup; we now replace .newItem with an empty command group so it never appears. Hovercraft is single-window and a second window has no useful state of its own.
  • Window → New Tab / Show Tab Bar is gone. macOS auto-tabbing is disabled at launch (NSWindow.allowsAutomaticWindowTabbing = false) so the entire tabs-cluster falls out of the Window menu.
  • Help → Send Hovercraft Feedback to Apple is gone. AppKit auto-injects this item via a private code path that runs after our CommandGroup(replacing: .help) resolves; we strip it post-build via an NSMenuDelegate hook on the Help menu, and re-strip on every menu open in case it's re-added by a localization or sheet-dismissal path.
  • Save Diagnostics… / Reset First-Run Walkthrough… / Skip Setup (Override)… are now hidden behind Option. They're support-only affordances and were cluttering the first-30-second view of the Help menu. Holding Option while the Help menu is open reveals them; they appear/disappear live as the user toggles the modifier (via an NSEvent flagsChanged monitor). Same convention Finder uses for Show Library, Mail uses for Refresh All Mailboxes.

Camera switcher reads as a button

The camera-picker control in the chrome bar was a thin 22pt circle with a faint border. Gruber flagged it as too subtle to register as interactive at a glance. Replaced with an ultraThinMaterial capsule containing the video icon plus a small chevron-down, matching the visual register of the lock and hand-slash badges already shipping in the live preview. Same menu, same camera list, just a control that telegraphs "I am a clickable menu."

7-day free trial, no card required

First launch with no license now auto-issues a 7-day anonymous trial. The full app surface is available for the duration—same camera path, same gestures, same window picker, no watermarks, no degraded modes. Trial state is persisted to Keychain alongside the license slot and bound to the machine's IOPlatformUUID so a copied keychain item on a different Mac doesn't grant a second trial there. On day 8 the app shows a small handshake sheet ("Your trial's done. Here's what comes next.") with a primary "Buy Hovercraft" button, an "I have a license" path that drops the user into the regular activation field, and a "Remind me in 24 hours" ghost link that hides the handshake for a day while leaving the app gated. After 24 hours the handshake returns. Trial state appears in the Save Diagnostics bundle (cohort number, start date, days remaining, whether the keychain item is bound to the current machine) so support requests have the right context. The product framing for this whole change came from Lisagor at Gruber's request before he linked to Hovercraft from Daring Fireball: card-up-front trials read as gated and corporate to a high-trust audience, watermark-on-expiry humiliates the user mid-meeting, and a single calm reminder email beats dripping. The implementation is local-only for v1.0.8; we'll harden against keychain-reset attacks in Keygen if abuse materializes.

Landing page: try-first, buy-second, with optional reminder

The hero on sandwich.vision/hovercraft leads with "Try it free for a week. No card. No catch. Just download." The Download button hits a public, rate-limited download endpoint that returns a 5-minute signed URL to the same notarized DMG the licensed-download path serves, so a Daring Fireball reader is running the app within seconds of clicking. The Buy button (now showing "Buy a license, from $19" so the price isn't mystery) anchors to the existing pricing section. Below the buttons, after the user clicks Download, an optional email field appears with the framing "Want a reminder before your trial ends? Drop your email here. Or just close this — the trial works either way." Email is gated on the reminder, not on the download, so the no-friction promise stays intact for anyone who wants to skip it.

Day-6 trial reminder, conditional day-7 follow-up

Users who opt into the reminder get a single calm email on day 6: "Your Hovercraft trial ends tomorrow." Subject and body are Lisagor's verbatim—four lines, one buy link, no feature recap, no urgency theatrics. If the day-6 email goes unopened for 24 hours, a shorter day-7 follow-up fires (subject Last day, Hovercraft trial, one sentence + buy link). If the day-6 was opened, the day-7 is suppressed. Postmark open-tracking drives the conditional. A daily Vercel cron at 09:30 UTC processes both queues; idempotent partial indexes ensure no row gets double-sent. Conversion (a license purchase tied to the same email) and unsubscribe both stop further sends.

Onboarding copy and button widths (Gruber round 2)

The Screen Recording permission slide read "Want to share an app window too?" — singular when it should be plural since the picker handles any window count. Now reads "Want to share app windows too?" with a parallel update to the deferred-state title. Onboarding action buttons (Allow / Skip / Restart / Try Again) all share a minWidth: 88 so the prominent Allow no longer renders narrower than the secondary Skip ("skinny jeans" effect Gruber flagged on the first-impression slide). Wide enough to confidently hold "Allow Camera Access" without inflating shorter labels.

Quit-warning popup removed

The "Are you sure you want to quit during a broadcast?" NSAlert had a "Don't show again" checkbox — a one-way trap with no UI to re-enable it. Gruber's call: a trapped preference is a worse failure mode than the occasional surprised quit, especially given Hovercraft is a presenter tool where most quits are intentional and the broadcast risk is rare. The alert is gone; ⌘Q now terminates immediately after dismissing any attached sheets.

View controls row (eye / hand / lock under the viewer)

Slide visibility, hand gestures, and input lock now live in their own dedicated row directly under the camera preview, anchored to the right. Three icon-only buttons in a small horizontal cluster, sized for unambiguous reading without dominating the chrome. The previous in-viewer floating badges (lockBadge, gesturesDisabledBadge) are removed entirely — Gruber: "controls should only be in one place." Active states use solid colored backgrounds with full-white icons, so the user-attention condition (slide hidden = yellow, gestures disabled = orange, input locked = red) is unmissable at a glance even on dark vibrancy material. Inactive states use a near-transparent background with a ghosted icon, so the row reads as quiet when nothing is wrong. The dev settings button (debug-builds only) moved from the chrome header to the left of the camera picker, keeping all interactive controls inside the chrome rather than spread across two rows. Visual treatment was designed in collaboration with Visionary.

Welcome deck: H added to the Hotkeys page

The first-run onboarding PDF's keyboard reference page (page 5) was missing the H hotkey for the hand-gestures toggle that shipped in v1.0.7. The Hotkeys page now lists all seven shortcuts in the order they live in the chrome cluster: F, FF, V, H, L, C, R. Top-row Y and row gap were retightened so seven cards still clear the slide-strip safe area.

Resizable window with diagonal aspect-locked drag

The window is now resizable. Drag any corner or edge: width clamps to [480, 1280]pt and height auto-derives via height = (width × 9/16) + chromeHeight, so the camera preview fills its full area edge-to-edge at every size with no pillarbox or letterbox, and the chrome below it stays the same readable size regardless of width. An NSWindowDelegate enforces the formula on every drag; AppKit's contentMin/MaxSize reasserts it on initial layout. Width and height grow at different rates as you drag wider — the hero portion scales proportionally with width while the chrome stays a fixed share — which is the right behavior for a tool whose output is fixed-aspect 16:9. Window size persists across launches via NSWindow.setFrameAutosaveName. Inside the preview, the slide corner-grab handles read live view bounds via GeometryReader, so resize handles stay correctly positioned as the window scales.

[v1.0.7] — 2026-05-07

L-shape pose retired; lock stays a deliberate act

Hovercraft shipped two ways to toggle input lock besides the keyboard L and the chrome lock button: a discrete-pose recognizer that watched for a thumb-and-index L shape in the same hand-pose stream the pinch path consumes. A user on a call with Adam this week kept getting input-locked, then unintentionally toggling it back open mid-conversation; the L shape (thumb out, index out, others curled) is approximately the shape your hand makes while holding a coffee cup or pointing at a screen. Lisagor's framing on the cut: input lock is a defensive control. A defensive control that fires on something you might do incidentally inverts its threat model. The peace-sign V pose stays — peace-sign-shaped hand movements aren't part of natural call gesticulation, and the hide/reveal toggle is a hero moment, not a defensive one. Bound L, the chrome lock button, and the click-to-unlock badge all still work; only the pose recognition path comes out. Six unused cases in HandGestureRecognizer go with it.

Lock indicator becomes icon-only; gestures-disabled gets its own badge

The top-trailing "Locked" pill in the live preview drops the text label and is now a single lock.fill icon in the same ultraThinMaterial capsule. A parallel hand.raised.slash badge appears in the same row when the H toggle is on, click-to-re-enable, identical visual register so the two state indicators read as one vocabulary. When both states are active, the two capsules sit side-by-side with a fixed stacking order (lock leads) and a 6pt gap, so they read as a row of two distinct controls rather than merging into one ambiguous badge — Lisagor flagged this specifically as a sub-second-glance legibility moment for someone mid-call. Each capsule animates and hit-tests independently.

H hotkey for the hand-gestures toggle

The chrome's hand-gesture toggle now has a keyboard shortcut: press H to flip hand gestures on or off. Same handler, same persistence, just a one-tap path that doesn't require finding the button in the chrome. Surfaces in the hotkey hints overlay between R (reset) and L (lock).

Help → Save Diagnostics…

New menu item that writes a self-contained text bundle to a path the user picks via standard save panel. Bundle includes app/system info (Hovercraft version, macOS version, hardware identifier, CPU brand), TCC permission state for Camera and Screen Recording, the embedded CameraExtension version, the live camera enumeration, license-status summary (sanitized — never the key itself; just a short fingerprint and the verification state), all vision.sandwich.Hovercraft.* UserDefaults keys with their values, and the most recent OSLogStore entries from the Hovercraft subsystem. Everything is gathered in-process via sandbox-friendly APIs (no shell-out) so it works inside the app's sandbox. The save panel's read-write entitlement is now granted (was read-only) so writing the bundle to Desktop or Downloads doesn't fail silently. Designed for support: "send me the diagnostics file" replaces "open Console, filter to subsystem…, copy the lines, paste them in".

Per-window thumbnail flip in the picker

The window picker has long had a hand-tuned allowlist of bundle IDs whose ScreenCaptureKit thumbnails come back vertically flipped (Apple system apps, mostly). Users still occasionally hit a window that lands wrong — a private app, a Webex/Discord client at a version we haven't seen, anything we couldn't enumerate ahead of time. Each row in the picker now carries an arrow-flip button on hover that toggles a per-bundle-ID override and persists it via UserDefaults. The override takes precedence over the allowlist; a user can correct a wrong-flipped app once and Hovercraft remembers it for that bundle forever after. Stored locally; no telemetry.

Setup sheet: Skip on Screen Recording really skips now

The setup sheet's Screen Recording bonus row had a regression where clicking Skip didn't unblock the "Take me there" button — the gate that said "the user has addressed the Screen Recording question" only treated the system-level "no longer requestable" state as addressed, not the user-visible "I clicked Skip" state. The user could click Skip, see the row visually defer, and still find themselves unable to leave the sheet. Fixed by recognizing the explicit defer signal as addressing the step; the system-level gate is still honored.

Help → Skip Setup (Override)…

New menu item (⌘⌥⇧S) that bypasses the setup sheet for the current launch with an explicit warning dialog. Targets a specific edge case — a development build whose system extension was replaced by a Developer-ID-signed production install, perpetually reporting installer.status != .installed to the dev binary even though the system is in fact correctly set up. The override is in-memory only (so a relaunch returns to the normal gating) and dismisses the sheet, but does not pretend setup is complete: camera/broadcast may still misbehave depending on what's wrong, and the warning copy says so. Functions as a support escape hatch and as a developer convenience without weakening the production setup story.

[v1.0.6] — 2026-05-07

Compatibility & FAQ page + Help menu entry

Two paying customers in the launch wave wrote in convinced Hovercraft was rendering their slide horizontally flipped on calls. In every case the culprit was the conferencing app's local self-view mirror — the outbound stream is correct, the local preview is mirrored as a vanity convenience and the user reads it as a Hovercraft bug. Same shape applies to other near-misses we've now seen at least once each: Webex-in-Safari and other browser-based conferencing not always surfacing CMIO virtual cameras, Zoom's Studio Effects mangling on-slide text into glyph soup when the user can't find the toggle, and ScreenCaptureKit's documented inability to hand off video frames embedded in window captures (Keynote video-on-slide is the canonical case). New page at sandwich.vision/hovercraft/compatibility documents all four with framing the user can read in a panic moment, and a Help → Compatibility & FAQ… menu item points at it directly.

One-time self-view mirror tooltip

The Compatibility page absorbs nothing in a live-demo panic. So in addition we now plant a small calm capsule at the top of the hero preview the first time the user has their own (non-Welcome) content loaded after onboarding completes. Reads "Your slide looks correct to your audience. Call apps mirror your own view—that's a call app thing, not Hovercraft. Learn more →". Fades in 1.2 seconds after content registers (lets the slide land before the chrome enters), auto-dismisses 14 seconds later or on click of ✕, persists a flag so it never reappears for that user. Visual register matches the lock badge and tracking warmup pill — same .ultraThinMaterial, hairline stroke, soft drop shadow — so it reads as system chrome rather than a marketing nudge. Top-center is the slot the chrome was already designed for; the lock badge (top-trailing) and tracking pill (top-leading) had been carrying comments referencing the reserved location for the first-run tooltip since the v1.0.5.x preview-overlay refactor. Lisagor framed the trigger and copy: lead with the reassurance, name the cause, never apologize. The Help → Reset First-Run Walkthrough… support tool also re-arms this flag, so a confused-user-walkthrough-reset puts them back in the same first-run state a fresh install does.

Hidden ⌘⌥U bypass for the update check

The Help → Check for Updates… menu item that shipped in v1.0.4 already bypasses UpdateChecker's 12-hour throttle and clears any prior dismissal. v1.0.6 adds a keyboard shortcut to it — ⌘⌥U — so support can say "press Cmd+Opt+U and tell me what you see" without having to walk a customer through the menu, and our own debug cycles stop round-tripping through the Help menu every time we want to test the update sheet against a freshly-bumped manifest. The shortcut is visible next to the menu label so a power user can discover it; same notification path, same checker call, no separate code surface to maintain.

[v1.0.5] — 2026-05-06

Disable Hand Gestures moves from Settings to the chrome

v1.0.4 shipped the gesture-disable toggle inside a SwiftUI Settings scene — Hovercraft → Settings… ⌘, opened a window with one switch in it. Adam caught the placement same-day and was right twice over. First, the merit-side argument: Hovercraft's whole UX lives in one floating window, and standing up a Settings window for an app that doesn't otherwise have one is itself a small trap. The toggle is most likely to be wanted mid-call, exactly when ⌘, → switch → close is more friction than a chrome button. Second, the toggle is view-level governance state — it changes how the live frame is interpreted — making it a conceptual sibling of the visibility (eye) and input-lock (lock) buttons, not a user preference in the OS-prefs sense. Lisagor had explicitly named this pattern as a trap during the v1.0.4 triage when ruling on Camera↔Source Swap ("a toggle buried in Settings for something that inverts the entire spatial grammar of the app is a trap. It's a first-class mode."), and the same generalization applied to gesture-disable; we just didn't run the check.

The hand button now sits between the eye and the lock in the source toolbar — [eye][hand][lock] — keeping the highest-frequency button (eye) leftmost where muscle memory expects it and putting the two input-governance affordances next to each other where they belong conceptually. SF Symbol hand.raised / hand.raised.slash parallels eye / eye.slash and lock.open / lock.fill. The Settings scene and SettingsView.swift are gone; the @AppStorage plumbing, the runtime sync from App-layer .onChange to FramePump.setGesturesEnabled, and the boot-time UserDefaults read in _startOnQueue all stay — that work was correct, just wrongly surfaced. UserDefaults key spelling moves to module scope in HovercraftApp.swift so the chrome's @AppStorage, the App-layer sync, and the pump's boot-time read all spell the same key without drift risk.

[v1.0.4] — 2026-05-06

Screen Recording joins the first-run setup

Window-share is gated by the macOS Screen Recording TCC permission, and through v1.0.3 that prompt was deferred to time-of-need: the user picked a window in the Window Picker, ScreenCaptureKit's first call triggered the system prompt, and the user was already mid-task before they understood why they were being asked. The setup sheet now offers it as a non-blocking bonus row beneath the three numbered required steps. The user has to make an explicit choice — Allow (system TCC prompt fires) or Skip (deferred for now, time-of-need fallback still works) — before "Take me there" enables. Skip is a fully valid resolution; the gate just prevents the choice from being silently breezed past, which was the case for returning users with steps 1 and 2 already approved (the celebration view used to swap in the moment step 3 went green and the bonus row was never seen). Lisagor's framing held: the first-impression wound is the silent breeze-past, not the choice itself.

Help → Check for Updates…

New menu item under Help that bypasses the in-app updater's 12-hour throttle and forces a fresh manifest fetch. When a newer version is available the existing update sheet appears as it would on auto-check; when there isn't, an alert confirms "You're up to date" so the click doesn't read as broken. The menu also clears any prior dismissal so a user who closed the update sheet for the current latest version yesterday and clicks Check today sees it resurface — the act of clicking expresses fresh intent that should override the prior dismissal. Useful for support ("can you check now?") and for our own debug-cycle without defaults delete shell incantations.

Settings → Disable hand gestures

First entry in a new SwiftUI Settings scene (Hovercraft → Settings… ⌘,). Toggling on suppresses the entire pose-detection path: stops the Vision request entirely (CPU/battery win for users who want Hovercraft on as a virtual camera but never use the pinch surface), drops any in-flight pinch session, and clears the gesture recognizer latches so a pinch in progress at the moment of toggle ends cleanly rather than getting stranded mid-grab. Mouse drag and corner-resize in the live preview remain fully functional, so the floating slide stays interactive without gestures. Distinct from the L-key Lock, which is a transient suppression bound to the input-lock latch; this toggle is permanent and persisted. Future user-facing preferences pile on inside the same Settings scene.

macOS-version-aware approve-step copy

Two customers on macOS 15 Sequoia were confused by the existing "click the By Category tab" instructions in setup step 3, because that tab doesn't exist on Sequoia. The "By Category" copy is load-bearing on macOS 26+ Tahoe — same toggle silently fails under "By App" and only activates the helper under "By Category → Camera Extensions" (a macOS bug Adam hit during launch-week testing). Sequoia doesn't have either the tab or the bug. The setup sheet now gates the copy on if #available(macOS 26, *) and routes Sequoia users straight to Login Items & Extensions where Camera Extensions sits directly. Same revealLoginItemsAndExtensions() URL behind both copy variants, so the button path is unchanged.

[v1.0.3] — 2026-05-05

Stable under sustained window-share

A long-running window share (the kind that happens during a real Zoom call where you're presenting for 5–30 minutes) could quietly run the SCStream sample-handler thread into the stack guard page and force-quit Hovercraft. The crash signature was a deep recursion (~6,640 levels) through Swift ABI closure-reabstraction thunks generated for the onFrame delivery callback as it crossed the HovercraftShared / Hovercraft framework boundary. Each delivered frame leaked roughly two stack frames through those thunks; after a few thousand frames at 30fps the kernel killed the queue thread with SIGBUS. We had a partial mitigation for a related (but distinct) v0.9.57 report from beta tester Noah, which protected against synchronous re-entry — that fix is still correct for what it covered, but it didn't address this leak.

The fix is to read the onFrame closure once at start(), before the SCStream begins delivering, and cache it in a plain stored property that the hot path reads with no Sendable/lock context. The lock-protected installation path stays for safety; only the per-frame read changes. Result: the framework-boundary thunk chain is gone for the hot path, so deliveries stop leaking stack. Verified across a 10+ minute Zoom + window-share session.

No more split-second celebration flash on launch

Returning users on the production build were seeing a brief flash of the setup sheet on every launch — a flicker of the 3-step checker, then the "You did it" celebration, then the app. Cause: installer.status initializes to .unknown for ~100ms while the system-extension query is in flight, which to shouldPresentSetupSheet looked like a regression. The sheet popped, conditions healed a frame later, and the sheet's asymmetric "only an explicit tap can dismiss the celebration" rule stranded the user in the celebration screen until they clicked through. Fix: the cold-launch presentation path no longer raises the sheet for users who've already completed setup. The sheet is now exclusively driven by .onChange regression detectors for already-onboarded users, and a regression-mode sheet auto-dismisses the moment conditions heal — only first-run mode triggers the celebration. Returning users land directly in the app with zero flash.

Screen Recording disclosure now expands

In the Window Picker's permission-denied state, the "Already enabled but still denied?" disclosure chevron stayed closed when clicked, hiding the troubleshooting help behind it. Cause was a one-character SwiftUI bug: @State private static var stuckDisclosureExpanded instead of @State private var stuckDisclosureExpanded. static @State doesn't participate in the SwiftUI observation graph, so toggling it never re-rendered the DisclosureGroup. Removed the static.

Help → Reset First-Run Walkthrough…

New menu item under Help that returns Hovercraft to its first-run state: clears setupCompleted, the first-pinch flag, and the welcome-shown flag, then quits and reopens straight into the guided setup sheet with the welcome guide ready to load. License, camera selection, last-deck bookmark, quit-warning preference, analytics first-launch flags, and update-checker state are all preserved. Built as a support tool — when a user writes in confused about a setup-related issue, this is one menu item to point at instead of defaults delete incantations or a reinstall.

[v1.0.2] — 2026-05-05

Buttery preview motion

The local preview now glides like visionOS. The broadcast composite is hard-locked to camera frame arrival (typically 30Hz), and the preview used to ride on the same cadence — so any Vision pose drop, camera bus hiccup, or transient CPU contention surfaced as a visible hitch in the slide. The fix decouples the local preview from the camera-rate path entirely: a 60Hz timer on the FramePump's userInteractive queue re-reads appearanceDriver.snapshot() (the interpolation graph is wall-clock-driven, so the slide keeps gliding toward the last known target even when no new pose lands), re-composites the mirrored preview using the most recently cached camera buffer, and publishes. The camera-arrival path now does only the broadcast composite + buffer caching — one render pass per camera frame instead of two. A first-frame seed avoids a 50ms blank gap on launch before the timer's first fire.

Net effect: dropped Vision frames and camera bus hiccups become invisible in the preview because the transform interpolation graph keeps advancing on its own clock. The broadcast / sink / Zoom output is untouched — same 30Hz cadence the wire format expects, exactly as before. This is a presenter-side polish: drops in the input pipeline stop being drops in the output preview.

Settle with weight

The pinch release used to coast to a stop with a cubic ease-out — smooth, but it read as "glide" rather than "settle". New AppearanceDriver.releaseSpring curve (quintic ease-out: 1 − (1−t)^5) decelerates more aggressively at the end, reading as a soft landing rather than a continued ride. Both the position release-coast and the scale rubber-band release adopt it; the coast magic number changes from velocity * duration / 3/ 5 to keep the seam C¹-continuous from the spring's last velocity into the new curve's initial slope (no visible kink at release). settleToCenterIfNearby defaults to the new curve and tightens 0.35s → 0.28s. The slide now arrives where you parked it with a tiny in-breath of weight, not a long exhale.

[v1.0.1] — 2026-05-04

First-run is now a calm guided setup

Allen Pike's first-run usability review (recorded reaction-track of an unprimed user trying Hovercraft for the first time) caught a stack of small confusions that compounded into a panicky 0-to-60 seconds. The fix is structural: a dedicated Setup Modal sheet replaces the old chrome-embedded checklist, presented on first launch and re-presented if a critical regression occurs (camera permission revoked, extension uninstalled). The modal owns three sequential steps — allow camera access, install the camera helper, approve the helper in System Settings — with calm pending states (numbered circles, no warning glyphs), pre-warnings about every macOS-native popup the user is about to see ("when macOS asks Hovercraft would like to use a new camera extension, click OK"), and dynamic copy that swaps as state changes. Behind the modal the live preview keeps running so the user can already see what they're about to use; the chrome dims 22% so attention stays on the modal.

The system camera-permission popup is now deferred until the user explicitly clicks "Allow Camera Access" in step 1 (framePump.start() is gated on cameraPermission.isAuthorized), so the very first thing a user sees is Hovercraft's own welcome, not a system modal asking for camera access before they understand what Hovercraft is. The helper-extension pre-warning in step 2 short-circuits the "OK or Open System Settings??" panic that came from the macOS-native confirmation dialog.

When all three steps go green, the modal swaps to a celebration state — "You did it. Here we go." — with one prize button: "Take me there". The modal's auto-dismiss was load-bearing-removed: an asymmetric presentSetupSheetIfNeeded() helper raises the binding from .onChange handlers but only the explicit "Take me there" tap can lower it, so the celebration always renders even if the last regression healed an instant before completion.

Self-relaunch actually relaunches now

Two of the three setup steps end in a "Restart Hovercraft" button, because macOS's TCC daemon needs a fresh process to pick up a newly-granted camera permission, and OSSystemExtensionRequest swap-in is cleanest with a clean host. The previous attempt at relaunchSelf called NSApp.terminate(nil), which was silently vetoed by AppKit because the SwiftUI sheet is interactiveDismissDisabled(true) — modal sessions block terminate by design. Result: button beep, no quit, user stuck. Swapped to NSWorkspace.shared.openApplication(at:configuration:) with createsNewApplicationInstance = true, then exit(0) 0.4s later. LaunchServices (a system daemon, not a child of our process) holds the new launch request, so the new instance survives our hard exit; exit(0) skips the AppKit terminate path entirely so the modal can't veto it; the 0.4s delay covers the cold-launch boot-to-visible window so the user never sees a moment of "no Hovercraft running". Same pattern that's worked in ExtensionInstaller.restartApp() since v0.9.3 — the regression was that the setup-modal rewrite stopped calling it.

AppDelegate.applicationShouldTerminate also now dismisses any attached window-modal sheet before it runs its "you have unsaved presentations" alert. macOS's "Quit and Reopen" prompt that follows a camera-permission grant was beeping and bouncing the user back to Hovercraft because the setup sheet was attached and the alert couldn't present over it.

FaceTime camera by default

First-run users with a Continuity-Camera-eligible iPhone in pocket were seeing the iPhone selected as the input camera before they'd even had a chance to think about it. Reversed CameraFeed.discover()'s priority order: built-in wide-angle first, external second, Continuity Camera last. Power users who want Continuity Camera switch to it once and that preference sticks; first-run users see themselves on the camera that's already pointed at them.

Welcome deck — Page 1 + Page 7 + R hotkey

Allen's review also caught that the Welcome deck's first page was easy to skip past without noticing it was a tutorial, and that the pinch gesture itself wasn't shown until page 2. Page 1 redesigned: dropped the "Welcome to Hovercraft" eyebrow, raised the "Here's how to get the hang of Hovercraft quickly." headline, and added a hero-scale (660pt long-side) white-on-dark pinch hand illustration in the lower-right quadrant — same first-person POV as the photo-derived asset on page 2, so the user sees the gesture they're about to need before they have to perform it. Keystroke hint moved to the lower-left (42pt @ 0.85 opacity) so the hand has room to breathe.

Page 7 (sign-off): dropped the "YOU'RE READY" eyebrow, removed the "or" glyph between the two affordance cards (the eyebrows already imply alternative), bumped the card body type 26pt → 32pt with proportionally bigger line spacing, and replaced "Then show all your friends." with "Then show everyone." at 52pt light with the last word italic via mixed-attribute drawing — the call-to-action is now the second-strongest beat on the page after the headline.

Pages 2 (Pinch) and 4 (V / L hand poses) bumped illustration sizes (540 → 680 and 620 → 760 long-side respectively) for visual consistency at preview scale. Page 5 (Hotkeys) added an R row: "Reset to starting pose". Hotkey hint strip in the chrome and the landing page's keys list updated to match.

R resets the slide

Press R anywhere in the app to animate the slide back to its default upper-right tuck pose (transform + scale). New users who pinched themselves into a corner had no recovery path short of pressing F (which swaps to a different appearance) or quitting; R is a single key with no side effects. Documented on page 5 of the Welcome deck, on the landing page's keys list, and in the in-app hotkey hint strip.

Scale clamp with rubber-band bounce

The pinch transform's scale lower bound moved from 0.10 → 0.25, so the slide can't be shrunk to a postage stamp anymore. Both bounds (0.25 and 1.4) now bounce: pinching past the limit deflects with rubber-band resistance and snaps back on release (0.28s easeOut, 0.15 overshoot budget). Same gesture feel as visionOS pinch-to-zoom, which is the closest cultural reference users have for an air-pinch on a flat surface.

Welcome deck loads centered, not tucked

When the Welcome deck loads (first launch and via Help → Load Hovercraft Guide), it now appears centered at 0.65 scale with a 0.6s ease-in animation, instead of in the upper-right tuck pose every other source loads into. Two reasons: (1) it's already legible at the size it appears, so first-time users can read the page-1 hint without having to figure out pinch first, and (2) starting larger and pulling smaller is a more discoverable pinch gesture than starting smaller and pulling larger — the user's hand naturally arrives at the slide rather than reaches up for it. Other sources (PDFs, images, live windows) keep the upper-right tuck.

"Capture window" → "Choose window"

Renamed the source-picker button and tooltip from "Capture window" (which read as a verb-noun command) to "Choose window" (which reads as a selection action and matches every conferencing app's mental model for window sharing). One word; small thing; Allen flagged it.

Pinch-to-grab tooltip removed

Dropped the floating "Pinch to grab the slide" tooltip that animated in over the slide on first launch. It was annoying for everyone who wasn't a first-run user and wasn't actually catching first-run users either — they were already trying the pinch from the Welcome deck's page-1 hand illustration. The Welcome deck IS the discoverability surface; the floating overlay was redundant noise.

Launch analytics

Hovercraft now emits launch-day analytics to PortableAnalytics. Seven events answer the question "are people getting from install to first successful use": app_launched, extension_authorized (first CMIO permission grant), source_added (PDF / image / live window), virtual_camera_selected / virtual_camera_released (a conferencing app picked Hovercraft as the camera, with paired session IDs for duration math), gesture_fired (pinch today, flick / fist / position / scale ready for v1), slide_advanced (keyboard / button / filmstrip / gesture, the north-star event), and license_activated (paired with a Keygen-license-ID identify). No PII: no email, no slide contents, no window titles, no machine fingerprint. Events flow into Sandwich Vision's Studio workspace on portableteam alongside Theater visionOS, tagged app_name = "hovercraft" so the shared workspace stays cleanly sliceable. Wire format is the open PortableAnalytics v0.1.0 SPM package; config lives in Hovercraft/Analytics.swift.

[v1.0.0] — 2026-05-03

Leaving beta

Hovercraft is 1.0. Promoting the v0.9.62 build (V-hide backdrop fix, lock button, Welcome deck rebuild, slide-strip active state, FF appearance restore) to the 1.0 marketing version. Landing page drops the "Beta" eyebrow, pricing renames the entry tier to "Hovercraft Lite", and the gated download endpoint serves Hovercraft-1.0.0.dmg.

Camera Extension no longer pegs a CPU after the host releases the camera

Beta-tester report: the camera extension stayed at ~98% of one CPU core for hours after the user finished a call, only stopping when the user disabled the extension from System Settings. Cause: the sink-stream's subscribe() re-arm chain was unconditional. When Zoom (or any camera client) released the device, the macOS-delivered stopStream callback tore down the sink on the device-source side but left client set and the recursive consumeSampleBuffer → completion → subscribe() chain running. Subsequent completions fired immediately with a "stream stopped" error and a nil buffer, and each completion blindly re-armed another consume — pegging the dispatch queue as fast as it could schedule. Fix: the sink stream now flips a running flag and clears client on stopStream, the subscribe() re-arm checks the flag before recursing, and an error completion abandons the chain instead of re-arming.

Camera Extension upgrades actually upgrade now

A second, deeper bug surfaced when the first CPU-fix re-seed didn't actually fix anything for the same beta tester: Activity Monitor showed the spinning camera extension daemon had 151 hours of accumulated CPU time despite a fresh install, which was impossible unless the running daemon predated the install. macOS only replaces a running CMIO Camera Extension when the new bundle's CFBundleVersion is strictly greater than what's installed, AND ExtensionInstaller enforces the same rule on the host side via its actionForReplacingExtension delegate (returning .cancel on equality, with a comment explaining why a same-version replace is "actively harmful"). The CameraExtension target's CURRENT_PROJECT_VERSION had been stuck at 14 across many releases — every install since the first one had been a silent no-op. Bumped to 15, which lets macOS swap the buggy daemon out the next time the host launches. Added a guard to scripts/notarize.sh (with a tracked scripts/.extension-cfbundleversion-last-shipped file) so this never ships again: the build fails loudly if CFBundleVersion didn't bump since the last successful release.

But the version bump alone wasn't enough. A third, even-deeper bug: the host app's install() (which submits OSSystemExtensionRequest.activationRequest and is the only path through which macOS ever considers replacing a running extension) only fires from explicit UI buttons. On a normal "tester reinstalls and relaunches" flow, FramePump observes the OLD daemon's still-live sink binding within milliseconds, calls noteExtensionRunning(), status flips to .installed, and the install button hides — so the activation request never gets submitted, version comparison never happens, and the OLD daemon keeps running indefinitely no matter how many .app reinstalls or CFBundleVersion bumps you do. (Confirmed: tester saw the new build at /Applications/Hovercraft.app while /Library/SystemExtensions/.../CameraExtension.systemextension had a modification date of 4/21, weeks earlier.) Bumped to 16 for this build and added a launch-time auto-upgrade check in ExtensionInstaller.checkForBundledUpgrade(): it reads the bundled extension's CFBundleVersion from Contents/Library/SystemExtensions/.../Info.plist, queries the OS for the installed version, and if bundled is strictly newer AND no sink binding has been observed yet, submits an activationRequest automatically. The existing actionForReplacingExtension delegate then returns .replace and the spinning daemon is killed and replaced. If a sink binding HAS already landed (for example: the user updated mid-Zoom-call via the in-app updater's "Restart to install"), the upgrade is deferred and the user gets a passive blue panel — "Camera helper update queued" with a one-click Restart Hovercraft button — so the queued fix doesn't silently sit there. Lisagor signed off on the launch-only-when-unbound invariant: replacing under a live CMIO DAL binding leaves FramePump unable to rebind for the rest of the session, so the deferred path is load-bearing, not paranoia. First-run and upgrade-run share the same code path: a missing installed extension surfaces as version 0, the inequality favours the bundled one, and install() performs a clean fresh install.

[v0.9.62] — 2026-05-02

V-hide no longer leaves a soft-focus rectangle behind

Caught while shooting the demo video: with a slide in F (full-frame, soft-focus background), triggering V to hide the window left a visible rectangle of blurred camera floating over an already-sharp world for the duration of the fade, then snapped off. Cause: the frosted-glass plane that lives behind the slide rounded-rect was drawing at full strength as long as the slide was nominally on-screen, while the slide above it faded smoothly and the global background blur racked back to sharp on its own (front-loaded visibility²) curve. Three layers, three different envelopes. Coupled the backdrop fill's alpha to the slide's opacity so the frosted-glass plane fades on the same envelope as the slide it sits under — the rectangle now dissolves cleanly instead of hovering.

[v0.9.61] — 2026-05-02

Lock / unlock now has a button

Two new entry points for the input lock that previously only responded to the L hotkey and the L hand pose:

  • A lock button in the chrome's source toolbar, sitting next to the eye (hide-slide) button. The icon flips between lock.open and lock.fill to mirror state; tooltip swaps too. Same toggle path the L key uses, so the three entry points share a single state machine.
  • The "Locked" pill that floats in the top-trailing corner of the hero preview is now click-to-unlock. A presenter who's mid-call and wants pinch back doesn't have to hunt for the chrome — they click the affordance that's already in their face. Hit-testing is gated on visibility so the invisible (unlocked) pill doesn't catch stray clicks in the corner.

Capture window — TCC reset hint for multi-build users

If a user has more than one Hovercraft build installed (a developer build alongside the release, an old App Store install alongside a hand-notarized one, etc.), macOS coalesces them into one "Hovercraft" row in System Settings → Screen Recording but keeps a separate TCC grant per code signature. Toggling the visible row only flips one of them, so the other build stays denied no matter how many times the user retries. The Capture-window permission empty state now has a collapsible "Already enabled but still denied?" disclosure that explains the cause and offers a one-click copy of the tccutil reset ScreenCapture vision.sandwich.Hovercraft command. Collapsed by default so the primary "open Settings → enable" path stays the obvious one for normal users.

Help → Load Hovercraft Guide + ? button

The Welcome deck now has a second discovery surface beyond the Help menu — a small circular ? button anchored bottom-right of the chrome (visual register matches the camera-picker glyph). Clicking it opens a confirmation dialog ("Load Hovercraft Guide? — This will replace what you currently have loaded") so a stray click during a live share doesn't blow away the user's current deck or window capture. The Help menu item, renamed from "Show Welcome Deck" to "Load Hovercraft Guide" to match the dialog's title, loads the deck directly without confirming — picking a menu item is already a deliberate two-step gesture.

Welcome deck — Manual page, hotkey legibility, sign-off

Three changes to the bundled walkthrough:

  • New page 6 ("Manual") inserted between Hotkeys and the sign-off, mirroring the Pinch page's layout. Tells users that mouse and trackpad still work for everything; the gesture story doesn't replace the manual one. Deck is now 7 pages.
  • Page 5 (Hotkeys) drops the rounded-rect "keycap pill" container that earlier revisions used. Bare bold monospaced letters in a right-aligned column read unmistakably as keys without competing with the surrounding type, and the two-letter "FF" cap no longer renders at a different shape than the single-letter caps.
  • Page 3 (Push) the z-axis arrow is rebuilt as a single outline glyph: open V at each tip, horizontal base wings extending out from the shaft, no fill. The earlier three-tone monochrome fill read as a literal solid object that competed with the slide content; an outline reads as a diagram and sits more politely next to the hand-drawn illustrations on surrounding pages.
  • Page 7 (sign-off) drops the "Tell all your friends. / sndw.ch/hover" footer in favour of larger affordance cards (580×320, up from 440×180) and a smaller "Then show all your friends." line below them. The URL was unevenly spaced at preview scale and there's no good way to copy a URL out of a deck loaded into a Zoom share anyway.

Hotkey hint strip — wider keys, more breathing room

The persistent hotkey hint strip in the chrome (V / F / FF / C / L) had two cramped patches: the keycap pills were tight-fitting squares that ballooned into a different shape for the two-letter "FF" cap, and the spacing between cap+label clusters was 14pt — close enough that the strip read as one dense line rather than five discrete entries. Dropped the pill containers entirely (consistent with the deck's page 5 treatment) and bumped the inter-entry spacing to 22pt.

[v0.9.60] — 2026-05-01

FF appearance restores when pulled out manually

When the user pinched out of FF (full-frame) without pressing F, the slide stayed sharp-cornered and fully opaque indefinitely until they pressed F. Root cause: the FF entry path snapshots the user's tuned opacity and cornerRadius and forces opacity to 1.0 / cornerRadius to 0 for the FF hold, but the only path that animated those back was performFillRestore — invoked exclusively from F. A manual pinch-out left the appearance overrides parked. Fixed by hooking pinchActivity.isPinching and, on transition false, checking whether we still hold FF appearance snapshots AND the transform has left every fill state. If both, we fire the same animateRestoreAppearance the F-press uses, so a manual pull-out feels identical to an F-press pull-out from the appearance side.

Slide-strip active state

The slide-strip overlay along the bottom of the floating viewer now actually shows which slide is active. Prior builds shipped the visual treatment for the active thumbnail (full opacity, soft halo, 1.05× scale bump) and the "n / N" counter pill, but neither updated as the user navigated, because SlideDeck.currentIndex was a computed property that delegated to the underlying SlideSource. The @Observable macro only instruments stored properties, so mutating the source's currentIndex directly never woke up the SwiftUI views that subscribe to the deck. Fixed by mirroring the index into a stored @Observable property on SlideDeck that every navigation method (next, previous, jump(to:), source-swap loaders) writes alongside the source-of-truth source. UI reads the mirror; the texture renderer keeps reading the source.

Onboarding deck — new authoring model

The Welcome deck (Hovercraft/Welcome to Hovercraft.pdf, surfaced on first launch and via Help → Show Welcome Deck) is rebuilt around a new authoring model: the Swift script is a typesetter (layout, typography, copy, theming, slide-strip safe-area math), and every illustration on the deck lives as a PNG asset under scripts/welcome-deck-assets/ that the script loads by name and drops into a named slot on the relevant page.

The v0.9.59 deck procedurally drew the V and L hand poses from CoreGraphics primitives. The result read as schematic at best and didn't share a visual register with Apple's hand.pinch SF Symbol it was paired with on the Pinch page. v0.9.60 replaces all three hand drawings (Pinch, V, L) with high-quality line-art illustrations dropped in by the user. Swap workflow: regenerate the PNG with the same filename, re-run swift scripts/generate_onboarding_pdf.swift. No code change required.

Slide-strip safe area in the deck

Every page now reserves a 200pt floor (~22% of canvas height) that the in-app slide-strip overlay occludes when the deck is loaded into the floating viewer. Bottom-edge content (keystroke hint, friends URL, hotkey rows) shifted up so nothing important hides behind the strip. The page background still paints to the bottom edge so the strip's translucent glass has surface to sit on.

Light theme — cream

Light pages (Pinch, Hide & Lock, Hotkeys) shifted from #F4F5FA (pale lavender) to #FAF8EC (warm cream) to match the canvas color of the new hand illustrations. Image edges sit invisibly on the page, no card-edge mismatch.

Help → Show Welcome Deck

Carryover from v0.9.59. Surfacing it again because the menu item is the way users will get to the rebuilt deck after first launch.

[v0.9.59] — 2026-04-30

Hand-pose gestures: V (peace sign), L

Two hand gestures that mirror existing keyboard hotkeys, so a presenter who's already gesturing on camera can drive the most common controls without reaching for the keyboard.

V (peace sign) — index and middle extended, ring and pinky curled (thumb don't-care, since both "thumb tucked" and "thumb out" peace signs are common) — fires the V key path, toggling the slide between visible and hidden in the camera feed.

L — index extended, thumb extended, middle / ring / pinky all curled — on either hand fires the L key path, toggling the input lock that gates pinch and cursor inputs.

Both gestures run through a new HandGestureRecognizer that lives next to PinchRecognizer over the same HandPoseFeed stream — separate machines so changes to the static-pose vocabulary can never regress the continuous pinch tracking we've been tuning across the v0.9.5x cycle. Each pose requires a 200ms continuous hold before firing to reject transient classifications.

Latching — the critical "fire once per held pose" guarantee. After a gesture fires, it enters a latched state: no further fires of that gesture until the arming pose has been continuously released for 100ms. Single-frame Vision flickers (confidence dips, brief occlusion) don't count as a release, so a held V-sign produces exactly one toggle, not a cycle. The 100ms threshold is two frames at 30fps plus a frame of margin for noisy lighting.

Per-finger "extended" classification uses the ratio dist(tip, wrist) / dist(PIP, wrist) against a 1.15 threshold — robust at any hand orientation, doesn't drift with hand size or distance from camera, and the gap between an extended-finger ratio (>1.3) and a curled-finger ratio (<1.0) is wide enough that natural in-between poses don't ping-pong across the boundary. Joint confidence floors match PinchRecognizer's 0.4 standard, so noisy frames are treated as "no clear classification" rather than as reset events.

The recognizer is actively suppressed while a pinch session is live, so a pinch-then-release with the hand still in frame can't false-fire as the hand reshapes. Suppression also clears any in-flight hold timer so the post-pinch reshape always starts from a clean slate.

An open-palm swipe gesture for slide nav was prototyped and cut. Extensive testing showed the open-palm pose was geometrically too close to a transitional hand shape between idle and pinch — wave motions tripped false-positive pinch begins, which then suppressed the gesture recognizer for the duration of the spurious pinch, which made waves feel like they "wore out" after the first few. Bidirectional gating (refuse pinch while open-palm is classified) untangled the interlock cleanly but the underlying classifier confusion was a fundamental product issue, not a tuning issue. Slide nav stays on the keyboard arrow keys, which is the better experience anyway. Lesson banked for any future motion gesture: prove the arming pose can't be confused with the pinch grip's transitional shape before tuning the trajectory layer.

F-toggle — FF pulls opacity and corners to a "presentation surface", plus deferred restore from margin

A double-tap on F (FF) escalates the slide to full-bleed fill, and now also pulls two appearance properties to their "screen" values: opacity goes to 1.0 (fully opaque) so the slide reads as a presentation surface rather than the slightly-translucent glass plate the user's tuned scale → opacity curve normally lands at near-fill scales, and corner radius animates to 0 so the slide hugs the canvas edge-to-edge instead of carrying its glass-pane rounded corners into the bleed.

Both transitions are animated, not snapped. The opacity curve and corner radius interpolate over the same 0.65s smootherStep envelope as the FF transform animation, driven by a per-frame display-link animator running in lockstep — so the slide grows, opens up, and squares its corners as one fluid composed move. The matching restore (single F from full-bleed) reverses both animations in sync, so as scale eases off the fill target the corner radius rounds back and opacity flows back along the user's tuned curve. The user's tuned values are snapshotted on entry-to-fill and held verbatim until the restore animation completes, so a dev-panel slider edit during a long FF hold doesn't pop the corner back to rounded mid-presentation.

The single-F → margin path is unchanged — opacity stays on the user's curve at the margin scale (the Day-5 PRD §4.3 "you sense the camera through it" feel) and corner radius stays at the tuned glass radius. Only the FF escalation forces both.

The other F-toggle fix in this build: pressing F at the margin-fill state now waits the same 350ms doubleTapWindow before restoring to the pre-fill pose, so a follow-up F (FF) can catch the pending restore and escalate to full-bleed instead. Without this, the sequence "F → margin → F-F to full-bleed" briefly retreated toward the original pose between the two F's of the FF before reversing course, which read as a stutter. From the at-full state, restore is still instant — there's no further escalation to disambiguate, so no waiting needed. Any new F press, deck swap, or other action cancels a pending deferred restore.

Edge case handled: a deck swap while parked at full-bleed cancels any in-flight appearance animation and instantly restores the user's opacity curve and corner radius so the new deck doesn't inherit the forced overrides.

Onboarding deck — V/L gestures, FF and C hotkeys, dot strip retired

The bundled "Welcome to Hovercraft" PDF that auto-loads on first launch grew to six pages to absorb everything that landed across the v0.9.5x cycle. The structural shape stayed the same — Welcome → gestures → keyboard → make-it-yours — but every part of the gesture and keyboard arc got fuller coverage.

Page 4 is new — Hide & Lock — a single light-themed sheet pairing the V (peace sign, hides the slide) and L (index + thumb, locks input) hand gestures, with a verb headline and a custom hand glyph for each. The glyphs are drawn from primitives (palm + per-finger stroked capsules + thumb) rather than SF Symbols — Apple's San Francisco set ships hand.pinch (used on the Pinch page) but no peace-sign or L-sign hand glyph as of macOS 26.2 SDK, so V and L are bespoke. They're proportioned and stroke-weighted to read as a family with the pinch hand: same line register, same scale-relative stroke width, same hollow-outline silhouette.

The Hotkeys page extends from three rows to five — F (fill the frame), FF (full-bleed fill), V (hide the slide), L (lock input), C (recenter the window) — matching the live in-app hotkey hint strip exactly. Row pitch tightens from 100 to 86pt and body type drops from 48 to 44pt to fit the two new entries on the page without crowding the headline above or the bottom margin.

The bottom progress dots are gone. The live slide-strip in the floating window already shows page count and current position; rasterized dots inside each slide were redundant and stole vertical real estate from the body composition. Each page got a small rebalance pass to take advantage of the freed bottom margin (Welcome's keystroke hint sits 8% lower; Pinch's hand glyph drops 4%; Push's body copy drops 4%).

A new "Help → Show Welcome Deck" menu item loads the bundled PDF on demand, so users who skipped past the deck on first launch — or who want to reference it again after the FF/C/V/L additions — can pull it back up without hunting through Finder for a sample file. Wired through NotificationCenter because the menu commands closure can't reach the rootView's slideDeck state directly.

App icon — H corners pulled toward the macOS 26 squircle radius

The H letterform's outer corner radius bumps from 0.039 → 0.12 (in the icon's normalized unit space — the H bars are 0.40 wide, so this is roughly 30% of bar-width). The change pulls the H's outer silhouette closer to the corner-rounding character of the macOS 26 squircle the icon sits inside, so the letter and its container read as the same family rather than as a square letter dropped onto a rounded backdrop. Inner corners (the four pairs that bracket the crossbar's negative space) stay sharp at 0, preserving the H's letterform legibility — only the outer silhouette warmed up. Re-exported through the IconLab pipeline; both Hovercraft/AppIcon.icon (Tahoe Liquid Glass bundle) and Hovercraft/Assets.xcassets/AppIcon.appiconset (legacy raster) regenerated in lockstep so platform variants stay visually identical.

Hotkey hint shows FF and C

The compact hotkey strip under the welcome chrome now lists FF (full bleed) and C (center) alongside V, F, and L. The double-tap-on-F shortcut and the new C composition tool were easy to miss without the hint — now they're right there with their siblings.

Blur leads the slide's exit on V-hide

A subtle visual artefact: when V faded the slide out, the global camera blur (which is scale-coupled and rides the slide's interpolated visibility) faded along with it on the same linear envelope. The eye read the two channels — slide fading and world resolving — as separate perceptual events sharing a clock. Adam's complaint: "the blurred background lingers as blurred for a split second before it goes sharp." A first attempt that gated blur off the target visibility (snap-to-sharp the moment V fires) made it worse: the area outside the slide rect popped sharp instantly while the slide's own pixels were still cross-fading over a backdrop that had already snapped, producing a disjoint discontinuity at the slide boundary.

Fix (per Lisagor): keep blur and visibility on the same shared clock — one global formula, no inside/outside split — but square the visibility coupling so the blur front-loads its decay. blur = visibility² * scaleToBlur(scale) instead of blur = visibility * scaleToBlur(scale). At visibility = 0.7 (early in the fade), blur is 0.49 — already half gone. At visibility = 0.5, blur is 0.25 — three-quarters gone. The world is most-of-the-way sharp by the time the slide is half faded, but the two channels never decouple, so there's no boundary discontinuity inside vs outside the slide rect. Reads as "the slide goes and the world is here" rather than "the slide goes and then the world resolves." On the way back in (V to show) the curve trails — blur ramps up slowly at first and catches up as the slide lands, which reads as the slide pulling the world out of focus around it as it arrives. Symmetric in math, asymmetric in feel, both in the right direction.

Implemented as a one-line change in AppearanceDriver._cook: transform.visibility * transform.visibility in place of transform.visibility for the blur factor only. Opacity coupling is unchanged.

WindowCapture stack-overflow guard

A crash report from Noah on v0.9.57 (macOS 26.5 beta, Mac Studio M2 Max with three displays) showed the SCStream sample-delivery thread killed by SIGBUS at the stack-guard page after 6,656 levels of mutual recursion through two functions in HovercraftShared. The recursion sat between the SCStreamOutput callback and the per-frame onFrame?(mtl) invocation that hands the texture to SlideDeck. The path is supposed to be flat — one frame in, one callback out — and the in-house pipeline reproduces flat with no growth. Most likely a re-entrant SCStream delivery on that specific OS/hardware combo, or a Swift runtime quirk in Sendable thunk dispatch under Apple Silicon LPDDR5; without a reliable repro we can't symbolicate the exact frames.

The defensive fix doesn't depend on knowing the cause: a non-recursive NSLock around the per-frame callback dispatch acts as a same-thread reentrancy detector. First entry succeeds and runs the callback as before; any synchronous re-entry on the same thread fails the tryLock, drops the new sample, and logs once. A dropped frame at 30fps is invisible. The crash was a force-quit during a live demo. Trade is obvious.

Pinch enter / exit defaults retuned

The pinch recognizer's pinchEnterRatio and pinchExitRatio defaults moved from (0.40, 0.45) to (0.30, 0.35) after Adam's bake-off in dev. Pinches now require a firmer-feeling close (enter at 30% of hand size instead of 40%) and release more responsively the moment fingers separate (exit at 35% instead of 45%), with the same 0.05 hysteresis preserved for jitter rejection. The earlier looser values were a v0.9.53 / v0.9.54 fix for close-range stickiness that the ratio-scaling formula has since absorbed; with that path now stable, the tighter pair feels closer to the physical "I'm pinching → I let go" intent.

[v0.9.58] — 2026-04-30

Two new hotkeys: C (center) and FF (full-bleed fill)

C animates the slide to the canvas center without changing scale. Idempotent — pressing C while already centered is a no-op (no kicked animation, no tilt impulse), so a tap-tap doesn't fire spurious motion. Tolerance is tight enough (~0.005 in canvas-fraction units, well below the soft-snap bias well's radius) that any deliberate user reposition counts as "off-center" and earns a recenter on the next press. Composition tool first; doesn't touch the F-toggle's pre-fill snapshot, so an F-then-C-then-F sequence still restores cleanly to the original pre-fill pose.

FF is a double-tap on F: the first F lands at the existing fill-with-margin (~3% inset per side, the "almost edge-to-edge" feel), and a second F arriving within 350ms escalates to full-bleed fill — no margin, the slide hugs the canvas exactly. After FF, a single F restores all the way back to the original pre-fill pose, just like a single F-toggle would. The 350ms window is just past the system double-click threshold, which feels right for a deliberate keyboard double-tap. Outside the window a second F is treated as a fresh single press, which from at-fill means "restore" (the existing behaviour). Optimistic-then-escalate beats delaying single-F to disambiguate: every user pays 0ms instead of 350ms for the common case, and the FF stage transition reads as an intentional two-stage "almost…there" rather than a stutter.

Both hotkeys reuse the F-toggle's animation feel — quintic smootherstep over 0.65s with damped tilt drive — so all three keyboard composition gestures (F, FF, C) move with a single visual vocabulary.

The bundled welcome deck still lists only F / V / L on its Hotkeys page; C and FF are advanced enough that surfacing them in the first-run onboarding would crowd the page without earning their keep. They live in the changelog and (eventually) the docs.

[v0.9.57] — 2026-04-29

Welcome deck survives the camera-extension restart

Adam tested v0.9.56 on his Intel Mac mini — clean install, camera extension installed correctly, app restarted as part of the install flow — and discovered a bug we'd shipped without seeing: the welcome deck disappeared on the post-restart launch. The root cause was the v0.9.55 design treating "the welcome deck rendered" as "the user has been onboarded". Stamping hovercraft.welcomeShown = true the moment the deck loaded meant any process that triggered an early relaunch (the camera-extension install being the canonical case) burned the one-shot before the user had actually seen, let alone engaged with, the deck.

v0.9.57 redefines the gate. The flag now means "the user has engaged with the app", and is written by the actions that prove engagement: replacing the welcome deck with their own PDF / image / window share (any of loadPDF / loadImage / loadWindow), or completing a first pinch (firstPinchCompletedKey, which the recognizer already writes for the first-run tooltip dismiss). Either signal retires the welcome deck for good. Otherwise — including across system-extension-install restarts — the welcome deck replays on the next launch where no other deck is bookmarked. A user who opens and closes the app without engaging gets it again on launch two; a user who pinched even once doesn't.

The drop zone also grows a small "Show welcome deck" link sized down to read as tertiary text — there for the curious, for the user who dismissed and wants it back, and as a manual recovery path if the gate ever misclassifies a real first run as engagement. It bypasses the gate entirely so summoning the deck is always one click away from the empty state.

Existing v0.9.55 / v0.9.56 users who already have welcomeShown = true on disk are unaffected; they're already past the onboarding question. The fix matters for net-new installs and for the post-extension-install relaunch path that brought it to light.

[v0.9.56] — 2026-04-29

Image-file drop, opt-out quit warning, Intel-Mac crash fix

Three small landings stacked into one bump.

Drop a still image as a slide. The drop zone (and the active deck surface) now accepts the standard image formats — PNG, JPG/JPEG, HEIC/HEIF, GIF, TIFF, BMP, WebP — anything ImageIO knows how to decode. The dropped image becomes a single-page deck via the new ImageSlideSource, sibling to PDFSlideSource and WindowCaptureSlideSource. Mixed drops resolve PDF-first, then image-first; unrecognised file types are silently ignored so a stray document in a multi-file drop doesn't block the rest. Single-image decks share the same security-scoped bookmark machinery as PDFs, so the last-loaded image reopens automatically on the next launch. The drop-zone hint text changes from "Drop a PDF here" to "Drop a PDF or image here" to match.

"Warn before quitting" is now an opt-out preference. When an app is reading from Hovercraft's virtual camera and the user tries to quit, the existing warning dialog (introduced in an earlier build) now carries a checked-by-default checkbox labelled "Warn before quitting". Unticking it persists vision.sandwich.Hovercraft.warnBeforeQuitting = false and silences the prompt for future quits while broadcasting; ticking it (or leaving it ticked) preserves the warning. The setting writes regardless of whether the user clicks Quit or Cancel — the act of unticking is itself the commitment to the new preference. The default is true (warn), enforced via register(defaults:) so a clean install lands on the safety net automatically. Users who want to re-enable the warning today need a defaults delete; a Settings-panel exposure can come later if anyone asks.

Intel-Mac DAL camera crash, fixed. John Siracusa's Mac Pro 7,1 crashed Hovercraft v0.9.3 the first time a USB camera tried to come online. The crash log pointed at -[AVCaptureDALDevice setActiveVideoMinFrameDuration:] throwing an uncatchable Objective-C exception from inside applyFormat. AVCaptureDALDevice is the legacy CoreMediaIO Device Abstraction Layer subclass — the path used by external USB cameras, virtual cameras, and Continuity Camera on Intel Macs. Some DAL devices (and DAL plugins) reject setActiveVideoMinFrameDuration: even when the format's videoSupportedFrameRateRanges advertises the requested rate, and Swift can't catch ObjC exceptions, so the assignment kills the app. Fix: detect DAL devices by class-name (AVCaptureDALDevice / *DALDevice) and skip frame-rate pinning on them entirely. The device keeps whatever rate the chosen format defaults to, which is fine — rate pinning was a CPU optimisation against Continuity Camera dropping us into 60 fps, not a correctness requirement. Modern Apple Silicon Macs talk to their built-in cameras through AVCaptureHALDevice and never hit this code. Siracusa needs to update from v0.9.3 to pick up the fix; subsequent users on Intel + USB cameras are pre-immunised.

[v0.9.55] — 2026-04-29

First-run welcome deck

Hovercraft now ships with a 5-page Welcome to Hovercraft.pdf in the app bundle. On first launch — or any launch where no prior deck has been picked yet — the app loads the welcome deck automatically, so the first thing the user sees in the floating window is content rather than an empty preview.

The deck is the gesture model in five spreads: a hero page that introduces the app and tells the user to hit the right arrow to advance, a Pinch page that explains reaching up and grabbing the slide window out of thin air, a Push page that explains moving the window forward and back along the depth axis, a Hotkeys page covering the F / V / L shortcuts, and a "now make it yours" page inviting the user to drop in their own PDF or share an open window. The look matches sandwich.vision/hovercraft exactly: same near-black ground, same blue-violet bloom, same SF Pro light weight, same letter-spacing on the eyebrow type. Apple's hand.pinch SF Symbol — the glyph their iOS / visionOS user guides use for this gesture — anchors the Pinch page; a custom forced-perspective z-axis arrow anchors the Push page. The final page surfaces the short link sndw.ch/hover with "Tell all your friends." as a soft call-to-action.

The deck is gated by a hovercraft.welcomeShown flag in UserDefaults, so it only auto-loads once. Existing users updating from v0.9.53 will not see it on update — their last-loaded deck restores normally — which is the right call: the welcome content is for net-new users discovering the app, not for users who already know how it works. (If we ever want to surface it again to existing users, it's a one-line defaults delete away or a future "re-introduce me" menu item.)

The PDF generator lives at scripts/generate_onboarding_pdf.swift so the deck can be regenerated whenever copy or visuals need to evolve, and the script is heavily commented with the reasoning behind type sizes (calibrated for the floating window's typical 3-4× preview downsample), color choices (pre-blended bloom stops to dodge PDF gradient-alpha quantization), and layout decisions.

Bundles v0.9.54's pinch-exit re-tune and the DEBUG-gate on dev controls (the v0.9.54 build was committed locally on 2026-04-29 but never published; v0.9.55 is the first build to ship those changes to users).

[v0.9.54] — 2026-04-29

Production builds drop dev controls; pinch-exit retuned to 0.45

Two small landings before public release. Adam dialed pinchExitRatio live on the Mac Studio after v0.9.53 shipped and settled on 0.45 as the right seed (down from v0.9.53's 0.48, which itself was down from v0.9.52's 0.65). The 0.05 hysteresis above pinchEnterRatio is still enough to suppress jitter at the boundary, and close-range exit lands at ~0.09 — squarely back in the desk-distance neighborhood where the original absolute threshold lived.

Second landing is the DEBUG-gate on dev controls, the last item from the pre-1.0 list that's been sitting in the todos through the v0.9.5x line. The slider-icon flip button in the chrome header, the ⌘D keyboard shortcut, and the entire reverse-side dev panel (sliders for the appearance, bias, tilt, opacity, tracking, and pinch tuning, plus the save/reset/export controls) all compile out of release builds via #if DEBUG. Public-facing builds now expose only the pickable camera, status pill, and source picker — the controls a regular user would ever touch. Debug builds are unchanged so internal tuning work continues against live sliders.

Selective ROI rescue (Commit K from the v0.9.52 plan) remains deferred — Lisagor's read still holds that K is a narrower job after the v0.9.52 + v0.9.53 + v0.9.54 soaks, and the right next soak window is 1.0 itself.

[v0.9.53] — 2026-04-29

Pinch-release responsiveness fix

Adam tested v0.9.52 on the Mac Studio and reported the build felt decent overall but with one glaring regression: unpinch detection was bad — the slide kept moving after the pinch was released. Tracing it back to v0.9.52's hand-size-relative pinch threshold (commit L): the back-derived 0.65 exit ratio was correct on average but catastrophic at close range, where pinchExitRatio × handSize ballooned to 0.13 (fingers had to separate 13% of frame diagonal to count as released). At desk distance the threshold matched the prior absolute behaviour exactly; at close range — exactly where Adam was test-driving the most — release felt broken.

  • Default pinchExitRatio lowered from 0.65 to 0.48. Hysteresis above pinchEnterRatio shrinks from 0.25 to 0.08, still plenty for jitter rejection. Close-range exit is now 0.10 instead of 0.13 — same neighborhood as desk-distance feel rather than a different gesture entirely.
  • Pinch ratios surfaced as dev-panel sliders. v0.9.52 added the unit-change but not the sliders, which is why the miscalibration needed a rebuild instead of a slider drag. From v0.9.53 onward, "Pinch enter" and "Pinch exit" are dialed live alongside the confidence floors.

Selective ROI rescue (Commit K from the v0.9.52 plan) defers further to v0.9.54 — Lisagor's read holds: K's job is narrower after I+J+L soak, and there's no reason to bundle the hotfix with the experiment.

[v0.9.52] — 2026-04-29

Hand-recognition robustness pass

Adam tested v0.9.5.1 on the Mac Studio and read it correctly: hand recognition is the single biggest perceived-quality lift available. The two failure modes were close-to-camera dropouts (the hand "stops being recognized" once it gets close enough to fill more than half the frame) and asymmetric latitude (pinches that start close-to-camera survive better than pinches that start far and pull close — the natural arc real users take). Both came back to a single bug dressed up as a tuning problem: the recognizer was holding wrist and middleMCP — postural anchors whose only job is to scale the pinch threshold — to the same confidence floor as thumbTip and indexTip, which carry the actual gesture geometry. Lisagor reframed the fix as an invariant: a locked pinch can only be released by a deliberate open, not by landmark dropout. v0.9.52 lands three commits toward that invariant.

  • Per-landmark confidence floors. Replaces the single minLandmarkConfidence floor (0.30 across all four tracked landmarks) with separate floors per role. Pinch landmarks (thumbTip, indexTip) gate strictly at 0.40; postural landmarks (wrist, middleMCP) gate permissively at 0.15. Stops punishing the wrong landmarks for being at the wrong end of the arm.
  • Named lock-release invariant. The pre-v0.9.52 design folded "no pose at all" and "pose returned but soft" into a single 0.8s grace clock, which is what made close-range pinches feel fragile — every soft-focus frame chipped away at the release timer even though the user wasn't doing anything. Now there are three explicit triggers — confirmedOpen, posturalAmbiguity, noPose — and only noPose ticks any release clock. Postural-ambiguity frames freeze the lock indefinitely and lean on spatial-continuity to filter false positives. The no-pose hard timeout was bumped from 0.8s to 1.5s, generous enough that brief out-of-frame moments don't kill the gesture, short enough that a user who genuinely walked away gets a clean release inside two seconds. The invariant is named in code so the next tuning pass doesn't accidentally erode it.
  • Engaged-vs-armed threshold split. Once a pinch is locked, the spatial-continuity machinery (chirality continuity, position-proximity fallback, the lock-release invariant above) is doing most of the false-positive filtering, so per-frame confidence can be relaxed to keep the gesture alive through frames that would have been rejected during arming. A single engagedFloorRelaxation knob (default 0.10) subtracts uniformly from every floor while locked, preserving the relative strictness between pinch and postural landmarks.
  • Hand-size-relative pinch thresholds. pinchEnter/pinchExit were absolute distances in normalized image space, which read as too tight at close range (the same physical pinch produces a much larger absolute thumb-to-index distance when the hand fills the frame). Renamed to pinchEnterRatio/pinchExitRatio and now scaled by the wrist→middleMCP distance the recognizer uses as a depth proxy. A "barely pinched at close range" now reads the same as "barely pinched at far range." Defaults (0.40 / 0.65) are feel-equivalent to the prior absolute thresholds at typical desk distance and expand gracefully as the hand approaches.

Sliders for all five new knobs (four per-landmark floors + lock relaxation) are surfaced in the dev panel for live tuning.

Selective ROI rescue (the constrained re-take of the reverted v0.9.5 H commit, for the partial-out-of-frame case Adam separately flagged) is deferred to v0.9.53 as its own soak build per Lisagor's read that it'll have a much narrower job once these three commits land.

[v0.9.5.1] — 2026-04-29

Hand-tracking smoothness pass

The v0.9.5 line is one large pass on perceived smoothness of the pinch gesture. Tracking is the same Vision pipeline — what changed is what we do with each pose between Vision and the slide.

  • Landmark-level 1€ smoothing. Per-landmark adaptive low-pass filter on every joint Vision returns. Cuts visible jitter at rest without adding noticeable latency under motion (the cutoff lifts as the hand moves, so brisk pinch-and-slide stays crisp).
  • Predictive position extrapolation. The composited slide position is projected ~15ms forward from velocity during sustained motion, gated off when the hand stops. Compensates for the camera→Vision→render pipeline without the ringing you get from a fixed lead.
  • Critical-damped spring on slide response. The slide's translation now follows pinch midpoint through a spring instead of a hard tether. Dialled to ride right at the critical-damping line so it never overshoots, never wobbles, just settles.
  • Chirality continuity tracking. When Vision flips its left/right call mid-gesture (it happens, especially with a hand turning palm-up), the recognizer now keeps the gesture going by matching position rather than chirality, so a confirmed pinch survives a momentary mis-classification.
  • Confirmation pulse on .began. A 200ms scale pulse on the slide the instant the pinch confirms. Tiny, not toy-like, but enough that "I just grabbed" stops being a guess.
  • Release easing on .ended. Letting go now coasts the slide to a stop over a tunable window (default 0.1s, capped by the spring's actual velocity) instead of stopping dead. Reads as a real object you set down rather than a dragged element snapped to a frame.

Tracking transparency

  • "Tracking warming up…" status pill. Vision's first-frame JIT prewarm can occasionally stretch from ~200ms (Apple Silicon, Neural Engine) to several seconds when the system has routed ML traffic onto its slow path. A small ultra-thin-material capsule with a spinner now appears in the top-leading corner of the preview during that window, so the multi-second wait reads as "tracking is loading" instead of "the pinch is broken." Disappears the instant the prewarm completes.

Dev-loop quality of life

  • DEBUG builds skip the /Applications relocation gate and the license gate, so re-running from Xcode never bounces through the install/activate flow when the binary is plainly running out of DerivedData. Release builds are unchanged.
  • License- and relocation-gate windows now have no traffic lights and a flush-with-content title bar when they do appear, so the framing matches the rest of the gate UI rather than reading as a mis-styled standard window.
  • Tunable parameters surfaced in the dev panel. Spring stiffness/damping, pulse magnitude/decay, release ease, prediction lead, and the 1€ filter cutoffs are all wired to live sliders so we can dial without rebuilds. Persists across launches.

[v0.9.4] — 2026-04-28

Onboarding, end-to-end

The first wave of beta feedback all converged on one cliff: getting the camera extension installed. v0.9.4 flattens four distinct failure modes Jalkut hit in a single session, three of which were unrecoverable without quit-and-relaunch. Every one of them now self-heals with a one-click affordance.

  • Pre-flight /Applications gate. Hovercraft now refuses to do anything until it's running from /Applications (or ~/Applications). Launched off a mounted DMG or a Downloads folder, the entire UI collapses to a calm "Move to Applications and Relaunch" panel that copies the bundle into place, launches the relocated copy, and quits. macOS rejects camera-extension activation from anywhere else, and v0.9.3 surfaced the rejection as an opaque "Failed" pill with no path forward.
  • Mounted-DMG sibling detection. Even after relocating, a still-mounted DMG holding a sibling Hovercraft.app causes sysextd to treat both bundles as competing registrations, which makes the toggle in System Settings → General → Login Items & Extensions bounce back off the moment the user flips it. Hovercraft now watches /Volumes for siblings on every mount/unmount notification and surfaces a per-volume "Eject Hovercraft.dmg" button ahead of every other piece of install guidance.
  • "Almost there" relaunch CTA. When macOS reports the extension is enabled but the host's CMIO DAL hasn't picked it up for the current process (the documented after-respawn path), the activation guidance pivots from "toggle it on" to "macOS says the extension is on, but Hovercraft can't see it yet" with a single-click Restart Hovercraft button. The installer polls OSSystemExtensionRequest.propertiesRequest every 3 seconds while in the awaiting-activation state to know authoritatively whether the toggle is on, with a 5-second grace window so the healthy bind path never sees the relaunch CTA flash on.
  • Click-to-reveal failure detail. The status pill becomes a button when the install request errors. Clicking it pops a panel with the OS's localizedDescription verbatim plus, for the failure modes we already know about, a one-line recommended action — drag-into-Applications, retry-the-password-sheet, re-download-the-DMG, try-again-on-superseded. A "Try Again" default-action button retries the request directly. v0.9.3 swallowed the underlying message; the popover was Jalkut's explicit ask.

License gate polish

  • Single-line HVCT entry. The HVCT-XXXX-XXXX-XXXX format introduced with the v2 short-key Keygen policies is short enough that the multi-line input from v0.9.3 (a hedge against legacy ED25519 keys) is no longer pulling its weight. Activation now uses a single 22pt centered monospaced field sized exactly to hold a filled key.
  • Traffic-light suppression on the gate. Closing or zooming the activation window can't make meaningful progress, so the traffic-light cluster is hidden for the duration of the gate and restored the instant a valid license drops the user into the main view. Cmd+Q remains the universal Mac escape hatch.

[v0.9.3] — 2026-04-28

Activation gate (direct channel)

  • Hard license gate at first launch. Hovercraft now refuses to bring up the camera UI until a valid Keygen license is on file. Single-pane activation screen: paste the key from the purchase email, press Activate, into the app. Keys are stored in the macOS Keychain (vision.sandwich.Hovercraft.license) so a reinstall doesn't re-prompt.
  • Cache + grace policy so paying customers on a plane don't get locked out: keychain validation < 7 days old is trusted instantly, 7–30 days requires an online check but tolerates network failure, > 30 days is hard-checked. A server-confirmed "rejected" answer (refund, revoke) clears the cache regardless of age.
  • Sandboxed network access added (com.apple.security.network.client) so the validator can reach api.keygen.sh. Stays scoped to the main app target — the camera extension does not need it.
  • Manual-issue path for friends and comp licenses: mint a Beta key in the Keygen dashboard, send it alongside the DMG. No Stripe involvement required. Documented in docs/DISTRIBUTION.md § "Manual-issue workflow".

Broadcast feed parity

  • Pinch indicator bar now appears in the virtual camera output, not just the local preview. The visionOS-style glass capsule that fades in below the floating slide on a confirmed pinch is now composited into the broadcast frame by FrameCompositor, so audience-side viewers on Zoom/Meet/Teams see the same gesture acknowledgement the presenter does. Opacity is smoothed on the FramePump cadence (~400ms ease) and the indicator render pass is short-circuited at near-zero opacity so the off-state has zero GPU cost. The previous SwiftUI overlay is gone — single source of truth for what audiences see.

[v0.9.1] — 2026-04-27

Post-demo hardening pass. The 0.9.0 build hung during a live customer demo because macOS Reactions preloaded its RealityKit VFX bundles into our process on first capture; that's now neutralised at the format layer rather than the gesture-trigger layer. The camera-extension onboarding cliff that the demo also surfaced — the unmarked manual toggle in Login Items & Extensions — now has explicit UI.

Performance and stability

  • macOS Reactions fully suppressed on Hovercraft's own capture. CameraFeed now picks an AVCaptureDevice.Format whose reactionEffectsSupported flag is false instead of letting sessionPreset resolve to whatever the OS prefers. With no Reactions-supporting format active, the OS skips the RealityKit confetti/balloon/firework/heart/laser bundle preload entirely — the workload that caused the demo-day hang. Falls back to the prior preset path on cameras (rare Continuity formats) where every variant supports Reactions.
  • Frame rate pinned to 30 fps on the per-format path so we don't accidentally land on a 60-fps Continuity mode that just costs CPU upstream of the compositor.

Onboarding

  • .awaitingActivation install state. The previous flow flipped the installer to "Ready" the moment OSSystemExtensionRequest finished, which is before the user has toggled the extension on in System Settings → General → Login Items & Extensions. The new state honestly represents "OS approved the kext, but it isn't running yet" and only transitions to "Ready" once FramePump confirms a live sink binding. UI grows a yellow guidance panel in this state with the exact path to the toggle and a one-click deep link to that pane.

[v0.9.0] — 2026-04-27

First notarized beta build. Universal (Apple Silicon + Intel), macOS 15 Sequoia minimum. Stapled .dmg clears Gatekeeper offline.

The hero

  • Pinch-to-pose gesture. A real visionOS-style magic trick on macOS: pinch in the air to grab the floating slide window and move, scale, or hide it with a hand gesture. Hand tracking via Vision's VNDetectHumanHandPoseRequest with a 1€ filter on the joint stream and a PinchRecognizer that handles lost-track grace, latency telemetry, and a clean session lifecycle.
  • Spring-damped tilt physics drive a subtle wobble on the slide window in response to motion velocity (gesture-driven and programmatic). Tuned to feel like an object with mass, not an animated rect.
  • visionOS-inspired glass overlay with a separately-tunable backdrop blur behind the slide. Global blur and backdrop blur are chained so they don't visually fight each other.
  • Soft-snap-to-center and soft-snap-to-fill-frame with a C¹-continuous cubic falloff. Anchored centering uses a smooth-breakaway resistance curve (r − B·t(1−t)²) so pulling out of the dock feels graceful, not abrupt.
  • Scale-coupled appearance: opacity and blur cook off scale so the window dissolves into the camera feed as it recedes and crisps up as it approaches the lens.

Inputs and sources

  • PDF deck source with arrow-key navigation and a live filmstrip (in-preview, only shown for multi-page decks).
  • Live window share via ScreenCaptureKit. Pick any open application window and project it into the overlay. Double-click to confirm in the picker. Texture origin is propagated through the compositor so window captures render right-side-up (CoreVideo top-left vs. Metal bottom-left).
  • Window-share strips app chrome. A per-bundle-ID heuristic crops title bar + tab strip + URL bar before the frame reaches the compositor, so the audience sees content rather than browser furniture. Safari, Chrome, Brave, Edge, Firefox, Opera, Vivaldi, and Arc each get a tuned inset; everything else gets the standard 28pt NSWindow title bar trim.
  • Source swap preserves pose. Replacing one source with another (PDF → PDF, PDF → window, window → window) keeps the user's current position and scale intact. Only the very first source of the session lands on the upper-right factory placement; subsequent swaps respect whatever the presenter just dialed in by hand.
  • Camera switcher with Continuity Camera support, hot-plug detection via the modern wasConnectedNotification API, distinct active-camera indicator, and CMIO virtual cameras (Hovercraft itself, OBS, etc.) filtered out of the picker.
  • Camera stall resilience: holds the last good frame for ~5s on a stalled feed instead of going black.

Hotkeys

  • / — page through deck.
  • V — toggle slide visibility (works while input is locked).
  • F — toggle "fill the frame" with a margin. Animates with a quintic smootherStep ease and a per-animation tilt-drive override (0.0025) so the move glides without wobble. Robust toggle: re-checks the current target each press, so manual moves between F presses don't strand you on a stale restore pose.
  • L — lock input. Disables hand-gesture and cursor input; programmatic changes still apply. V continues to function in lock mode.
  • A compact V / F / L hotkey tip strip lives directly under the source selector for first-run discoverability.

Mouse and trackpad

  • Drag the slide in the live preview to translate it.
  • Pinch-magnify on a trackpad scales the slide.
  • Two-finger scroll is a gentler scale fallback for mice without pinch. Polarity matches the natural "push away / pull closer" mental model: swipe up to shrink, swipe down to enlarge.

Dev panel

  • Live tuning sliders for tilt (stiffness, damping, drive, max-angle, accel-clamp), opacity min/max, backdrop blur sigma, and bias config (center radius/strength, fill radius/strength).
  • Two-column LazyVGrid layout to keep the panel compact.
  • Manual numeric entry on every slider, plus Save as Default to persist tuning to UserDefaults via Codable.
  • Pinch indicator bar overlays the live preview during a pinch session, scaled proportionally to the slide.

Build, signing, distribution

  • MARKETING_VERSION aligned to 0.9.0 across the app, the HovercraftShared framework, and the camera system extension.
  • Universal Hovercraft.app and Hovercraft.dmg, both signed with Developer ID Application: Sandwich Vision Inc. (VMAY6SU9SH) and stapled with separate Apple notary tickets.
  • Self-healing CMIO virtual-camera installation across reinstalls (Day 3.5).
  • Sink-to-source latency probe instrumentation in the extension pipeline.
  • scripts/notarize.sh produces a Gatekeeper-clean .dmg end-to-end in ~75s once the Apple notary service responds.

Known limitations

  • License entry screen not yet built; this build is open (no Keygen activation gate). See docs/DISTRIBUTION.md pre-launch checklist.
  • macOS 15 Sequoia minimum.
  • First launch requires admin password (camera system extension install) and Camera + Screen Recording TCC grants.

Already running Hovercraft? Open the app and check the menu bar — new versions surface as a sheet on launch (0.9.52+).

Lost your download link? support@sandwich.vision