Files
LlamaCasty/HANDOFF.md
T
gramps 44344c9327 fix: Change Account signed out BEFORE the browser opened
Reported 2026-09-27: click avatar -> Change Account -> cancel in the Google
chooser -> back to a signed-out app, then "cannot sign-in because the callback
url was not released".

Two independent defects:

1. ChangeAccountAsync ran `await SignOutYouTubeAsync(); await SignInAsync();`.
   SignOutYouTubeAsync -> TokenStore.Clear() -> deletes ytLlive.auth, so the
   working account's token was destroyed before the browser even opened. It is
   now just `await SignInAsync()`: success overwrites the token via the
   sessionChanged hook, cancel/failure keeps the current session. The explicit
   sign-out was never needed.

2. Abandoning the browser gives the app no signal, so the OAuth attempt held
   localhost:8765 for its full 5-minute timeout and a retry could not bind. A
   new AuthenticateAsync now cancels and awaits the abandoned attempt first.
   That takeover is serialised by a SemaphoreSlim: the first version cleared
   the in-flight marker before awaiting, so a caller arriving in that window saw
   a free port and raced to bind it -- reproducing the very failure it was
   written to prevent. My own test caught that; reasoning had "proved" it fine.

Also adds an `openBrowser` ctor seam so tests never shell out to a real
browser, and corrects the ai.md line that documented "Logout / Change Account ->
SignOutYouTubeAsync" as intended behaviour.

Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort
Full suite 370/370, 0 warnings. Coverage gap noted in HANDOFF.md: the ordering
fix has no automated test because _youtubeAuth is hardcoded in MainViewModel
and ChangeAccountAsync is private.

The reported cross-instance effect is NOT this cause and stays open -- no code
path can sign out a second instance (per-instance TokenStore, local delete, no
server-side revoke).
2026-09-27 16:02:05 -07:00

12 KiB
Raw Blame History

HANDOFF — current state

Branch: main (pre-1.0, no feature branches — creator ruling 2026-08-24). Last pushed: e7cded6 (pricing/Store fee docs). Local, unpushed: 536676f (recording filename ms) and the Change Account fix in this unit — ask before pushing.

🚨 FIXED 2026-09-27 — "Change Account" no longer signs you out before the browser opens

Reported as: "I clicked on my signed-in account pic, then clicked sign-in to another account. It opened the browser page to login under a different account and I canceled the request. But when returned to the app, I see logged out — and should not have happened! Cannot sign-in because the callback url was not released." Also reported as hitting both running instances.

Two independent defects, and the second one was masking a race:

  1. The sign-out ran first. ChangeAccountAsync was await SignOutYouTubeAsync(); await SignInAsync();. SignOutYouTubeAsync → TokenStore.Clear() → deletes ytLlive.auth. So the working account's token was destroyed before the browser even opened; cancelling the chooser left nothing. Now ChangeAccountAsync is just await SignInAsync() — success overwrites the token via the sessionChanged hook, cancel/failure keeps the current session. No sign-out is needed at all.
  2. The retry couldn't rebind the callback port. Abandoning the browser gives the app no signal, so the attempt held localhost:8765 for its full 5-minute timeout. A new AuthenticateAsync now cancels and awaits the abandoned attempt before binding, serialised by a SemaphoreSlim (_authGate) — without the gate, the takeover window let a third caller see a free port and race to bind it, i.e. the takeover reproduced the original failure. My own test caught this.

The cross-instance claim was NOT the cause and is still open as a separate question: no code path can sign out a second instance (TokenStore is per-InstanceProfile.DataRoot, Clear() is a local file delete with no server-side revoke, and no second instance had a token at inspection time). Verify both processes carried distinct YTLIVE_INSTANCE values and ran Debug builds before chasing it.

Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort. Full suite 370/370, 0 warnings (build ytLive.Tests.csproj, not just the app project, or VSTest runs a stale test DLL). Added an openBrowser ctor seam to YouTubeAuthService so tests never launch a real browser. Coverage gap: the ordering fix (defect 1) has no automated test — _youtubeAuth is hardcoded in MainViewModel and ChangeAccountAsync is private, so a real test would shell out to the browser. Wiring an auth seam into MainViewModel is a deliberate follow-up, not a bug-fix extra.

✅ DECIDED 2026-09-27 — pricing: $10/mo subscription, or $400 lifetime, no annual

