Files
LlamaCasty/Services/Entitlement/SteamEntitlement.cs
T
gramps 6d1c982e26 feat(entitlement): IEntitlement seam — Steam DLC answers, everything fails closed
The first half of surge day 1. One question, "may this session emit Pro output?", with
one method on the interface so nothing downstream knows which rail proved it.

SteamEntitlement reads the lifetime SKU the way Valve intends: SteamAPI.Init() then
SteamApps.BIsDlcInstalled(dlcAppId). Four different failure paths all resolve to the
free tier rather than to Pro or to a crash — no DLC id configured, no Steam client,
steam_api64.dll missing (a throw on first touch), and simply not owned. A copied folder
run without Steam is the common case, not an exceptional one.

NotConfigured() is the shipping default until Steamworks creates the DLC app; it answers
false without ever touching the native library, so today's builds behave exactly as they
did before this commit.

HasProOutput is now IsPremium || DeveloperMode || rail, with an internal EntitlementOverride
test seam (internal, because a public setter here is a licence bypass). Four tests: no id
reads false, no client reads false without throwing, the fake reports what a test sets, and
the view model takes its answer from the rail.

Steamworks.NET 2024.8.0 added; the native DLL still comes from scripts/steam-sdk.sh, so the
csproj change is three lines and no formatting churn.
2026-11-01 08:13:25 -08:00

83 lines
3.0 KiB
C#

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>
/// </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 bool _initAttempted;
private bool _available;
private bool? _cached;
public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId;
/// <summary>Configured with no product id — the state before the Steamworks app exists.</summary>
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;
}
}
}
/// <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();
_available = false;
_cached = null;
_initAttempted = false;
}
}