diff --git a/Distribution.md b/Distribution.md index 457d9f3..7bc0c22 100644 --- a/Distribution.md +++ b/Distribution.md @@ -97,9 +97,13 @@ version banner** — Steam is the update path, and that policy has not changed. ## 4. Entitlement wiring -- `IEntitlement` has two implementations: **Steam DLC** (lifetime, local) and **Polar** (monthly, - network + 7–14 day lease). Resolution is `Pro = steamDlc || polarValid`; anything unresolved is - the free profile. +- `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. +- **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. diff --git a/HANDOFF.md b/HANDOFF.md index 84c306c..708e822 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -17,11 +17,11 @@ ## Next action -**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Undo/redo, the rail gear and the -EDIT alignment landed early on 11-01, so day 6 has room. Day 1 unchanged: licensing — -`IEntitlement` + Steam DLC + fake, `HasProOutput` on the seam, greyed tiers, licence UI's four -states, system messages, licence ID in About. Days 2–6 in `TASKS.md` → "The surge". -No scope beyond that list. No polish before day 6. +**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". ## Landmines diff --git a/Services/Entitlement/FakeEntitlement.cs b/Services/Entitlement/FakeEntitlement.cs new file mode 100644 index 0000000..a090edd --- /dev/null +++ b/Services/Entitlement/FakeEntitlement.cs @@ -0,0 +1,13 @@ +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. +/// +public sealed class FakeEntitlement : IEntitlement +{ + public FakeEntitlement(bool isPro = false) => IsPro = isPro; + + public bool IsPro { get; set; } +} diff --git a/Services/Entitlement/IEntitlement.cs b/Services/Entitlement/IEntitlement.cs new file mode 100644 index 0000000..9c7065c --- /dev/null +++ b/Services/Entitlement/IEntitlement.cs @@ -0,0 +1,19 @@ +namespace ytLive.Services.Entitlement; + +/// +/// Where "is this copy licensed for Pro output?" comes from. +/// +/// 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. +/// +public interface IEntitlement +{ + /// True when this session may emit above the free output budget. + bool IsPro { get; } +} diff --git a/Services/Entitlement/SteamEntitlement.cs b/Services/Entitlement/SteamEntitlement.cs new file mode 100644 index 0000000..45dfc58 --- /dev/null +++ b/Services/Entitlement/SteamEntitlement.cs @@ -0,0 +1,82 @@ +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. +/// +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 bool _initAttempted; + private bool _available; + private bool? _cached; + + public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId; + + /// Configured with no product id — the state before the Steamworks app exists. + public static SteamEntitlement NotConfigured() => new(0); + + public bool IsPro + { + get + { + if (_dlcAppId == 0) return false; + if (_cached is { } known) return known; + + if (!_initAttempted) + { + _initAttempted = true; + try + { + // No steam_appid.txt and no client → false. That is the documented free path. + _available = SteamAPI.Init(); + } + catch (Exception) + { + // A missing steam_api64.dll throws on first touch. Free tier, never a crash. + _available = false; + } + } + + if (!_available) { _cached = false; return false; } + + try + { + var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId)); + _cached = owned; + return owned; + } + catch (Exception) + { + return false; + } + } + } + + /// 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(); + _available = false; + _cached = null; + _initAttempted = false; + } +} diff --git a/TASKS.md b/TASKS.md index 17e283c..914b120 100644 --- a/TASKS.md +++ b/TASKS.md @@ -58,7 +58,7 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`. | 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** — `steam_api64.dll` + `SteamAPI.Init`, `BIsDlcInstalled(dlcAppId)` behind `IEntitlement`, free profile when Steam is absent | [`commercial`](docs/ai/commercial.md) | +| — | 🔶 | **Steamworks entitlement** — `IEntitlement` + `SteamEntitlement` (Steamworks.NET) + fake **done**; needs the DLC app id from Steamworks, then the ownership path is live | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **Entitlement seam** — Polar customer-portal path for the monthly (org ID public, org token never in the app), `conditions: {channel_id}`, 7–14 day lease, `--activate` CLI. **No claim page, no server** | [`commercial`](docs/ai/commercial.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** — four states, 7/1/0-day notices, "monthly is on the website, not Steam" line, portal link, licence ID in About | [`commercial`](docs/ai/commercial.md) | diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs index b86e031..cf08765 100644 --- a/ViewModels/MainViewModel.OutputProfile.cs +++ b/ViewModels/MainViewModel.OutputProfile.cs @@ -1,4 +1,5 @@ using ytLive.Helpers; +using ytLive.Services.Entitlement; namespace ytLive.ViewModels; @@ -23,7 +24,26 @@ public partial class MainViewModel /// settable inside a development build (see ), so this expression /// reduces to IsPremium in a Release binary. /// - public bool HasProOutput => IsPremium || DeveloperMode; + public bool HasProOutput => IsPremium || DeveloperMode || _entitlement.IsPro; + + /// + /// 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(); + + /// 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. + internal IEntitlement EntitlementOverride + { + get => _entitlement; + set + { + _entitlement = value; + OnPropertyChanged(nameof(HasProOutput)); + } + } private bool _developerMode; diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md index 9da098c..f2bd443 100644 --- a/docs/ai/commercial.md +++ b/docs/ai/commercial.md @@ -56,11 +56,16 @@ touches a feature. Fail closed: anything unresolved is the free tier. ### Steam (lifetime) -- **Steam owns the lifetime SKU.** `ISteamApps::BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`. - It is a local check against Steam's own state — no server, no key, no lease. +- **Steam owns the lifetime 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. +- **Fail closed, four ways:** no DLC 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 for the Steam SKU**, so nothing to paste, email or lose. +- **No licence key exists for the Steam SKU**, so nothing to paste, email or lose. A refunded DLC + simply stops reporting ownership on the next start. ### Polar (monthly, sold off-Steam) diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs new file mode 100644 index 0000000..753b273 --- /dev/null +++ b/ytLive.Tests/EntitlementTests.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using Xunit; +using ytLive.Services.Entitlement; +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. +/// +/// 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. +/// +public class EntitlementTests +{ + [Fact] + public void No_configured_dlc_id_reads_as_no_entitlement() + { + // The state before the Steamworks app exists: pure false, no native call attempted. + Assert.False(SteamEntitlement.NotConfigured().IsPro); + } + + [Fact] + public void A_steam_entitlement_without_a_client_is_the_free_tier_not_a_crash() + { + // 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); + } + + [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); + } + + [Fact] + public void The_view_model_takes_its_answer_from_the_rail() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-entitlement-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + try + { + var vm = new MainViewModel(); + + // Default rail is NotConfigured → free output. + Assert.False(vm.HasProOutput); + + vm.EntitlementOverride = new FakeEntitlement(true); + Assert.True(vm.HasProOutput); + + vm.EntitlementOverride = new FakeEntitlement(false); + Assert.False(vm.HasProOutput); + } + finally + { + MainViewModel.LayoutPathOverride = null; + try { File.Delete(tempDb); } catch (IOException) { } + } + } +} diff --git a/ytLive.csproj b/ytLive.csproj index 266426b..4846332 100644 --- a/ytLive.csproj +++ b/ytLive.csproj @@ -175,6 +175,9 @@ + +