**Creator's ruling, verbatim: "Let's just go with $10 monthly recurring subscription for option A and, for option B, a lifetime sub for $400. No annual." ⛔ No .99 prices (earlier ruling: "Let's stop with the x.99 stuff - I find that irritating. Just say: ten bucks a month"). This supersedes the 2026-09-21 one-time $29 → $49 model and the old $99/yr sub. No feature gating: free gets everything, the branding flash is the only paid delta.

✅ Store revenue share VERIFIED — 15%, and subscriptions are NOT penalised. Read from the ADA v8.10 PDF itself (downloaded, text-extracted — not a search excerpt). §6(b)(i) charges 15% on "any Apps (and any In-App Products in such Apps) that are not listed in Section 6(b)(iii)"; 12% is Games-only; 30% covers Xbox console apps/games, Xbox non-subscription IAP, and Windows 8/Phone 8. LlamaCasty is a Windows PC App — not a Game, not Xbox, not Win8 — so 6(b)(i) governs both tiers. The agreement's own changelog (v8.0, Oct 26 2017) says it outright: "implement the 85/15 revenue share for non-Game subscriptions."

⇒ $10/mo nets $8.50/mo (~$102/yr). $400 lifetime nets $340. Also confirmed: the 15% applies after VAT/GST (Net Receipts), payouts are monthly above a $50 threshold, and no better small-indie rate was found in the standard terms.

⛔ Two obligations §6(h) attaches to the recurring tier (why lifetime is the hedge): (1) we must fulfil the subscription for the entire period as marketed, and on breach Microsoft may refund the customer "the full amount, plus taxes... in Microsoft's sole discretion"; (2) raising the price disables auto-renew — so $10 is effectively locked for the product's life absent Microsoft's price-change process.

⏳ Still open: the $50 subscriber→lifetime upgrade-discount SKU was discussed and is not approved — do not declare it. Individual vs Company Partner Center account still unanswered (get it right before enrolling).

✅ DECIDED 2026-09-27 — distribution is the Microsoft Store: MSIX + Store IAP

Creator's criteria, verbatim: "zero headaches, minimal maintenance (for me) while still providing accountability and a reasonably easy upgrade flow." Route A is the only option where all four are solved by handing the work to Microsoft rather than to a certificate vendor: $0/yr, no certificate, no HSM, no annual renewal, no SmartScreen ramp, plus Store auto-update, Store-side payments/entitlements/refunds/support, and Microsoft review as the accountability layer. The rejected options and their reasons are in TASKS/research-store-certification.md §3 — read that before reopening the question, not before re-deriving it.

The big consequence: the whole licensing backend is deleted. PolarLicenseService, PolarLicense, MainViewModel.License.cs (PremiumUrl, customer portal, the OfflineGracePeriod = 14 days subscription-era artifact, renewal/lapse copy) and the wrong "Polar unlocks alerts" string all become dead code. IsPremium comes from the Store entitlement instead of an HTTP call — which also deletes the entire "network flaky → app thinks I'm expired" bug class. Velopack, the update URL and the DO droplet go too.

What does not change: the entitlement. Free gets everything; the branding flash stays the ONLY paid delta. Store IAP changes how IsPremium is obtained, never what it gates.

⛔ The old "still open: the price" note is retired — the price is settled, see the top of this file. The old "~$69 floor" and "don't break the launch-price promise" notes refer to the old Polar storefront and are not current.

The first code unit: bundle ffmpeg (TASK 48 item 1)

Two real defects the research surfaced — neither is fixed yet, this was a docs unit:

  1. ⛔ FfmpegLocator downloads an unsigned exe from GitHub and runs it. Store policy 10.2.2 (dynamic code inclusion) verbatim, and the root cause of the 2026-09-01 failure — the pin aged out of BtbN's 14-day retention and the download 404'd on the creator's first real recording attempt. FfmpegLocator.cs:30-35 records the incident but still reads like a design choice. Fix: bundle it. Also deletes the startup network dependency.
  2. ⛔ Distribution.md:318 recommended a $400+/yr EV certificate for a SmartScreen bypass Microsoft removed in March 2024. Fixed last commit — had it shipped, it would have cost $400+/yr to buy what $150 buys. Now moot anyway (MSIX is signed by Microsoft), but the lesson in MyMistakes.md stands: a policy citation is a claim about scope, not just text.

