feat: initialize existing development playout PCs

This commit is contained in:
2026-07-26 23:11:29 +09:00
parent 8b628908f0
commit 483b2785a0
18 changed files with 4029 additions and 74 deletions

View File

@@ -57,6 +57,14 @@ internal static class DevelopmentLiveLaunchBootstrap
InteropSha256Property
};
private static readonly string[] ArmedEnvironmentNames =
[
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
ModeEnvironmentVariable,
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable
];
internal static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
@@ -119,7 +127,9 @@ internal static class DevelopmentLiveLaunchBootstrap
if (string.IsNullOrWhiteSpace(authorizationFilePath))
{
return Rejected("development-live-authorization-unavailable");
return RejectedAndDisarm(
platform,
"development-live-authorization-unavailable");
}
byte[] json;
@@ -131,19 +141,25 @@ internal static class DevelopmentLiveLaunchBootstrap
}
catch (Exception exception) when (IsExpectedFileFailure(exception))
{
return Rejected("development-live-authorization-unavailable");
return RejectedAndDisarm(
platform,
"development-live-authorization-unavailable");
}
if (!TryParseAuthorization(json, out var nativeSha256, out var interopSha256))
{
return Rejected("development-live-authorization-invalid");
return RejectedAndDisarm(
platform,
"development-live-authorization-invalid");
}
return TryApplyEnvironment(platform, nativeSha256, interopSha256)
? new DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus.Applied,
"development-live-applied")
: Rejected("development-live-environment-failed");
: RejectedAndDisarm(
platform,
"development-live-environment-failed");
}
private static bool TryParseAuthorization(
@@ -240,17 +256,10 @@ internal static class DevelopmentLiveLaunchBootstrap
string nativeSha256,
string interopSha256)
{
string[] names =
[
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
ModeEnvironmentVariable,
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable
];
var originals = new Dictionary<string, string?>(StringComparer.Ordinal);
try
{
foreach (var name in names)
foreach (var name in ArmedEnvironmentNames)
{
originals.Add(name, platform.GetProcessEnvironmentVariable(name));
}
@@ -271,9 +280,9 @@ internal static class DevelopmentLiveLaunchBootstrap
}
catch (Exception exception) when (IsExpectedEnvironmentFailure(exception))
{
for (var index = names.Length - 1; index >= 0; index--)
for (var index = ArmedEnvironmentNames.Length - 1; index >= 0; index--)
{
var name = names[index];
var name = ArmedEnvironmentNames[index];
if (!originals.TryGetValue(name, out var original))
{
continue;
@@ -296,6 +305,30 @@ internal static class DevelopmentLiveLaunchBootstrap
}
}
private static DevelopmentLiveBootstrapResult RejectedAndDisarm(
IDevelopmentLiveBootstrapPlatform platform,
string code)
{
// An exact Debug request that failed validation must not fall through to
// inherited process state from an earlier shell or Visual Studio session.
// The app also forces this requested-but-not-applied state to DryRun.
for (var index = ArmedEnvironmentNames.Length - 1; index >= 0; index--)
{
var name = ArmedEnvironmentNames[index];
try
{
platform.SetProcessEnvironmentVariable(name, null);
}
catch (Exception exception) when (IsExpectedEnvironmentFailure(exception))
{
// MainWindow's request provenance remains the final fail-closed
// boundary even if the host denies cleanup of a stale value.
}
}
return Rejected(code);
}
private static string? StrictString(JsonElement value) =>
value.ValueKind == JsonValueKind.String ? value.GetString() : null;

View File

@@ -5,6 +5,19 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
public sealed class PlayoutOptions
{
/// <summary>
/// Process-local provenance set only by the exact Debug Development Live
/// bootstrap loader. It is not a JSON or environment configuration surface.
/// </summary>
internal bool DevelopmentLiveBootstrapApplied { get; set; }
/// <summary>
/// Canonical executable-relative Cuts root captured by the Development Live
/// loader. Final validation uses it to prevent later replacement of the
/// verified build payload.
/// </summary>
internal string? DevelopmentLiveExecutableSceneDirectory { get; set; }
public PlayoutMode Mode { get; set; } = PlayoutMode.DryRun;
public string Host { get; set; } = "127.0.0.1";

View File

@@ -1,3 +1,4 @@
using System.Security;
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.Core.Playout;
@@ -7,12 +8,55 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
public static class PlayoutOptionsLoader
{
internal const int MaximumDevelopmentLiveConfigurationFileBytes = 64 * 1024;
public const string LiveAuthorizationEnvironmentVariable =
"MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT";
public const string RequiredLiveAuthorizationValue =
"I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH";
private static readonly string[] DevelopmentLiveSceneAllowlist =
[
"5001", "5006", "5011", "5016", "50160", "5023", "5024", "5025",
"5026", "5029", "5032", "5037", "5068", "5070", "5072", "5074",
"5076", "5077", "5078", "5079", "5080", "5081", "5082", "5083",
"5084", "5085", "5086", "50860", "5087", "5088", "6001", "6067",
"8001", "8002", "8003", "8018", "8032", "8035", "8040", "8046",
"8051", "8056", "8061", "8067", "N5001"
];
private static readonly IReadOnlySet<string> DevelopmentLiveProperties =
new HashSet<string>(StringComparer.Ordinal)
{
"mode",
"host",
"port",
"tcpMode",
"clientPort",
"sceneDirectory",
"outputChannel",
"layoutIndex",
"legacySceneFadeDuration",
"legacySceneBackgroundKind",
"legacySceneBackgroundAssetPath",
"legacySceneBackgroundVideoLoopCount",
"legacySceneBackgroundVideoLoopInfinite",
"legacyBackgroundDirectory",
"testProcessWindowTitlePattern",
"testSceneAllowlist",
"trustedLiveOutputEnabled",
"queueCapacity",
"connectTimeoutMilliseconds",
"operationTimeoutMilliseconds",
"disconnectTimeoutMilliseconds",
"processPollIntervalMilliseconds",
"reconnectDelayMilliseconds",
"maximumReconnectAttempts",
"reconnectEnabled",
"maximumAutomaticRefreshesPerTakeIn"
};
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
@@ -43,6 +87,32 @@ public static class PlayoutOptionsLoader
return options;
}
/// <summary>
/// Loads the inert LocalAppData base profile for an already-applied exact
/// Debug Development Live bootstrap. This path deliberately ignores every
/// process environment override and native operator folder selection. Live
/// mode is armed from bootstrap provenance, and the scene root is pinned to
/// the verified Cuts payload beside the executable.
/// </summary>
internal static PlayoutOptions LoadDevelopmentLive(
string? path = null,
string? baseDirectory = null)
{
var options = LoadDevelopmentLiveJson(
path ?? DefaultPath,
requireProtectedDefaultPath: path is null);
EnsureDevelopmentLiveBaseContract(options);
options.Mode = PlayoutMode.Live;
options.SceneDirectory = null;
ApplyExecutableAssetDefaults(
options,
baseDirectory ?? AppContext.BaseDirectory);
options.DevelopmentLiveExecutableSceneDirectory = options.SceneDirectory;
options.DevelopmentLiveBootstrapApplied = true;
return options;
}
/// <summary>
/// Applies only the two non-command asset roots selected by the native settings
/// screen. Process environment values are applied afterwards and therefore retain
@@ -111,6 +181,287 @@ public static class PlayoutOptionsLoader
}
}
private static void EnsureDevelopmentLiveBaseContract(PlayoutOptions options)
{
if (options.Mode != PlayoutMode.DryRun ||
!IsLiteralLoopback(options.Host) ||
options.Port is < 1 or > 65_535 ||
options.TcpMode != 1 ||
options.ClientPort != 0 ||
options.OutputChannel is < 0 ||
options.LayoutIndex != 10 ||
!string.IsNullOrWhiteSpace(options.SceneDirectory) ||
options.LegacySceneFadeDuration != 6 ||
options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None ||
!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath) ||
options.LegacySceneBackgroundVideoLoopCount != 2004 ||
!options.LegacySceneBackgroundVideoLoopInfinite ||
!string.IsNullOrWhiteSpace(options.LegacyBackgroundDirectory) ||
!string.IsNullOrWhiteSpace(options.TestProcessWindowTitlePattern) ||
options.TestSceneAllowlist is null ||
!options.TestSceneAllowlist.SequenceEqual(
DevelopmentLiveSceneAllowlist,
StringComparer.Ordinal) ||
!options.TrustedLiveOutputEnabled ||
options.QueueCapacity != 64 ||
options.ConnectTimeoutMilliseconds != 5_000 ||
options.OperationTimeoutMilliseconds != 5_000 ||
options.DisconnectTimeoutMilliseconds != 3_000 ||
options.ProcessPollIntervalMilliseconds != 1_000 ||
options.ReconnectDelayMilliseconds != 1_000 ||
options.ReconnectEnabled ||
options.MaximumReconnectAttempts != 0 ||
options.MaximumAutomaticRefreshesPerTakeIn != 0)
{
throw new PlayoutConfigurationException(
"Development Live requires the exact protected local base configuration.");
}
}
private static PlayoutOptions LoadDevelopmentLiveJson(
string path,
bool requireProtectedDefaultPath)
{
try
{
var fullPath = Path.GetFullPath(path);
if (requireProtectedDefaultPath)
{
EnsureProtectedDevelopmentLivePath(fullPath);
}
var attributes = File.GetAttributes(fullPath);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw new InvalidDataException();
}
byte[] json;
using (var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan))
{
if (stream.Length is <= 0 or >
MaximumDevelopmentLiveConfigurationFileBytes)
{
throw new InvalidDataException();
}
json = new byte[checked((int)stream.Length)];
stream.ReadExactly(json);
}
using var document = JsonDocument.Parse(
json,
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 4
});
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidDataException();
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in root.EnumerateObject())
{
if (!DevelopmentLiveProperties.Contains(property.Name) ||
!seen.Add(property.Name))
{
throw new InvalidDataException();
}
}
if (seen.Count != DevelopmentLiveProperties.Count ||
!IsExactString(root, "mode", "DryRun") ||
!IsLoopbackString(root, "host") ||
!IsIntegerInRange(root, "port", 1, 65_535) ||
!IsExactInteger(root, "tcpMode", 1) ||
!IsExactInteger(root, "clientPort", 0) ||
!IsNull(root, "sceneDirectory") ||
!IsNullableNonNegativeInteger(root, "outputChannel") ||
!IsExactInteger(root, "layoutIndex", 10) ||
!IsExactInteger(root, "legacySceneFadeDuration", 6) ||
!IsExactString(root, "legacySceneBackgroundKind", "None") ||
!IsNull(root, "legacySceneBackgroundAssetPath") ||
!IsExactInteger(root, "legacySceneBackgroundVideoLoopCount", 2004) ||
!IsExactBoolean(root, "legacySceneBackgroundVideoLoopInfinite", true) ||
!IsNull(root, "legacyBackgroundDirectory") ||
!IsNull(root, "testProcessWindowTitlePattern") ||
!IsExactStringArray(
root,
"testSceneAllowlist",
DevelopmentLiveSceneAllowlist) ||
!IsExactBoolean(root, "trustedLiveOutputEnabled", true) ||
!IsExactInteger(root, "queueCapacity", 64) ||
!IsExactInteger(root, "connectTimeoutMilliseconds", 5_000) ||
!IsExactInteger(root, "operationTimeoutMilliseconds", 5_000) ||
!IsExactInteger(root, "disconnectTimeoutMilliseconds", 3_000) ||
!IsExactInteger(root, "processPollIntervalMilliseconds", 1_000) ||
!IsExactInteger(root, "reconnectDelayMilliseconds", 1_000) ||
!IsExactInteger(root, "maximumReconnectAttempts", 0) ||
!IsExactBoolean(root, "reconnectEnabled", false) ||
!IsExactInteger(root, "maximumAutomaticRefreshesPerTakeIn", 0))
{
throw new InvalidDataException();
}
return JsonSerializer.Deserialize<PlayoutOptions>(
json,
JsonOptions())
?? throw new InvalidDataException();
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or SecurityException or
InvalidDataException or ArgumentException or NotSupportedException or
JsonException)
{
throw new PlayoutConfigurationException(
"Development Live local configuration is invalid.", exception);
}
}
private static void EnsureProtectedDevelopmentLivePath(string fullPath)
{
var localApplicationData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localApplicationData))
{
throw new InvalidDataException();
}
var localRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(localApplicationData));
var expectedPath = Path.Combine(
localRoot,
"MBN_STOCK_WEBVIEW",
"Config",
"playout.local.json");
if (!string.Equals(fullPath, expectedPath, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
var directory = Path.GetDirectoryName(fullPath);
while (!string.IsNullOrWhiteSpace(directory) &&
!string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase))
{
if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidDataException();
}
directory = Path.GetDirectoryName(directory);
}
if (!string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
}
private static bool IsExactString(
JsonElement root,
string propertyName,
string expected)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.String &&
string.Equals(value.GetString(), expected, StringComparison.Ordinal);
}
private static bool IsLoopbackString(JsonElement root, string propertyName)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.String &&
IsLiteralLoopback(value.GetString());
}
private static bool IsLiteralLoopback(string? host) =>
string.Equals(host, "127.0.0.1", StringComparison.Ordinal) ||
string.Equals(host, "::1", StringComparison.Ordinal);
private static bool IsIntegerInRange(
JsonElement root,
string propertyName,
int minimum,
int maximum)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.Number &&
value.TryGetInt32(out var parsed) &&
parsed >= minimum &&
parsed <= maximum;
}
private static bool IsExactInteger(
JsonElement root,
string propertyName,
int expected) =>
IsIntegerInRange(root, propertyName, expected, expected);
private static bool IsNullableNonNegativeInteger(
JsonElement root,
string propertyName)
{
var value = root.GetProperty(propertyName);
return value.ValueKind == JsonValueKind.Null ||
value.ValueKind == JsonValueKind.Number &&
value.TryGetInt32(out var parsed) &&
parsed >= 0;
}
private static bool IsExactBoolean(
JsonElement root,
string propertyName,
bool expected)
{
var value = root.GetProperty(propertyName);
return expected
? value.ValueKind == JsonValueKind.True
: value.ValueKind == JsonValueKind.False;
}
private static bool IsNull(JsonElement root, string propertyName) =>
root.GetProperty(propertyName).ValueKind == JsonValueKind.Null;
private static bool IsExactStringArray(
JsonElement root,
string propertyName,
IReadOnlyList<string> expected)
{
var value = root.GetProperty(propertyName);
if (value.ValueKind != JsonValueKind.Array ||
value.GetArrayLength() != expected.Count)
{
return false;
}
var index = 0;
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String ||
!string.Equals(
item.GetString(),
expected[index],
StringComparison.Ordinal))
{
return false;
}
index++;
}
return true;
}
private static PlayoutOptions LoadJson(string path)
{
if (!File.Exists(path))

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using System.Net;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
@@ -125,6 +126,52 @@ internal sealed record ValidatedPlayoutOptions(
throw Invalid(nameof(options.TestSceneAllowlist));
}
if (options.DevelopmentLiveBootstrapApplied)
{
if (options.Mode != PlayoutMode.Live)
{
throw Invalid(nameof(options.Mode));
}
if (!IsLiteralLoopback(host))
{
throw Invalid(nameof(options.Host));
}
string? expectedSceneDirectory;
try
{
expectedSceneDirectory = string.IsNullOrWhiteSpace(
options.DevelopmentLiveExecutableSceneDirectory)
? null
: Path.TrimEndingDirectorySeparator(Path.GetFullPath(
options.DevelopmentLiveExecutableSceneDirectory));
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
throw Invalid(nameof(options.SceneDirectory));
}
if (expectedSceneDirectory is null ||
!string.Equals(
sceneDirectory,
expectedSceneDirectory,
StringComparison.OrdinalIgnoreCase) ||
options.LegacySceneFadeDuration != 6 ||
options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None ||
!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath) ||
!string.IsNullOrWhiteSpace(options.LegacyBackgroundDirectory) ||
options.ReconnectEnabled ||
options.MaximumReconnectAttempts != 0 ||
options.MaximumAutomaticRefreshesPerTakeIn != 0)
{
throw new PlayoutConfigurationException(
"Development Live configuration no longer matches its protected startup contract.");
}
}
if (options.Mode == PlayoutMode.Test)
{
if (!IsLiteralLoopback(host))