Files
gramps de81fa3c5d dev-only: per-instance state so two LlamaCasty instances can run side by side
Pre-1.0 the creator streams in one instance and screen-captures it from
another. Nothing prevented a second instance - there is no single-instance
mutex and no port anywhere. What broke it was SHARED STATE, and two of the
collisions were hard failures rather than annoyances:

- the layout DB. The model is read-whole-scene / write-whole-scene, so two
  instances saving different layouts clobber each other.
- the WebView2 user data folder. Chromium takes an exclusive lock on it, so
  the second instance of the same exe does not start at all.
- the auth token store. A test instance would overwrite the real YouTube
  sign-in with its own.
- startup.log, where two appenders interleave and a crash in either instance
  becomes ambiguous.

Set YTLIVE_INSTANCE=<id> and the process gets a private root at
%APPDATA%\ytLlive\instances/<id>/ for those four. Recording folder and the
ffmpeg tools cache stay shared on purpose - the cache should be shared, and
the creator picks the record folder. Global hotkeys stay un-namespaced: if
both instances register the same one, Windows refusing the second is the
correct answer.

An id that is not letters/digits/dash/underscore is rejected and degrades to
the primary profile, so the variable can never walk out of the profile
directory or name a UNC path.

The entire implementation is inside #if DEBUG. A Release build compiles to
DataRoot => DefaultRoot and WebViewDataFolder => null, and the call sites are
unconditional so Release cannot drift by forgetting an #if. The harness lives
with the tests: InstanceIsolationTests covers the two-identities contract,
the primary-instance no-op, per-instance WebView folders, path-traversal
rejection, that the real output paths actually move with the profile, and a
source-level assertion that the #else arm IS production behaviour.

Verified against a real Release build: the InstanceVariable field is absent
from its metadata and no "instances" path segment survives, while DataRoot
and WebViewDataFolder are present in both configurations. Grepping for
YTLIVE_INSTANCE proves nothing - a const is inlined at compile time and
appears in neither build, which cost one wasted verification round.

Docs for this unit (the InstanceProfile paragraph in ai.md, the 1.0 gate in
TASKS.md, and the MyMistakes/HANDOFF entries) landed in the previous commit,
because they share those files with the branding-credit work.
2026-09-27 08:56:12 -07:00

78 lines
3.9 KiB
C#

using System;
using System.IO;
namespace ytLive.Helpers;
/// <summary>
/// Which run of LlamaCasty this process is, and where its private state lives.
/// <para><b>Dev-only affordance (creator directive, 2026-09-26).</b> Pre-1.0 the creator
/// needs two instances side by side: one streaming, one screen-capturing the other.
/// Two instances on one Windows profile collide on state, and two of those collisions
/// are hard failures rather than annoyances:
/// <list type="bullet">
/// <item><b>The SQLite layout DB.</b> The app's model is read-whole-scene / write-whole-scene,
/// so two instances saving different layouts clobber each other.</item>
/// <item><b>The WebView2 user data folder.</b> Chromium takes an exclusive lock on it; the
/// second instance of the same exe simply fails to start.</item>
/// <item><b>The auth token store.</b> A test instance would overwrite the real YouTube
/// sign-in.</item>
/// <item><b>startup.log.</b> Two appenders interleave into one file, so a crash in either
/// instance is ambiguous.</item>
/// </list>
/// So in <c>DEBUG</c> a process can claim an identity and gets its own private root
/// under <c>instances\&lt;id&gt;</c>: <c>$env:YTLIVE_INSTANCE=2; dotnet run</c>.
/// Recording, screen capture and the ffmpeg <c>tools\</c> cache are deliberately LEFT
/// shared — the cache should be shared, and the creator chooses the record folder.
/// Global hotkeys are also left alone: if both instances register the same one, Windows
/// refuses the second, which is the correct answer.</para>
/// <para><b>At 1.0 this disappears.</b> The whole implementation is inside
/// <c>#if DEBUG</c>; a Release build compiles to the two pass-throughs at the bottom —
/// no environment variable, no alternate folder, not even the variable's NAME in the
/// binary. Call sites are unconditional, so Release cannot accidentally diverge: it
/// always resolves the one real user profile.</para>
/// </summary>
public static class InstanceProfile
{
#if DEBUG
/// <summary>Environment variable naming this instance. Absent or blank = the primary
/// instance on the real user profile.</summary>
public const string InstanceVariable = "YTLIVE_INSTANCE";
/// <summary>This instance's id, or null for the primary instance.</summary>
public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable));
/// <summary>Private state root: the real profile normally, a per-instance subfolder
/// when an id is claimed.</summary>
public static string DataRoot => Id is { } id
? Path.Combine(DefaultRoot, "instances", id)
: DefaultRoot;
/// <summary>Chromium's user data folder, or null to let WebView2 pick its default
/// (which is what Release always does). Must be per-instance or the second
/// instance dies on Chromium's exclusive lock.</summary>
public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2");
/// <summary>Reject anything that is not a plain path segment, so the variable can
/// never be used to walk out of the profile directory or name a UNC path.</summary>
private static string? Sanitize(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var trimmed = raw.Trim();
foreach (var c in trimmed)
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') return null;
return trimmed;
}
#else
// Release: one profile, no alternate paths, no environment variable. Multi-instance
// is a development affordance, not a shipping feature.
public static string DataRoot => DefaultRoot;
public static string? WebViewDataFolder => null;
#endif
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
/// double-L — do not "fix" it, users already have their layouts there.</summary>
public static string DefaultRoot => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive");
}