⛔ Two invariants that break silently if touched

  • Full trust, or recording breaks. DefaultRecordFolder() writes to Downloads/MyVideos; that passes through unvirtualized only at mediumIL. Flip the manifest to appContainer and output vanishes with no error. A comment is owed there when the manifest lands (TASK 48 item 6) — not before.
  • Chat is rendered, never stored. That non-persistence half is the load-bearing part of the 11.12 certification answer. No chat history, moderation log, analytics, or crash payload may be added without re-arguing 11.12 first.

Landmines

  • Stale fact inside TASKS.md (left alone, flagged here per the Scope Lock). The "1.0 gates" section asserts "TASK 36 is now shipped", but the catalog row 36 reads ☐ Queued and task-36-gold-pass.md still shows items 1–6 unchecked. The line most likely means item 2 (branding flash goes live) shipped at 9761b1d. Needs a one-line fix, not a code change — flagging rather than fixing because it is not this unit's job.
  • A stale ytLive.exe (PID 2544) locked bin/.../ytLive.exe and broke dotnet run with MSB3027. Killed. If the build fails to copy ytLive.exe, check for a running instance first.
  • LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder is flaky by environment (needs an interactive desktop session; 2 fail / 1 pass in isolation). Run the suite with ytLive CLOSED. Not a regression.
  • RealAppHost.RunAsync exists for a reason — a frame-pump test MUST await inside it. A blocking wait occupies the one shared STA thread and hangs the whole suite with no output. Always background a vstest run and poll the log; a foreground piped vstest returns nothing in this shell even on success.
  • GlobalHotkeys: two instances registering the SAME global hotkey — Windows refuses the second. Left alone deliberately.
  • MSIX writes under a real package identity are unverified. The docs say full trust passes user-profile writes through, but confirm on a real packaged build before trusting it with recordings (TASK 48 item 6).

Test state

367/367 as of 938c5b3. Not re-run for this unit — docs only, no code touched.

Uncommitted / untracked

  • MARCOM.md, MONETIZATION.md — both edited (privacy-copy guard; the Polar-obsolete banner and the re-opened-price note) and both gitignored by design (confidential business files, .gitignore:10-11). They stay local, as intended. Same for CREDENTIALS.md — never commit those.

Next

  1. Bundle ffmpeg (TASK 48 item 1) — unblocked, first code unit, fixes a real user-facing failure and clears the one hard certification gate.
  2. Settle the price — one-time vs subscription, and the number. Then declare the IAP products in Partner Center. Blocks: the Store listing, and the OverlayHost.xaml copy.
  3. The packaging project + Package.appxmanifest (TASK 48 items 2–3) — full trust, webcam/microphone, English only; Velopack deleted. Add the DefaultRecordFolder() comment in the same unit.
  4. Polar teardown (TASK 48 item 0) — PolarLicenseService, PolarLicense, MainViewModel.License.cs, the OverlayHost.xaml string, the csproj/App.xaml.cs Velopack sites. IsPremium ← Store entitlement. Do this as one unit — a half-torn-down licensing path is worse than either end state.
  5. Verify the Store revenue-share rate — before step 2, not after.
  6. Vertical recording verification — the compositor tier is proven by Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop; the record path has never been run. FramePump.cs:645 flags off-size tiers as a known follow-up.
  7. WACK + certification notes + the privacy policy (TASK 48 items 4–5) — the policy must state camera/mic is OS-gated, nothing is retained, and nothing is fetched at runtime.
  8. Post-session efficacy report — roll up CurrentHealth (dropped frames, duration, health message) when a stream or recording ends. SessionTeardownTests is the natural home.
  9. Measure whether the Windows camera toggle gates DShow — gates all privacy-forward copy (MARCOM.md guard). Not a certification risk; a trust risk.
  10. TASK 36:212 stale "shipped" line — one-line fix, needs a hand.

Dropped from the list because the ruling made them moot: multi-instance's ffmpeg tools-dir race (item 1 makes it disappear), scripts/publish.sh + the LlamaCasty.exe rename (the Store packages and delivers — revisit only if we ever ship outside the Store), and the alert-gating copy (the wrong string dies with the Polar teardown in step 4).

Everything else in the v1 queue: stream resilience (32), bandwidth step-down (33), scheduled streams (34), scene-linked audio (35), the master limiter (12), text source (3.16), reward events (3.20), the media picker (21), webcam identity (9.5), settings units A/C/D (40), and the defaults split (37). Ship-checklist items live in TASKS.md -> "1.0 gates".