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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user