All notable changes to AwayCam. Dates are when the work landed locally.
The format loosely follows Keep a Changelog. AwayCam is pre-1.0; expect changes.
[Unreleased] — UAT hardening, honest privacy, performance, one brand
Fixed
- Recording no longer fails when the camera is off, and Re-record can't lose your loop. Recording hard-failed whenever the camera wasn't already running (onboarding, Away/Standby, Re-record — the camera is stopped once a take finishes); it now powers the camera itself and only fails fast if permission is actually missing. Re-record and "replace loop" deleted the current take before the new one was confirmed, so a cancel or failure left zero loops and a broken preview — the old take is now deleted only once a new one succeeds. Plus: trim replaces the file atomically, deleting the active loop while Away republishes state so the extension doesn't reference a deleted file, and a batch of smaller flow fixes.
- Auto-Return could never fire. The face detector is fed by the capture session, but the camera was powered off the instant you went Away — so it never saw you come back. The camera now stays on while Away + Auto-Return is active (the camera indicator staying lit while it watches is inherent to the feature), and a face-triggered return suppresses idle Auto-Away until you're actually active again so the two don't ping-pong.
- A hi-res imported loop no longer balloons memory. Loops decoded at native size as full BGRA buffers — a 4K import could hold gigabytes resident. Decode is now capped at a 1080p box (the wire is 720p, so no visible loss).
- A transient streaming-signal miss can't yank you to Live mid-call. A single missed client-count read flipped "streaming" false, and call-end auto-return then exposed the real camera. The read now has hysteresis: a miss holds the previous state, and "not streaming" requires three consecutive zero reads.
Changed
- The privacy claim now matches the shipped entitlements. The Privacy
Receipt, diagnostics, in-app panel, and site claimed "no network-client
entitlement / macOS structurally blocks all outbound" — but the app grants
com.apple.security.network.client(Sparkle needs it to fetch the signed update feed), and a one-linecodesigndisproves the claim. Reworded everywhere to the true (and still strong) claim: your camera, audio, and loops are never transmitted; no telemetry, analytics, or account; the only network use is checking for AwayCam's own code-signed updates. The Privacy Receipt now reads the entitlement from the app's own live code signature, so the document always matchescodesign. - One palette across the app and the site. The app shipped three different mode palettes (main window, dropdown, site — all different); every mode-colored surface now routes through a single source of truth: green (Live, "on air") / amber (Away) / slate (Standby), matching the site. The premium gradient and the accent color are now the brand teal (was a clashing periwinkle blue), the mode-switch tiles gained hover feedback, and the site gained full dark mode — automatic via the OS setting plus a manual light/dark toggle (flash-free, CSP-safe).
Performance
- Steady-state efficiency, no behavior change: the in-app preview's 30 fps
timer now pauses when no preview is on screen (the common case during a
meeting); camera device discovery dropped from a 1 Hz main-thread poll to a
~5 s backstop (connect/disconnect observers already react instantly); and
alive-motion brightness reuses a
CVPixelBufferPoolinstead of allocating a fresh multi-megabyte buffer every frame.
Docs / hygiene
- Test count corrected again: README claimed 427; the suite is at 446
(confirmed by counting
AwayCamTests/*.swift, matching commitc26a303's "446 tests green"). - Removed stale references to
LiveCameraSource.swift(README's project layout and the extension-entitlements list) — the file was deleted and the extension has had no camera entitlement since commit28ab5b1; README's "Known limitations" now describes the actual current Live-mode path (relayed by the host app over the CMIO channel) instead of the removed extension-owns-its-own-capture-session design. - "1080p" wording that survived the 0.7.1 reframe. The 0.7.1 entry above
says "Paywall, comparison tables, settings pickers, and site copy updated"
to "Higher-quality feed" — but
docs/MONETIZATION.md's own feature table anddocs/APP_STORE_CONNECT_SETUP.md's Apple-reviewer notes still said "1080p loops" / "1080p loop resolution." Reworded both to match the shipped claim (the pipeline is still hardcoded to 720p; seedocs/RELEASE_QA_AND_HANDOFF_ 2026-07.md§4). Also caught two lingering "two App Store Connect products" mentions in MONETIZATION.md — it's three (Lifetime/Annual/Monthly). docs/SEARCH_CONSOLE.mdsitemap count was doubly stale. Said 10 discovered URLs in one spot and 17 in another (the site grew from 11 to 17 pages since the count was last corrected);site/public/sitemap.xmlhas 17 — made both spots agree.docs/LAUNCH_COPY.mdstill had the pre-privacy-fix "zero network entitlement at all" pitch in all five launch-channel drafts (HN, PH, Reddit, X/Bluesky, one-liner) — the same false claim this changelog's "privacy claim now matches the shipped entitlements" entry (above) fixed everywhere else, just missed in this doc. Reworded to the true, still-strong claim; flagged at the top of the doc to re-check before actually posting.- Documented the previously-undocumented Sparkle failure paths (download
fails / appcast signature doesn't verify) in
docs/RUNNING.md— it's stock Sparkle behavior (no custom delegate is wired up), fails closed, and nothing needed building; a matching one-liner was added todocs/SUPPORT_RUNBOOK.mdfor the customer-facing side.
[Unreleased] — Auto-Away
Added
- Auto-Away — step away and it switches on its own. A new Pro automation:
after you've been idle at the keyboard/mouse for a chosen window (2/5/10/15 min),
AwayCam switches to your loop automatically, then back to Live when you return.
Pair it with Auto-Return for a fully hands-free round trip. Uses only the system
idle time (a passive read — no keystrokes, no Accessibility permission). Off by
default; Settings ▸ Automation. (
IdleAutoAwayReconcilercovers the state machine, incl. never touching a manual Away.)
[Unreleased] — v0.9 "Money UX"
Added
- One-tap license activation. The app registers the
awaycam://URL scheme (awaycam://redeem?key=…); the license email now links toawaycam.app/redeem?key=…, whose "Open in AwayCam" button activates Pro in one tap — no more copy-pasting the key, for new purchases and for every renewal email. Handled both viaonOpenURLand AppKit'sapplication(_:open:)so it works for a windowless menu-bar app. - Self-service license recovery.
GET /license/lateston the fulfillment worker returns the newest key for an email — anti-enumeration: the caller must already hold a genuine key for that email. - Annual savings math ("≈ $2.92/mo — save 42% vs monthly") on the pricing page and in the in-app paywall.
- What's New auto-surfaces after an update. A pure, unit-tested
WhatsNewGateshows the sheet once per new version — never on first run — and the in-app entries (stale at 0.3.2) were brought current.
Fixed
- The "Open in AwayCam" button never appeared — the /redeem page used an
inline script, which the site's own CSP (
script-src 'self') blocks. Now an external same-origin bundle; confirmed no other inline executable scripts site-wide. - Quitting with "Keep my Away loop running after I quit" OFF left the loop replaying forever — the standby hand-off was published fire-and-forget and abandoned on process exit. It's now flushed synchronously on quit.
- A thread race in the extension's replay ring (the initial state refresh ran on a CMIO thread, racing the timer-queue-confined ring — a crash vector) and renewals hidden from the admin panel / recovery endpoint once the KV store passed 1000 keys (listing now paginates fully). Plus smaller fixes from the same 9-issue adversarial round.
[Unreleased] — v0.8 "The engine tells the truth"
Depends on on-device QA of the camera extension before release (see the QA script in the PR). The unit-testable pieces are covered by the suite.
Added
- Standalone Away — your loop survives the app quitting or crashing. The
camera extension now retains the recent composited away frames it's already
receiving and, if the app stops pushing while you're in Away, keeps replaying
them (ping-ponged, so the wrap is seamless) instead of dropping to the standby
frame. Previously "step away" required the app to stay alive; now you can quit
it and a meeting still sees your loop. New setting "Keep my Away loop running
after I quit" (Settings ▸ Controls), on by default; only applies while you're
in Away at quit time. (
AwayReplayCursorcovers the replay index math.) - Plan-aware licenses (v2). Minted keys now embed the plan (
"t": monthly / annual / lifetime), so the app can say "your Monthly subscription lapsed" instead of always guessing "Annual." Additive and backward-compatible — keys minted before this verify and work unchanged.
Fixed
- The "is a meeting app using AwayCam?" signal works again. It's now read back from the extension over a CMIO property (the stream's live client count) instead of a heartbeat file the extension's user couldn't share with the app. This un-hides the three features gated in v0.7.1: Standby on Next Join, call-end auto-return, and the "currently streaming" status row.
- Pro/trial expiry is enforced mid-session. A resident menu-bar app now re-checks license/trial expiry hourly and on wake, so Pro drops to Free at the real expiry moment instead of lingering until the next relaunch.
Still on-device / follow-up (not in this cut)
- Real 1080p resolution through the wire (needs a CMIO stream-format renegotiation that must be verified against live consumers). The v0.7.1 "Higher-quality feed" (higher-JPEG-quality encode) ships in the meantime.
- Raising
SWIFT_STRICT_CONCURRENCYfromminimaltotargeted.
[0.7.1] — "Integrity" — make every advertised claim true or withdraw it
Fixed
- First-run "Record a Loop Now" showed no recording UI. The guided recording
sheet was hosted only on
MainView, which isn't on screen during onboarding, so the onboarding record step started a capture with no visible countdown/ preview. The sheet is now hosted onRootView, so it presents in both onboarding and the routed states. - Removed a dead paywall sheet (
isPaywallPresented, never set true) and a no-op opacity modifier on the status dot.
Changed
- macOS floor is now stated consistently as macOS 14 (Sonoma). The build
already targeted 14.0 while the appcast, site, and README claimed 13 — a macOS
13 user would have downloaded an app that couldn't launch. Appcast
minimumSystemVersion, the download/pricing/FAQ copy, the footer, and the README now all say macOS 14+. - "1080p" is no longer sold as a Pro feature until it actually reaches the wire. The frame pusher was hardcoded to 720p, so the advertised 1080p never applied. The Pro entitlement is reframed as a "Higher-quality feed" and is now genuinely delivered today (the pusher encodes at a higher JPEG quality for the higher tier); real 1080p resolution returns in v0.8 once the pipeline carries it. Paywall, comparison tables, settings pickers, and site copy updated.
- Notification permission is requested at the end of onboarding instead of lazily the first time a speaking / long-away / trial-ending alert fires (which popped an OS prompt mid-meeting).
Hidden (temporarily, pending the v0.8 heartbeat fix)
- Standby on Next Join, "return to Live when the call ends," and the "currently
streaming" status row are hidden. They all ride on the extension's
consumer-heartbeat, which currently writes to a container the app (a different
user) can't read, so the signal never reads true. A single
AppActions.heartbeatReliableflag gates them; v0.8 lands a CMIO read-back heartbeat and flips it back on.
Docs / hygiene
- Backfilled this changelog (0.4.8 → 0.7.0). Corrected the README architecture
section to describe the real CMIO-property IPC (state via
awst, frames viaawfr) rather than the superseded App-Group-file frame path. Fixed the sitemap-count and App-Store-Connect-doc inaccuracies. Removed the strayAwayCam 2/3.xcodeprojXcodeGen duplicates and widened.gitignore.
[0.7.0] — 2026-07-11 — Four parallel core-feature enhancements + pusher fix
Fixed
- Variety had zero effect on the real camera feed. The frame pusher only ever played the single active clip, so "Variety" (mixing several loops) changed only the in-app dropdown preview — a real meeting still saw one repeating clip. The pusher now plays the whole variety ring, matching the preview.
Added
- Smarter Auto-Return — confirms it's really you back at your desk via face geometry, not just any face, to cut false triggers.
- Blink-aware loop seams — recorded loops avoid landing on a mid-blink frame at the loop point.
- Variety lead-clip rotation — sessions rotate which clip leads instead of always starting with the same one, so repeated sessions don't look identical.
- Live pre-recording hints — real-time nudges toward better lighting/framing (too dark, too bright, no face) before you record.
[0.6.1] — 2026-07-11 — Loop stabilization
Added
- Recorded loops are stabilized against small hand/desk camera shake so a slightly wobbly recording still loops cleanly.
[0.6.0] — 2026-07-11 — Seamless-switch head-tilt matching
Changed
- The Live→Away handoff now also matches head tilt (not just position and size), making the switch to the loop harder to notice.
[0.5.9] — 2026-07-11 — Face-aware seamless-switch centering
Changed
- The Live→Away handoff centers on your face (position + size), not just overall brightness, so the loop starts from a pose close to where you actually were.
[0.5.8] — 2026-07-11 — Menu-bar Settings cohesion
Fixed
- Gave the menu bar a direct path to Settings (right-click), tightening the dropdown↔Settings flow.
[0.5.7] — 2026-07-10 — Dead Settings link fix
Fixed
- Fixed the dead "All Settings…" item in the menu-bar dropdown.
[0.5.6] — 2026-07-10 — CMIO stream self-heal
Fixed
- The virtual-camera stream could wedge if the extension restarted. The device control now re-resolves the stream every ~3s so a restarted extension self-heals instead of serving nothing.
[0.5.5] — 2026-07-09 — Auto-update verified end-to-end
Fixed
- Verified the Sparkle 0.5.4→0.5.5 auto-update path end-to-end (sandboxed install).
[0.5.4] — 2026-07-09 — Sparkle sandboxed install fix
Fixed
- Fixed the sandboxed Sparkle auto-update install.
[0.5.1] — 2026-07-09 — Sparkle in-app auto-update
Added
- Opt-in in-app auto-update via Sparkle, EdDSA-signed against the appcast at
awaycam.app/appcast.xml. Kept opt-in to preserve the no-network-by-default posture.
[0.5.0] — 2026-07-09 — Safety + realism + robustness batch
Added
- A batch of realism (loop jitter, simulated lag, alive-motion lighting drift), safety, and cross-process-robustness improvements.
[0.4.8] — 2026-07-09 — Live cold-start warm-up frame
Added
- A branded "Starting camera…" warm-up frame for the brief Live cold-start on the first Live of a session, so participants never see black or "Camera paused" while the webcam spins up.
[0.4.0] — Menu-bar-only app, no Dock icon; launch at login
Changed
- AwayCam no longer shows a Dock icon or Cmd-Tab entry — it now behaves like CamLoop and most menu-bar utilities: launching it from Applications/ Spotlight puts it straight in the menu bar, with the main window opening alongside. Closing the window no longer leaves an orphaned Dock icon behind; the app keeps running from the menu bar exactly as before. Everything (mode switching, recording, gallery, Auto-Return, standby, upgrade, quit) was already reachable from the status-bar menu — this just stops the Dock icon from being the only way back in when the window is closed.
- Removed the "Show menu bar item" toggle — with no Dock icon, the menu bar is now the only way to reach the app, so it's always on rather than a setting that could strand a user with no way back in.
Added
- Launch at login, on by default. A "Launch at login" toggle replaces
the old menu-bar toggle in Settings > Controls, backed by
SMAppService(macOS 13+, no separate helper tool). Enabled automatically once per install — on first launch after upgrading to this version, and for brand-new installs on completing onboarding — and never re-applied after that, so turning it off in Settings sticks.
[0.3.7] — Automatic seamless loop-point detection
Added
- When a recorded loop's quality check flags a visible start/end "jump" (Forward loop mode), AwayCam now searches the clip for a smoother loop point — a start/end pair whose frames look most alike — and offers a one-tap "Trim to a smoother loop point" action right in the quality banner. Accepting it opens the Trim sheet pre-selected to that range instead of the usual default, so you can preview before committing.
Fixed
- Trimming a loop was hardcoded to a 15s maximum window regardless of tier —
a Pro user who recorded a 30-second loop could only ever trim it back
down to 15s, silently undermining the Pro "Longer loops" entitlement
whenever they used Trim.
TrimMath/TrimSheet/LoopTrimServiceare now tier-aware, matching the fix already applied to recording itself in v0.3.5.
[0.3.6] — Pre-launch UAT and gap analysis
Fixed
- Free-tier 1-loop cap could be bypassed. The recording sheet's "Re-record" button called the recorder directly, skipping the cap check that gates opening the sheet in the first place — and each take was additive, never replacing the last, so repeated Re-record taps let a Free user accumulate loops past the advertised 1-loop limit within a single sheet session. Re-record now deletes the take it's redoing first, matching what the button actually promises ("try again," not "save another").
- Stripe fulfillment worker: a network-level failure calling MailerSend (as opposed to a non-2xx response) was uncaught and could 500 a webhook request that had already successfully minted and logged the license — Stripe would see a failure and retry unnecessarily. Now caught and reported the same way a non-2xx response already was.
- Stripe fulfillment worker: an unrecognized
metadata.planor billingrecurring.intervalvalue silently fell through to "annual" with no log line, hiding a real Payment Link misconfiguration. Still falls through the same way (never fails a sale over this), but now logs a warning so the condition is actually visible. - Admin panel: escaped customer email / order id before they reach
innerHTML, closing a low-severity stored-XSS surface (single admin user, Basic-Auth gated, but a real unescaped sink).
[0.3.5] — Pro's "Longer loops" now actually records longer loops
Fixed
- Pro's
longLoopsentitlement had no effect on the actual recording step —LoopRecordingServicehardcoded Free's 15s cap regardless of tier, so the in-app paywall's "Longer loops" promise was silently broken. Pro now has a real, concrete 30s cap (Entitlements.proMaxLoopSeconds, twice Free's), wired through the whole path: the recording engine, the duration picker (now offers 30s for Pro), the General settings duration slider, and the paywall/Status feature-comparison table (now shows "30s" instead of the vague "Longer"). A Pro→Free downgrade correctly re-clamps a saved 30s default back to 15s, same as every other Pro-gated setting.
[0.3.4] — Recording-sheet freeze and missing Scheduled Loops
Fixed
- The recording sheet's Cancel button did nothing once a recording had
started (
.awaitingStability/.countdown/.recording/.finishingphases) — it stopped the in-progress recording but left the sheet on screen, looking frozen. It was missing theshowRecordingSheet = false+dismiss()pair its pre-recording sibling already had. Also gated Auto-Return's face-presence analysis on the recorder being idle, so it can't compete with an in-progress recording for camera frames. - The Automation settings tab's "Scheduled loops" section — last of five in that tab's Form — sat past where the fixed-height Settings window could scroll to reach it. Grew the window so Automation's content fits without scrolling.
[0.3.3] — Move AwayCam to its own dedicated Stripe account
Fixed
- Checkout was showing an unrelated business's name ("AllSecurityNews.com")
instead of AwayCam's — a side effect of the products living on a Stripe
account shared across several unrelated sites, which only supports one
business identity account-wide. Fixed by moving AwayCam's products, prices,
Payment Links, and webhook to a new, dedicated Stripe account with its own
business_profile.name("AwayCam"). All three in-app Buy buttons andpricing.astronow point at the new account's Payment Links; the old shared-account products/prices/Payment Links/webhook were disabled (not deleted, for audit history). Re-verified end-to-end against the new account/webhook secret — all three plans, both renewal paths, and rejection of a forged signature.
[0.3.2] — Three-tier pricing: Monthly, Annual, Lifetime
Changed
- Pricing revised from two tiers to three. Lifetime $29.99/Annual $14.99 → Monthly $4.99/mo, Annual $34.99/yr (now the featured "Best value" tier), Lifetime $69.99 (exactly 2× Annual, by design — see docs/MONETIZATION.md for the full rationale). Also fixed a real bug found in the process: the in-app Upgrade sheet's buy buttons opened the marketing pricing page instead of each product's Stripe checkout directly — now each button (Monthly, Annual, Lifetime) opens its own live Payment Link.
- Cloudflare worker (
server/stripe-webhook/) now mints and renews all three plans: a Payment Link'smetadata.plandistinguishes Monthly from Annual at initial checkout (both aremode: "subscription"), and a renewal invoice's own line-itemprice.recurring.intervaldistinguishes them forinvoice.payment_succeeded— no extra Stripe API calls either way. Monthly keys carry a ~35-day signed expiry (vs. Annual's ~370). AwayCamShared/Purchases.swift:ProProductgains a.monthlycase;UpgradeViewshows three purchase buttons instead of two.- Verified end-to-end against the live worker with HMAC-signed synthetic events for all three plans plus both renewal paths; 308 Swift tests pass.
[0.3.1] — Launch signing-key rotation
Signed, notarized re-release ahead of the paid launch. No user-facing feature changes.
Changed
- License signing key rotated for launch. Generated a fresh Ed25519 keypair
and embedded its public half in the app; the matching private key is held only
by the seller (Cloudflare Worker secret / password manager), never in the repo.
Verified end-to-end: keys minted by both
Scripts/sign_license.swiftand the Stripe worker's TypeScript path validate against the new embedded key through the realLicenseKeyverifier, and any key from a different key is rejected. Safe to rotate now because no license had been sold yet.
[0.3.0] — Billing integrity, full accessibility, admin tooling
Second signed, notarized release. No launch-blocking bugs carried over from 0.2.0; this closes the customer-lifecycle gap analysis, completes Phase 4/5 of the launch plan, and adds a support-side admin panel.
Added/Fixed — Phase 4 & Phase 5 close-out (parallel agents)
- Settings: split the overloaded 8-section "Behavior" tab into "Controls" and "Automation" (every Pro-gated automation feature grouped together); added a confirmation dialog before "Reset all to defaults" (hotkeys); fixed camera-picker visibility for single-camera users (a read-only status row instead of a Picker with one meaningless option).
- Trim sheet: replaced the twin abstract Sliders with a thumbnail-strip
scrubber (12 evenly-sampled frames, draggable Start/End handles, dimmed
out-of-range portions). Caught and fixed a real bug during review: the
first draft anchored handle position to
DragGesture's absolute.location, fragile depending on view hierarchy; rewritten with the standard translation-plus-captured-start-value pattern (@GestureState). - Menu bar: added a live automation-status line and a "Standby on Next Join" pre-arm toggle (one-shot: switches to Standby the moment a meeting app next starts pulling frames from the virtual camera).
- vs-camloop.astro: refreshed against the app's current state — ghost- frame overlay parity, a new automation-visibility differentiator row, and a real accuracy fix (removed a false "public changelog / developed in the open" claim that contradicted the Terms page's "proprietary" language).
- Verified: full app build + all 307 Swift tests pass; site builds clean. This closes Phase 4 (Differentiation vs CamLoop) entirely — two of its five items (ghost-frame overlay, live automation indicators) turned out to already be shipped when checked before starting this work.
Fixed — accessibility pass, part 2: every remaining view
- Completes the accessibility pass started on Onboarding/Settings/Recording/
Paywall — now every view with actual UI has real VoiceOver support
(
RootView.swift, a pure router, is correctly the only 0). The recurring gap across AboutStatusView, PrivacyAndTroubleshooting's privacy-audit and troubleshooting-diagnostics rows: status conveyed only via icon color/shape (a green check vs. a red X reads identically without a word saying which). Every such row now states the result explicitly: "Camera access: Granted", "Outbound network connections: None, verified", "Hotkeys aren't working: needs attention." TrimSheet's Start/End trim sliders used theonEditingChangedinitializer, which has no label parameter at all — VoiceOver said just "slider" for both with no way to tell which was which.- Added a safe default at the
NSViewRepresentablelevel (setAccessibilityElement(false)) for the three raw video-rendering surfaces (camera preview, pixel-buffer, loop playback) so every call site is protected by default — this incidentally fixed MainView's live preview too without needing to touch that file. Theme.swift's sharedModePill/StatusDotcomponents (used all over the app) fixed once, benefiting every screen that uses them.- Verified: full app build + all 307 Swift tests pass.
Fixed — accessibility pass on the four highest-traffic views
- Onboarding, Settings, Recording, and Paywall (the plan's own priority order)
went from zero accessibility modifiers each to real VoiceOver support:
decorative icons that duplicated adjacent text are hidden rather than read
as noise; icon-only buttons (delete, resend, weekday toggles) and
.labelsHidden()controls got real accessibility labels; selection state conveyed only by color (the trial-choice buttons, weekday pickers) now also exposes an.isSelectedtrait. Two real structural gaps: the paywall's Free/Pro comparison table was three disconnectedTextcolumns with no way to tell which value was Free vs. Pro (now one combined label per row), and the recording sheet's countdown/recording/saving flow had no accessible state at all (now a phase-aware label: "Recording, 4 of 8 seconds", etc.). Verified: full app build + all 307 Swift tests still pass.
Added — admin panel for license lookup
/adminon the Stripe webhook worker — a small dashboard listing every issued/renewed license (email, plan, issued/expiry, status), with search and a "resend email" action for "I never got my key" support requests. Reads the optionalLICENSES_KVrecord; not a Stripe replacement — refunds and subscription cancellation still happen in the Stripe Dashboard (each row links out to a Stripe customer search). Gated behind HTTP Basic Auth (ADMIN_USERNAME/ADMIN_PASSWORDWorker secrets) — every/admin*route responds 503 rather than open if either is unset. Caught and fixed a bug during local testing (wrangler dev) where constructing aResponseat module scope crashed the entire Worker on startup, including the live Stripe webhook — moved it inside the request handler.
Fixed — full customer-lifecycle gap analysis (billing integrity)
- Annual subscriptions had no renewal, cancellation, or expiry mechanism.
The license payload had no expiry field and the Stripe worker only ever
handled the initial checkout — cancel after month one, keep Pro forever.
Annual keys now embed a signed expiry (~370 days), renewed automatically on
each successful Stripe renewal charge (
invoice.payment_succeeded); a lapsed/cancelled subscription simply isn't renewed and expires on schedule — no online revocation call needed, so the zero-network-entitlement privacy posture is unchanged.LicenseKeynow reports.expired(genuine signature, past its date) distinctly from.invalid, and the paywall shows a "your Pro subscription has expired — renew" prompt instead of a generic upsell. Lifetime keys are completely unaffected (no expiry field, as before). - Payment path had no durability or health visibility. Added an optional
Workers KV record of every issued/renewed license (order → email/key/plan/
expiry) for support lookups, beyond Cloudflare's short-lived request logs;
added an unauthenticated
/healthendpoint for external uptime monitoring. - What's-new surface (Annual subscribers "renewed blind" — no changelog in
the app) and build-number automation (
make bump) — both closed. - Diagnostics export now includes license status (none / valid / expired) so a support triage can tell "never bought" from "bought, lapsed" at a glance.
See docs/LAUNCH_PLAN.md Phase 6 for the full gap analysis, including the non-code items (email deliverability, a Stripe/Resend disclosure on the privacy page, Stripe Tax) that still need the founder's own accounts.
Fixed (hardening pass — 19 bugs from an adversarial multi-agent review)
- 1080p paywall bypass.
publishState()wrote the loop resolution straight fromsettings.loopQuality, so a Free user who forcedhd1080(e.g. viadefaults write) got 1080p on the virtual camera. The resolution is now clamped to 720p unlesshd1080is unlocked, matching the other Pro-gated state fields. - Free 1-loop cap bypass via import. Importing didn't check the loop limit, so
it could exceed the Free cap. Import now enforces
canSaveAnotherLoopper file (and prompts upgrade), exactly like recording. - Export could destroy an existing file. Export removed the destination then
copied; a failed copy left the user's file gone. Export is now atomic
(
LoopImport.atomicCopy: stage to a temp sibling, then replace), so a mid-copy failure never touches the original. - Pro settings not cleared on downgrade. A Pro→Free drop (or trial expiry) only
clamped 1080p; hotkeys, auto-return, safeguard, crossfade, lag, alive motion, and
schedules stayed "on" in the UI.
SettingsManager.clampToEntitlementsnow resets every Pro toggle for any locked feature. - Scheduler could get stuck in Away. The edge-triggered logic desynced if the
app launched inside a window already in Away, or scheduling was enabled mid-window
on a manual Away — leaving the user stuck Away forever. Rewrote
evaluateSchedulesas robust state reconciliation (tracks scheduler-owned Away + a dismissed-window id), and it never changes mode mid-recording. - Schedule edits weren't applied until the next 20s tick. Added a
$schedulesobserver so edits re-evaluate immediately. - Pixel-buffer robustness.
AliveMotion.applyBrightnessnow rejects non-32BGRA buffers (was a corruption/crash vector) and copies full scanlines so row padding is never left uninitialized;FrameBlender.blendgot the same format guard + padding fix. - Corrupt-import guard. Import now validates the clip is a playable video with
the modern async
AVAsset.loadAPIs before copying (no more silent fake metadata / main-thread block), refuses a file already in the library, and surfaces a clear error. - App Group resilience.
publishStatere-probes the container instead of staying permanently degraded after a transient failure. - Smaller: hotkeys/overlapping-schedule consistency. 6 new regression tests.
Added
Variety — play through several of your loops so you don't repeat (Pro). A single short loop is the deepest "this is faked" tell: watch for twenty seconds and the same gestures recur on a few-second cycle. Timing jitter, simulated lag, and lighting drift all disguise how the loop replays, but the content still repeats. With Variety on (and 2+ saved loops), AwayCam concatenates several of your loops into one long ring — with crossfade bridges between clips so the seams don't hard-cut — so the apparent repeat period stretches from seconds to minutes. Deliberately not clip-swapping mid-stream (a stall-prone hot-path dance): it reuses the existing ping-pong / forward indexing over a longer ring, so jitter, lag, lighting drift, and Seamless switch all keep working unchanged. The frame budget is split across clips (
LoopRotation.perClipFrameCap) to bound memory, and mismatched-size or undecodable clips are skipped so the ring stays uniform. Gated end-to-end (the published state honors entitlements, so the gate holds for the virtual camera, not just the in-app preview). On-ethos: it only ever replays footage you recorded of yourself — no synthesized frames. Pure, tested helpers (LoopRotation) plus a newLoopFrameProvider.loadMultiple. 9 new tests.Seamless switch — match the loop's entry frame to your live pose (free). Flipping from Live to Away always started the loop on frame 0, so the viewer saw your real face at one pose and then an instant cut to the loop's first frame — a visible jump that announces "something just happened." Now, on each Live→Away transition, AwayCam picks the loop frame whose coarse luminance layout best matches your last live frame, so the switch lands on a near-matching pose and the cut is far less noticeable. Pure, testable selection (
LoopEntry.bestEntryIndexoverLoopMetrics.frameDifference) plus a 12×12 luminance sampler read straight off theCVPixelBuffer; wired into both the in-app preview and the camera extension so they behave identically, and applied only on a genuine mode transition (a settings-only refresh never re-seeks mid-playback). Graceful fallback to frame 0 whenever there's no live frame. Like alive-motion this is a coarse global-luminance comparison, not face/ blink modeling — consistent with the "no AI face data" promise (the live frame is compared in memory and never stored). 12 new tests.Freemium model (Free / Pro), modeled on CamLoop. The core "step away" utility is free; Pro unlocks quantity, control, automation, and polish.
- Free: 1 saved loop, ping-pong, menu-bar control, standby, the virtual camera (720p).
- Pro: unlimited loops, global hotkeys, Auto-Return, speaking safeguard, forward-loop crossfade, 1080p.
- Pure, tested gating in
Entitlements(Tier/Feature) + anEntitlementStorewith a stubbedisProflag (no real payment yet — clean StoreKit / license-key seam). 17 new tests. - "Visible but locked" UX: PRO badges + an Upgrade chip, a paywall
(
UpgradeView) with the Free-vs-Pro pitch, the loop cap that prompts upgrade on a 2nd recording, and a dev-only unlock/lock toggle in the Status panel. - See docs/MONETIZATION.md.
Status panel. A new first tab in Settings (and a menu-bar Status… item) that rolls up the whole readiness checklist at a glance: camera access, shared storage, account tier / virtual-camera install state, whether it's streaming right now, the active loop, app version, and the privacy promise — with an overall "Ready / Almost ready / Action needed" pill.
Keyboard-driven gallery. Arrow keys move a focus ring across loops; ⏎ sets the active loop, ⌫ deletes, ⌘T trims, ⌘R renames. Hints shown in the gallery footer.
Quality nudge while recording. Right after you record, AwayCam checks the clip and, if it's dark / static / jumpy, shows the same hints in the preview step so you can re-record or trim before saving — or keep it anyway.
Loop quality warnings. Recorded loops are analyzed for brightness, motion, and the first↔last boundary jump. The gallery flags a loop that looks too dark, too static ("frozen"), or that would show a visible cut in a Forward loop, with a tooltip explaining how to fix it. Pure metrics in
LoopMetrics, 15 tests.In-app account-status guidance. When the camera system extension can't install because of signing/account limits, AwayCam now says "Needs a paid Apple Developer account" and explains that everything else still works — instead of a raw error. Shown in Settings ▸ Virtual Camera and Troubleshooting, pointing to
docs/RUNNING.md. Classifier is shared + tested.Trim right after recording. The record sheet's preview step now has a Trim… button, so you can tighten a clip immediately after capture (not only later from the gallery).
StoreKit-ready purchase seam. Purchases go through a
PurchaseServiceprotocol: a no-billingStubPurchaseService(default — unlocks Pro locally) and a complete, compiled-but-unwiredStoreKitPurchaseService(StoreKit 2: load products, purchase, restore, verify entitlements). The paywall now offers Monthly/Lifetime + Restore and routes through the coordinator; flipping to real IAP is a one-line backend swap. Product catalog (ProProduct) tested.Single-source Free-vs-Pro comparison (
FeatureComparison, derived fromFeature.allCases) so the paywall table and Status panel never drift. Tested.7-day Pro free trial (CamLoop-style, no card). One-time, time-based:
Entitlements.effectiveTiergrants Pro while the trial is active and reverts to Free when it expires (re-evaluated on each launch/appear). Pure date logic with clock-skew protection; the paywall shows "Start 7-day free trial" + a days-left countdown. 8 new tests.Airtight 1080p downgrade. Dropping from Pro to Free now clamps a stored 1080p loop-quality back to 720p, so the paywall can't be bypassed by toggling tiers.
Subtle loop timing jitter ("Add subtle motion variance"). A perfectly periodic loop is the biggest "this is faked" tell. With this opt-in toggle the loop occasionally holds a frame one extra tick (≤ ~33ms, never two in a row), so its period drifts slightly each pass instead of repeating like clockwork — a brief natural micro-stillness, never a stutter. Pure, seedable policy (
LoopJitter+ aSplitMix64PRNG) shared by the app preview and the camera extension so both behave identically; off by default and free (not Pro-gated). 13 new tests.Simulated network lag — "Realistic connection" (Pro). A real call is never perfectly smooth: Wi-Fi hiccups produce short freeze-then-catch-up stalls. This Pro toggle injects occasional multi-frame stalls (≈0.2–0.6s) between smooth stretches, so a flawless loop doesn't give itself away. Pure, seedable
SimulatedLagpolicy (sibling toLoopJitter), gated end-to-end — the published state the extension reads now honors entitlements, so the gate holds for the virtual camera, not just the in-app preview. 13 new tests.Lifelike lighting drift — "alive" motion (Pro). Even with timing jitter, a loop can read as "off" because the light never changes. This toggle adds a very subtle, slowly-drifting brightness modulation (±3.5%, ~8s cycle) so the frame feels lit by a live room rather than frozen under constant light. Deliberately a global luminance multiply — not face/blink/breathing — so it stays cheap, on every loop, and consistent with the "no AI face data" promise. Pure, seedable
AliveMotionwaveform + a saturating brightness LUT, shared by the app preview and the extension; gated end-to-end. 16 new tests.Import & export loops (Pro). Bring in any
.mov/.mp4/.m4vas an Away Loop (it's probed for real duration/resolution and copied under a collision-free name), and export any loop back out to back up or move between Macs. Import lives in the gallery footer; Export is on each loop's context menu. Pure filename logic (LoopImport: importability, sanitization, unique naming) is tested.Scheduled loops (Pro). Set recurring auto-Away windows ("Lunch, 12–1, Mon–Fri") and AwayCam switches to your active loop during each window and returns to Live when it ends. Edge-triggered so manually leaving Away mid-window isn't undone until the next one; handles midnight-wrapping windows. Pure
LoopSchedule/LoopSchedulermodel + decision logic, 18 tests; a 20s coordinator timer applies it. Configured in Settings ▸ Behavior.Stronger privacy / trust panel. Reframed the Privacy tab around guarantees a cloud-AI competitor can't make: 100% on-device, no AI face data (AwayCam only replays your own clip — it never analyzes, morphs, or models your face), no network / no telemetry, only your own camera. Adds a "Verify it yourself" audit (no outbound connections, no sign-in, no face recognition, where loops live) — backed by the real sandbox entitlements: the app ships with no network-client entitlement, so the OS enforces "nothing leaves your Mac."
Fixed
- Paywall bypass when a Pro trial expired. The 1080p downgrade clamp (and the
hotkey/crossfade re-gating) lived only in
entitlementsChanged(), which runs on explicit tier changes (purchase / restore / lock / start-trial). But a 7-day trial expiring is silent:onAppear()only calledentitlements.refresh(), which updated the reported tier yet skipped the clamp — so a user who picked 1080p during the trial kept recording 1080p loops after it lapsed.onAppear()now compares the tier acrossrefresh()and runsentitlementsChanged()when it changed, making the downgrade airtight on the trial-expiry path too. Found via code review; covered by 2 newEntitlementStoretests. - Crash when recording a loop. Finishing a recording swapped the preview to
AVKit's
VideoPlayer, which aborted with a Swift superclass-metadata fatal (getSuperclassMetadataduringNSViewRepresentable._makeView) on this macOS/Swift runtime. ReplacedVideoPlayer(in the record + trim sheets) with a lightweight AVFoundationAVPlayerLayer-backedLoopPlayerView, avoiding AVKit entirely. Found during on-machine QA (crash reproduced by the user). - "Get Started" didn't leave onboarding.
RootViewbranched oncoordinator.settings.hasCompletedOnboarding, but a nested ObservableObject's change doesn't re-render a view observing only the parent — so the flag flipped but the view never switched to the main window. Fixed by injectingSettingsManagerinto the environment and observing it directly inRootView. Found during on-machine QA (reproduced by the user). - Window couldn't be reopened after closing. Closing the main window left
the app running (it's a menu-bar utility) but with no way to bring the window
back via the Dock. Added
applicationShouldHandleReopenso clicking the Dock icon (or reopening the app) restores the window. Found during on-machine QA.
Removed
- "Custom camera name" from the Pro feature list. The paywall advertised
renaming the virtual camera, but the device's
localizedNameis fixed when the CMIO extension process launches and there's no setting/storage to change it — CMIO has no live-rename API, so honoring it would mean reactivating the system extension (a paid-account, approval-gated operation) on every edit. Rather than ship a fragile flow, theFeature.customCameraNamecase was removed; the single-sourceFeatureComparisondrops it from the paywall and Status panel automatically.
Changed
- Git history re-attributed to the GitHub account identity.
[0.2.0] — Launch-prep: real monetization + first-run reliability
First signed, notarized public release with a working purchase path.
Monetization architecture
- Fixed a launch-blocking flaw: the shipped DMG couldn't take payment.
Release builds always selected
StoreKitPurchaseService, but StoreKit 2 can't fetch products or process a purchase in a Developer ID direct-distribution build (no App Store receipt/environment) — the paywall's buy buttons were dead in the exact build customers download. NewPurchaseChannel(.direct/.appStore/.development) +LicensePurchaseService: Release now defaults to the direct/license-key channel, with StoreKit gated behindAWAYCAM_MASfor a future Mac App Store build. 4 new tests. - License redemption is now the primary activation path on the direct
channel (buy on awaycam.app → paste the emailed key), promoted from a buried
"Have a license key?" link to an always-visible field. Buy buttons open the
website instead of calling a
purchase()that would just fail. - Stripe fulfillment backend (
server/stripe-webhook/): a Cloudflare Worker that mints an Ed25519 license key oncheckout.session.completedand emails it. The signing was cross-verified against the app's realLicenseKey.isValidcheck. Manual fulfillment works with zero email setup (keys are logged). Full setup indocs/STRIPE_SETUP.md. - Trial-expiry pre-warning — a local notification 2 days before the 7-day trial ends, listing what turns off; auto-cancelled on purchase/redemption.
- Free-tier "replace loop" — the 1-loop-cap paywall now offers to replace the existing loop (with a confirmation before deleting it), and the Active Loop status shows "1 of 1 (Free)" proactively instead of only via a paywall.
First-run reliability (7 dead-ends closed)
- Extension-install step is state-aware — guides through System Settings approval, explains + links docs for the no-paid-account case (with reassurance the rest of the app works), offers "Try Again" on failure, shows progress while installing. Previously a raw status label with no recovery path.
- Missing/corrupt loop files show an explicit "Clip missing" tile instead of an infinite loading spinner.
- Quality grade badges are tap-to-explain (popover), not hover-only; activating a low (C) grade loop now asks "use it anyway?" first.
- Restored-mode transparency — relaunching into Away/Standby shows a "Restored your last mode" banner with a one-tap Go Live, instead of a silent frozen feed.
- Speaking-safeguard banner gained an "I'm still away" dismiss, so a false positive (background noise) doesn't force leaving Away mode.
- Loop duration picker (3s/5s/8s/15s, tier-aware) moved into the recording sheet, from several clicks away in Settings.
- Multi-file import reports honestly when the Free cap is hit mid-batch ("Imported 2 of 5 — upgrade to import the rest").
Trust & discoverability
- Menu bar shows Free/Pro/trial status + an Upgrade path (previously the primary in-meeting surface never surfaced the paywall), plus "Check for Updates…" (manual, opens the download page — no background network polling) and a "Keyboard Shortcuts…" reference.
- Onboarding no longer silently starts the trial — starting it and skipping it are now two explicit, equally-weighted choices.
- Global hotkey registration failures (permission denial, conflicts) now surface a warning in Settings with a link to the Accessibility pane, instead of failing silently with the toggle still showing "on".
- Virtual-camera status self-heals — re-checks device visibility on each heartbeat and reacts to device connect/disconnect, so the status goes green when the extension's device appears a few seconds after launch.
Distribution
- Signed + notarized DMG, drag-to-Applications installer, published from awaycam.app. Terms / EULA / Refund pages describe the real direct-sale flow.
[0.1.0] — Initial MVP + first enhancement pass
Added
- Core app + virtual camera. SwiftUI menu-bar host app and a modern Core Media I/O camera extension exposing "AwayCam Camera", sharing state only through an App Group. Modes: Live / Away (ping-pong loop) / Standby / Error.
- Recording & playback. Guided countdown recording (3–8s), local clip + JSON
metadata, async
AVAsset.loaddecode, branded AppKit-free standby frame, and an optional forward-loop crossfade. - Multi-loop gallery with thumbnails, set-active, rename (custom labels), and delete; restore-last-mode on launch (opt-in).
- In-app loop trimming (in/out points, 3–8s enforced by pure
TrimMath). - Controls. Menu bar, global hotkeys (⌥⌘A/L/S), and app-menu shortcuts.
- Optional smarts. Vision face-presence auto-return and a mic-level speaking-while-away safeguard (both off by default). Streaming heartbeat so the app can tell when a meeting app is consuming the camera.
- Onboarding, settings, privacy, and troubleshooting panels with the product's copy.
- Tooling. XcodeGen project,
setup.sh,Makefile(run/build/test/icon/demo+ notarization targets), GitHub Actions CI, a generated app icon, an architecture diagram, and a synthetic demo GIF. - Docs.
README,CONTRIBUTING,COPYRIGHT(proprietary), anddocs/RUNNING.mdcovering the no-account / free-team / paid signing paths.
Notes
- Installing/activating the system extension and using the virtual camera in meeting apps requires a paid Apple Developer Program account. Everything else — recording, preview of every mode, the gallery, and the full test suite — works without one.