feat(entitlement): pivot to paid app ownership

Check Steam ownership of the paid app with BIsSubscribedApp; keep Debug fake ownership out of Release. Ref: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
This commit is contained in:
2026-11-02 09:18:03 -08:00
parent 54902cb838
commit ff5e8d94ae
17 changed files with 229 additions and 217 deletions
+3 -5
View File
@@ -1,13 +1,11 @@
namespace ytLive.Services.Entitlement;
/// <summary>
/// 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.
/// </summary>
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; }
}
+4 -11
View File
@@ -1,19 +1,12 @@
namespace ytLive.Services.Entitlement;
/// <summary>
/// Where "is this copy licensed for Pro output?" comes from.
/// Whether the current Steam account owns the paid app.
///
/// <para><b>One question, two sources.</b> 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 <c>Services/Entitlement</c> may know
/// which rail proved it.</para>
///
/// <para><b>Fail closed.</b> An implementation that cannot reach its source, cannot load its
/// native library, or has no product id configured returns <c>false</c>. Pro is never a default,
/// never a cached "probably", and never the result of an exception being swallowed as true.</para>
/// <para>Steam is the source of truth. Failure to reach Steam, load its native library, or
/// resolve the paid app ID returns <c>false</c>.</para>
/// </summary>
public interface IEntitlement
{
/// <summary>True when this session may emit above the free output budget.</summary>
bool IsPro { get; }
bool IsAppOwned { get; }
}
+33 -28
View File
@@ -3,41 +3,50 @@ using Steamworks;
namespace ytLive.Services.Entitlement;
/// <summary>
/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
///
/// <para><b>This is the whole purchase story.</b> 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.</para>
///
/// <para><b>⛔ Never sell the monthly plan through this.</b> 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 (<see cref="PolarEntitlement"/> when it
/// lands); this class only ever reads what Valve already sold.</para>
///
/// <para><b>Steam absent is normal, not exceptional.</b> A copied folder, a shortcut run without
/// the client, or a CI machine has no Steam — <see cref="SteamAPI.Init"/> returns false and the
/// app runs as the free tier. It must never refuse to start over an entitlement lookup.</para>
/// 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.
/// </summary>
public sealed class SteamEntitlement : IEntitlement
{
/// <summary>The DLC's own app id, not the base app's. Zero = not configured yet, which reads
/// as "no entitlement" rather than "try anyway".</summary>
private readonly uint _dlcAppId;
private readonly uint _paidAppId;
private readonly Func<bool> _initializeSteam;
private readonly Func<uint, bool> _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)
{
}
/// <summary>Configured with no product id — the state before the Steamworks app exists.</summary>
internal SteamEntitlement(
uint paidAppId,
Func<bool> initializeSteam,
Func<uint, bool> isAppSubscribed,
Action shutdownSteam)
{
_paidAppId = paidAppId;
_initializeSteam = initializeSteam;
_isAppSubscribed = isAppSubscribed;
_shutdownSteam = shutdownSteam;
}
/// <summary>Configured with no paid app ID; ownership fails closed without touching Steam.</summary>
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
}
}
/// <summary>Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
/// would be wrong, so nothing here schedules it automatically.</summary>
public void Shutdown()
{
if (_available) SteamAPI.Shutdown();
if (_available) _shutdownSteam();
_available = false;
_cached = null;
_initAttempted = false;