feat: make LegacyParityApp playout live-only

This commit is contained in:
2026-07-28 14:52:59 +09:00
parent 11d3849933
commit ea96a08ad5
23 changed files with 456 additions and 270 deletions

View File

@@ -14,6 +14,10 @@ public partial class App : Application
internal static bool IsDevelopmentLiveLaunchRequested { get; private set; }
internal static string? DevelopmentLiveStartupFailureCode { get; private set; }
internal static string? DevelopmentLiveStartupFailureMessage { get; private set; }
public App()
{
InitializeComponent();
@@ -43,40 +47,31 @@ public partial class App : Application
_mainInstance = registeredInstance;
_mainInstance.Activated += OnMainInstanceActivated;
#if DEBUG
const bool isDebugBuild = true;
#else
const bool isDebugBuild = false;
#endif
#if SOURCE_ONLY_RUNTIME
const bool isSourceOnlyBuild = true;
#else
const bool isSourceOnlyBuild = false;
#endif
var launchArguments = isSourceOnlyBuild
? null
: 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}");
// A fresh clone cannot become a full runtime inside the already compiled
// source-only process. Show only the native two-folder setup window; the
// reviewed initializer builds the verified runtime for the next F5.
var showFirstRunSetup = isSourceOnlyBuild && isDebugBuild;
_window = showFirstRunSetup
? new FirstRunSetupWindow()
: new MainWindow();
#if SOURCE_ONLY_RUNTIME
IsDevelopmentLiveLaunchRequested = false;
IsDevelopmentLiveLaunch = false;
DevelopmentLiveStartupFailureCode = null;
DevelopmentLiveStartupFailureMessage = null;
_window = new FirstRunSetupWindow();
#else
var launchArguments = DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
args.Arguments,
Environment.GetCommandLineArgs());
var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(launchArguments);
IsDevelopmentLiveLaunchRequested = developmentLive.IsRequested;
IsDevelopmentLiveLaunch = developmentLive.IsApplied;
DevelopmentLiveStartupFailureCode = developmentLive.IsLaunchAllowed
? null
: developmentLive.Code;
DevelopmentLiveStartupFailureMessage = developmentLive.FailureMessage;
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");
_window = new MainWindow();
#endif
_window.Activate();
}

View File

@@ -205,7 +205,7 @@
Text="External legacy runtime paths are enabled. Cuts files remain in the selected folder and 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). Run scripts\Initialize-ExistingDevelopmentPc.ps1 to create the verified local Required binding, 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 setup build." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyCutsSourceRoot)')"
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />

View File

