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.
This commit is contained in:
+3
-5
@@ -5,15 +5,13 @@ namespace ytLive.Helpers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
||||||
/// where the WPF app has no visible console. Writes to
|
/// where the WPF app has no visible console. Writes to
|
||||||
/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive
|
/// %APPDATA%\ytLlive\startup.log (or a per-instance subfolder in DEBUG — see
|
||||||
/// a hard crash.
|
/// InstanceProfile), appending synchronously so entries survive a hard crash.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AppLog
|
public static class AppLog
|
||||||
{
|
{
|
||||||
private static readonly string LogPath = Path.Combine(
|
private static readonly string LogPath = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
InstanceProfile.DataRoot, "startup.log");
|
||||||
"ytLlive",
|
|
||||||
"startup.log");
|
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
private static readonly object Gate = new();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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\<id></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");
|
||||||
|
}
|
||||||
@@ -14,9 +14,7 @@ namespace ytLive.Helpers;
|
|||||||
public static class TokenStore
|
public static class TokenStore
|
||||||
{
|
{
|
||||||
public static string DefaultPath => Path.Combine(
|
public static string DefaultPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
InstanceProfile.DataRoot, "ytLlive.auth");
|
||||||
"ytLlive",
|
|
||||||
"ytLlive.auth");
|
|
||||||
|
|
||||||
public static void Save(YouTubeChannel channel, string? path = null)
|
public static void Save(YouTubeChannel channel, string? path = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
@@ -245,7 +246,12 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
AdditionalBrowserArguments =
|
AdditionalBrowserArguments =
|
||||||
"--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion",
|
"--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates the composition controller off-screen under the main
|
/// <summary>Creates the composition controller off-screen under the main
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// <para>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).</para>
|
||||||
|
/// <para>These facts live with the tests on purpose. The app's side is a
|
||||||
|
/// <c>#if DEBUG</c> block in <see cref="InstanceProfile"/>; 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.</para>
|
||||||
|
/// </summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user