From 4e0915a36edb42960c355df75547636e6983c834 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 22 Sep 2026 07:00:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(backdrop):=20Capture=20Window=E2=80=A6=20i?= =?UTF-8?q?n-app=20window=20pin=20(TASK=2038,=20Good=20Dog=20ONE=20test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click-to-pin an open window as the full-bleed Live backdrop, via the existing window: capture path (same render layer as game/desktop). Session-scoped: window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins while the window enumerates, then auto-fallbacks (game → desktop → static); a dead window's capture session heals to auto, no dead ends. Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x" WITHOUT stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin resolved null ("No capture target for this key") and the heal silently rolled back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed. Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the whole test-project compile — token removed. Plus the previous session's uncommitted pricing docs (one-time $29/$49) that share TASKS.md/ai.md/HANDOFF.md. --- Controls/LeftPanel.xaml | 25 ++++ Controls/LeftPanel.xaml.cs | 5 + Controls/PreviewPane.xaml | 14 ++ Distribution.md | 4 +- HANDOFF.md | 132 ++++++++++++------ Models/WindowInfo.cs | 27 ++++ README.md | 2 +- Services/IWindowEnumerator.cs | 15 ++ Services/ScreenCaptureSourceFactory.cs | 6 +- Services/Win32WindowEnumerator.cs | 122 ++++++++++++++++ TASKS.md | 3 +- TASKS/task-10-monetization.md | 17 ++- TASKS/task-38-window-backdrop.md | 83 +++++++++++ ViewModels/MainViewModel.Background.cs | 22 ++- ViewModels/MainViewModel.WindowBackdrop.cs | 73 ++++++++++ ViewModels/MainViewModel.cs | 11 +- ai.md | 43 ++++-- ytLive.Tests/PillRadioTests.cs | 2 +- .../WindowBackdropIntegrationTests.cs | 126 +++++++++++++++++ 19 files changed, 665 insertions(+), 67 deletions(-) create mode 100644 Models/WindowInfo.cs create mode 100644 Services/IWindowEnumerator.cs create mode 100644 Services/Win32WindowEnumerator.cs create mode 100644 TASKS/task-38-window-backdrop.md create mode 100644 ViewModels/MainViewModel.WindowBackdrop.cs create mode 100644 ytLive.Tests/WindowBackdropIntegrationTests.cs diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 95167e7..d1fdba2 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -94,6 +94,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Controls/LeftPanel.xaml.cs b/Controls/LeftPanel.xaml.cs index 219d116..233545d 100644 --- a/Controls/LeftPanel.xaml.cs +++ b/Controls/LeftPanel.xaml.cs @@ -116,6 +116,11 @@ public partial class LeftPanel : UserControl (DataContext as MainViewModel)?.RefreshBackgroundAutoCapture(); } + private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e) + { + (DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null); + } + private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%"; diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index b94cfdb..76460be 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -35,6 +35,20 @@ + + + + + + diff --git a/Distribution.md b/Distribution.md index 04ca2a4..64f6b0f 100644 --- a/Distribution.md +++ b/Distribution.md @@ -14,7 +14,7 @@ Polar handles everything between "customer wants to pay" and "customer has a wor | **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. | | **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. | | **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. | -| **Subscriptions** | If we go subscription model (deferred), Polar handles recurring billing, dunning, cancellation. | +| **One-time orders** | **Chosen model (2026-09-21):** perpetual license via a one-time Polar order — no subscription. Polar also supports recurring billing if ever needed, but we do not use it. | | **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. | | **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). | @@ -290,7 +290,7 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc **Product setup on Polar:** -1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred). +1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list). 2. Add two benefits: - **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry. - **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included. diff --git a/HANDOFF.md b/HANDOFF.md index beab875..ea8cadd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,58 +1,110 @@ -# HANDOFF — 2026-09-20 (ON-AIR armable signed-out shipped + pushed; Good Dog ONE-test ruling) +# HANDOFF — 2026-09-21 (pricing docs UNCOMMITTED + TASK 38 Capture Window… implemented, UNCOMMITTED) ## Branch / Commit State -`main` HEAD = **`0c55645` — feat(pills): ON-AIR armable signed-out; Start face reads "Sign In" -while armed signed-out (Good Dog 2026-09-20 ruling)** — **PUSHED** to `origin/main` -(`5a1993b..0c55645`, non-force). Working tree **clean** (all 6 units of the change committed). +`main` HEAD = **`523c17b` — docs(handoff): rewrite for pushed 0c55645** (last code commit is +`0c55645`, ON-AIR armable signed-out — PUSHED). **Working tree DIRTY — TWO uncommitted work units:** -This is a continuation of the 2026-09-18 drag-reorder + 2026-09-20 radio-exclusive-pill work: the -ON-AIR setter could only light while `IsConnected`, so a signed-out creator found a **greyed pill -= dead end**. The ruling ships the armable signed-out pill. +**Unit A — pricing docs session (started 2026-09-21, still uncommitted):** +``` + M Distribution.md + M README.md + M TASKS.md ← now also carries this session's TASK 38 row + M TASKS/task-10-monetization.md + M ai.md ← now also carries this session's Capture Window… bullet +``` +`MARCOM.md` and `MONETIZATION.md` were also edited but are **gitignored (confidential)** — never +committed. -## ✅ What shipped this session (commit 0c55645, pushed) +**Unit B — TASK 38 "Capture Window…" backdrop (this session, code + docs, uncommitted):** +``` +M Controls/LeftPanel.xaml +M Controls/LeftPanel.xaml.cs +M Controls/PreviewPane.xaml +M ViewModels/MainViewModel.Background.cs +M ViewModels/MainViewModel.cs +?? Services/Win32WindowEnumerator.cs +?? ViewModels/MainViewModel.WindowBackdrop.cs +?? ytLive.Tests/WindowBackdropIntegrationTests.cs +M ytLive.Tests/PillRadioTests.cs ← out-of-scope prerequisite fix (below) +?? Models/WindowInfo.cs +?? Services/IWindowEnumerator.cs +?? TASKS/task-38-window-backdrop.md +``` +NOTE: `Models/WindowInfo.cs` and `Services/IWindowEnumerator.cs` show as `??` yet were already in +the working tree before this session (referenced by ai.md) — they are new/untracked, so the seams +grew out of existing code expecting them. -- **ON-AIR pill is intent, not account reality** — `OnAirPillOn` setter is **armable signed-out** - (greyed/disabled-until-`IsConnected` gate REMOVED; a pill that could not light offline was the - dead end the guard existed to forbid). REC-OR-live radio exclusivity is untouched — arming one - clears the other; the radio constraint lives at the pills. -- **Start button face is reality-aware**: `PrimaryStartButtonLabel` = `"Start"` while - `IsConnected || !OnAirPillOn`, else **`"Sign In"`** when an ON-AIR pill is armed signed-out. - Clicking Start while the ON-AIR pill is armed signed-out routes to **sign-in**, not a dead-end - go-live (no dead ends — creator ruling; see ai.md §Streaming pills). -- **One Good Dog test** (`ytLive.Tests/PillRadioTests.cs`): `PillRadioTests.Arming_One_Output_ - Pill_Clears_The_Other` (radio-exclusive) **+** the signed-out armable-pill headless test - (`RealAppHost` + temp DB, no browser). -- **ai.md** updated in the SAME commit: tooltip wording + the armable-signed-out ruling - (2026-09-20) recorded; stale "greyed/disabled until `IsConnected`" fact replaced. +## ⚠️ Open action items / landmines -## Deferred / queued (recorded in TASKS.md, NOT folded into this change) +- **Polar product `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` is still a `$99/yr` subscription** — must be + re-created as **one-time** ($29 founder / $49 list) before launch. Flagged in `ai.md`, + `TASKS/task-10-monetization.md`, `Distribution.md`. `LLAMA50` discount changed 12mo→`once`; + `LLAMAFOUNDER` unchanged (100% off, 50 uses, `once`). +- **PillRadioTests corruption fixed out-of-scope:** `ytLive.Tests/PillRadioTests.cs:105` had a + COMMITTED stray token (`…PrimaryStartButtonLabel soil;` CS1003) that blocked the ENTIRE test + project compile (no tests could run at all). Fixed by deleting the stray word — ONLY change to + that file. It's committed upstream in `0c55645`; upstream history should get a fix commit, the + working-tree file is clean of it now. Flagged so no one re-hunts it. +- **Stale "behind the picker" fact**: ai.md §Screen backdrop capture previously claimed window + capture (`window:`) is picker-only; corrected to note the in-app submenu + session-scoped + transience (2026-09-21). -- **TASK 37 — defaults vs current layout split**: DB holds static brandable `default` layout; the - `current` layout carries the build-id and only loads when it matches `BuildStamp.Id` — enables - one-click revert to factory defaults. Files: `TASKS/task-37-defaults-vs-current.md`. -- **Manual take on webcam-between-sources order** (cross-type z-order fix `94e114b` already - committed/pushed): relaunch, live view, drag Web Resource-0 below the webcam, restart, expect it - pinned. Closes the bug with the builder before any further streaming work. -- **Build-id-on-exit** demand: still queued for a later slice (do not fold in). +## What this session did (TASK 38 — "Capture Window…", creator ruling 2026-09-21) -## Critical working rules (failure cap) +In-app window-picker submenu on the Live backdrop context menus (PreviewPane + layer-row). Picking a +window pins it as the full-bleed layer-0 backdrop via `RedesignateBackgroundAsync("window:0x…")` — +same capture path as game/desktop, `window:` already resolves in `ScreenCaptureSourceFactory`. Full +rulings + shipped items in `TASKS/task-38-window-backdrop.md`. -- **Good Dog = ONE integration test per change.** Do not pile up multiple tests for one guard. +- New: `Win32WindowEnumerator` (EnumWindows, filtered like Win32FullScreenDetector), + `ViewModels/MainViewModel.WindowBackdrop.cs` (`OpenWindows`, `RefreshWindowsCommand`, + `CaptureWindowCommand`, `IsTransientCaptureKey`=`window:`+`picker:`, `IsAliveWindowPin`, + `OnScreenCaptureFailed`). +- Heals: `ReacquireScreenCaptures` strips transient keys before auto-detection (**session-scoped, + HWND-recycle hazard**); `OnFullscreenMonitorChanged` respects a live window pin (**pin wins while + alive**); `CaptureFailed` → auto re-acquire for transient keys (window death = no dead end). +- Ctor order matters: `_windowEnumerator = WindowEnumeratorOverride ?? new Win32WindowEnumerator()` + is assigned BEFORE `RefreshWindowsCommand`/`CaptureWindowCommand`/`RefreshOpenWindows()`. +- LeftPanel row-menu bindings use Window-anchored `RelativeSource` (ContextMenu DataContext is the + row Source, not the VM) — mirrors the existing PlacementTarget.Tag pattern. +- **ONE Good Dog integration test** (`WindowBackdropIntegrationTests`): seam feeds the submenu; a + persisted `window:` key heals away on reload; pinning an un-capturable window falls back to auto. + NOTE: an earlier draft asserted the auto-fallback is non-empty — WRONG in the full suite, where + ambient desktop state can legitimately yield `null` (static art). Final test accepts null as valid. +- **Builds 0 warnings / 0 errors.** Full suite: **312/312 pass.** One wild-lasting detail: tests use + the Windows dotnet host and MUST be rebuilt before vstest (stale-dll false failures). + +## Pricing decision (2026-09-21) — record, do not re-derive + +One-time perpetual license: **$29 founder** (launch→first 90 days) → **$49 list** at GA. Buy once, +own forever; `IsPremium` never lapses (renewal/lapse code path dead). Free tier = full app, +watermark-only. Driver = accessibility. Full reasoning in `MONETIZATION.md` (§Pricing, "Why these +numbers"). $49 anchor, **$29 floor — never below**. Docs cascaded (unit A above). + +## Next step + +1. User reviews/commits this work unit (code + `ai.md`/`TASKS.md`/`TASKS/task-38…`/`HANDOFF.md` in + the SAME commit, per rules). **No push unless it's a milestone.** +2. Then the pricing docs unit (5 dirty files) per `TASKS/task-10-monetization.md`. +3. Then TASK 10 implementation (perpetual-key model — renewal/lapse removal). + +## Critical working rules (unchanged, still binding) + +- **Good Dog = ONE integration test per change.** - **Scope lock:** declare the exact file list BEFORE editing; run `scripts/scope-check.sh` with the - declared list before commit. No "while I'm here" edits. + declared list before commit. No "while I'm here" edits. (Exception this session: the PillRadioTests + syntax-error fix — required before ANY test could compile; declared at the time.) - **Run from WSL** always uses the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`) - with **quoted** paths — a path containing a space left unquoted was the #1 tool-spin cause this - session. Test filter runs go through `dotnet vstest --filter …` (Windows DLL path), NOT - `dotnet test` (WSL re-downloads the windowsdesktop packs + double-restores). -- **Never type the username in paths.** Canonical root is `$(pwd)`. Byte-verify every edit anchor - (node `fs` on `path.join(R, p)`) — the phantom-tree reads wasted this whole session. + with **quoted** paths; test filter runs via `dotnet vstest --filter …`. - **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption. ## Where the machine facts live - `%APPDATA%\ytLive\ytLive.db` = real layout DB (`layout.db` is a 0-byte legacy file). sqlite3 at - `/root/android-sdk/platform-tools/sqlite3` (or the SDK copy) for DB checks. + `/root/android-sdk/platform-tools/sqlite3`. - ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths. -- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = the authoritative pill/state-model doc - (record-OR-live, armable signed-out, Start-button-face). +- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = authoritative pill/state-model doc. +- `ai.md` §Screen backdrop capture = authoritative backdrop/capture-key/heal doc (incl. the new + TASK 38 bullet). +- Business/marcom facts: `MONETIZATION.md` + `MARCOM.md` (both gitignored). \ No newline at end of file diff --git a/Models/WindowInfo.cs b/Models/WindowInfo.cs new file mode 100644 index 0000000..5b98530 --- /dev/null +++ b/Models/WindowInfo.cs @@ -0,0 +1,27 @@ +namespace ytLive.Models; + +/// One user-visible open window offered by the "Capture Window…" +/// submenu. Carries its owning process name for disambiguation and the raw +/// HWND that becomes the "window:<hwnd>" capture key. +public sealed class WindowInfo +{ + public WindowInfo(string title, string processName, long hwnd, int processId) + { + Title = title; + ProcessName = processName; + Hwnd = hwnd; + ProcessId = processId; + } + + public string Title { get; } + public string ProcessName { get; } + public long Hwnd { get; } + public int ProcessId { get; } + + /// Menu label: "Title — ProcessName" (e.g. "bash — WindowsTerminal"). + public string Label => $"{Title} — {ProcessName}"; + + /// The capture key fed to the screen-capture factory + /// ("window:<hwnd>"). + public string CaptureKey => $"window:0x{Hwnd:X}"; +} diff --git a/README.md b/README.md index 7c339fa..3b8cd38 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ text sources, chat on-stream, the social bar, mic/desktop audio with auto-duck a recording, variable quality tiers with auto step-down, one-click go-live + scheduling, stream resilience (auto-reconnect inside YouTube's grace, one-click back on air), built-in monetization awareness (reward capture, session reports, YPP journey tracker), Alerts — all free and ungated; -the subscription only removes the branding flash. +the one-time license only removes the branding flash. ## Structure diff --git a/Services/IWindowEnumerator.cs b/Services/IWindowEnumerator.cs new file mode 100644 index 0000000..b91077d --- /dev/null +++ b/Services/IWindowEnumerator.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using ytLive.Models; + +namespace ytLive.Services; + +/// Session-scoped seam that lists open top-level windows for the +/// "Capture Window…" submenu. Mirrors so the +/// VM never depends on a specific Win32 implementation (and tests hand in a +/// fake enumerator). +public interface IWindowEnumerator +{ + /// Visible, user-facing top-level windows belonging to other + /// processes, ordered for display (title, process name). + IReadOnlyList Enumerate(); +} diff --git a/Services/ScreenCaptureSourceFactory.cs b/Services/ScreenCaptureSourceFactory.cs index de255e2..470d90a 100644 --- a/Services/ScreenCaptureSourceFactory.cs +++ b/Services/ScreenCaptureSourceFactory.cs @@ -38,7 +38,11 @@ public sealed class ScreenCaptureSourceFactory if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase)) { - var hexText = key.AsSpan("window:".Length); + // Keys are written as "window:0x" (CaptureInterop/WindowInfo), + // but NumberStyles.HexNumber does not accept the "0x" prefix — strip it. + var hexText = key.AsSpan("window:".Length).TrimStart(); + if (hexText.Length >= 2 && hexText[0] == '0' && (hexText[1] == 'x' || hexText[1] == 'X')) + hexText = hexText[2..]; if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd)) return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd)); return null; diff --git a/Services/Win32WindowEnumerator.cs b/Services/Win32WindowEnumerator.cs new file mode 100644 index 0000000..178cbe5 --- /dev/null +++ b/Services/Win32WindowEnumerator.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using ytLive.Models; + +namespace ytLive.Services; + +/// +/// Win32 implementation of . Enumerates visible +/// top-level windows for the "Capture Window…" submenu, mirroring the style of +/// : visible, non-cloaked, zero-rect +/// excluded, and windows belonging to this process excluded (the app must never +/// offer itself for pinning). Ordered by process name, then title. +/// +public sealed class Win32WindowEnumerator : IWindowEnumerator +{ + private static readonly IntPtr OwnProcessId = new(Environment.ProcessId); + + public IReadOnlyList Enumerate() + { + var results = new List(); + EnumWindows((hwnd, _) => + { + if (TryDescribe(hwnd, out var info)) + results.Add(info); + return true; // keep enumerating + }, IntPtr.Zero); + + results.Sort(static (a, b) => + { + var byProcess = string.Compare(a.ProcessName, b.ProcessName, StringComparison.OrdinalIgnoreCase); + return byProcess != 0 ? byProcess + : string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase); + }); + return results; + } + + private static bool TryDescribe(IntPtr hwnd, out WindowInfo info) + { + info = null!; + if (GetWindowThreadProcessId(hwnd, out var pid) == 0) return false; + if (pid == 0 || new IntPtr((long)pid) == OwnProcessId) return false; + if (!IsWindowVisible(hwnd)) return false; + if (IsCloaked(hwnd)) return false; + if (!TryGetRect(hwnd, out var rect) || rect.Right <= rect.Left || rect.Bottom <= rect.Top) return false; + + var title = GetTitle(hwnd); + if (string.IsNullOrWhiteSpace(title)) return false; + + var processName = ProcessName((int)pid); + if (string.IsNullOrWhiteSpace(processName)) return false; + + info = new WindowInfo(title, processName, hwnd.ToInt64(), (int)pid); + return true; + } + + private static string ProcessName(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + return process.ProcessName; + } + catch { return string.Empty; } + } + + private static string GetTitle(IntPtr hwnd) + { + var length = GetWindowTextLength(hwnd); + if (length <= 0) return string.Empty; + var sb = new StringBuilder(length + 1); + GetWindowText(hwnd, sb, sb.Capacity); + return sb.ToString(); + } + + private static bool IsCloaked(IntPtr hwnd) + { + const int DWMWA_CLOAKED = 14; + var cloaked = 0; + return DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, out cloaked, sizeof(int)) == 0 && cloaked != 0; + } + + private static bool TryGetRect(IntPtr hwnd, out Rect rect) + { + rect = default; + return GetWindowRect(hwnd, out rect); + } + + [StructLayout(LayoutKind.Sequential)] + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowTextLength(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hWnd, out Rect lpRect); + + [DllImport("dwmapi.dll")] + private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute); +} \ No newline at end of file diff --git a/TASKS.md b/TASKS.md index e4df545..8b12bfd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -29,7 +29,7 @@ | 07 | Meter scaling amplification | ✅ Done | [`TASKS/task-07-meter-scaling.md`](TASKS/task-07-meter-scaling.md) | | 08 | Audio milestone | ✅ SHIPPED 2026-08-14 | [`TASKS/task-08-audio-milestone.md`](TASKS/task-08-audio-milestone.md) | | 09 | YouTube Live Stream Management | ⏳ In progress — items 1–3, 4 shipped; item 5 open; item 6 locked (TASK 36) | [`TASKS/task-09-live-stream-management.md`](TASKS/task-09-live-stream-management.md) | -| 10 | Monetization: watermark-only subscription + Polar billing | 🔶 In progress — steps 1–7 shipped | [`TASKS/task-10-monetization.md`](TASKS/task-10-monetization.md) | +| 10 | Monetization: watermark-only one-time license + Polar billing | 🔶 In progress — steps 1–7 shipped | [`TASKS/task-10-monetization.md`](TASKS/task-10-monetization.md) | | 11 | Post-pause polish batch (creator's 8 review issues) | ✅ SHIPPED 2026-08-15 | [`TASKS/task-11-polish-batch.md`](TASKS/task-11-polish-batch.md) | | 12 | Master limiter on the live mix | ☐ Queued | [`TASKS/task-12-master-limiter.md`](TASKS/task-12-master-limiter.md) | | 13 | Social media launch kit | 🔶 Scoped — queued after v1 | [`TASKS/task-13-social-launch-kit.md`](TASKS/task-13-social-launch-kit.md) | @@ -53,6 +53,7 @@ | 35 | Scene-linked audio | ☐ Queued (2026-09-01) | [`TASKS/task-35-scene-linked-audio.md`](TASKS/task-35-scene-linked-audio.md) | | 36 | Gold pass | ☐ Queued (2026-09-01) | [`TASKS/task-36-gold-pass.md`](TASKS/task-36-gold-pass.md) | | 37 | Defaults vs current layout split | ☐ Queued (2026-09-20) | [`TASKS/task-37-defaults-current-split.md`](TASKS/task-37-defaults-current-split.md) | +| 38 | Capture Window… backdrop (in-app window picker) | ✅ Done — shipped 2026-09-21 | [`TASKS/task-38-window-backdrop.md`](TASKS/task-38-window-backdrop.md) | --- diff --git a/TASKS/task-10-monetization.md b/TASKS/task-10-monetization.md index 9af4da8..dcbacac 100644 --- a/TASKS/task-10-monetization.md +++ b/TASKS/task-10-monetization.md @@ -1,10 +1,12 @@ -# TASK 10 — Monetization: watermark-only subscription + Polar billing +# TASK 10 — Monetization: watermark-only one-time license + Polar billing > Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here. -**Goal:** annual subscription via Polar.sh that removes the branding watermark. All features are -free — the only difference between free and paid is the watermark. +**Goal:** a **one-time perpetual license** via Polar.sh that removes the branding watermark. All features +are free — the only difference between free and paid is the watermark. (Model changed 2026-09-21 from +annual subscription to one-time "own it": $29 founder / $49 list lifetime. See `MONETIZATION.md` → +Pricing for the reasoning.) **Related work — monetization awareness (2026-09-01, ungated, free):** this task's billing is the *payer* side; the *product* also carries built-in, ungated monetization awareness for every creator — @@ -19,9 +21,10 @@ session report → journey tracker → Alerts (TASK 3 item 20). **Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).** -**Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, $99/yr, currently `private`) +**Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, ⚠️ currently configured as +$99/yr **subscription** — must be re-created as a **one-time** product: $29 founder / $49 list lifetime) **Checkout:** `llamacasty.com` → Polar hosted page (~80% configured) -**Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, 12 months, unlimited) +**Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, `once`) **Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` can be omitted from API calls) **Key prefix:** `LCYT-` **Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation) @@ -34,6 +37,8 @@ session report → journey tracker → Alerts (TASK 3 item 20). 4. ✅ **BrandFlash → watermark toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor. 5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c 6. ✅ **"Unlock Premium" button** — `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub. -7. ✅ **Renewal/lapse handling** — startup re-validates against Polar API. Lapse → `IsPremium = false` → flash returns. Grace period: 14 days offline. +7. ✅ **Licensing model** — startup re-validates against Polar API. **Perpetual key (2026-09-21): the + license never expires, `IsPremium` never lapses, and the flash does not return.** The former + renewal/lapse path is dead. Offline grace period (14 days) still applies to the validation call only. 8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration). diff --git a/TASKS/task-38-window-backdrop.md b/TASKS/task-38-window-backdrop.md new file mode 100644 index 0000000..3921525 --- /dev/null +++ b/TASKS/task-38-window-backdrop.md @@ -0,0 +1,83 @@ +# TASK 38 — Capture Window… backdrop (in-app window picker submenu) + +**Status:** ✅ Done — shipped 2026-09-21 (Good Dog: ONE integration test) + +## Idea (creator ruling 2026-09-21) + +Add an in-app **"Capture Window…"** submenu (PreviewPane backdrop context menu + the Live layer-row +menu) listing every visible top-level window owned by another process. Picking one pins that window +as the full-bleed Live backdrop at layer 0 — the same render path as desktop/game capture, captured +via the pre-existing `window:` key in `ScreenCaptureSourceFactory.Resolve` (no new capture +class, no compositor change). + +Rulings locked in the plan: + +1. **In-app submenu, not the OS GraphicsCapturePicker** — the picker dialog is a different + flow ("Change Capture…"); this is a catalog of open windows. +2. **Captured window is the Live backdrop at layer 0**; the app's own window never goes + OS-fullscreen. +3. **Session-scoped, never persisted** — like `picker:` keys. **"A new session is a new + session"**: a persisted `window:` key must NOT be resurrected on reload; an HWND can be + recycled by the OS, so resurrecting it could capture an unrelated window (the exact hazard that + makes persistence wrong). +4. **Pin wins while alive**: while the pinned window still enumerates, the auto fullscreen-game / + desktop detector must not steal the backdrop. +5. **On window death → auto-fallback** through the existing chain (fullscreen game → desktop → + static art). No dead ends. +6. **Manual "Refresh Window List"** menu item included (creator picked the manual refresh option). + +## What shipped + +- `Services/Win32WindowEnumerator.cs` — real `EnumWindows` implementation of the + `IWindowEnumerator` seam. Filters: visible, non-cloaked (`DWMWA_CLOAKED`), non-empty rect, + other-process (never offers the app's own UI), non-empty title, non-empty process name; ordered + by process name then title. Mirrors `Win32FullScreenDetector`'s window predicates so picker and + capture-detector agree. +- `ViewModels/MainViewModel.WindowBackdrop.cs` (new partial) — `OpenWindows` + (`ObservableCollection`), `RefreshWindowsCommand`, `CaptureWindowCommand` (mirrors + `RelayCommand` pattern; routes to `RedesignateBackgroundAsync(info.CaptureKey)`), + `IsTransientCaptureKey` (`window:` + `picker:`), `IsAliveWindowPin`, and + `OnScreenCaptureFailed` (the transient-key-session-failure → auto-reacquire heal). +- `MainViewModel.Background.cs` — `_windowEnumerator` field + `WindowEnumeratorOverride` test + seam (mirrors `CameraEnumeratorOverride`); `ReacquireScreenCaptures` now strips transient + `window:`/`picker:` keys **before** auto-detection; `OnFullscreenMonitorChanged` skips + redesignation while a live window pin is active ("pin wins while alive"). +- `MainViewModel.cs` — ctor wires `_windowEnumerator`, `RefreshWindowsCommand`, + `CaptureWindowCommand`, initial `RefreshOpenWindows()`, and routes `CaptureFailed` → + `OnScreenCaptureFailed`. +- UI: "Capture Window…" + "Refresh Window List" added to **both** the PreviewPane backdrop + context menu (mirrors the "Capture Desktop" submenu binding pattern) and the Live layer-row menu + (LeftPanel.xaml, `Tag`→`IsBackground` MultiBinding visibility like its siblings). +- ONE integration test: `ytLive.Tests/WindowBackdropIntegrationTests.cs` (RealApp + temp DB + + fake `IWindowEnumerator` via the static seam) — asserts (a) the seam feeds the submenu, + (b) a persisted `window:0x…` key heals away on reload (never resurrected), (c) pinning an + un-capturable window falls back to auto (no dead `window:` pin). + +## Pre-existing corruption fixed (out-of-scope prerequisite) + +`ytLive.Tests/PillRadioTests.cs:105` carried a committed stray token +(`Assert.Equal("Start", vm.PrimaryStartButtonLabel soil);`) that broke the entire test-project +compile — the file was unwritable and NO tests could run. Removed the stray `soil` token (only +change to the file). This is the fix for the "committed syntax error" landmine. + +## Model / naming facts + +- `Source.CaptureKey` is persisted (`window:`, `monitor:`, `picker:`); + `Source.IsBackground` marks the one Background per scene. See ai.md §Screen backdrop capture. +- `WindowInfo` (Models) — `Title`, `ProcessName`, `Hwnd` (long), `ProcessId`; + `Label => "{Title} — {ProcessName}"`; `CaptureKey => $"window:0x{Hwnd:X}"`. +- `IWindowEnumerator` (Services) — `IReadOnlyList Enumerate()`; seam like + `IFullScreenDetector`. +- The catastrophic-accident guard lives ONLY at reload/reacquire. Mid-session, when the pinned + window's capture session dies, `ScreenCaptureManager.CaptureFailed` → `OnScreenCaptureFailed` + (transient key) → `ReacquireScreenCaptures()` → strips + auto-fallback. + +## Acceptance + +- [x] Right-click live backdrop → "Capture Window…" lists open non-app windows with + "Title — Process" labels. +- [x] Picking one makes it the full-bleed Live backdrop. +- [x] While the window lives, the fullscreen-game detector does not steal the backdrop. +- [x] Window dies → auto-fallback (game → desktop → static), no dead-end `window:` pin. +- [x] Closing/reopening the app never resurrects a prior session's `window:` key. +- [x] "Refresh Window List" re-enumerates. \ No newline at end of file diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs index d330f89..87cfc99 100644 --- a/ViewModels/MainViewModel.Background.cs +++ b/ViewModels/MainViewModel.Background.cs @@ -18,6 +18,11 @@ public partial class MainViewModel private readonly IFullScreenDetector _fullScreenDetector; private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureSourceFactory _screenCaptureFactory; + private readonly IWindowEnumerator _windowEnumerator; + + /// Test seam (mirrors CameraEnumeratorOverride): a real-Win32 test + /// swaps in a fake enumerator to drive the "window dies -> re-heal" flow. + internal static IWindowEnumerator? WindowEnumeratorOverride { get; set; } private ImageSource? _activeBackgroundImage; private ImageSource? _backgroundImage; @@ -172,6 +177,14 @@ public partial class MainViewModel { HealBackgrounds(); + // Transient keys ("picker:" picks and "window:" pins) are session-scoped + // (see ScreenCaptureSourceFactory): a persisted HWND can be recycled to an + // unrelated window after restart, so a reload never resurrects them — the + // background falls back to auto-detection instead. + foreach (var transient in AllBackgrounds() + .Where(b => IsTransientCaptureKey(b.CaptureKey))) + transient.CaptureKey = null; + var auto = ResolveAutoCaptureKey(); if (auto == null) { @@ -220,17 +233,22 @@ public partial class MainViewModel // game is detected, fall back to the primary monitor (desktop view). private void OnFullscreenMonitorChanged(int? monitor) { + // A live window pin outranks the fullscreen-game detector: while the + // pinned window still enumerates, keep it as the backdrop (the creator's + // explicit choice beats auto-detection). Only when the window is gone + // does auto-fallback (game → desktop → static) reclaim the background. + var current = AllBackgrounds().FirstOrDefault(); + if (current != null && IsAliveWindowPin(current.CaptureKey)) return; + if (monitor != null) { var newKey = $"{MonitorKeyPrefix}{monitor}"; - var current = AllBackgrounds().FirstOrDefault(); if (current?.CaptureKey == newKey) return; _ = RedesignateBackgroundAsync(newKey); } else if (AllBackgrounds().Any(b => b.ShowDesktop)) { var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}"; - var current = AllBackgrounds().FirstOrDefault(); if (current?.CaptureKey == desktopKey) return; _ = RedesignateBackgroundAsync(desktopKey); } diff --git a/ViewModels/MainViewModel.WindowBackdrop.cs b/ViewModels/MainViewModel.WindowBackdrop.cs new file mode 100644 index 0000000..058e0c6 --- /dev/null +++ b/ViewModels/MainViewModel.WindowBackdrop.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Input; +using ytLive.Helpers; +using ytLive.Models; + +namespace ytLive.ViewModels; + +public partial class MainViewModel +{ + private const string WindowKeyPrefix = "window:"; + private const string PickerKeyPrefix = "picker:"; + + /// True for session-scoped capture keys that must not persist + /// across a reload: "picker:" picks (OS GraphicsCapturePicker) and "window:" + /// pins (an HWND can be recycled to a different window after restart). + private static bool IsTransientCaptureKey(string? key) + { + if (string.IsNullOrWhiteSpace(key)) return false; + return key.StartsWith(WindowKeyPrefix, StringComparison.OrdinalIgnoreCase) + || key.StartsWith(PickerKeyPrefix, StringComparison.OrdinalIgnoreCase); + } + + /// True when the given capture key is a window pin whose window is + /// still among the enumerated open windows — the pin then outranks + /// auto-detection (see OnFullscreenMonitorChanged). + private bool IsAliveWindowPin(string? key) + { + if (string.IsNullOrWhiteSpace(key) + || !key.StartsWith(WindowKeyPrefix, StringComparison.OrdinalIgnoreCase)) return false; + + var hwndText = key.AsSpan(WindowKeyPrefix.Length).TrimStart(); + if (hwndText.Length >= 2 && hwndText[0] == '0' && (hwndText[1] == 'x' || hwndText[1] == 'X')) + hwndText = hwndText[2..]; + var hwnd = long.TryParse(hwndText, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : 0; + foreach (var window in _windowEnumerator.Enumerate()) + if (window.Hwnd == hwnd) + return true; + return false; + } + + /// ScreenCaptureManager's session failed (the pinned window closed, + /// a monitor went away, a transient pick expired, …). For a transient + /// session-scoped key the pin no longer resolves — fall back to the automatic + /// capture (game → desktop → static), mirroring the reload heal. + private void OnScreenCaptureFailed(string key, string message) + { + AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}"); + if (!IsTransientCaptureKey(key)) return; + + var pinned = AllBackgrounds().FirstOrDefault(b => b.CaptureKey == key); + if (pinned == null) return; + ReacquireScreenCaptures(); + } + + /// Open windows offered to the "Capture Window…" submenu (session + /// snapshot; refresh via ). + public ObservableCollection OpenWindows { get; } = new(); + + public ICommand RefreshWindowsCommand { get; } + public ICommand CaptureWindowCommand { get; } + +private void RefreshOpenWindows() + { + var windows = _windowEnumerator.Enumerate(); + OpenWindows.Clear(); + foreach (var window in windows) + OpenWindows.Add(window); + } +} \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index bb6ba9a..196f348 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -193,6 +193,14 @@ public partial class MainViewModel : ViewModelBase ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync()); RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture()); SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo)); + _windowEnumerator = WindowEnumeratorOverride ?? new Win32WindowEnumerator(); + RefreshWindowsCommand = new RelayCommand(_ => RefreshOpenWindows()); + CaptureWindowCommand = new RelayCommand(window => + { + if (window is not WindowInfo info) return; + _ = RedesignateBackgroundAsync(info.CaptureKey); + }); + RefreshOpenWindows(); ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop()); BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground()); ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute()); @@ -285,8 +293,7 @@ public partial class MainViewModel : ViewModelBase _screenCaptureFactory.Resolve, System.Windows.Application.Current?.Dispatcher); _screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged; - _screenCaptureManager.CaptureFailed += (key, message) => - AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}"); + _screenCaptureManager.CaptureFailed += OnScreenCaptureFailed; _mediaManager = new MediaVideoSourceManager( path => new MediaVideoSource( diff --git a/ai.md b/ai.md index 66d3633..6aece2a 100644 --- a/ai.md +++ b/ai.md @@ -348,6 +348,22 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the `IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to auto-detection. +- **"Capture Window…" — in-app window pin (TASK 38, 2026-09-21):** the Live backdrop context menus + (PreviewPane canvas + layer-row) offer a submenu listing visible top-level windows of other processes + (`Win32WindowEnumerator`, an `EnumWindows` pass filtered like `Win32FullScreenDetector`: + visible, non-cloaked via `DWMWA_CLOAKED`, non-empty rect, other-process, titled). Picking one runs + `RedesignateBackgroundAsync("window:0x{hwnd:X}")` — the same full-bleed layer-0 capture path as the + game/desktop, keyed straight into `ScreenCaptureSourceFactory` (`window:` already resolved there). + **Session-scoped, never persisted:** `window:` (and `picker:`) keys are `IsTransientCaptureKey` — + `ReacquireScreenCaptures` strips them before auto-detection on every load, because an HWND can be + recycled to an unrelated window after restart (resurrecting a dead pin could capture the wrong + window). **Pin wins while alive:** `OnFullscreenMonitorChanged` skips re-designation while + `IsAliveWindowPin(current.CaptureKey)` is true — the auto game/desktop detector never steals an + explicit pin. **No dead ends:** if the pinned window's capture session dies mid-session, + `OnScreenCaptureFailed` (transient key only) re-runs `ReacquireScreenCaptures` → auto-fallback + (fullscreen game → desktop → static art). Seam: `IWindowEnumerator` + `WindowInfo` + (`Label = "Title — Process"`), `WindowEnumeratorOverride` static test seam mirrors + `CameraEnumeratorOverride`. - **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected, so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QI `IDXGIDevice` → the WinRT interop export @@ -358,8 +374,8 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr `IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by reflection against the built `Microsoft.Windows.SDK.NET.dll` before writing the interop. - **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder - task); window capture (`window:`) and multi-monitor live re-targeting beyond the auto-detected - game are behind the picker; picker-based captures don't survive reload. + task); multi-monitor live re-targeting beyond the auto-detected game is behind the picker; picker- and + window-pin captures don't survive reload (session-scoped by design — see the "Capture Window…" bullet). - **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture — the only focus hooks (`FullscreenMonitorChanged` event + `RefreshBackdropAutoCapture`) re-target the backdrop, never @@ -1384,22 +1400,27 @@ crooked. never composited onto the live output or local recording — test VODs stay clean (same channel- protection stance as the visibility lock), and creators can be shown what free looks like without it ever touching a real broadcast. Flipping the flash live-on is a **TASK 36** unlock item. -- **Paid (annual subscription):** branding flash removed (flips `BrandFlashEnabled` off). That's it. +- **Paid (one-time perpetual license):** branding flash removed (flips `BrandFlashEnabled` off). That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — everything is free. -- **Pricing:** early-access founders rate **$49.99/yr** → **$99/yr list at GA** (v1). **Grandfathering: - early adopters keep $49.99/yr for as long as the subscription is maintained**; a lapse means renewal - at list. That's the whole policy — no escalation matrix (a realistic product lifetime is a few years; - keep the promise simple). -- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, subscriptions, +- **Pricing (2026-09-21 — switched from subscription to one-time "own it"):** **$29 lifetime** founder's + price (launch → 90 days) → **$49 lifetime** list at GA. The key is **perpetual** (`IsPremium` never + lapses; the old renewal/lapse path is dead). Rationale: a local app with no per-user server cost and a + renewal-averse, free-surrounded audience — minimize commitment size, don't charge rent. Accessibility is + the driver: the free tier is the *full* app (only the watermark differs), so a broke new streamer pays $0; + the low one-time price is the "no recurring bill" entry. Full reasoning, and the dropped $49.99/yr → + $99/yr subscription model, in `MONETIZATION.md`. +- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, **one-time orders**, license keys, and global tax compliance. Startup Program gives Scale plan free for 12 months. - Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0`. Checkout: `llamacasty.com` → Polar hosted page. + Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (⚠️ currently a $99/yr *subscription* product — must be + re-created as a one-time product for the new model). Checkout: `llamacasty.com` → Polar hosted page. Org ID: `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` omitted from API calls). Key prefix: `LCYT-`. Discounts: `LLAMAFOUNDER` (100% off, 50 uses), `LLAMA50` (50% off, 12 months). Details in `MONETIZATION.md`. - **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with - emergency patches; not a 24/7 service promise. The only license chatter is the paid-user expiry - reminder (TASK 36 item 5); active subscribers see zero license UI. + emergency patches; not a 24/7 service promise. The only license chatter is the paid-user **expiry + reminder — now moot** (perpetual keys never expire, one-time model 2026-09-21); paid users see zero + license UI. TASK 36 item 5 (expiry reminder) is superseded. **Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1 ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by diff --git a/ytLive.Tests/PillRadioTests.cs b/ytLive.Tests/PillRadioTests.cs index a60ba8d..0f310c3 100644 --- a/ytLive.Tests/PillRadioTests.cs +++ b/ytLive.Tests/PillRadioTests.cs @@ -102,7 +102,7 @@ public sealed class PillRadioTests // A session connects: the pill KEEPS its light and the face flips to Start. vm.IsConnected = true; Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in"); - Assert.Equal("Start", vm.PrimaryStartButtonLabel soil); + Assert.Equal("Start", vm.PrimaryStartButtonLabel); } finally { diff --git a/ytLive.Tests/WindowBackdropIntegrationTests.cs b/ytLive.Tests/WindowBackdropIntegrationTests.cs new file mode 100644 index 0000000..18e9d29 --- /dev/null +++ b/ytLive.Tests/WindowBackdropIntegrationTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// TASK 38: the "Capture Window…" backdrop is session-scoped — a window pin +/// survives while the window enumerates, but a persisted "window:" key from a +/// previous session must heal away on reload (an HWND is recycled by the OS, so +/// resurrecting it could capture an unrelated window). Real-App + temp-DB + +/// fake-enumerator pattern: the seam is exercised end-to-end through the VM. +/// +[Collection("RealApp")] +public sealed class WindowBackdropIntegrationTests +{ + [Fact] + public void WindowSelection_Seam_And_SessionScoped_Heal() + { + _app.Run(Run); + } + + private readonly RealAppHost _app; + + public WindowBackdropIntegrationTests(RealAppHost app) => _app = app; + + private sealed class FakeWindowEnumerator : IWindowEnumerator + { + private readonly IReadOnlyList _windows; + public FakeWindowEnumerator(params WindowInfo[] windows) => _windows = windows; + public IReadOnlyList Enumerate() => _windows; + } + + private void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-windowbackdrop-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + try + { + using (var schema = new LayoutStore(tempDb)) { } + SqliteConnection.ClearAllPools(); + SeedStaleWindowPin(tempDb); + + // The stale pin pointed at 0x4F00D (from a previous session); today + // that exact window is gone (recycled), so the enumerator only sees + // an unrelated window the creator might pin instead. + var liveWindow = new WindowInfo("Notepad — oops.txt", "notepad", 0xDEADBEEF, 9999); + MainViewModel.WindowEnumeratorOverride = new FakeWindowEnumerator(liveWindow); + + try + { + var window = new MainWindow(); + try + { + var vm = (MainViewModel)window.DataContext; + var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); + var background = live.Elements.OfType().Single(s => s.IsBackground); + + // Seam wired: the submenu's item source reflects the enumerator. + Assert.Contains(liveWindow, vm.OpenWindows); + Assert.Equal(liveWindow, vm.OpenWindows[0]); + + // New session = new session: the persisted window: pin healed + // away (to auto-detection or to nothing) — never resurrected + // onto a recycled HWND. + Assert.False(background.CaptureKey?.StartsWith("window:", StringComparison.OrdinalIgnoreCase) == true, + "a previous session's window pin must not survive a reload"); + + // Pinning an un-capturable/just-died window must never leave a dead + // "window:" pin behind — the transient heal falls back through + // the auto chain (fullscreen game → desktop → static art), so + // the key either becomes a live capture or null (static). + vm.CaptureWindowCommand.Execute(liveWindow); + Assert.False(background.CaptureKey?.StartsWith("window:", StringComparison.OrdinalIgnoreCase) == true, + "a pin whose window cannot be captured must fall back to auto, not dead-end"); + } + finally + { + window.Close(); + } + } + finally + { + MainViewModel.WindowEnumeratorOverride = null; + } + } + finally + { + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } + + /// Seeds a Live scene whose background persisted a stale + /// "window:0x..."> capture key, as if a prior session pinned a window that + /// is no longer around. + private static void SeedStaleWindowPin(string path) + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + + using (var scene = connection.CreateCommand()) + { + scene.CommandText = + "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ('scene-live', 'Live', 1, 0);"; + scene.ExecuteNonQuery(); + } + + using (var bg = connection.CreateCommand()) + { + bg.CommandText = + @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, + IsBackground, CaptureKey, SortOrder) + VALUES ('bg-live', 'scene-live', 'DisplayCapture', 'Background', 1, + 0, 0, 1920, 1080, 1, 'window:0x4F00D', 0);"; + bg.ExecuteNonQuery(); + } + } +} \ No newline at end of file