@@ -32,21 +32,33 @@ public sealed partial class MainWindow
{
try
{
if (_developmentLiveStartupBlocked)
{
_playoutInitializationError =
App.DevelopmentLiveStartupFailureMessage ??
"Live playout startup validation failed.";
return;
}
var explicitLocalConfigurationExists = File.Exists(
PlayoutOptionsLoader.DefaultPath);
_playoutOptions =
_isDevelopmentLiveLaunch &&
!_developmentLiveStartupBlocked
_isDevelopmentLiveLaunch
? PlayoutOptionsLoader.LoadDevelopmentLive(
baseDirectory: AppContext.BaseDirectory,
operatorSceneDirectory:
_appliedOperatorSettings.SceneDirectory)
: PlayoutOptionsLoader.Load(
operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory,
forceSafeDryRun: IsSourceOnlyBuild ||
_isDevelopmentLiveLaunchRequested ||
_developmentLiveStartupBlocked);
operatorBackgroundDirectory:
_appliedOperatorSettings.BackgroundDirectory);
if (_playoutOptions.Mode != PlayoutMode.Live)
{
throw new PlayoutConfigurationException(
"The application requires a validated Live playout configuration. " +
"DryRun fallback is disabled.");
}
ResetRefreshState();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions,
@@ -75,6 +87,7 @@ public sealed partial class MainWindow
}
catch (Exception exception)
{
_developmentLiveStartupBlocked = true;
_playoutInitializationWarning = null;
_playoutInitializationError = exception is PlayoutConfigurationException
? exception.Message

View File

@@ -64,6 +64,9 @@ public sealed partial class MainWindow : Window
{
_isDevelopmentLiveLaunch = App.IsDevelopmentLiveLaunch;
_isDevelopmentLiveLaunchRequested = App.IsDevelopmentLiveLaunchRequested;
_developmentLiveStartupBlocked =
_isDevelopmentLiveLaunchRequested &&
!_isDevelopmentLiveLaunch;
// Capture and remove Gate A bearer material before XAML can create WebView2.
// Only the native process keeps the capability digest for this launch.
_playoutLaunchAuthorization =
@@ -119,9 +122,15 @@ public sealed partial class MainWindow : Window
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
CorePagePlanProvider? fixedPagePlanProvider = null;
string? initializationError = null;
var databaseInitializationSucceeded = false;
try
{
if (_developmentLiveStartupBlocked)
{
throw new InvalidOperationException(
App.DevelopmentLiveStartupFailureMessage ??
"Live playout startup validation failed.");
}
if (IsSourceOnlyBuild)
{
throw new InvalidOperationException(
@@ -198,14 +207,16 @@ 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에서는 데이터베이스 연결을 사용하지 않습니다."
initializationError = _developmentLiveStartupBlocked
? App.DevelopmentLiveStartupFailureMessage ??
"Live playout startup validation failed."
: IsSourceOnlyBuild
? "소스 전용 설정 빌드에서는 데이터베이스 연결을 사용하지 않습니다."
: "데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
stockLookup = new UnavailableStockLookup(initializationError);
industrySelectionService = new UnavailableIndustrySelectionService(
@@ -241,14 +252,6 @@ 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);

View File

@@ -3,10 +3,6 @@
"MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)": {
"commandName": "MsixPackage",
"commandLineArgs": "--development-live"
},
"MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)": {
"commandName": "MsixPackage",
"commandLineArgs": ""
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Security;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -8,6 +9,7 @@ internal enum DevelopmentLiveBootstrapStatus
{
Skipped,
Applied,
GateABypassed,
Rejected
}
@@ -16,6 +18,31 @@ internal readonly record struct DevelopmentLiveBootstrapResult(
string Code)
{
public bool IsApplied => Status == DevelopmentLiveBootstrapStatus.Applied;
public bool IsLaunchAllowed =>
Status is DevelopmentLiveBootstrapStatus.Applied or
DevelopmentLiveBootstrapStatus.GateABypassed;
public bool IsRequested =>
Status is DevelopmentLiveBootstrapStatus.Applied or
DevelopmentLiveBootstrapStatus.Rejected;
public string? FailureMessage => Status == DevelopmentLiveBootstrapStatus.Rejected
? Code switch
{
"development-live-arguments-invalid" =>
"Live startup was rejected because the launch arguments were not recognized.",
"development-live-gate-a-inspection-failed" =>
"Gate A launch authorization could not be inspected.",
"development-live-authorization-unavailable" =>
"Live startup authorization is unavailable.",
"development-live-authorization-invalid" =>
"Live startup authorization is invalid.",
"development-live-environment-failed" =>
"Live startup authorization could not be applied to this process.",
_ => "Live startup authorization failed."
}
: null;
}
internal interface IDevelopmentLiveBootstrapPlatform
@@ -28,10 +55,10 @@ internal interface IDevelopmentLiveBootstrapPlatform
}
/// <summary>
/// Arms the existing process-scoped Live and vendor-binary gates for one explicit
/// Visual Studio Debug launch. The ordinary app startup path never calls this with
/// a true debug-build capability, and the authorization file is deliberately kept
/// outside the package and source tree.
/// Arms the existing process-scoped Live and vendor-binary gates for a full-runtime
/// application launch. Both ordinary no-argument launches and the retained exact
/// development-live argument use the same strict local authorization file, which
/// is deliberately kept outside the package and source tree.
/// </summary>
internal static class DevelopmentLiveLaunchBootstrap
{
@@ -46,6 +73,8 @@ internal static class DevelopmentLiveLaunchBootstrap
private const string AuthorizationProperty = "authorization";
private const string NativeSha256Property = "nativeSha256";
private const string InteropSha256Property = "interopSha256";
private const string InvalidLaunchArgumentsMarker =
"\0MBN_STOCK_WEBVIEW_INVALID_LAUNCH_ARGUMENTS";
private static readonly IReadOnlySet<string> AllowedProperties =
new HashSet<string>(StringComparer.Ordinal)
@@ -65,6 +94,14 @@ internal static class DevelopmentLiveLaunchBootstrap
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable
];
private static readonly string[] GateAEnvironmentNames =
[
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable,
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable
];
internal static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
@@ -86,11 +123,22 @@ internal static class DevelopmentLiveLaunchBootstrap
return activationArguments;
}
return processArguments.Count == 2
? processArguments[1]
: null;
return processArguments.Count switch
{
<= 1 => null,
2 => processArguments[1],
_ => InvalidLaunchArgumentsMarker
};
}
internal static DevelopmentLiveBootstrapResult TryApply(
string? launchArguments) =>
TryApply(
launchArguments,
isDebugBuild: true,
DefaultPath,
new SystemDevelopmentLiveBootstrapPlatform());
internal static DevelopmentLiveBootstrapResult TryApply(
string? launchArguments,
bool isDebugBuild) =>
@@ -108,21 +156,32 @@ internal static class DevelopmentLiveLaunchBootstrap
{
ArgumentNullException.ThrowIfNull(platform);
if (!string.Equals(
// Retain the isDebugBuild parameter for callers compiled against the
// previous API. Release and Debug now intentionally share this exact gate.
_ = isDebugBuild;
if (!string.IsNullOrEmpty(launchArguments) &&
!string.Equals(
launchArguments,
ExactLaunchArgument,
StringComparison.Ordinal))
{
return new DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus.Skipped,
"development-live-not-requested");
return Rejected("development-live-arguments-invalid");
}
if (!isDebugBuild)
if (!TryHasGateAEnvironment(platform, out var hasGateAEnvironment))
{
return Rejected("development-live-gate-a-inspection-failed");
}
// Gate A owns and consumes its process-scoped bearer values in
// PlayoutLaunchAuthorization. Do not read the ordinary authorization file
// and do not set, clear or roll back any environment value in this branch.
if (hasGateAEnvironment)
{
return new DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus.Skipped,
"development-live-debug-only");
DevelopmentLiveBootstrapStatus.GateABypassed,
"development-live-gate-a-bypassed");
}
if (string.IsNullOrWhiteSpace(authorizationFilePath))
@@ -305,13 +364,37 @@ internal static class DevelopmentLiveLaunchBootstrap
}
}
private static bool TryHasGateAEnvironment(
IDevelopmentLiveBootstrapPlatform platform,
out bool hasGateAEnvironment)
{
hasGateAEnvironment = false;
try
{
foreach (var name in GateAEnvironmentNames)
{
if (platform.GetProcessEnvironmentVariable(name) is not null)
{
hasGateAEnvironment = true;
}
}
return true;
}
catch (Exception exception) when (IsExpectedEnvironmentFailure(exception))
{
hasGateAEnvironment = false;
return false;
}
}
private static DevelopmentLiveBootstrapResult RejectedAndDisarm(
IDevelopmentLiveBootstrapPlatform platform,
string code)
{
// An exact Debug request that failed validation must not fall through to
// A required Live 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.
// The app treats this requested-but-not-applied state as a startup failure.
for (var index = ArmedEnvironmentNames.Length - 1; index >= 0; index--)
{
var name = ArmedEnvironmentNames[index];
@@ -354,7 +437,7 @@ internal static class DevelopmentLiveLaunchBootstrap
private static bool IsExpectedFileFailure(Exception exception) =>
exception is IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException;
InvalidDataException or ArgumentException or NotSupportedException;
private static bool IsExpectedEnvironmentFailure(Exception exception) =>
exception is ArgumentException or SecurityException or InvalidOperationException;

View File

@@ -88,11 +88,10 @@ public static class PlayoutOptionsLoader
}
/// <summary>
/// Loads the inert LocalAppData base profile for an already-applied exact
/// Debug Development Live bootstrap. This path deliberately ignores process
/// environment and ordinary playout-profile scene overrides. Live mode is
/// armed from bootstrap provenance, and the scene root is pinned to the
/// native folder selected during first-run setup.
/// Loads the protected LocalAppData Live profile for an already-applied
/// Development Live bootstrap. This path deliberately ignores process
/// environment and ordinary playout-profile scene overrides. The scene root
/// is pinned to the native folder selected during first-run setup.
/// </summary>
internal static PlayoutOptions LoadDevelopmentLive(
string? path = null,
@@ -188,7 +187,7 @@ public static class PlayoutOptionsLoader
private static void EnsureDevelopmentLiveBaseContract(PlayoutOptions options)
{
if (options.Mode != PlayoutMode.DryRun ||
if (options.Mode != PlayoutMode.Live ||
!IsLiteralLoopback(options.Host) ||
options.Port is < 1 or > 65_535 ||
options.TcpMode != 1 ||
@@ -285,7 +284,7 @@ public static class PlayoutOptionsLoader
}
if (seen.Count != DevelopmentLiveProperties.Count ||
!IsExactString(root, "mode", "DryRun") ||
!IsExactString(root, "mode", "Live") ||
!IsLoopbackString(root, "host") ||
!IsIntegerInRange(root, "port", 1, 65_535) ||
!IsExactInteger(root, "tcpMode", 1) ||