feat(backdrop): Capture Window… in-app window pin (TASK 38, Good Dog ONE test)
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> 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<Hwnd>" 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.
This commit is contained in:
@@ -94,6 +94,31 @@
|
|||||||
</MultiBinding>
|
</MultiBinding>
|
||||||
</MenuItem.Visibility>
|
</MenuItem.Visibility>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem Header="Capture Window…"
|
||||||
|
ItemsSource="{Binding DataContext.OpenWindows, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||||
|
<MenuItem.Visibility>
|
||||||
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
<Binding Path="IsBackground"/>
|
||||||
|
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</MenuItem.Visibility>
|
||||||
|
<MenuItem.ItemContainerStyle>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Header" Value="{Binding Label}"/>
|
||||||
|
<Setter Property="Command"
|
||||||
|
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||||
|
</Style>
|
||||||
|
</MenuItem.ItemContainerStyle>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Refresh Window List" Click="BackgroundMenu_RefreshWindowList">
|
||||||
|
<MenuItem.Visibility>
|
||||||
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
<Binding Path="IsBackground"/>
|
||||||
|
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</MenuItem.Visibility>
|
||||||
|
</MenuItem>
|
||||||
<Separator>
|
<Separator>
|
||||||
<Separator.Visibility>
|
<Separator.Visibility>
|
||||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ public partial class LeftPanel : UserControl
|
|||||||
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
|
(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<double> e)
|
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,20 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</MenuItem.ItemContainerStyle>
|
</MenuItem.ItemContainerStyle>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem Header="Capture Window…"
|
||||||
|
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
|
||||||
|
ItemsSource="{Binding OpenWindows}">
|
||||||
|
<MenuItem.ItemContainerStyle>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Header" Value="{Binding Label}"/>
|
||||||
|
<Setter Property="Command"
|
||||||
|
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
|
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||||
|
</Style>
|
||||||
|
</MenuItem.ItemContainerStyle>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Refresh Window List" Command="{Binding RefreshWindowsCommand}"
|
||||||
|
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
|
||||||
<MenuItem Header="Refresh Desktop" Command="{Binding RefreshCaptureCommand}"
|
<MenuItem Header="Refresh Desktop" Command="{Binding RefreshCaptureCommand}"
|
||||||
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
|
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
+2
-2
@@ -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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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). |
|
| **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:**
|
**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:
|
2. Add two benefits:
|
||||||
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
|
- **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.
|
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
|
||||||
|
|||||||
+92
-40
@@ -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
|
## Branch / Commit State
|
||||||
|
|
||||||
`main` HEAD = **`0c55645` — feat(pills): ON-AIR armable signed-out; Start face reads "Sign In"
|
`main` HEAD = **`523c17b` — docs(handoff): rewrite for pushed 0c55645** (last code commit is
|
||||||
while armed signed-out (Good Dog 2026-09-20 ruling)** — **PUSHED** to `origin/main`
|
`0c55645`, ON-AIR armable signed-out — PUSHED). **Working tree DIRTY — TWO uncommitted work units:**
|
||||||
(`5a1993b..0c55645`, non-force). Working tree **clean** (all 6 units of the change committed).
|
|
||||||
|
|
||||||
This is a continuation of the 2026-09-18 drag-reorder + 2026-09-20 radio-exclusive-pill work: the
|
**Unit A — pricing docs session (started 2026-09-21, still uncommitted):**
|
||||||
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.
|
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**
|
## ⚠️ Open action items / landmines
|
||||||
(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.
|
|
||||||
|
|
||||||
## 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:<hwnd>`) 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
|
## What this session did (TASK 38 — "Capture Window…", creator ruling 2026-09-21)
|
||||||
`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).
|
|
||||||
|
|
||||||
## 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
|
- **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`)
|
- **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
|
with **quoted** paths; test filter runs via `dotnet vstest <dll> --filter …`.
|
||||||
session. Test filter runs go through `dotnet vstest <dll> --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.
|
|
||||||
- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption.
|
- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption.
|
||||||
|
|
||||||
## Where the machine facts live
|
## Where the machine facts live
|
||||||
|
|
||||||
- `%APPDATA%\ytLive\ytLive.db` = real layout DB (`layout.db` is a 0-byte legacy file). sqlite3 at
|
- `%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.
|
- 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
|
- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = authoritative pill/state-model doc.
|
||||||
(record-OR-live, armable signed-out, Start-button-face).
|
- `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).
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Menu label: "Title — ProcessName" (e.g. "bash — WindowsTerminal").</summary>
|
||||||
|
public string Label => $"{Title} — {ProcessName}";
|
||||||
|
|
||||||
|
/// <summary>The capture key fed to the screen-capture factory
|
||||||
|
/// ("window:<hwnd>").</summary>
|
||||||
|
public string CaptureKey => $"window:0x{Hwnd:X}";
|
||||||
|
}
|
||||||
@@ -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
|
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
|
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;
|
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
|
## Structure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>Session-scoped seam that lists open top-level windows for the
|
||||||
|
/// "Capture Window…" submenu. Mirrors <see cref="IFullScreenDetector"/> so the
|
||||||
|
/// VM never depends on a specific Win32 implementation (and tests hand in a
|
||||||
|
/// fake enumerator).</summary>
|
||||||
|
public interface IWindowEnumerator
|
||||||
|
{
|
||||||
|
/// <summary>Visible, user-facing top-level windows belonging to other
|
||||||
|
/// processes, ordered for display (title, process name).</summary>
|
||||||
|
IReadOnlyList<WindowInfo> Enumerate();
|
||||||
|
}
|
||||||
@@ -38,7 +38,11 @@ public sealed class ScreenCaptureSourceFactory
|
|||||||
|
|
||||||
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
|
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var hexText = key.AsSpan("window:".Length);
|
// Keys are written as "window:0x<Hwnd:X>" (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))
|
if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd))
|
||||||
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
|
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Win32 implementation of <see cref="IWindowEnumerator"/>. Enumerates visible
|
||||||
|
/// top-level windows for the "Capture Window…" submenu, mirroring the style of
|
||||||
|
/// <see cref="Win32FullScreenDetector"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Win32WindowEnumerator : IWindowEnumerator
|
||||||
|
{
|
||||||
|
private static readonly IntPtr OwnProcessId = new(Environment.ProcessId);
|
||||||
|
|
||||||
|
public IReadOnlyList<WindowInfo> Enumerate()
|
||||||
|
{
|
||||||
|
var results = new List<WindowInfo>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
| 07 | Meter scaling amplification | ✅ Done | [`TASKS/task-07-meter-scaling.md`](TASKS/task-07-meter-scaling.md) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||||
|
|
||||||
|
|
||||||
**Goal:** annual subscription via Polar.sh that removes the branding watermark. All features are
|
**Goal:** a **one-time perpetual license** via Polar.sh that removes the branding watermark. All features
|
||||||
free — the only difference between free and paid is the watermark.
|
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
|
**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 —
|
*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).**
|
**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)
|
**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)
|
**Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` can be omitted from API calls)
|
||||||
**Key prefix:** `LCYT-`
|
**Key prefix:** `LCYT-`
|
||||||
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
|
**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.
|
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
|
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.
|
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).
|
8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration).
|
||||||
|
|
||||||
|
|||||||
@@ -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:<hwnd>` 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:<hwnd>` 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<WindowInfo>`), `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:<hwnd>`, `monitor:<n>`, `picker:<displayname>`);
|
||||||
|
`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<WindowInfo> 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.
|
||||||
@@ -18,6 +18,11 @@ public partial class MainViewModel
|
|||||||
private readonly IFullScreenDetector _fullScreenDetector;
|
private readonly IFullScreenDetector _fullScreenDetector;
|
||||||
private readonly ScreenCaptureManager _screenCaptureManager;
|
private readonly ScreenCaptureManager _screenCaptureManager;
|
||||||
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
|
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
|
||||||
|
private readonly IWindowEnumerator _windowEnumerator;
|
||||||
|
|
||||||
|
/// <summary>Test seam (mirrors CameraEnumeratorOverride): a real-Win32 test
|
||||||
|
/// swaps in a fake enumerator to drive the "window dies -> re-heal" flow.</summary>
|
||||||
|
internal static IWindowEnumerator? WindowEnumeratorOverride { get; set; }
|
||||||
|
|
||||||
private ImageSource? _activeBackgroundImage;
|
private ImageSource? _activeBackgroundImage;
|
||||||
private ImageSource? _backgroundImage;
|
private ImageSource? _backgroundImage;
|
||||||
@@ -172,6 +177,14 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
HealBackgrounds();
|
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();
|
var auto = ResolveAutoCaptureKey();
|
||||||
if (auto == null)
|
if (auto == null)
|
||||||
{
|
{
|
||||||
@@ -220,17 +233,22 @@ public partial class MainViewModel
|
|||||||
// game is detected, fall back to the primary monitor (desktop view).
|
// game is detected, fall back to the primary monitor (desktop view).
|
||||||
private void OnFullscreenMonitorChanged(int? monitor)
|
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)
|
if (monitor != null)
|
||||||
{
|
{
|
||||||
var newKey = $"{MonitorKeyPrefix}{monitor}";
|
var newKey = $"{MonitorKeyPrefix}{monitor}";
|
||||||
var current = AllBackgrounds().FirstOrDefault();
|
|
||||||
if (current?.CaptureKey == newKey) return;
|
if (current?.CaptureKey == newKey) return;
|
||||||
_ = RedesignateBackgroundAsync(newKey);
|
_ = RedesignateBackgroundAsync(newKey);
|
||||||
}
|
}
|
||||||
else if (AllBackgrounds().Any(b => b.ShowDesktop))
|
else if (AllBackgrounds().Any(b => b.ShowDesktop))
|
||||||
{
|
{
|
||||||
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
||||||
var current = AllBackgrounds().FirstOrDefault();
|
|
||||||
if (current?.CaptureKey == desktopKey) return;
|
if (current?.CaptureKey == desktopKey) return;
|
||||||
_ = RedesignateBackgroundAsync(desktopKey);
|
_ = RedesignateBackgroundAsync(desktopKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:";
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
private static bool IsTransientCaptureKey(string? key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key)) return false;
|
||||||
|
return key.StartsWith(WindowKeyPrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| key.StartsWith(PickerKeyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Open windows offered to the "Capture Window…" submenu (session
|
||||||
|
/// snapshot; refresh via <see cref="RefreshWindowsCommand"/>).</summary>
|
||||||
|
public ObservableCollection<WindowInfo> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,6 +193,14 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
|
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
|
||||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
|
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
|
||||||
SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo));
|
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());
|
ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop());
|
||||||
BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
|
BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
|
||||||
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
||||||
@@ -285,8 +293,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_screenCaptureFactory.Resolve,
|
_screenCaptureFactory.Resolve,
|
||||||
System.Windows.Application.Current?.Dispatcher);
|
System.Windows.Application.Current?.Dispatcher);
|
||||||
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
|
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
|
||||||
_screenCaptureManager.CaptureFailed += (key, message) =>
|
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
|
||||||
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
|
|
||||||
|
|
||||||
_mediaManager = new MediaVideoSourceManager(
|
_mediaManager = new MediaVideoSourceManager(
|
||||||
path => new MediaVideoSource(
|
path => new MediaVideoSource(
|
||||||
|
|||||||
@@ -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
|
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
|
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to
|
||||||
auto-detection.
|
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,
|
- **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
|
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
|
||||||
feature array) → QI `IDXGIDevice` → the WinRT interop export
|
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
|
`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.
|
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
|
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
|
||||||
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
|
task); multi-monitor live re-targeting beyond the auto-detected game is behind the picker; picker- and
|
||||||
game are behind the picker; picker-based captures don't survive reload.
|
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-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 —
|
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
|
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-
|
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
|
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.
|
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.
|
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:
|
- **Pricing (2026-09-21 — switched from subscription to one-time "own it"):** **$29 lifetime** founder's
|
||||||
early adopters keep $49.99/yr for as long as the subscription is maintained**; a lapse means renewal
|
price (launch → 90 days) → **$49 lifetime** list at GA. The key is **perpetual** (`IsPremium` never
|
||||||
at list. That's the whole policy — no escalation matrix (a realistic product lifetime is a few years;
|
lapses; the old renewal/lapse path is dead). Rationale: a local app with no per-user server cost and a
|
||||||
keep the promise simple).
|
renewal-averse, free-surrounded audience — minimize commitment size, don't charge rent. Accessibility is
|
||||||
- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, subscriptions,
|
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.
|
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).
|
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`.
|
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
|
- **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
|
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
|
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
|
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.
|
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
|
**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
|
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ public sealed class PillRadioTests
|
|||||||
// A session connects: the pill KEEPS its light and the face flips to Start.
|
// A session connects: the pill KEEPS its light and the face flips to Start.
|
||||||
vm.IsConnected = true;
|
vm.IsConnected = true;
|
||||||
Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in");
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<WindowInfo> _windows;
|
||||||
|
public FakeWindowEnumerator(params WindowInfo[] windows) => _windows = windows;
|
||||||
|
public IReadOnlyList<WindowInfo> 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<Source>().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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user