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

@@ -62,6 +62,73 @@ public sealed class DatabaseRuntime
return CreateDefault(fallbackConfigurationPath);
}
/// <summary>
/// Creates the Development Live database runtime from the one protected
/// current-user LocalAppData legacy INI, or from the protected LocalAppData
/// JSON when that INI is absent. Unlike the ordinary compatibility path, this
/// never probes executable or operator-selected Res folders and ignores all
/// database environment overrides.
/// </summary>
public static DatabaseRuntime CreateDevelopmentLiveLocalOverride()
{
var localApplicationData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
return CreateDevelopmentLiveLocalOverride(localApplicationData);
}
internal static DatabaseRuntime CreateDevelopmentLiveLocalOverride(
string localApplicationData)
{
try
{
ArgumentException.ThrowIfNullOrWhiteSpace(localApplicationData);
var localRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(localApplicationData));
var resourceDirectory = Path.Combine(
localRoot,
"MBN_STOCK_WEBVIEW",
"Res");
var legacyIniPath = Path.Combine(resourceDirectory, "MmoneyCoder.ini");
if (TryEnsureOrdinaryFile(legacyIniPath))
{
EnsureNoReparsePointDirectoryChain(resourceDirectory, localRoot);
return Create(
new LegacyIniDatabaseOptionsLoader(_ => null).Load(legacyIniPath));
}
var configurationDirectory = Path.Combine(
localRoot,
"MBN_STOCK_WEBVIEW",
"Config");
EnsureNoReparsePointDirectoryChain(configurationDirectory, localRoot);
var jsonPath = Path.Combine(
configurationDirectory,
"database.local.json");
EnsureOrdinaryFile(jsonPath);
var options = new DatabaseOptionsLoader(_ => null).Load(jsonPath);
if (!options.IsConfigured(MMoneyCoderSharp.Data.DataSourceKind.Oracle) ||
!options.IsConfigured(MMoneyCoderSharp.Data.DataSourceKind.MariaDb))
{
throw new DatabaseConfigurationException(
"Development Live database configuration is unavailable.");
}
return Create(options);
}
catch (DatabaseConfigurationException)
{
throw;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
InvalidDataException or NotSupportedException or
System.Security.SecurityException)
{
throw new DatabaseConfigurationException(
"Development Live database configuration is unavailable.");
}
}
private static string? ResolveLegacyOverridePath()
{
var localAppData = Environment.GetFolderPath(
@@ -75,6 +142,56 @@ public sealed class DatabaseRuntime
"MmoneyCoder.ini");
}
private static void EnsureNoReparsePointDirectoryChain(
string directory,
string trustedRoot)
{
var current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(trustedRoot));
while (!string.Equals(current, root, StringComparison.OrdinalIgnoreCase))
{
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidDataException();
}
var parent = Path.GetDirectoryName(current);
if (string.IsNullOrWhiteSpace(parent) ||
string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent));
}
}
private static void EnsureOrdinaryFile(string path)
{
var attributes = File.GetAttributes(Path.GetFullPath(path));
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw new InvalidDataException();
}
}
private static bool TryEnsureOrdinaryFile(string path)
{
try
{
EnsureOrdinaryFile(path);
return true;
}
catch (FileNotFoundException)
{
return false;
}
catch (DirectoryNotFoundException)
{
return false;
}
}
public static DatabaseRuntime Create(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);

View File

@@ -10,6 +10,10 @@ public partial class App : Application
private Window? _window;
private AppInstance? _mainInstance;
internal static bool IsDevelopmentLiveLaunch { get; private set; }
internal static bool IsDevelopmentLiveLaunchRequested { get; private set; }
public App()
{
InitializeComponent();
@@ -55,9 +59,14 @@ public partial class App : Application
: DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
args.Arguments,
Environment.GetCommandLineArgs());
IsDevelopmentLiveLaunchRequested = string.Equals(
launchArguments,
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
StringComparison.Ordinal);
var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(
launchArguments,
isDebugBuild);
IsDevelopmentLiveLaunch = developmentLive.IsApplied;
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");

View File

@@ -178,9 +178,10 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<!--
The active legacy DB INI contains credentials. It is available beside the
executable for local F5/unpackaged development only and is never MSIX Content.
Historical copies, backups, archives and afiedt.buf.txt are not included at all.
The active legacy DB INI contains credentials and is never a build input or
MSIX Content. Local F5 and packaged development use the current user's
LocalAppData overlay. Historical copies, backups, archives and
afiedt.buf.txt are not included at all.
-->
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
@@ -192,10 +193,13 @@
<Message Condition="'$(LegacyRuntimeAssetsMode)' == 'Auto' and
'$(LegacyRuntimeAssetsEnabled)' != 'true'"
Importance="high"
Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Full runtime use requires a Required build with the approved external root." />
Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Run scripts\Initialize-ExistingDevelopmentPc.ps1 before Development Live." />
<Message Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'"
Importance="high"
Text="Verified full legacy runtime assets are enabled. Database credentials remain in the current user's LocalAppData overlay." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyRuntimeSourceRoot)')"
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory, or use /p:LegacyRuntimeAssetsMode=Auto for a source-only DryRun build." />
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Run scripts\Initialize-ExistingDevelopmentPc.ps1 to create the verified local Required binding, or use /p:LegacyRuntimeAssetsMode=Auto for a source-only DryRun build." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyCutsSourceRoot)')"
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />

View File

@@ -34,10 +34,17 @@ public sealed partial class MainWindow
{
var explicitLocalConfigurationExists = File.Exists(
PlayoutOptionsLoader.DefaultPath);
_playoutOptions = PlayoutOptionsLoader.Load(
operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory,
forceSafeDryRun: IsSourceOnlyBuild);
_playoutOptions =
_isDevelopmentLiveLaunch &&
!_developmentLiveStartupBlocked
? PlayoutOptionsLoader.LoadDevelopmentLive(
baseDirectory: AppContext.BaseDirectory)
: PlayoutOptionsLoader.Load(
operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory,
forceSafeDryRun: IsSourceOnlyBuild ||
_isDevelopmentLiveLaunchRequested ||
_developmentLiveStartupBlocked);
ResetRefreshState();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions,

View File

@@ -35,6 +35,8 @@ public sealed partial class MainWindow : Window
}
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly bool _isDevelopmentLiveLaunch;
private readonly bool _isDevelopmentLiveLaunchRequested;
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
@@ -49,6 +51,7 @@ public sealed partial class MainWindow : Window
private LegacyOperatorSettings _operatorSettings;
private DatabaseRuntime? _databaseRuntime;
private bool _closing;
private bool _developmentLiveStartupBlocked;
private bool _interactionMetricsRefreshQueued;
private bool _intentBusy;
private bool _windowSubclassAttached;
@@ -59,6 +62,8 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
_isDevelopmentLiveLaunch = App.IsDevelopmentLiveLaunch;
_isDevelopmentLiveLaunchRequested = App.IsDevelopmentLiveLaunchRequested;
// Capture and remove Gate A bearer material before XAML can create WebView2.
// Only the native process keeps the capability digest for this launch.
_playoutLaunchAuthorization =
@@ -73,15 +78,22 @@ public sealed partial class MainWindow : Window
EnsureCloseConfirmationAttached();
var localApplicationData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
var runtimeCutMenuOverridePath = string.IsNullOrWhiteSpace(localApplicationData)
var runtimeCutMenuOverridePath =
_isDevelopmentLiveLaunch ||
string.IsNullOrWhiteSpace(localApplicationData)
? null
: Path.Combine(
localApplicationData,
"MBN_STOCK_WEBVIEW",
"Res",
"종목.ini");
var selectedResourceDirectory = _appliedOperatorSettings.ResourceDirectory;
var cutMenuComposition = selectedResourceDirectory is null
var selectedResourceDirectory = _isDevelopmentLiveLaunch
? null
: _appliedOperatorSettings.ResourceDirectory;
var cutMenuComposition = _isDevelopmentLiveLaunch
? LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(
AppContext.BaseDirectory)
: selectedResourceDirectory is null
? LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
runtimeCutMenuOverridePath,
AppContext.BaseDirectory)
@@ -111,6 +123,7 @@ public sealed partial class MainWindow : Window
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
CorePagePlanProvider? fixedPagePlanProvider = null;
string? initializationError = null;
var databaseInitializationSucceeded = false;
try
{
if (IsSourceOnlyBuild)
@@ -127,6 +140,8 @@ public sealed partial class MainWindow : Window
: Path.Combine(selectedResourceDirectory, "MmoneyCoder.ini");
_databaseRuntime = _playoutLaunchAuthorization.IsGateA
? DatabaseRuntime.CreateDefault()
: _isDevelopmentLiveLaunch
? DatabaseRuntime.CreateDevelopmentLiveLocalOverride()
: DatabaseRuntime.CreateLegacyExecutableDefault(
AppContext.BaseDirectory,
legacyOverridePath: selectedLegacyDatabaseIni,
@@ -185,9 +200,12 @@ public sealed partial class MainWindow : Window
new LegacyStockMasterIdentityValidationService(_databaseRuntime.Executor);
operatorCatalogSchemaValidationService =
new LegacyOperatorCatalogSchemaValidationService(_databaseRuntime.Executor);
databaseInitializationSucceeded = true;
}
catch
{
_databaseRuntime = null;
fixedPagePlanProvider = null;
initializationError = IsSourceOnlyBuild
? "소스 전용 DryRun에서는 데이터베이스 연결을 사용하지 않습니다."
: "데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
@@ -225,6 +243,14 @@ public sealed partial class MainWindow : Window
DataQueryExecutor.Reset();
}
if (_isDevelopmentLiveLaunch && !databaseInitializationSucceeded)
{
// Development Live is one fail-closed startup transaction. A
// missing or partially initialized protected DB runtime must not
// leave the independently armed playout gate eligible for use.
_developmentLiveStartupBlocked = true;
}
_industryWorkflow = new LegacyIndustrySelectionWorkflow(
industrySelectionService,
actionLayout: runtimeUiCatalogs.IndustryLayout);
@@ -310,7 +336,10 @@ public sealed partial class MainWindow : Window
if (!_closing)
{
StartDatabaseHealthMonitor();
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
if (!_developmentLiveStartupBlocked)
{
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
}
}
}

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))