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