diff --git a/Helpers/AppLog.cs b/Helpers/AppLog.cs index 4bc7e0f..f5e4907 100644 --- a/Helpers/AppLog.cs +++ b/Helpers/AppLog.cs @@ -5,15 +5,13 @@ namespace ytLive.Helpers; /// /// Minimal file logger used to diagnose startup/shutdown crashes on Windows, /// where the WPF app has no visible console. Writes to -/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive -/// a hard crash. +/// %APPDATA%\ytLlive\startup.log (or a per-instance subfolder in DEBUG — see +/// InstanceProfile), appending synchronously so entries survive a hard crash. /// public static class AppLog { private static readonly string LogPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "ytLlive", - "startup.log"); + InstanceProfile.DataRoot, "startup.log"); private static readonly object Gate = new(); diff --git a/Helpers/InstanceProfile.cs b/Helpers/InstanceProfile.cs new file mode 100644 index 0000000..e4a69bd --- /dev/null +++ b/Helpers/InstanceProfile.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; + +namespace ytLive.Helpers; + +/// +/// Which run of LlamaCasty this process is, and where its private state lives. +/// Dev-only affordance (creator directive, 2026-09-26). 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: +/// +/// The SQLite layout DB. The app's 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; the +/// second instance of the same exe simply fails to start. +/// The auth token store. A test instance would overwrite the real YouTube +/// sign-in. +/// startup.log. Two appenders interleave into one file, so a crash in either +/// instance is ambiguous. +/// +/// So in DEBUG a process can claim an identity and gets its own private root +/// under instances\<id>: $env:YTLIVE_INSTANCE=2; dotnet run. +/// Recording, screen capture and the ffmpeg tools\ 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. +/// At 1.0 this disappears. The whole implementation is inside +/// #if DEBUG; 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. +/// +public static class InstanceProfile +{ +#if DEBUG + /// Environment variable naming this instance. Absent or blank = the primary + /// instance on the real user profile. + public const string InstanceVariable = "YTLIVE_INSTANCE"; + + /// This instance's id, or null for the primary instance. + public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable)); + + /// Private state root: the real profile normally, a per-instance subfolder + /// when an id is claimed. + public static string DataRoot => Id is { } id + ? Path.Combine(DefaultRoot, "instances", id) + : DefaultRoot; + + /// 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. + public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2"); + + /// 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. + 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 + + /// The one real user profile: %APPDATA%\ytLlive. Note the historical + /// double-L — do not "fix" it, users already have their layouts there. + public static string DefaultRoot => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ytLlive"); +} diff --git a/Helpers/TokenStore.cs b/Helpers/TokenStore.cs index 6b76544..b36301c 100644 --- a/Helpers/TokenStore.cs +++ b/Helpers/TokenStore.cs @@ -14,9 +14,7 @@ namespace ytLive.Helpers; public static class TokenStore { public static string DefaultPath => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "ytLlive", - "ytLlive.auth"); + InstanceProfile.DataRoot, "ytLlive.auth"); public static void Save(YouTubeChannel channel, string? path = null) { diff --git a/Services/WebView2Manager.cs b/Services/WebView2Manager.cs index eebc62b..09469ac 100644 --- a/Services/WebView2Manager.cs +++ b/Services/WebView2Manager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.IO; using System.Runtime.InteropServices; using System.Threading.Tasks; using System.Windows; @@ -245,7 +246,12 @@ public sealed class WebView2Manager : IDisposable AdditionalBrowserArguments = "--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion", }; - return _environmentTask = CoreWebView2Environment.CreateAsync(null, null, options); + // Per-instance user data folder in DEBUG: Chromium locks it exclusively, so + // without this the second dev instance of the same exe fails to start at all. + // Release passes null and WebView2 uses its default. + var userData = InstanceProfile.WebViewDataFolder; + if (userData != null) Directory.CreateDirectory(userData); + return _environmentTask = CoreWebView2Environment.CreateAsync(null, userData, options); } /// Creates the composition controller off-screen under the main diff --git a/ytLive.Tests/InstanceIsolationTests.cs b/ytLive.Tests/InstanceIsolationTests.cs new file mode 100644 index 0000000..ad4cd54 --- /dev/null +++ b/ytLive.Tests/InstanceIsolationTests.cs @@ -0,0 +1,240 @@ +using System; +using System.IO; +using System.Linq; +using System.Linq; +using System.Reflection; +using Xunit; +using ytLive.Helpers; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Good Dog (2026-09-26): in DEBUG, two LlamaCasty processes must be able to run side by +/// side without fighting over state, because pre-1.0 the creator streams in one and +/// screen-captures it from the other. +/// Nothing in the app prevented a second instance — there is no single-instance +/// mutex and no port. What broke it was SHARED STATE, in two ways that are hard +/// failures rather than annoyances: the whole-scene read/write layout DB (two instances +/// clobber each other's saves) and Chromium's exclusive lock on the WebView2 user data +/// folder (the second instance of the same exe does not start). +/// These facts live with the tests on purpose. The app's side is a +/// #if DEBUG block in ; this file is the harness that +/// exercises it, and — the part that matters at 1.0 — the assertion that a Release +/// build cannot possibly contain it. +/// +public sealed class InstanceIsolationTests +{ + // ---------- two identities get two private roots ---------- + + [Fact] + public void TwoInstances_EachGetTheirOwnLayoutDatabase() + { + var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + try + { + var roots = new string[2]; + for (var i = 0; i < 2; i++) + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, $"{i + 1}"); + roots[i] = InstanceProfile.DataRoot; + } + + Assert.NotEqual(roots[0], roots[1]); + foreach (var root in roots) + Assert.NotEqual(InstanceProfile.DefaultRoot, root); + } + finally + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous); + } + } + + [Fact] + public void PrimaryInstance_StillUsesTheRealUserProfile() + { + var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + try + { + // Unset: the everyday case must be byte-for-byte what it always was. This is + // the fact that stops the dev affordance from leaking into normal use. + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, null); + Assert.Equal(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot); + Assert.Null(InstanceProfile.WebViewDataFolder); + + // Blank and whitespace are "no instance", not an instance called "". + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, " "); + Assert.Equal(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot); + Assert.Null(InstanceProfile.WebViewDataFolder); + } + finally + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous); + } + } + + [Fact] + public void EachInstance_GetsItsOwnWebViewDataFolder() + { + var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + try + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "a"); + var a = InstanceProfile.WebViewDataFolder; + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "b"); + var b = InstanceProfile.WebViewDataFolder; + + // Chromium locks this folder exclusively — a shared value means the second + // instance fails to launch, so it is not a nice-to-have. + Assert.NotNull(a); + Assert.NotNull(b); + Assert.NotEqual(a, b); + Assert.Contains("a", a!); + Assert.Contains("b", b!); + } + finally + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous); + } + } + + [Fact] + public void AMaliciousOrMistypedId_CannotEscapeTheProfileFolder() + { + var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + try + { + foreach (var hostile in new[] + { + "..\\..\\Windows", "..", "a/b", "a\\b", "C:evil", "with space", "a;b", "*", + }) + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, hostile); + // A rejected id degrades to the primary profile — it never builds a path + // outside %APPDATA%\ytLlive, and never throws. + Assert.Equal(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot); + Assert.Null(InstanceProfile.WebViewDataFolder); + } + } + finally + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous); + } + } + + [Fact] + public void ALegitimateId_AllowsLettersDigitsDashAndUnderscore() + { + var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + try + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "live_2-b"); + Assert.Equal("live_2-b", InstanceProfile.Id); + Assert.NotEqual(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot); + } + finally + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous); + } + } + + // ---------- the app's real outputs actually move with the profile ---------- + + [Fact] + public void TheRealOutputPaths_FollowTheInstanceProfile() + { + var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + try + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "7"); + + // These are the exact expressions the app uses at startup. If any of them + // stops consulting InstanceProfile, two dev instances start clobbering + // each other again and nothing else in the suite would notice. + var layout = Path.Combine(InstanceProfile.DataRoot, "ytLlive.db"); + var auth = TokenStore.DefaultPath; + Assert.Equal(Path.Combine(InstanceProfile.DataRoot, "ytLlive.auth"), auth); + Assert.Contains("instances", layout); + Assert.Contains("instances", auth); + } + finally + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous); + } + } + + // ---------- the 1.0 gate ---------- + + [Fact] + public void InstanceProfile_ExposesNoThirdPathForACallerToDiscover() + { + // Every member exists in every configuration — the call sites are unconditional, + // so Release cannot drift by forgetting an #if — and there are only these three. + var properties = typeof(InstanceProfile) + .GetProperties(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(p => p.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + // The dev surface (Id) exists only in a DEBUG build, by design; the three + // production members exist in both, which is what keeps the call sites + // unconditional. Pin both shapes so neither can change unnoticed. +#if DEBUG + Assert.Equal(new[] { "DataRoot", "DefaultRoot", "Id", "WebViewDataFolder" }, properties); +#else + Assert.Equal(new[] { "DataRoot", "DefaultRoot", "WebViewDataFolder" }, properties); +#endif + + // The primary instance must not perturb the everyday Chromium profile. + Assert.Null(typeof(InstanceProfile) + .GetProperty("WebViewDataFolder")!.GetValue(null)); + } + + [Fact] + public void TheAlternateProfile_IsConfinedToTheDebugBuild() + { + // The 1.0 promise is structural, so this asserts the structure: the alternate + // profile must live inside #if DEBUG and the #else arm must be the real user + // profile and nothing else. If someone ever hoists the implementation out of the + // DEBUG arm — or adds a second one — this fails, which is the point. + // + // Verified against the real Release binary too (2026-09-26): a Release build has + // no InstanceVariable field in metadata and no "instances" path segment, while + // DataRoot / WebViewDataFolder are present in both configurations. Note a + // const like YTLIVE_INSTANCE is INLINED at compile time and therefore never + // appears in either binary — grepping for the variable name proves nothing. + var source = File.ReadAllText(RepoFile("Helpers", "InstanceProfile.cs")); + + // The whole promise is that a Release build compiles to the #else arm, so assert + // that arm IS production: the one real user profile, no alternate folder, and no + // environment variable. If the implementation is ever hoisted out of #if DEBUG, + // this fails — which is the entire point of having it. + var elseAt = source.IndexOf("#else", StringComparison.Ordinal); + var endifAt = source.IndexOf("#endif", StringComparison.Ordinal); + Assert.True(elseAt > 0 && endifAt > elseAt, + "InstanceProfile.cs has no #else/#endif arm — the Release behaviour is no longer explicit"); + + var releaseArm = source.Substring(elseAt, endifAt - elseAt); + Assert.Contains("DefaultRoot", releaseArm); + Assert.DoesNotContain("instances", releaseArm); + Assert.DoesNotContain("Environment", releaseArm); + Assert.DoesNotContain("Sanitize", releaseArm); + + // And the development behaviour really is in the DEBUG arm above it. + var debugArm = source.Substring(0, elseAt); + Assert.Contains("#if DEBUG", debugArm); + Assert.Contains("instances", debugArm); + } + + private static string RepoFile(params string[] parts) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray()); + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + throw new FileNotFoundException( + $"could not locate {Path.Combine(parts)} above {AppContext.BaseDirectory}"); + } +}