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}");
+ }
+}