diff --git a/Distribution.md b/Distribution.md index 7bc0c22..3df9888 100644 --- a/Distribution.md +++ b/Distribution.md @@ -53,8 +53,8 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S | SDK zip (sign in to partner first) | `partner.steamgames.com/downloads/steamworks_sdk.zip` | | Docs home | `partner.steamgames.com/doc/home` | | SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` | -| DLC configuration | `partner.steamgames.com/doc/store/application/dlc` | -| `BIsDlcInstalled` | `partner.steamgames.com/doc/api/ISteamApps` | +| Demo configuration | `partner.steamgames.com/doc/store/application/demos` | +| Paid app ownership (`BIsSubscribedApp`) | `partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp` | | Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` | | Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` | | Wallet rule (in-app purchases) | `partner.steamgames.com/doc/features/microtransactions` | @@ -64,17 +64,17 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S 1. **Steamworks account** → onboarding (NDA, identity, company details, tax interview, payout bank, then the **$100 Steam Direct fee** per app). The store page cannot be configured before the fee. -2. **App IDs.** One **base app** (free) and one **DLC app** (the lifetime Pro). Note both numbers: - the DLC app ID is what the app passes to `BIsDlcInstalled`. -3. **Depot.** One depot holding the publish output; the base app and the DLC share the install - directory, and the DLC package grants no files of its own — it is entitlement only. +2. **App IDs.** One paid app and its associated free Demo. The Demo has its own App ID; it checks + ownership of the paid app with `BIsSubscribedApp(paidAppId)`. +3. **Builds.** Keep one codebase. Steam gives the Demo a separate release checklist/build setup; + shared depots can avoid duplicating common files once the paid app is released. 4. **Steamworks SDK.** `./scripts/steam-sdk.sh ~/Downloads/steamworks_sdk_165.zip` extracts `steam_api64.dll` from `sdk/redistributable_bin/win64/` into `steam/` (gitignored) and stages `steam_appid.txt` = 480 — Valve's Spacewar app, so `SteamAPI.Init()` works locally before LlamaCasty has an App ID. Add `--contentbuilder` on the machine that uploads a depot. The csproj copies the DLL **beside the exe** (`Link`) with `ExcludeFromSingleFile`, and copies `steam_appid.txt` in Debug only — never into a depot. The managed side is the `Steamworks.NET` - package (`SteamApps.BIsDlcInstalled`). + package (`SteamApps.BIsSubscribedApp`). 5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is the window where wishlists accumulate. @@ -89,31 +89,28 @@ publish → steam-stage → steamcmd + app_build.vdf → build upload → set li 2. **Set the build live** in Steamworks → *Builds* → the new build → **Set Build Live** on the default branch. That *is* the release; there is no artifact to hash and no manifest to submit. 3. **Announce** with Steam Events (release notes appear in the library and on the store page). -4. **Verify**: install from a clean Steam account, confirm `BIsDlcInstalled` returns false, buy the - DLC (or use a test licence), confirm the high tiers unlock, then refund the test purchase. +4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false, + then test with an account that owns the paid app and confirm the higher tiers unlock. **Updates** are the same upload with a higher build ID. The app keeps **no self-updater and no version banner** — Steam is the update path, and that policy has not changed. ## 4. Entitlement wiring -- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today - (`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in - beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds - the Debug-only `DeveloperMode`; anything unresolved is the free profile. -- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it - answers false without touching the native library. Set the id when Steamworks creates the DLC. +- `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app + ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag. +- The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without + touching the native library. - **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned. -- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use - the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout, - no price and no payment field. Confirm the wording with Steamworks support before shipping it. +- The EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve + that this satisfies the Steam Subscriber Agreement's software terms. - **Never gate a feature.** The only delta is the output profile. ## 5. Release checklist - Build clean, 0 warnings (`./scripts/verify.sh`), tests green. - `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile. -- DLC ownership false → free profile; true → every tier offered. +- Paid-app ownership false → free profile; true → higher output tiers offered. - `LlamaCasty.exe` rename landed (never rename a live exe). - Clean Steam install on a second machine, then an update pushed and observed. - Store page public for the full two weeks; support contact and announcements configured. @@ -123,15 +120,15 @@ version banner** — Steam is the update path, and that policy has not changed. - **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and disclaims warranty. -- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube today, **Polar** the - day licence validation ships. Steam itself is named by Valve's own policy, not ours. +- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is + named by Valve's own policy, not ours. - **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays `lgpl-shared`; no GPL component may be linked (see `product.md` → "Licensing"). - **DMCA**: a takedown needs only the notice — host, link, good-faith statement, signature. Statutory damages need a registration ($65). Worth doing once there is something to pirate. -- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a free tier - good enough that a crack buys nothing, Steam's own delivery, short Polar leases, and a licence ID - in About. **Not obfuscation, not a native DLL, not hardware locking.** +- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful + free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a + native DLL, not hardware locking.** ## 7. Effort, once @@ -141,4 +138,4 @@ version banner** — Steam is the update path, and that policy has not changed. | Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day | | Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release | | Store page + assets (screenshots, capsule art) | 1–2 days | -| Clean-account install + DLC purchase/refund test | 0.5 day | +| Clean-account Demo install + paid-app ownership test | 0.5 day | diff --git a/HANDOFF.md b/HANDOFF.md index 708e822..f298182 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,40 +1,32 @@ -# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced) +# HANDOFF — 2026-11-02 (Steam ownership check) ## State -- Branch `main`, level with `origin` through `8f79b20`; this unit is local. -- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs - said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`. -- **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the - licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB. -- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps, - fail closed) resolved once in `BuildEncoderOptions` — one ffmpeg process, two outputs. - `MainViewModel.DeveloperMode` bypasses it in Debug only. -- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the - **$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. Entitlement is - `steamDlcOwned || polarKeyValid`; the app must start without Steam, falling back to free. -- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex. +- `main`; local work unit, not pushed. +- Distribution plan: one paid Steam app ($79 announced launch price, then $99) plus its associated + free Demo. Same code; no subscription, DLC purchase, or GitHub binary. +- `IEntitlement.IsAppOwned` feeds the app-wide `MainViewModel.IsAppOwned` flag. Debug uses a fake + owned result; the fake is excluded from Release. +- Release ownership uses `BIsSubscribedApp(paidAppId)` and fails closed when the ID or Steam is + unavailable. **The paid app ID is not configured yet; the Steamworks application is pending.** +- Steam's private-use clause is not, by itself, a proven blocker. EULA wording should expressly + allow commercial/monetized streaming; OBS is the relevant Steam software precedent. See + `MyMistakes.md` → Steam distribution. ## Next action -**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time: -`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`, -with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed -early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in -About. Days 2–6 in `TASKS.md` → "The surge". +Day 1: grey out higher tiers for non-owners and link to the paid Steam page; then the system-message +strip. The queued Debug-only test-access rule also needs checking. Keep one integration test per +change. ## Landmines -- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles - libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`. -- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release - build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`). -- **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays - `false` and entitlement must never enable it. -- **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app. -- **The org access token never ships.** `organization_id` is public; the token is not. -- **The site publishes the model, never the numbers**, and never takes payment or links a checkout. -- **No self-updater and no version banner**: Steam updates the app. -- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests` - carries 3 xUnit warnings the app-only gate does not see. -- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA, not tokens. +- The Demo has its own App ID. Ownership must check the paid app ID, never the Demo ID or test ID + `480`. +- No fake ownership code or developer panel in Release. +- No Steam checkout in the app; Steam handles purchase and updates. +- The paid app ID is not available yet; Release stays fail-closed until it is configured. +- `verify.sh` clean app build passes; `EntitlementTests` passes 5/5. Full suite currently has 13–14 + UI/content failures; connection to this change is unconfirmed, investigate separately. +- Bundled FFmpeg must stay outside the single-file exe (`ExcludeFromSingleFile="true"`). +- Build with Windows `dotnet.exe`; run tests per class (the whole suite can hang in WASAPI teardown). diff --git a/MyMistakes.md b/MyMistakes.md index 6aff0fe..ee12959 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -209,11 +209,11 @@ After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total. `scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or `THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in. -### STEAM IS THE DISTRIBUTOR — AND WHAT IT DOES NOT ALLOW (2026-10-31) +### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02) -**Why recorded:** the distribution answer changed three times in one day (winget → zip+GitHub → -Steam), and each pivot invalidated a doc written hours earlier. These are the facts that won't -change. +**Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker +for monetized streaming. Check the exception and the established Steam software precedent before +raising it again. - **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this @@ -221,16 +221,25 @@ change. - **Steam updates the app** — no self-updater, no version banner, no update endpoint. - **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam - customers can only make purchases from the Steam Wallet."* So a subscription sold through Polar - inside a Steam build is a policy violation. **Lifetime = plain DLC bought on the store page** - (the app only calls `BIsDlcInstalled`); the monthly is *stated*, never *sold*, in-app. -- **Steam can technically do recurring billing** (`ISteamMicroTxn` + `ProcessAgreement`) but its own - doc opens with *"Recurring Subscriptions is not a fully supported feature"* and requires a server - of yours to poll renewals — more infrastructure than Polar, for a 30% cut. + customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout. +- **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam + Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly + allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is + distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is + the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly + allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call + this a Steam blocker based only on §5. + Sources: https://store.steampowered.com/subscriber_agreement/ · + https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html +- **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp` + checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed. + Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp +- **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub + binary. The demo links to the paid store page and can send a one-time wishlist notification. - **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the `LlamaCasty.exe` rename **before** the first depot upload, not after. -- **The Steam store page must be public two weeks before release**, and the $100 Steam Direct fee - must be paid before the page can be configured at all. +- **The paid app's Steam store page must be public two weeks before release.** The Steamworks + application and $100 fee are already submitted; Valve's decision is pending. ### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51) diff --git a/Services/Entitlement/FakeEntitlement.cs b/Services/Entitlement/FakeEntitlement.cs index a090edd..3dafdc8 100644 --- a/Services/Entitlement/FakeEntitlement.cs +++ b/Services/Entitlement/FakeEntitlement.cs @@ -1,13 +1,11 @@ namespace ytLive.Services.Entitlement; /// -/// Test double for the entitlement rail. Production never uses it — a fake that could be reached -/// in a shipped build is a licence bypass, so it lives here only because tests need to drive both -/// answers without a Steam client or a network. +/// Development/test ownership result. Release builds exclude this type. /// public sealed class FakeEntitlement : IEntitlement { - public FakeEntitlement(bool isPro = false) => IsPro = isPro; + public FakeEntitlement(bool isAppOwned = false) => IsAppOwned = isAppOwned; - public bool IsPro { get; set; } + public bool IsAppOwned { get; set; } } diff --git a/Services/Entitlement/IEntitlement.cs b/Services/Entitlement/IEntitlement.cs index 9c7065c..968f1f1 100644 --- a/Services/Entitlement/IEntitlement.cs +++ b/Services/Entitlement/IEntitlement.cs @@ -1,19 +1,12 @@ namespace ytLive.Services.Entitlement; /// -/// Where "is this copy licensed for Pro output?" comes from. +/// Whether the current Steam account owns the paid app. /// -/// One question, two sources. The lifetime SKU is a Steam DLC; the monthly is a Polar -/// lease. Both answer this interface, and everything downstream — the output profile, the greyed -/// tier list, the notices — reads only this. Nothing outside Services/Entitlement may know -/// which rail proved it. -/// -/// Fail closed. An implementation that cannot reach its source, cannot load its -/// native library, or has no product id configured returns false. Pro is never a default, -/// never a cached "probably", and never the result of an exception being swallowed as true. +/// Steam is the source of truth. Failure to reach Steam, load its native library, or +/// resolve the paid app ID returns false. /// public interface IEntitlement { - /// True when this session may emit above the free output budget. - bool IsPro { get; } + bool IsAppOwned { get; } } diff --git a/Services/Entitlement/SteamEntitlement.cs b/Services/Entitlement/SteamEntitlement.cs index 45dfc58..25b1670 100644 --- a/Services/Entitlement/SteamEntitlement.cs +++ b/Services/Entitlement/SteamEntitlement.cs @@ -3,41 +3,50 @@ using Steamworks; namespace ytLive.Services.Entitlement; /// -/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client. -/// -/// This is the whole purchase story. There is no key, no server and no lease — the -/// customer buys the DLC on the store page and Steam already knows. The app asks once and caches -/// the answer; a refunded DLC stops reporting ownership the next time the app starts. -/// -/// ⛔ Never sell the monthly plan through this. Steamworks requires in-app purchases -/// to use the Steam Wallet, so anything sold from inside the app would be a policy violation. The -/// monthly lives on the website and is billed by Polar ( when it -/// lands); this class only ever reads what Valve already sold. -/// -/// Steam absent is normal, not exceptional. A copied folder, a shortcut run without -/// the client, or a CI machine has no Steam — returns false and the -/// app runs as the free tier. It must never refuse to start over an entitlement lookup. +/// Reads ownership of the paid app from Steam. In the free Demo, this checks the associated paid +/// app ID; no key, server, or LlamaCasty account is involved. A missing ID, Steam client, or native +/// library fails closed so the app can continue with the free output profile. /// public sealed class SteamEntitlement : IEntitlement { - /// The DLC's own app id, not the base app's. Zero = not configured yet, which reads - /// as "no entitlement" rather than "try anyway". - private readonly uint _dlcAppId; + private readonly uint _paidAppId; + private readonly Func _initializeSteam; + private readonly Func _isAppSubscribed; + private readonly Action _shutdownSteam; private bool _initAttempted; private bool _available; private bool? _cached; - public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId; + public SteamEntitlement(uint paidAppId) + : this( + paidAppId, + SteamAPI.Init, + appId => SteamApps.BIsSubscribedApp(new AppId_t(appId)), + SteamAPI.Shutdown) + { + } - /// Configured with no product id — the state before the Steamworks app exists. + internal SteamEntitlement( + uint paidAppId, + Func initializeSteam, + Func isAppSubscribed, + Action shutdownSteam) + { + _paidAppId = paidAppId; + _initializeSteam = initializeSteam; + _isAppSubscribed = isAppSubscribed; + _shutdownSteam = shutdownSteam; + } + + /// Configured with no paid app ID; ownership fails closed without touching Steam. public static SteamEntitlement NotConfigured() => new(0); - public bool IsPro + public bool IsAppOwned { get { - if (_dlcAppId == 0) return false; + if (_paidAppId == 0) return false; if (_cached is { } known) return known; if (!_initAttempted) @@ -45,12 +54,10 @@ public sealed class SteamEntitlement : IEntitlement _initAttempted = true; try { - // No steam_appid.txt and no client → false. That is the documented free path. - _available = SteamAPI.Init(); + _available = _initializeSteam(); } catch (Exception) { - // A missing steam_api64.dll throws on first touch. Free tier, never a crash. _available = false; } } @@ -59,7 +66,7 @@ public sealed class SteamEntitlement : IEntitlement try { - var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId)); + var owned = _isAppSubscribed(_paidAppId); _cached = owned; return owned; } @@ -70,11 +77,9 @@ public sealed class SteamEntitlement : IEntitlement } } - /// Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread - /// would be wrong, so nothing here schedules it automatically. public void Shutdown() { - if (_available) SteamAPI.Shutdown(); + if (_available) _shutdownSteam(); _available = false; _cached = null; _initAttempted = false; diff --git a/TASKS.md b/TASKS.md index 320ecc8..a4446b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -17,7 +17,7 @@ corrected. No new scope mid-week; anything discovered goes on a list for day 6. | Day | Units | Done when | |---|---|---| -| **1 · Mon** — licensing | `IEntitlement` seam + Steam DLC impl + fake · `HasProOutput` on the seam · greyed tier entries · licence UI four states + 7/1/0-day notices + "monthly is on the website, not Steam" · licence ID in About · system messages (`SystemMessage`/`MessageCenter` strip) · Debug-only `InternalsVisibleTo` | Fake drives all four states; Steam-less launch is the free profile; About shows the licence ID | +| **1 · Mon** — licensing | Paid-app ownership check (`BIsSubscribedApp`) + Debug fake + app-wide `IsAppOwned` · greyed tier entries · simple demo-to-paid status and Steam store link · system messages (`SystemMessage`/`MessageCenter` strip) · Debug-only `InternalsVisibleTo` | Fake covers owned/not-owned; Release fails closed without Steam or the paid app ID; ownership sets the app-wide flag | | **2 · Tue** — release blockers | Visibility picker + `UpdateBroadcast` · `StreamConfig` defaults · TASK 53 output contract (geometry test per tier, 9:16 e2e) · 9·5 webcam identity key · Move transitions (per-layer carry-across-scenes) | A public broadcast starts and changes while live; every tier pinned by a test | | **3 · Wed** — resilience | TASK 32: blip retry → measured grace → amber RECONNECTING → one-click Back on air → fragmented MP4 → ingest pre-flight · 33 bandwidth step-down | Killing the network mid-broadcast recovers without ending it | | **4 · Thu** — content & media | 3·17 Text layer · 21 media acquisition wiring + file picker + loop/volume UI · 49 chat profanity filter · 34 scheduled streams | Text layer renders/persists; media file plays, loops, has volume; scheduled stream created | @@ -57,13 +57,13 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`. | 49 | ☐ | Chat profanity filter: local, opt-in, **non-persistent**, user-supplied word list | [`task-49`](TASKS/task-49-chat-profanity-filter.md) | | 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) | | 53 | ☐ | Output-format contract: pin every tier with a geometry test, prove 9:16 end-to-end, recorded file matches output | [`task-53`](TASKS/task-53-output-formats.md) | -| 54 | 🔶 | Freemium: EULA + privacy shipped; **no licence path built** — needs the entitlement seam, the output-profile cap and the licence UI below; site copy still says free forever | [`task-54`](TASKS/task-54-marcom-free-merch.md) | -| — | 🔶 | **Steamworks entitlement** — `IEntitlement` + `SteamEntitlement` (Steamworks.NET) + fake **done**; needs the DLC app id from Steamworks. **Polar path retired** — one-time Steam DLC only | [`commercial`](docs/ai/commercial.md) | +| 54 | 🔶 | Freemium: EULA + privacy shipped; free Steam Demo and paid app use one codebase; ownership path and demo conversion UI remain in the rows below | [`task-54`](TASKS/task-54-marcom-free-merch.md) | +| — | 🔶 | **Steam ownership** — `BIsSubscribedApp(paidAppId)` + Debug fake + app-wide `IsAppOwned`; paid app ID awaits Steamworks | [`commercial`](docs/ai/commercial.md) | | — | ❌ | **Polar subscription path** — retired 2026-11-01 with the one-price model. Design kept in `decisions.md`, not built | [`decisions`](docs/ai/decisions.md) | -| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries in the dropdown, entitlement wiring | [`commercial`](docs/ai/commercial.md) | -| — | ☐ | **Licence UI** — two states only: owned → Pro · not owned → greyed tiers + "unlock on Steam" (opens the store overlay). No key entry, no notices, no portal | [`commercial`](docs/ai/commercial.md) | +| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries driven by `IsAppOwned` | [`commercial`](docs/ai/commercial.md) | +| — | ☐ | **Demo conversion UI** — owned users get higher tiers; demo users see greyed tiers and a Steam store link. No key entry, subscription notices, or portal | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **System messages** — `SystemMessage` + `MessageCenter`, strip above the dock, dedupe by key, short history, never composited | [`ui-surface`](docs/ai/ui-surface.md) | -| — | ☐ | **Steam packaging** — rename, `publish` output + `steam_api64.dll`, depot, `steamcmd` upload, store page + assets, DLC app | [`Distribution.md`](Distribution.md) | +| — | ☐ | **Steam packaging** — `publish` output + `steam_api64.dll`, paid app and associated Demo setup, depot, `steamcmd` upload, store pages + assets | [`Distribution.md`](Distribution.md) | | — | ☐ | **Training-video series** — capture the first 8–10 recurring questions as videos on `@llamacasty`; each is documentation, channel content and monetization at once | MARCOM → support→content loop | | 9·5 | ☐ | Webcam identity key reconciliation | [`task-09`](TASKS/task-09-live-stream-management.md) | | 14 | 🔶 | Creator feedback batch — branch 3+ | [`task-14`](TASKS/task-14-creator-feedback-batch.md) | diff --git a/TASKS/task-48-distribution-msix.md b/TASKS/task-48-distribution-msix.md index b27d03e..e0b9dc7 100644 --- a/TASKS/task-48-distribution-msix.md +++ b/TASKS/task-48-distribution-msix.md @@ -1,8 +1,8 @@ # TASK 48 — Distribution & packaging: route decision, MSIX, signing -> ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: a -> free app on Steam with the paid edition as a Steam DLC; the monthly plan is sold off-Steam. There -> is no direct download, no winget and no Store submission. Live policy: +> ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: one +> paid app and its associated free Demo, using the same code. There is no subscription, GitHub +> binary, direct download, winget or Store submission. Live policy: > [`docs/ai/commercial.md`](../docs/ai/commercial.md); mechanics: [`Distribution.md`](../Distribution.md). > The MSIX build steps below still work and are simply unused. diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs index cf08765..ed521dc 100644 --- a/ViewModels/MainViewModel.OutputProfile.cs +++ b/ViewModels/MainViewModel.OutputProfile.cs @@ -4,44 +4,45 @@ using ytLive.Services.Entitlement; namespace ytLive.ViewModels; /// -/// The entitlement surface: whether this session may emit output above the free budget. +/// The app-wide Steam ownership flag and its output-profile consumer. /// -/// Deliberately a separate partial from License.cs: that file owns *how* a -/// licence is validated (Polar today), this one owns *what the answer means* for output. -/// Steam's DLC check and Polar's lease both collapse into ; nothing -/// downstream of cares which one proved it. +/// Only the entitlement service decides whether the current Steam account owns the paid app. +/// Development builds use a fake owned result; Release builds use Steam. /// public partial class MainViewModel { - /// - /// The single input to . - /// - /// Fail closed: an unresolved or unreadable licence is simply false, so a - /// build with no entitlement emits the free profile. There is no "unknown" branch to get - /// wrong. - /// - /// Developer mode never ships: is only ever - /// settable inside a development build (see ), so this expression - /// reduces to IsPremium in a Release binary. - /// - public bool HasProOutput => IsPremium || DeveloperMode || _entitlement.IsPro; + private IEntitlement _entitlement = CreateEntitlement(); + private bool _isAppOwned; - /// - /// The entitlement rail for this session. Lifetime = Steam DLC; until the DLC app id exists it - /// is , which reads false (fail closed). Tests - /// inject a fake through . - /// - private IEntitlement _entitlement = SteamEntitlement.NotConfigured(); + public bool IsAppOwned + { + get => _isAppOwned; + private set + { + if (!SetProperty(ref _isAppOwned, value)) return; + OnPropertyChanged(nameof(HasProOutput)); + } + } - /// Test seam: swap the rail without a Steam client. Internal — the test assembly is - /// the only caller, and a public setter here would be a licence bypass surface. + public bool HasProOutput => DeveloperMode || IsAppOwned; + + private static IEntitlement CreateEntitlement() + { +#if DEBUG + return new FakeEntitlement(true); +#else + return SteamEntitlement.NotConfigured(); +#endif + } + + /// Test seam: swap the ownership source without a Steam client. internal IEntitlement EntitlementOverride { get => _entitlement; set { _entitlement = value; - OnPropertyChanged(nameof(HasProOutput)); + IsAppOwned = value.IsAppOwned; } } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 76fbefc..0fa047a 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -192,6 +192,7 @@ public partial class MainViewModel : ViewModelBase public MainViewModel() { AppLog.Write("MainViewModel ctor begin"); + IsAppOwned = _entitlement.IsAppOwned; _youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret, sessionChanged: ch => TokenStore.Save(ch)); _youtubeStream = new YouTubeStreamService(_youtubeAuth); diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md index bf01b3e..22884b7 100644 --- a/docs/ai/commercial.md +++ b/docs/ai/commercial.md @@ -10,9 +10,9 @@ storefront, support or promo work. Cause of death for every retired path: - **Free tier — every feature, every scene, every overlay**, free forever, no account, no watermark, nothing nagged inside the output. It is not a trial. -- **Pro is a one-time Steam purchase: a DLC on the free app.** - **$79 introductory → $99** on a stated date before launch. No subscription, no lifetime SKU, no - third-party checkout. +- **Pro is the paid Steam app; the free Demo is associated with it.** The paid app is **$79 during + the announced launch period, then $99** on the announced date. No subscription or third-party + checkout. - **The only delta is output quality.** Features are never gated; a payment buys pixels. - **No watermark, ever**, in either edition. The branding flash is retired as a paid delta and stays off (`ShowBrandFlash`, default `false`). @@ -49,25 +49,28 @@ The cap is a pixel budget plus a frame rate, not the word "720p": - **The greyed menu entry is the sales surface.** It stays clickable, says what it costs, opens checkout, and is labelled ("720p30 · free tier") — never silently missing, because silence reads as broken. -- **Activation is live**, not restart-gated: activating enables the high tiers and a lapse - (cancel, revoke, sign-out, channel switch) greys them again on the next refresh. +- **Ownership is checked at app startup** and cached in `IsAppOwned` for that run. Purchases and + refunds are reflected on the next app start; there is no subscription lapse or sign-out path. - **Bitrate follows the profile** — a capped user is not offered a 1080p bitrate. ## Entitlement — one source -**Pro is `steamDlcOwned`, and nothing else.** It resolves to the `OutputProfile`; it never touches a -feature. Fail closed: anything unresolved is the free tier. +**Paid-app ownership is the one entitlement.** Steam sells one paid app; its associated free demo is +the purchase funnel. Both use the same code. The demo checks whether the current Steam account owns +the paid app, then exposes the higher output tiers. The app-wide `IsAppOwned` value is the binary +ownership result used by later feature checks. -- **Steam owns the SKU.** `BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`, behind - `IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state — no - server, no key, no lease, no account, no email. -- **Fail closed, four ways:** no DLC app id configured, `SteamAPI.Init()` false (no client), a +- **Steam owns the purchase.** The demo calls `BIsSubscribedApp(paidAppId)` after `SteamAPI.Init`, + behind `IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state — + no server, key, lease, or LlamaCasty account. +- **Fail closed:** no paid app ID configured, `SteamAPI.Init()` false (no client), a thrown native load (missing `steam_api64.dll`), and "not owned" all resolve to the free tier. None of them may crash the app or grant Pro. - **Steam down or not running** → free profile, with a line explaining why, never a refusal to start. A Steam build launched outside Steam is the common case for people who pin shortcuts. -- **No licence key exists**, so nothing to paste, email, lose or reclaim. A refunded DLC simply - stops reporting ownership on the next start. +- **Development builds fake ownership as true.** `FakeEntitlement` is excluded from Release builds. +- **No licence key exists**, so nothing to paste, email, lose or reclaim. A refunded purchase stops + reporting ownership on the next Steam check. - **The upgrade path is the Steam store overlay** (`ActivateGameOverlayToStore`) from the greyed tier menu — Valve's own surface, so the purchase stays inside the Steam Wallet rules. Never a checkout of ours, never an external payment page. @@ -80,20 +83,20 @@ re-derived. ## Channels — Steam only -**Steam is the distributor.** One free app on the store, the paid edition as a Steam **DLC**, and -no other vector: no direct download, no winget, no Scoop/Chocolatey, no Microsoft Store. The store -page is the single authoritative source for the download, the stats and the reviews. +**Steam is the distributor.** One paid app and its associated free Demo; no direct download, +GitHub binary, winget, Scoop/Chocolatey, or Microsoft Store. The store page is the single +authoritative source for the download, stats, and reviews. -- **Pro = Steam DLC, one-time.** Bought on the **store page**; the app only reads ownership - (`ISteamApps::BIsDlcInstalled`). No purchase flow, no Wallet code, nothing to reconcile. +- **The paid app is one-time purchase.** The demo's App ID is separate, but it shares the code and + checks ownership of the paid app (`ISteamApps::BIsSubscribedApp`). No in-app checkout or license + server. - **The website sends people to Steam.** It publishes the model and links the store page; it takes no payment, collects no email, and hosts no binaries. One shop, one price, one receipt. - **No in-app checkout of ours, ever.** Steamworks requires in-app purchases to use the Steam Wallet, so the only purchase surface is Steam's own store overlay. -- **Boot shape:** the app ships `steam_api64.dll` beside the exe and calls `SteamAPI.Init`. That - is a functional dependency, not the rejected "native helper DLL for IP protection" — Steam - ownership cannot be checked without it. Launched without Steam running, the app must fall back to - the **free profile** and say so, never refuse to start. +- **Boot shape:** the app ships `steam_api64.dll` beside the exe and calls `SteamAPI.Init`. Launched + without Steam running, the app falls back to the **free profile** and says so; it never refuses to + start. - **Updates are Steam's.** The depot push is the update; the app keeps no updater and no version banner (the old "no self-updater" rule stays, for the same reason: it works differently). - **Portable mode is no longer a distributable.** `portable.txt` remains as a feature for anyone who @@ -135,8 +138,8 @@ repo for bugs that survive the friction of needing an account. - **The issue tracker is a filter, deliberately.** Bugs go to the public releases repo on GitHub, which requires an account; Steam Discussions stay pinned and unread; Announcements carry known issues and release notes. Steam refunds are Valve's own (2 h / 14 days) and cost us nothing. -- **Nobody is locked out over a licence.** A billing hiccup drops the app to the free profile with - a banner and a portal link — never a blocked stream, never a modal. +- **Nobody is locked out of streaming.** If Steam ownership is unavailable or not owned, the app + remains usable at the free output profile; no modal or account portal. ## The introductory price — the only promotion @@ -176,15 +179,13 @@ pointless and a leak that is traceable — not an unbreakable binary. - **The free tier is the real defence.** If the only delta is pixels above 921,600, a cracked build buys almost nothing and forfeits updates, support and the publisher's signature. -- **Steam DRM is optional and not needed.** The DLC check is already the entitlement; adding +- **Steam DRM is optional and not needed.** The app ownership check is the entitlement; adding Valve's DRM wrapper buys nothing a determined crack respects, and it can break an exe on a machine that never runs Steam. - **The distribution is not a handout.** Steam deliveries go through Valve's client and account, so there is no public zip for a mirror site to repackage, and no hash pin for us to maintain. -- **Steam is the licence state** — a refunded DLC stops reporting ownership the next time Steam is - asked, and the app drops to the free profile on the next start. Nothing to revoke by hand. -- **The Steam purchase is in About** — a leaked build traces back to a Steam account, which Valve - can action; we hold nothing to trace. +- **Steam is the ownership state** — a refunded purchase stops reporting ownership on the next + check. The app holds no customer account or license key. ⛔ **Rejected, do not re-propose:** a native helper DLL for "IP protection", obfuscation, hardware locking, remote code that downloads and executes, and gating asset downloads as an auth check. diff --git a/docs/ai/decisions.md b/docs/ai/decisions.md index cf19947..acc85df 100644 --- a/docs/ai/decisions.md +++ b/docs/ai/decisions.md @@ -5,10 +5,11 @@ you are about to re-open one of them. **Live commercial rules (prices, entitleme signing, support, promo) are in [`commercial.md`](commercial.md)**; the other live rules live in the subsystem files. This is the graveyard with cause of death. -## The business model — one price, settled 2026-11-01 +## The business model — one paid app plus a free Demo, settled 2026-11-02 -**$79 one-time, raising to $99 on a stated date. Sold only on Steam, as a DLC on the free app.** -The website publishes the model and links the store page; it never takes payment. +**$79 one-time during the announced launch period, then $99 on the announced date.** Sold only on +Steam as one paid app, with an associated free Demo using the same code. Demo ownership checks the +paid app's Steam App ID. The website links the store page; it never takes payment or hosts binaries. **What died the same day:** @@ -49,10 +50,10 @@ overlay. remote is Gitea). The route is a separate **`llamacasty-releases`** Gitea repo push-mirrored to a public GitHub repo: issues and assets public, code private, one publish direction. -## Distribution — Steam exclusive (2026-10-31, final) +## Distribution — Steam exclusive (2026-10-31; app structure updated 2026-11-02) -**Steam is the only vector.** A free app on Steam with the paid edition as a Steam **DLC**; the -monthly plan is sold outside Steam and never purchasable in a Steam build. Live rules: +**Steam is the only vector.** One paid app and its associated free Demo; same code, no GitHub +download, subscription, or second paid SKU. Live rules: [`commercial.md`](commercial.md). **Why the other vectors died, all on the same day:** @@ -68,10 +69,8 @@ monthly plan is sold outside Steam and never purchasable in a Steam build. Live - **The public `llamacasty-releases` repo** survives only as the **issue tracker**; it holds no binaries, no manifest and no version file. -**The constraint that shapes the app:** Steamworks requires in-app purchases to use the **Steam -Wallet**. Therefore the monthly is *stated*, never *sold*, inside the Steam build — no checkout, no -price, no payment fields. The lifetime is plain DLC bought on the store page, so it needs no -transaction code at all. +**The purchase happens on the paid app's Steam store page.** The Demo links there; Steam owns +checkout, delivery, updates, and the paid-app ownership result. The app contains no checkout flow. ## Support — decided 2026-10-31 diff --git a/docs/ai/index.md b/docs/ai/index.md index e769bc1..6ac15c1 100644 --- a/docs/ai/index.md +++ b/docs/ai/index.md @@ -14,7 +14,7 @@ Read the file whose row matches the task. There is no required reading order. | [`output.md`](output.md) | The bundled `ffmpeg.exe` and the encoder subprocess (args, probe order, seams), local recording (REC/ON-AIR, filenames, private-broadcast close-out), and the WASAPI capture → filter → mix → named-pipe audio chain (TRAX, sync offset, ducking, limiter) | Touching ffmpeg args/bundling, encoder or recording behaviour, REC/ON-AIR, the audio mix and its meters, or loudness/sync problems | | [`environment.md`](environment.md) | LAN hosts/IPs/SSH and the Gitea push recipe, the Windows `dotnet.exe` build + run commands, the test suite map, and the hermetic real-`MainWindow` test rule | Before building, running, adding a test, or reaching any box on the LAN | | [`product.md`](product.md) | Brand/naming split, AI transparency, the licensing prohibitions, the freemium posture, auth/account assumptions, and packaging behaviour | Branding, naming, packaging, or a legal/third-party obligation | -| [`commercial.md`](commercial.md) | The live commercial policy: model and prices, the output-profile cap (stream **and** recording), entitlement (`steamDlcOwned \|\| polarKeyValid`; Steam DLC for lifetime, Polar for the monthly, no server), **Steam as the only channel**, signing (moot), the Founding 200 promo, costs, **support policy**, anti-piracy posture | Any question about money or support: pricing, licence keys, what free gets, how it ships, what a sale costs | +| [`commercial.md`](commercial.md) | The live commercial policy: model and prices, the output-profile cap (stream **and** recording), paid-app ownership (`BIsSubscribedApp`), **Steam as the only channel**, signing (moot), costs, **support policy**, anti-piracy posture | Any question about money or support: pricing, what free gets, how it ships, what a sale costs | | [`youtube-api.md`](youtube-api.md) | Go-live ordering + the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, and chat-insert shape | Touching go-live, broadcast lifecycle, health polling, streaming close-out, or the chat service | | [`overlays.md`](overlays.md) | The social bar, the native alert box (six animations, alert clip, ticker) and the chat box conventions — everything composited over the frame | Adding or changing anything drawn on top of the output | | [`decisions.md`](decisions.md) | Retired paths with cause of death: the paid-tier history and pricing, the free-forever era, Store IAP, the Store ordering, rejected monetization and anti-piracy ideas, and the permanent out-of-product list | You are about to re-open a decision that was already settled | diff --git a/docs/ai/product.md b/docs/ai/product.md index 5facfab..a94d042 100644 --- a/docs/ai/product.md +++ b/docs/ai/product.md @@ -199,9 +199,9 @@ Live channel policy, the artifact and costs: [`commercial.md`](commercial.md). B mechanics: [`Distribution.md`](../../Distribution.md). Retired routes and cause of death: [`decisions.md`](decisions.md). -**One vector: Steam.** A free app on Steam with the paid edition as a Steam **DLC**; the monthly -plan is sold outside Steam and is never purchasable inside a Steam build (Steamworks requires the -Steam Wallet for in-app purchases). No direct download, no winget, no Microsoft Store. +**One vector: Steam.** One paid app plus its associated free Demo. They use the same code; the Demo +checks ownership of the paid app to unlock higher output quality. No direct download, no GitHub +binary, no winget, no Microsoft Store, and no subscription. **Consequences that are architecture, not packaging detail:** @@ -209,7 +209,7 @@ Steam Wallet for in-app purchases). No direct download, no winget, no Microsoft app keeps nothing that downloads or executes, for the same Store-policy reason it always had. `ytLive.Tests/StorePackagingTests.cs` must keep asserting that no self-updater returns. - **⛔ `steam_api64.dll` is a functional dependency**, not the rejected "native helper DLL for IP - protection" — Steam DLC ownership cannot be read without it. The app must start without Steam + protection" — Steam app ownership cannot be read without it. The app must start without Steam running and fall back to the free profile. - **The MSIX package stays buildable but is now unused** — `scripts/package-msix.sh` (TASK 48 slice 2) still produces `artifacts/LlamaCasty.msix` from `Packaging/Package.appxmanifest` + @@ -265,11 +265,10 @@ a non-Steam download exists: ### Distribution and licensing are independent — the channel never decides billing -The Steam build sells the lifetime as **Steam DLC** and reads it with `BIsDlcInstalled`, while the -monthly plan is billed by **Polar** outside Steam. Steamworks forbids taking payment inside the app -with anything but the Steam Wallet, so the app states where the monthly lives and never sells it. -The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a -distribution decision. +Steam distributes the paid app and its associated free demo. They use the same code; the demo asks +Steam whether the current account owns the paid app, and the answer controls access to higher output +quality. The paid app is a one-time purchase: $79 during the announced launch period, then $99 on +the announced date. The app's EULA must expressly allow commercial and monetized streaming use. ### ⛔ "Only when the user says so" is enforced by Windows, not by us @@ -311,4 +310,3 @@ nothing. COPPA is very unlikely to bind (not child-directed), but note the enfor (FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) — so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no persisted chat or reward payloads." - diff --git a/scripts/steam-sdk.sh b/scripts/steam-sdk.sh index dd5cdc2..da6addf 100644 --- a/scripts/steam-sdk.sh +++ b/scripts/steam-sdk.sh @@ -72,4 +72,4 @@ PY fi echo "" -echo "Next: the entitlement seam (IEntitlement + SteamApps.BIsDlcInstalled)." +echo "Next: configure the paid Steam App ID for IEntitlement (SteamApps.BIsSubscribedApp)." diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs index 1c3838f..e78e9cf 100644 --- a/ytLive.Tests/EntitlementTests.cs +++ b/ytLive.Tests/EntitlementTests.cs @@ -7,12 +7,10 @@ using ytLive.ViewModels; namespace ytLive.Tests; /// -/// The entitlement seam (2026-11-01): one question — "may this session emit Pro output?" — with a -/// Steam DLC rail, a Polar rail to come, and a fail-closed default. +/// Steam ownership is cached once into the app-wide binary flag. /// -/// The rule that must never break: **no configured rail means the free profile**. A missing -/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may -/// crash the app or quietly grant Pro. +/// The rule that must never break: a missing paid app ID, Steam client, or native library +/// fails closed. /// // Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a // shared collection xUnit runs them in parallel and the two view models fight over one temp DB. @@ -20,27 +18,44 @@ namespace ytLive.Tests; public class EntitlementTests { [Fact] - public void No_configured_dlc_id_reads_as_no_entitlement() + public void No_configured_paid_app_id_reads_as_not_owned() { - // The state before the Steamworks app exists: pure false, no native call attempted. - Assert.False(SteamEntitlement.NotConfigured().IsPro); + Assert.False(SteamEntitlement.NotConfigured().IsAppOwned); } [Fact] - public void A_steam_entitlement_without_a_client_is_the_free_tier_not_a_crash() + public void Steam_ownership_checks_the_paid_app_id() { - // No steam_appid.txt in the test host and no client running: Init fails, we report false. - var entitlement = new SteamEntitlement(1234567u); - Assert.False(entitlement.IsPro); + const uint paidAppId = 1234567u; + uint queriedAppId = 0; + var entitlement = new SteamEntitlement( + paidAppId, + initializeSteam: () => true, + isAppSubscribed: appId => + { + queriedAppId = appId; + return true; + }, + shutdownSteam: () => { }); + + Assert.True(entitlement.IsAppOwned); + Assert.Equal(paidAppId, queriedAppId); + } + + [Fact] + public void A_steam_entitlement_without_a_client_fails_closed() + { + var entitlement = new SteamEntitlement(1234567u, () => false, _ => true, () => { }); + Assert.False(entitlement.IsAppOwned); } [Fact] public void The_fake_reports_what_a_test_sets() { var fake = new FakeEntitlement(true); - Assert.True(fake.IsPro); - fake.IsPro = false; - Assert.False(fake.IsPro); + Assert.True(fake.IsAppOwned); + fake.IsAppOwned = false; + Assert.False(fake.IsAppOwned); } [Fact] @@ -52,14 +67,16 @@ public class EntitlementTests { var vm = new MainViewModel(); - // Default rail is NotConfigured → free output. - Assert.False(vm.HasProOutput); - - vm.EntitlementOverride = new FakeEntitlement(true); + Assert.True(vm.IsAppOwned); Assert.True(vm.HasProOutput); vm.EntitlementOverride = new FakeEntitlement(false); + Assert.False(vm.IsAppOwned); Assert.False(vm.HasProOutput); + + vm.EntitlementOverride = new FakeEntitlement(true); + Assert.True(vm.IsAppOwned); + Assert.True(vm.HasProOutput); } finally { diff --git a/ytLive.csproj b/ytLive.csproj index bf1ea99..9e7e9d5 100644 --- a/ytLive.csproj +++ b/ytLive.csproj @@ -95,6 +95,7 @@ reference to it and compiles cleanly. --> +