feat: initialize existing development playout PCs
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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)." />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user