feat: add audited one-shot prepare gate
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
#nullable enable
|
||||
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the native database-write boundary that is requesting
|
||||
/// authorization. The request deliberately contains no operator-entered data.
|
||||
/// </summary>
|
||||
public enum DatabaseMutationTarget
|
||||
{
|
||||
OperatorCatalog,
|
||||
NamedPlaylist,
|
||||
ManualFinancial
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes one validated transaction immediately before its executor can
|
||||
/// create a database connection.
|
||||
/// </summary>
|
||||
public sealed record DatabaseMutationAuthorizationRequest(
|
||||
DatabaseMutationTarget Target,
|
||||
DataSourceKind Source,
|
||||
Guid OperationId,
|
||||
string MutationKind);
|
||||
|
||||
/// <summary>
|
||||
/// Final authorization seam for state-changing database operations. Returning
|
||||
/// false denies the transaction before a connection or transaction is created.
|
||||
/// Throwing also fails closed and is never converted into an allow decision.
|
||||
/// </summary>
|
||||
public interface IDatabaseMutationAuthorization
|
||||
{
|
||||
bool IsAuthorized(DatabaseMutationAuthorizationRequest request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides the compatibility policy used when existing constructors do not
|
||||
/// supply an explicit database-mutation authorization policy.
|
||||
/// </summary>
|
||||
public static class DatabaseMutationAuthorization
|
||||
{
|
||||
public static IDatabaseMutationAuthorization AllowAll { get; } =
|
||||
new AllowAllDatabaseMutationAuthorization();
|
||||
|
||||
private sealed class AllowAllDatabaseMutationAuthorization
|
||||
: IDatabaseMutationAuthorization
|
||||
{
|
||||
public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the active policy denies a database mutation before any native
|
||||
/// database connection is created.
|
||||
/// </summary>
|
||||
public sealed class DatabaseMutationAuthorizationException : InvalidOperationException
|
||||
{
|
||||
public DatabaseMutationAuthorizationException(
|
||||
DatabaseMutationAuthorizationRequest request,
|
||||
Exception? innerException = null)
|
||||
: base(
|
||||
$"The {request?.Target} database mutation was denied before a connection was opened.",
|
||||
innerException)
|
||||
{
|
||||
Request = request ?? throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
public DatabaseMutationAuthorizationRequest Request { get; }
|
||||
}
|
||||
|
||||
internal static class DatabaseMutationAuthorizationGuard
|
||||
{
|
||||
internal static void Demand(
|
||||
IDatabaseMutationAuthorization authorization,
|
||||
DatabaseMutationAuthorizationRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authorization);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
bool authorized;
|
||||
try
|
||||
{
|
||||
authorized = authorization.IsAuthorized(request);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new DatabaseMutationAuthorizationException(request, exception);
|
||||
}
|
||||
|
||||
if (!authorized)
|
||||
{
|
||||
throw new DatabaseMutationAuthorizationException(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
|
||||
{
|
||||
private readonly IDatabaseConnectionFactory _connectionFactory;
|
||||
private readonly ITransientDatabaseErrorDetector _errorDetector;
|
||||
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
|
||||
private readonly TimeSpan _operationTimeout;
|
||||
private readonly Action<DataSourceKind, DbCommand> _configureCommand;
|
||||
|
||||
@@ -28,7 +29,22 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector ?? new TransientDatabaseErrorDetector(),
|
||||
ConfigureProviderCommand)
|
||||
ConfigureProviderCommand,
|
||||
DatabaseMutationAuthorization.AllowAll)
|
||||
{
|
||||
}
|
||||
|
||||
public OperatorCatalogMutationExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector? errorDetector,
|
||||
IDatabaseMutationAuthorization mutationAuthorization)
|
||||
: this(
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector ?? new TransientDatabaseErrorDetector(),
|
||||
ConfigureProviderCommand,
|
||||
mutationAuthorization)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -37,6 +53,21 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector errorDetector,
|
||||
Action<DataSourceKind, DbCommand> configureCommand)
|
||||
: this(
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector,
|
||||
configureCommand,
|
||||
DatabaseMutationAuthorization.AllowAll)
|
||||
{
|
||||
}
|
||||
|
||||
internal OperatorCatalogMutationExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector errorDetector,
|
||||
Action<DataSourceKind, DbCommand> configureCommand,
|
||||
IDatabaseMutationAuthorization mutationAuthorization)
|
||||
{
|
||||
_connectionFactory = connectionFactory ??
|
||||
throw new ArgumentNullException(nameof(connectionFactory));
|
||||
@@ -44,6 +75,8 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
|
||||
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
|
||||
_configureCommand = configureCommand ??
|
||||
throw new ArgumentNullException(nameof(configureCommand));
|
||||
_mutationAuthorization = mutationAuthorization ??
|
||||
throw new ArgumentNullException(nameof(mutationAuthorization));
|
||||
|
||||
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
|
||||
validationOptions.ValidateRuntimeSettings();
|
||||
@@ -56,6 +89,23 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
|
||||
{
|
||||
ValidateTransaction(transaction);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
DatabaseMutationAuthorizationGuard.Demand(
|
||||
_mutationAuthorization,
|
||||
new DatabaseMutationAuthorizationRequest(
|
||||
DatabaseMutationTarget.OperatorCatalog,
|
||||
transaction.Source,
|
||||
transaction.OperationId,
|
||||
transaction.Kind.ToString()));
|
||||
}
|
||||
catch (DatabaseMutationAuthorizationException exception)
|
||||
{
|
||||
throw new OperatorCatalogMutationException(
|
||||
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
|
||||
outcomeUnknown: false,
|
||||
exception);
|
||||
}
|
||||
var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source);
|
||||
if (commandTimeoutSeconds is < 1 or > 600)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed partial class OracleManualFinancialMutationExecutor
|
||||
{
|
||||
private readonly IDatabaseConnectionFactory _connectionFactory;
|
||||
private readonly ITransientDatabaseErrorDetector _errorDetector;
|
||||
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
|
||||
private readonly TimeSpan _operationTimeout;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
private readonly Action<DbCommand> _configureCommand;
|
||||
@@ -31,7 +32,22 @@ public sealed partial class OracleManualFinancialMutationExecutor
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector ?? new TransientDatabaseErrorDetector(),
|
||||
ConfigureOracleCommand)
|
||||
ConfigureOracleCommand,
|
||||
DatabaseMutationAuthorization.AllowAll)
|
||||
{
|
||||
}
|
||||
|
||||
public OracleManualFinancialMutationExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector? errorDetector,
|
||||
IDatabaseMutationAuthorization mutationAuthorization)
|
||||
: this(
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector ?? new TransientDatabaseErrorDetector(),
|
||||
ConfigureOracleCommand,
|
||||
mutationAuthorization)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -40,12 +56,29 @@ public sealed partial class OracleManualFinancialMutationExecutor
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector errorDetector,
|
||||
Action<DbCommand> configureCommand)
|
||||
: this(
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector,
|
||||
configureCommand,
|
||||
DatabaseMutationAuthorization.AllowAll)
|
||||
{
|
||||
}
|
||||
|
||||
internal OracleManualFinancialMutationExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector errorDetector,
|
||||
Action<DbCommand> configureCommand,
|
||||
IDatabaseMutationAuthorization mutationAuthorization)
|
||||
{
|
||||
_connectionFactory = connectionFactory ??
|
||||
throw new ArgumentNullException(nameof(connectionFactory));
|
||||
ArgumentNullException.ThrowIfNull(resilienceOptions);
|
||||
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
|
||||
_configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand));
|
||||
_mutationAuthorization = mutationAuthorization ??
|
||||
throw new ArgumentNullException(nameof(mutationAuthorization));
|
||||
|
||||
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
|
||||
validationOptions.ValidateRuntimeSettings();
|
||||
@@ -73,6 +106,23 @@ public sealed partial class OracleManualFinancialMutationExecutor
|
||||
|
||||
ValidateTransaction(transaction);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
DatabaseMutationAuthorizationGuard.Demand(
|
||||
_mutationAuthorization,
|
||||
new DatabaseMutationAuthorizationRequest(
|
||||
DatabaseMutationTarget.ManualFinancial,
|
||||
DataSourceKind.Oracle,
|
||||
transaction.OperationId,
|
||||
transaction.Kind.ToString()));
|
||||
}
|
||||
catch (DatabaseMutationAuthorizationException exception)
|
||||
{
|
||||
throw new ManualFinancialMutationException(
|
||||
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
|
||||
outcomeUnknown: false,
|
||||
exception);
|
||||
}
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(_operationTimeout);
|
||||
var operationToken = timeoutSource.Token;
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
|
||||
{
|
||||
private readonly IDatabaseConnectionFactory _connectionFactory;
|
||||
private readonly ITransientDatabaseErrorDetector _errorDetector;
|
||||
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
|
||||
private readonly TimeSpan _operationTimeout;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
private readonly Action<DbCommand> _configureCommand;
|
||||
@@ -29,7 +30,22 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector ?? new TransientDatabaseErrorDetector(),
|
||||
ConfigureOracleCommand)
|
||||
ConfigureOracleCommand,
|
||||
DatabaseMutationAuthorization.AllowAll)
|
||||
{
|
||||
}
|
||||
|
||||
public OracleNamedPlaylistMutationExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector? errorDetector,
|
||||
IDatabaseMutationAuthorization mutationAuthorization)
|
||||
: this(
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector ?? new TransientDatabaseErrorDetector(),
|
||||
ConfigureOracleCommand,
|
||||
mutationAuthorization)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -38,6 +54,21 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector errorDetector,
|
||||
Action<DbCommand> configureCommand)
|
||||
: this(
|
||||
connectionFactory,
|
||||
resilienceOptions,
|
||||
errorDetector,
|
||||
configureCommand,
|
||||
DatabaseMutationAuthorization.AllowAll)
|
||||
{
|
||||
}
|
||||
|
||||
internal OracleNamedPlaylistMutationExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector errorDetector,
|
||||
Action<DbCommand> configureCommand,
|
||||
IDatabaseMutationAuthorization mutationAuthorization)
|
||||
{
|
||||
_connectionFactory = connectionFactory ??
|
||||
throw new ArgumentNullException(nameof(connectionFactory));
|
||||
@@ -45,6 +76,8 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
|
||||
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
|
||||
_configureCommand = configureCommand ??
|
||||
throw new ArgumentNullException(nameof(configureCommand));
|
||||
_mutationAuthorization = mutationAuthorization ??
|
||||
throw new ArgumentNullException(nameof(mutationAuthorization));
|
||||
|
||||
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
|
||||
validationOptions.ValidateRuntimeSettings();
|
||||
@@ -72,6 +105,23 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
|
||||
|
||||
ValidateTransaction(transaction);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
DatabaseMutationAuthorizationGuard.Demand(
|
||||
_mutationAuthorization,
|
||||
new DatabaseMutationAuthorizationRequest(
|
||||
DatabaseMutationTarget.NamedPlaylist,
|
||||
DataSourceKind.Oracle,
|
||||
transaction.OperationId,
|
||||
transaction.Kind.ToString()));
|
||||
}
|
||||
catch (DatabaseMutationAuthorizationException exception)
|
||||
{
|
||||
throw new NamedPlaylistMutationException(
|
||||
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
|
||||
outcomeUnknown: false,
|
||||
exception);
|
||||
}
|
||||
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(_operationTimeout);
|
||||
|
||||
@@ -6,6 +6,12 @@ namespace MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
public sealed record LegacyExecutePlayoutIntent(LegacyOperatorPlayoutCommand Command)
|
||||
: LegacyUiIntent;
|
||||
|
||||
/// <summary>
|
||||
/// Process-scoped Gate A PREPARE request. The opaque capability is validated and
|
||||
/// consumed by the native launch authorization; it is never emitted in state.
|
||||
/// </summary>
|
||||
public sealed record LegacyGateAPrepareIntent(string Capability) : LegacyUiIntent;
|
||||
|
||||
public sealed record LegacySetFadeDurationIntent(int Duration)
|
||||
: LegacyUiIntent;
|
||||
|
||||
@@ -17,6 +23,7 @@ internal static class LegacyPlayoutUiIntentParser
|
||||
{
|
||||
public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch
|
||||
{
|
||||
"gate-a-prepare" => ParseGateAPrepare(payload),
|
||||
"prepare-playout" => ParseEmpty(
|
||||
payload,
|
||||
static () => new LegacyExecutePlayoutIntent(
|
||||
@@ -43,6 +50,29 @@ internal static class LegacyPlayoutUiIntentParser
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static LegacyUiIntent? ParseGateAPrepare(JsonElement payload)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
payload.GetRawText().Length > 128 ||
|
||||
payload.EnumerateObject().Count() != 1 ||
|
||||
!payload.TryGetProperty("capability", out var capabilityElement) ||
|
||||
capabilityElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var capability = capabilityElement.GetString();
|
||||
if (capability is null || capability.Length != 64 ||
|
||||
capability.Any(character =>
|
||||
character is not (>= '0' and <= '9') and
|
||||
not (>= 'A' and <= 'F')))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LegacyGateAPrepareIntent(capability);
|
||||
}
|
||||
|
||||
private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
|
||||
@@ -5,6 +5,7 @@ using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.Storage.Pickers;
|
||||
using WinRT.Interop;
|
||||
@@ -45,7 +46,9 @@ public sealed partial class MainWindow
|
||||
: null;
|
||||
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
|
||||
initialComposition);
|
||||
_playoutEngine = PlayoutEngineFactory.Create(_playoutOptions);
|
||||
_playoutEngine = PlayoutEngineFactory.Create(
|
||||
_playoutOptions,
|
||||
_playoutLaunchAuthorization);
|
||||
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
|
||||
if (_databaseRuntime is not null)
|
||||
{
|
||||
@@ -118,8 +121,19 @@ public sealed partial class MainWindow
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
|
||||
LegacyOperatorPlayoutCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
bool gateAPrepareClaimed = false,
|
||||
LegacyOperatorPlayoutRequest? gateAPrepareRequest = null)
|
||||
{
|
||||
if (_playoutLaunchAuthorization.IsGateA &&
|
||||
(!gateAPrepareClaimed ||
|
||||
command != LegacyOperatorPlayoutCommand.Prepare ||
|
||||
gateAPrepareRequest is null))
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"Gate A 검증 회차에서는 승인된 5001 PREPARE 한 번만 실행할 수 있습니다.");
|
||||
}
|
||||
|
||||
var engine = _playoutEngine;
|
||||
var workflow = _playoutWorkflow;
|
||||
if (engine is null || workflow is null)
|
||||
@@ -163,8 +177,9 @@ public sealed partial class MainWindow
|
||||
switch (command)
|
||||
{
|
||||
case LegacyOperatorPlayoutCommand.Prepare:
|
||||
var request = _controller.CreatePlayoutRequest(
|
||||
_compositionOptions!.Current.FadeDuration);
|
||||
var request = gateAPrepareRequest ??
|
||||
_controller.CreatePlayoutRequest(
|
||||
_compositionOptions!.Current.FadeDuration);
|
||||
result = await workflow.PrepareAsync(
|
||||
request.Playlist,
|
||||
request.SelectedIndexZeroBased,
|
||||
@@ -265,10 +280,112 @@ public sealed partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
|
||||
string capability,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_playoutLaunchAuthorization.IsGateA ||
|
||||
!_playoutLaunchAuthorization.TryClaimGateAPrepare(capability))
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"Gate A PREPARE 승인이 없거나 이미 사용되었습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateExactGateAPrepareRequest(out var request))
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"Gate A PREPARE 직전 상태가 승인된 삼성전자 5001 page 1 조건과 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
return await ExecuteOperatorPlayoutAsync(
|
||||
LegacyOperatorPlayoutCommand.Prepare,
|
||||
cancellationToken,
|
||||
gateAPrepareClaimed: true,
|
||||
gateAPrepareRequest: request).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Success, rejection, cancellation and unknown outcome all consume the
|
||||
// one process-lifetime capability. There is deliberately no retry path.
|
||||
_playoutLaunchAuthorization.CompleteGateAPrepare();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryCreateExactGateAPrepareRequest(
|
||||
out LegacyOperatorPlayoutRequest? request)
|
||||
{
|
||||
request = null;
|
||||
var snapshot = _controller.Current;
|
||||
if (snapshot.Playlist.Count != 1 ||
|
||||
_compositionOptions?.Current is not { } composition ||
|
||||
composition.FadeDuration != 6 ||
|
||||
composition.BackgroundKind != LegacySceneBackgroundKind.None)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var row = snapshot.Playlist[0];
|
||||
if (!row.IsEnabled || !row.IsActive ||
|
||||
!string.Equals(row.MarketText, "코스피", StringComparison.Ordinal) ||
|
||||
!string.Equals(row.StockName, "삼성전자", StringComparison.Ordinal) ||
|
||||
!string.Equals(row.GraphicType, "1열판기본", StringComparison.Ordinal) ||
|
||||
!string.Equals(row.Subtype, "현재가", StringComparison.Ordinal) ||
|
||||
!string.Equals(row.PageText, "1/1", StringComparison.Ordinal) ||
|
||||
!string.Equals(row.StockCode, "005930", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
LegacyOperatorPlayoutRequest candidate;
|
||||
try
|
||||
{
|
||||
candidate = _controller.CreatePlayoutRequest(6);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.SelectedIndexZeroBased != 0 ||
|
||||
candidate.Playlist.Count != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entry = candidate.Playlist[0];
|
||||
var selection = entry.Selection;
|
||||
if (!entry.IsEnabled ||
|
||||
!string.Equals(entry.EntryId, row.RowId, StringComparison.Ordinal) ||
|
||||
!string.Equals(entry.CutCode, "5001", StringComparison.Ordinal) ||
|
||||
entry.FadeDuration != 6 ||
|
||||
entry.PageNavigation?.IsEnabled == true ||
|
||||
entry.MovingAverageSelectionSource is not null ||
|
||||
selection is null ||
|
||||
!string.Equals(selection.GroupCode, "코스피", StringComparison.Ordinal) ||
|
||||
!string.Equals(selection.Subject, "삼성전자", StringComparison.Ordinal) ||
|
||||
!string.Equals(selection.GraphicType, "1열판기본", StringComparison.Ordinal) ||
|
||||
!string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) ||
|
||||
!string.Equals(selection.DataCode, "005930", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(
|
||||
int fadeDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (fadeDuration is < 0 or > 19)
|
||||
{
|
||||
return _controller.ReportError("DissolveTime 값이 올바르지 않습니다.");
|
||||
@@ -308,6 +425,12 @@ public sealed partial class MainWindow
|
||||
private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
|
||||
@@ -349,6 +472,12 @@ public sealed partial class MainWindow
|
||||
private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
|
||||
@@ -534,6 +663,27 @@ public sealed partial class MainWindow
|
||||
LegacyConfirmManualListIntent or
|
||||
LegacyImportManualListsIntent;
|
||||
|
||||
private static bool IsPersistentWriteIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyDeleteUc4SelectedThemeIntent or
|
||||
LegacyDeleteUc6SelectedExpertIntent or
|
||||
LegacySaveOperatorCatalogIntent or
|
||||
LegacyConfirmNativeDialogIntent or
|
||||
LegacyAddComparisonPairIntent or
|
||||
LegacyMoveComparisonPairIntent or
|
||||
LegacyDeleteSelectedComparisonPairIntent or
|
||||
LegacyDeleteAllComparisonPairsIntent or
|
||||
LegacySaveManualFinancialIntent or
|
||||
LegacyDeleteManualFinancialIntent or
|
||||
LegacyDeleteAllManualFinancialIntent or
|
||||
LegacySaveManualNetSellIntent or
|
||||
LegacySaveManualViIntent or
|
||||
LegacyConfirmManualListIntent or
|
||||
LegacyImportManualListsIntent or
|
||||
LegacyCreateNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistToIntent or
|
||||
LegacyDeleteSelectedNamedPlaylistIntent;
|
||||
|
||||
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
|
||||
LegacySetManualNetSellCellIntent or
|
||||
LegacyAddManualViResultIntent or
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Runtime.InteropServices;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using MMoneyCoderSharp.Data;
|
||||
@@ -21,6 +22,7 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
|
||||
private readonly WindowSubclassProcedure _windowSubclassProcedure;
|
||||
private readonly LegacyOperatorController _controller;
|
||||
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
|
||||
@@ -36,6 +38,10 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
// Capture and remove Gate A bearer material before XAML can create WebView2.
|
||||
// Only the native process keeps the capability digest for this launch.
|
||||
_playoutLaunchAuthorization =
|
||||
PlayoutLaunchAuthorization.CaptureFromEnvironment();
|
||||
_windowSubclassProcedure = OnWindowSubclassMessage;
|
||||
InitializeComponent();
|
||||
EnsureCloseConfirmationAttached();
|
||||
@@ -72,6 +78,8 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
_databaseRuntime = DatabaseRuntime.CreateDefault();
|
||||
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||
var databaseMutationAuthorization =
|
||||
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
|
||||
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
|
||||
industrySelectionService = new LegacyIndustrySelectionService(
|
||||
_databaseRuntime.Executor);
|
||||
@@ -91,17 +99,23 @@ public sealed partial class MainWindow : Window
|
||||
_databaseRuntime.Executor,
|
||||
new OracleManualFinancialMutationExecutor(
|
||||
_databaseRuntime.ConnectionFactory,
|
||||
_databaseRuntime.Options.Resilience));
|
||||
_databaseRuntime.Options.Resilience,
|
||||
errorDetector: null,
|
||||
mutationAuthorization: databaseMutationAuthorization));
|
||||
manualFinancialStockSearchService = new LegacyStockSearchService(
|
||||
_databaseRuntime.Executor);
|
||||
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
|
||||
_databaseRuntime.Executor,
|
||||
new OracleNamedPlaylistMutationExecutor(
|
||||
_databaseRuntime.ConnectionFactory,
|
||||
_databaseRuntime.Options.Resilience));
|
||||
_databaseRuntime.Options.Resilience,
|
||||
errorDetector: null,
|
||||
mutationAuthorization: databaseMutationAuthorization));
|
||||
var operatorCatalogMutationExecutor = new OperatorCatalogMutationExecutor(
|
||||
_databaseRuntime.ConnectionFactory,
|
||||
_databaseRuntime.Options.Resilience);
|
||||
_databaseRuntime.Options.Resilience,
|
||||
errorDetector: null,
|
||||
mutationAuthorization: databaseMutationAuthorization);
|
||||
themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService(
|
||||
_databaseRuntime.Executor,
|
||||
operatorCatalogMutationExecutor);
|
||||
@@ -377,6 +391,15 @@ public sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (_playoutLaunchAuthorization.IsGateA &&
|
||||
IsPersistentWriteIntent(intent))
|
||||
{
|
||||
PostState(ReportIntentFailure(
|
||||
intent,
|
||||
"Gate A 검증 회차에서는 DB 및 로컬 영구 저장 변경이 허용되지 않습니다."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_controller.Current.Dialog?.IsConfirmation == true &&
|
||||
intent is not LegacyReadyIntent and
|
||||
not LegacyConfirmNativeDialogIntent and
|
||||
@@ -469,6 +492,7 @@ public sealed partial class MainWindow : Window
|
||||
LegacySaveCurrentNamedPlaylistToIntent or
|
||||
LegacyDeleteSelectedNamedPlaylistIntent or
|
||||
LegacyExecutePlayoutIntent or
|
||||
LegacyGateAPrepareIntent or
|
||||
LegacySetFadeDurationIntent or
|
||||
LegacyToggleBackgroundIntent or
|
||||
LegacyChooseBackgroundIntent)
|
||||
@@ -961,6 +985,10 @@ public sealed partial class MainWindow : Window
|
||||
await _controller.ConfirmManualListsAsync(cancellationToken),
|
||||
LegacyImportManualListsIntent =>
|
||||
await _controller.ImportLegacyManualListsAsync(cancellationToken),
|
||||
LegacyGateAPrepareIntent prepare =>
|
||||
await ExecuteGateAPrepareAsync(
|
||||
prepare.Capability,
|
||||
cancellationToken),
|
||||
LegacyExecutePlayoutIntent playout =>
|
||||
await ExecuteOperatorPlayoutAsync(
|
||||
playout.Command,
|
||||
@@ -1038,6 +1066,25 @@ public sealed partial class MainWindow : Window
|
||||
outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or
|
||||
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
|
||||
|
||||
private sealed class LaunchDatabaseMutationAuthorization
|
||||
: IDatabaseMutationAuthorization
|
||||
{
|
||||
private readonly PlayoutLaunchAuthorization _authorization;
|
||||
|
||||
public LaunchDatabaseMutationAuthorization(
|
||||
PlayoutLaunchAuthorization authorization)
|
||||
{
|
||||
_authorization = authorization ??
|
||||
throw new ArgumentNullException(nameof(authorization));
|
||||
}
|
||||
|
||||
public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return _authorization.AllowsDatabaseWrites;
|
||||
}
|
||||
}
|
||||
|
||||
private void PostState(LegacyOperatorSnapshot snapshot)
|
||||
{
|
||||
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
|
||||
|
||||
@@ -71,12 +71,23 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
|
||||
private readonly ILateBoundComActivator _activator;
|
||||
private readonly ILateBoundComMethodInvoker _invoker;
|
||||
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
|
||||
private readonly bool _rollbackOnPrepareFailure;
|
||||
|
||||
public DynamicK3dSessionFactory()
|
||||
: this(
|
||||
new InstalledK3dInteropComActivator(),
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new InstalledK3dEventHandlerFactory())
|
||||
new InstalledK3dEventHandlerFactory(),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(bool rollbackOnPrepareFailure)
|
||||
: this(
|
||||
new InstalledK3dInteropComActivator(),
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new InstalledK3dEventHandlerFactory(),
|
||||
rollbackOnPrepareFailure)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -84,32 +95,40 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
|
||||
: this(
|
||||
activator,
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new ActivatorK3dEventHandlerFactory(activator))
|
||||
new ActivatorK3dEventHandlerFactory(activator),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComMethodInvoker invoker)
|
||||
: this(activator, invoker, new ActivatorK3dEventHandlerFactory(activator))
|
||||
: this(
|
||||
activator,
|
||||
invoker,
|
||||
new ActivatorK3dEventHandlerFactory(activator),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComMethodInvoker invoker,
|
||||
IK3dEventHandlerFactory eventHandlerFactory)
|
||||
IK3dEventHandlerFactory eventHandlerFactory,
|
||||
bool rollbackOnPrepareFailure = true)
|
||||
{
|
||||
_activator = activator;
|
||||
_invoker = invoker;
|
||||
_eventHandlerFactory = eventHandlerFactory;
|
||||
_rollbackOnPrepareFailure = rollbackOnPrepareFailure;
|
||||
}
|
||||
|
||||
public IK3dSession Create() => new DynamicK3dSession(
|
||||
_activator,
|
||||
new RuntimeComObjectReleaser(),
|
||||
_invoker,
|
||||
_eventHandlerFactory);
|
||||
_eventHandlerFactory,
|
||||
_rollbackOnPrepareFailure);
|
||||
}
|
||||
|
||||
internal interface ILateBoundComActivator
|
||||
@@ -155,6 +174,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
private readonly ILateBoundComObjectReleaser _releaser;
|
||||
private readonly ILateBoundComMethodInvoker _invoker;
|
||||
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
|
||||
private readonly bool _rollbackOnPrepareFailure;
|
||||
private readonly K3dEventQueue _eventQueue = new();
|
||||
private object? _engine;
|
||||
private object? _eventHandler;
|
||||
@@ -177,7 +197,8 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
activator,
|
||||
new RuntimeComObjectReleaser(),
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new ActivatorK3dEventHandlerFactory(activator))
|
||||
new ActivatorK3dEventHandlerFactory(activator),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -204,12 +225,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComObjectReleaser releaser,
|
||||
ILateBoundComMethodInvoker invoker,
|
||||
IK3dEventHandlerFactory eventHandlerFactory)
|
||||
IK3dEventHandlerFactory eventHandlerFactory,
|
||||
bool rollbackOnPrepareFailure = true)
|
||||
{
|
||||
_activator = activator;
|
||||
_releaser = releaser;
|
||||
_invoker = invoker;
|
||||
_eventHandlerFactory = eventHandlerFactory;
|
||||
_rollbackOnPrepareFailure = rollbackOnPrepareFailure;
|
||||
}
|
||||
|
||||
public bool IsConnected => _engine is not null && _player is not null;
|
||||
@@ -484,7 +507,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transactionStarted)
|
||||
if (transactionStarted && _rollbackOnPrepareFailure)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,32 +1,135 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
using System.Net;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout;
|
||||
|
||||
public static class PlayoutEngineFactory
|
||||
{
|
||||
public static IPlayoutEngine CreateDefault() =>
|
||||
Create(PlayoutOptionsLoader.Load());
|
||||
Create(
|
||||
PlayoutOptionsLoader.Load(),
|
||||
PlayoutLaunchAuthorization.CaptureFromEnvironment());
|
||||
|
||||
public static IPlayoutEngine Create(PlayoutOptions options)
|
||||
public static IPlayoutEngine Create(PlayoutOptions options) =>
|
||||
Create(options, PlayoutLaunchAuthorization.CaptureFromEnvironment());
|
||||
|
||||
public static IPlayoutEngine Create(
|
||||
PlayoutOptions options,
|
||||
PlayoutLaunchAuthorization launchAuthorization)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(launchAuthorization);
|
||||
ValidateGateAOptions(options, launchAuthorization);
|
||||
var validated = ValidatedPlayoutOptions.Create(options);
|
||||
ITornadoProcessProbe processProbe = new TornadoProcessProbe();
|
||||
Func<bool>? finalConnectSafetyCheck = null;
|
||||
var abandonOnTargetMismatch = false;
|
||||
var startMonitor = validated.Mode != PlayoutMode.Disabled;
|
||||
|
||||
if (launchAuthorization.IsGateA)
|
||||
{
|
||||
var safetyProbe = new WindowsPgmDiagnosticSafetyProbe();
|
||||
var request = new PgmConnectDiagnosticRequest(
|
||||
validated.Host,
|
||||
validated.Port,
|
||||
"PGM");
|
||||
var parsedHost = IPAddress.Parse(validated.Host);
|
||||
var baseline = safetyProbe.Capture(request, parsedHost);
|
||||
if (!baseline.IsEligible ||
|
||||
baseline.Identity is not { } identity ||
|
||||
identity.ProcessId != launchAuthorization.ExpectedPgmProcessId ||
|
||||
identity.StartTimeUtcTicks !=
|
||||
launchAuthorization.ExpectedPgmStartTimeUtcTicks)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A could not confirm the exact approved PGM process and listener.");
|
||||
}
|
||||
|
||||
bool HasExactTarget()
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = safetyProbe.Capture(request, parsedHost);
|
||||
return current.IsEligible && baseline.HasSameTarget(current);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
processProbe = new PgmCutsSequenceProcessProbe(
|
||||
safetyProbe,
|
||||
request,
|
||||
parsedHost,
|
||||
baseline);
|
||||
finalConnectSafetyCheck = HasExactTarget;
|
||||
abandonOnTargetMismatch = true;
|
||||
startMonitor = false;
|
||||
}
|
||||
|
||||
IStaDispatcher? dispatcher = validated.Mode is PlayoutMode.Test or PlayoutMode.Live
|
||||
? new StaDispatcher(validated.QueueCapacity)
|
||||
: null;
|
||||
return new TornadoPlayoutEngine(
|
||||
validated,
|
||||
new DynamicK3dSessionFactory(),
|
||||
new DynamicK3dSessionFactory(
|
||||
rollbackOnPrepareFailure: !launchAuthorization.IsGateA),
|
||||
new K3dRegistrationProbe(),
|
||||
new TornadoProcessProbe(),
|
||||
processProbe,
|
||||
dispatcher,
|
||||
new EnvironmentLiveAuthorization(),
|
||||
TimeProvider.System);
|
||||
launchAuthorization,
|
||||
TimeProvider.System,
|
||||
startMonitor,
|
||||
finalConnectSafetyCheck,
|
||||
abandonOnTargetMismatch,
|
||||
beforeSdkDispatchClaim: launchAuthorization.IsGateA
|
||||
? launchAuthorization.EnsureGateASdkDispatchAuthorized
|
||||
: null,
|
||||
launchAuthorization: launchAuthorization);
|
||||
}
|
||||
|
||||
internal static void ValidateGateAOptions(
|
||||
PlayoutOptions options,
|
||||
PlayoutLaunchAuthorization launchAuthorization)
|
||||
{
|
||||
if (!launchAuthorization.IsGateA)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var allowlist = (options.TestSceneAllowlist ?? [])
|
||||
.Select(static value => value?.Trim())
|
||||
.ToArray();
|
||||
if (options.Mode != PlayoutMode.Live ||
|
||||
!options.TrustedLiveOutputEnabled ||
|
||||
!string.Equals(options.Host?.Trim(), "127.0.0.1", StringComparison.Ordinal) ||
|
||||
options.Port != 30001 ||
|
||||
options.TcpMode != 1 ||
|
||||
options.ClientPort != 0 ||
|
||||
options.OutputChannel is not null ||
|
||||
options.LayoutIndex != 10 ||
|
||||
!PlayoutLaunchAuthorization.IsExactGateASceneDirectory(
|
||||
options.SceneDirectory) ||
|
||||
allowlist.Length != 1 ||
|
||||
!string.Equals(allowlist[0], "5001", StringComparison.Ordinal) ||
|
||||
options.ReconnectEnabled ||
|
||||
options.MaximumReconnectAttempts != 0 ||
|
||||
options.MaximumAutomaticRefreshesPerTakeIn != 0 ||
|
||||
options.LegacySceneFadeDuration != 6 ||
|
||||
options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None ||
|
||||
!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A requires the exact closed 5001-only Live configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
/// <summary>
|
||||
/// Process-lifetime authorization captured before WebView2 is created. Gate A is
|
||||
/// deliberately narrower than normal Live mode: one CONNECT and one claimed
|
||||
/// PREPARE of the approved 5001 cut are the only requests that can cross the
|
||||
/// native engine boundary.
|
||||
/// </summary>
|
||||
public sealed class PlayoutLaunchAuthorization : ILiveAuthorization, IDisposable
|
||||
{
|
||||
public const string GateACapabilityEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_PREPARE_CAPABILITY";
|
||||
|
||||
public const string GateAPgmProcessIdEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_PGM_PID";
|
||||
|
||||
public const string GateAPgmStartTimeUtcTicksEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_PGM_START_TIME_UTC_TICKS";
|
||||
|
||||
public const string GateAExpiresAtUtcTicksEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_EXPIRES_AT_UTC_TICKS";
|
||||
|
||||
public const string GateAApprovedSceneFilePath =
|
||||
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts\5001.t2s";
|
||||
|
||||
public const string GateAApprovedSceneSha256 =
|
||||
"99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7";
|
||||
|
||||
private const int GateAArmed = 0;
|
||||
private const int GateAOperatorClaimed = 1;
|
||||
private const int GateAEnginePrepareClaimed = 2;
|
||||
private const int GateATerminal = 3;
|
||||
private const int CapabilityLength = 64;
|
||||
private static readonly long MaximumGateALifetimeTicks = TimeSpan.FromMinutes(15).Ticks;
|
||||
private static readonly object GateACaptureSync = new();
|
||||
private static bool _gateAObservedForProcess;
|
||||
|
||||
private readonly object _gateASync = new();
|
||||
private readonly byte[]? _gateACapabilityHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly long _authorizationCapturedTimestamp;
|
||||
private readonly TimeSpan _authorizedLifetime;
|
||||
private FileStream? _gateASceneLease;
|
||||
private int _gateAState;
|
||||
private bool _connectClaimed;
|
||||
private bool _capabilityHashCleared;
|
||||
|
||||
private PlayoutLaunchAuthorization(
|
||||
bool liveAuthorized,
|
||||
byte[]? gateACapabilityHash,
|
||||
int? expectedPgmProcessId,
|
||||
long? expectedPgmStartTimeUtcTicks,
|
||||
long? expiresAtUtcTicks,
|
||||
TimeProvider timeProvider,
|
||||
long authorizationCapturedTimestamp,
|
||||
TimeSpan authorizedLifetime,
|
||||
FileStream? gateASceneLease)
|
||||
{
|
||||
IsAuthorizedForThisLaunch = liveAuthorized;
|
||||
_gateACapabilityHash = gateACapabilityHash;
|
||||
ExpectedPgmProcessId = expectedPgmProcessId;
|
||||
ExpectedPgmStartTimeUtcTicks = expectedPgmStartTimeUtcTicks;
|
||||
ExpiresAtUtcTicks = expiresAtUtcTicks;
|
||||
_timeProvider = timeProvider;
|
||||
_authorizationCapturedTimestamp = authorizationCapturedTimestamp;
|
||||
_authorizedLifetime = authorizedLifetime;
|
||||
_gateASceneLease = gateASceneLease;
|
||||
}
|
||||
|
||||
public bool IsAuthorizedForThisLaunch { get; }
|
||||
|
||||
public bool IsGateA => _gateACapabilityHash is not null;
|
||||
|
||||
public bool AllowsDatabaseWrites => !IsGateA;
|
||||
|
||||
public bool AllowsCompositionMutation => !IsGateA;
|
||||
|
||||
public int? ExpectedPgmProcessId { get; }
|
||||
|
||||
public long? ExpectedPgmStartTimeUtcTicks { get; }
|
||||
|
||||
public long? ExpiresAtUtcTicks { get; }
|
||||
|
||||
public static string GateAApprovedSceneDirectory =>
|
||||
Path.GetDirectoryName(GateAApprovedSceneFilePath)!;
|
||||
|
||||
internal bool AllowsAutomaticReconnect => !IsGateA;
|
||||
|
||||
internal bool AllowsSdkDisconnect => !IsGateA;
|
||||
|
||||
internal static PlayoutLaunchAuthorization Unrestricted { get; } =
|
||||
new(
|
||||
liveAuthorized: false,
|
||||
gateACapabilityHash: null,
|
||||
expectedPgmProcessId: null,
|
||||
expectedPgmStartTimeUtcTicks: null,
|
||||
expiresAtUtcTicks: null,
|
||||
TimeProvider.System,
|
||||
authorizationCapturedTimestamp: 0,
|
||||
authorizedLifetime: TimeSpan.Zero,
|
||||
gateASceneLease: null);
|
||||
|
||||
/// <summary>
|
||||
/// Captures all authorization material once, removes the bearer values from
|
||||
/// the process environment, then acquires and hashes the approved cut through
|
||||
/// a read-only/non-write-sharing handle. The handle remains owned by this
|
||||
/// authorization for the native application lifetime.
|
||||
/// </summary>
|
||||
public static PlayoutLaunchAuthorization CaptureFromEnvironment() =>
|
||||
CaptureFromEnvironment(
|
||||
TimeProvider.System,
|
||||
GateAApprovedSceneFilePath,
|
||||
GateAApprovedSceneSha256);
|
||||
|
||||
internal static PlayoutLaunchAuthorization CaptureFromEnvironment(
|
||||
TimeProvider timeProvider,
|
||||
string approvedSceneFilePath,
|
||||
string approvedSceneSha256)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneFilePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneSha256);
|
||||
|
||||
lock (GateACaptureSync)
|
||||
{
|
||||
return CaptureFromEnvironmentLocked(
|
||||
timeProvider,
|
||||
approvedSceneFilePath,
|
||||
approvedSceneSha256);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ResetGateACaptureLatchForTests()
|
||||
{
|
||||
lock (GateACaptureSync)
|
||||
{
|
||||
_gateAObservedForProcess = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayoutLaunchAuthorization CaptureFromEnvironmentLocked(
|
||||
TimeProvider timeProvider,
|
||||
string approvedSceneFilePath,
|
||||
string approvedSceneSha256)
|
||||
{
|
||||
var liveAuthorized = PlayoutOptionsLoader.IsLiveAuthorizedForThisLaunch();
|
||||
var capability = Environment.GetEnvironmentVariable(
|
||||
GateACapabilityEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var processIdText = Environment.GetEnvironmentVariable(
|
||||
GateAPgmProcessIdEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var startTimeText = Environment.GetEnvironmentVariable(
|
||||
GateAPgmStartTimeUtcTicksEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var expiresAtText = Environment.GetEnvironmentVariable(
|
||||
GateAExpiresAtUtcTicksEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var anyGateAValue = capability is not null ||
|
||||
processIdText is not null ||
|
||||
startTimeText is not null ||
|
||||
expiresAtText is not null;
|
||||
var gateAWasAlreadyObserved = _gateAObservedForProcess;
|
||||
if (anyGateAValue)
|
||||
{
|
||||
// Latch before validation, cleanup, or file I/O so even an exception in
|
||||
// those paths cannot make a second process-local capture eligible.
|
||||
_gateAObservedForProcess = true;
|
||||
}
|
||||
|
||||
ClearGateAEnvironment();
|
||||
if (gateAWasAlreadyObserved || anyGateAValue)
|
||||
{
|
||||
ClearLiveAuthorizationEnvironment();
|
||||
}
|
||||
|
||||
if (gateAWasAlreadyObserved)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A launch authorization was already observed by this process.");
|
||||
}
|
||||
|
||||
if (!anyGateAValue)
|
||||
{
|
||||
return new PlayoutLaunchAuthorization(
|
||||
liveAuthorized,
|
||||
gateACapabilityHash: null,
|
||||
expectedPgmProcessId: null,
|
||||
expectedPgmStartTimeUtcTicks: null,
|
||||
expiresAtUtcTicks: null,
|
||||
timeProvider,
|
||||
authorizationCapturedTimestamp: 0,
|
||||
authorizedLifetime: TimeSpan.Zero,
|
||||
gateASceneLease: null);
|
||||
}
|
||||
|
||||
// Observing any Gate A launch value permanently consumes the process-wide
|
||||
// capture slot before validation or file I/O. A malformed, expired, or
|
||||
// otherwise rejected first attempt must never be corrected or re-injected
|
||||
// within the same native process.
|
||||
// The already-captured Live decision is retained only in the returned native
|
||||
// object. Its static bearer was removed above before validation or file I/O.
|
||||
|
||||
var nowUtcTicks = timeProvider.GetUtcNow().UtcTicks;
|
||||
var capturedTimestamp = timeProvider.GetTimestamp();
|
||||
if (!IsExactCapability(capability) ||
|
||||
!int.TryParse(
|
||||
processIdText,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var processId) ||
|
||||
processId <= 0 ||
|
||||
!long.TryParse(
|
||||
startTimeText,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var startTimeUtcTicks) ||
|
||||
startTimeUtcTicks <= 0 ||
|
||||
!long.TryParse(
|
||||
expiresAtText,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var expiresAtUtcTicks) ||
|
||||
expiresAtUtcTicks <= nowUtcTicks ||
|
||||
expiresAtUtcTicks - nowUtcTicks > MaximumGateALifetimeTicks)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A launch authorization is incomplete, expired, or invalid.");
|
||||
}
|
||||
|
||||
var sceneLease = AcquireApprovedSceneLease(
|
||||
approvedSceneFilePath,
|
||||
approvedSceneSha256);
|
||||
return new PlayoutLaunchAuthorization(
|
||||
liveAuthorized,
|
||||
HashCapability(capability!),
|
||||
processId,
|
||||
startTimeUtcTicks,
|
||||
expiresAtUtcTicks,
|
||||
timeProvider,
|
||||
capturedTimestamp,
|
||||
TimeSpan.FromTicks(expiresAtUtcTicks - nowUtcTicks),
|
||||
sceneLease);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically consumes the browser-to-native PREPARE capability. Invalid
|
||||
/// values do not consume it. Expiry is checked immediately before the state
|
||||
/// transition and permanently closes the gate.
|
||||
/// </summary>
|
||||
public bool TryClaimGateAPrepare(string? capability)
|
||||
{
|
||||
if (!IsGateA || !IsExactCapability(capability))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidateHash = HashCapability(capability!);
|
||||
try
|
||||
{
|
||||
lock (_gateASync)
|
||||
{
|
||||
if (TerminateIfExpiredLocked() ||
|
||||
_gateAState != GateAArmed ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
candidateHash,
|
||||
_gateACapabilityHash!))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-read native time at the exact state boundary. A claim at the
|
||||
// expiry tick is rejected and can never be rearmed.
|
||||
if (TerminateIfExpiredLocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_gateAState = GateAOperatorClaimed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(candidateHash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently closes the PREPARE capability after the native request,
|
||||
/// regardless of success, rejection, cancellation, timeout, or unknown outcome.
|
||||
/// The approved cut lease intentionally remains held until native shutdown.
|
||||
/// </summary>
|
||||
public void CompleteGateAPrepare()
|
||||
{
|
||||
if (!IsGateA)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gateASync)
|
||||
{
|
||||
EnterTerminalLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryClaimEngineCommand(
|
||||
PlayoutOperation operation,
|
||||
PlayoutCue? cue)
|
||||
{
|
||||
if (!IsGateA)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (_gateASync)
|
||||
{
|
||||
if (TerminateIfExpiredLocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operation)
|
||||
{
|
||||
case PlayoutOperation.Connect when !_connectClaimed:
|
||||
_connectClaimed = true;
|
||||
return true;
|
||||
case PlayoutOperation.Prepare:
|
||||
return TryClaimEnginePrepareLocked(cue);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final native guard invoked on the STA immediately before an SDK dispatch
|
||||
/// latch can be claimed. This closes the registration/queue-delay window after
|
||||
/// the outer command claim. Expiry or a terminal gate is reported as a safety
|
||||
/// exception so the engine quarantines without issuing cleanup commands.
|
||||
/// </summary>
|
||||
internal void EnsureGateASdkDispatchAuthorized()
|
||||
{
|
||||
if (!IsGateA)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gateASync)
|
||||
{
|
||||
if (TerminateIfExpiredLocked() || _gateAState == GateATerminal)
|
||||
{
|
||||
throw new K3dConnectSafetyException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal bool CanTakeIn => !IsGateA;
|
||||
|
||||
internal string RejectionMessage(PlayoutOperation operation) =>
|
||||
IsGateA
|
||||
? $"Gate A does not authorize another {operation} command for this process."
|
||||
: "The playout command is not authorized for this launch.";
|
||||
|
||||
internal static PlayoutLaunchAuthorization CreateGateAForTests(
|
||||
string capability,
|
||||
bool liveAuthorized = true,
|
||||
int expectedPgmProcessId = 1,
|
||||
long expectedPgmStartTimeUtcTicks = 1,
|
||||
TimeProvider? timeProvider = null,
|
||||
long? expiresAtUtcTicks = null,
|
||||
FileStream? gateASceneLease = null)
|
||||
{
|
||||
if (!IsExactCapability(capability))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A test Gate A capability must be exactly 64 uppercase hexadecimal characters.",
|
||||
nameof(capability));
|
||||
}
|
||||
|
||||
var clock = timeProvider ?? TimeProvider.System;
|
||||
var nowUtcTicks = clock.GetUtcNow().UtcTicks;
|
||||
var expiry = expiresAtUtcTicks ??
|
||||
nowUtcTicks + MaximumGateALifetimeTicks;
|
||||
return new PlayoutLaunchAuthorization(
|
||||
liveAuthorized,
|
||||
HashCapability(capability),
|
||||
expectedPgmProcessId,
|
||||
expectedPgmStartTimeUtcTicks,
|
||||
expiry,
|
||||
clock,
|
||||
clock.GetTimestamp(),
|
||||
TimeSpan.FromTicks(Math.Max(0, expiry - nowUtcTicks)),
|
||||
gateASceneLease);
|
||||
}
|
||||
|
||||
internal static bool IsExactGateASceneDirectory(string? directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)),
|
||||
Path.TrimEndingDirectorySeparator(
|
||||
Path.GetFullPath(GateAApprovedSceneDirectory)),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
ArgumentException or
|
||||
NotSupportedException or
|
||||
PathTooLongException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (IsGateA)
|
||||
{
|
||||
lock (_gateASync)
|
||||
{
|
||||
EnterTerminalLocked();
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _gateASceneLease, null)?.Dispose();
|
||||
}
|
||||
|
||||
private bool TryClaimEnginePrepareLocked(PlayoutCue? cue)
|
||||
{
|
||||
if (_gateAState != GateAOperatorClaimed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsExactGateAPrepareCue(cue))
|
||||
{
|
||||
EnterTerminalLocked();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TerminateIfExpiredLocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_gateAState = GateAEnginePrepareClaimed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TerminateIfExpiredLocked()
|
||||
{
|
||||
if (ExpiresAtUtcTicks is not { } expiry)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expired = _timeProvider.GetUtcNow().UtcTicks >= expiry;
|
||||
if (!expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
expired = _timeProvider.GetElapsedTime(
|
||||
_authorizationCapturedTimestamp,
|
||||
_timeProvider.GetTimestamp()) >= _authorizedLifetime;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A broken monotonic clock must never extend live authority.
|
||||
expired = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!expired)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnterTerminalLocked();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnterTerminalLocked()
|
||||
{
|
||||
_gateAState = GateATerminal;
|
||||
if (!_capabilityHashCleared && _gateACapabilityHash is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(_gateACapabilityHash);
|
||||
_capabilityHashCleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExactGateAPrepareCue(PlayoutCue? cue)
|
||||
{
|
||||
if (cue is null ||
|
||||
!string.Equals(cue.SceneName, "5001", StringComparison.Ordinal) ||
|
||||
!string.Equals(cue.SceneFile, "5001.t2s", StringComparison.Ordinal) ||
|
||||
cue.FadeDuration != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var disabledBackgroundCount = 0;
|
||||
foreach (var mutation in cue.Mutations ?? [])
|
||||
{
|
||||
switch (mutation)
|
||||
{
|
||||
case PlayoutUseBackground
|
||||
{
|
||||
IsEnabled: false,
|
||||
Timing: PlayoutMutationTiming.BeforeTransaction
|
||||
}:
|
||||
disabledBackgroundCount++;
|
||||
break;
|
||||
case PlayoutUseBackground or
|
||||
PlayoutSetBackgroundTexture or
|
||||
PlayoutSetBackgroundVideo:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return disabledBackgroundCount == 1;
|
||||
}
|
||||
|
||||
private static bool IsExactCapability(string? value) =>
|
||||
value is { Length: CapabilityLength } && value.All(static character =>
|
||||
character is >= '0' and <= '9' or >= 'A' and <= 'F');
|
||||
|
||||
private static byte[] HashCapability(string capability) =>
|
||||
SHA256.HashData(Encoding.ASCII.GetBytes(capability));
|
||||
|
||||
private static FileStream AcquireApprovedSceneLease(
|
||||
string sceneFilePath,
|
||||
string expectedSha256)
|
||||
{
|
||||
if (!IsExactCapability(expectedSha256))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The approved Gate A scene hash is invalid.");
|
||||
}
|
||||
|
||||
FileStream? lease = null;
|
||||
byte[]? expectedHash = null;
|
||||
byte[]? actualHash = null;
|
||||
try
|
||||
{
|
||||
lease = new FileStream(
|
||||
Path.GetFullPath(sceneFilePath),
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 64 * 1024,
|
||||
FileOptions.SequentialScan);
|
||||
expectedHash = Convert.FromHexString(expectedSha256);
|
||||
actualHash = SHA256.HashData(lease);
|
||||
lease.Position = 0;
|
||||
if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The approved Gate A scene hash does not match the leased file.");
|
||||
}
|
||||
|
||||
return lease;
|
||||
}
|
||||
catch (PlayoutConfigurationException)
|
||||
{
|
||||
lease?.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
IOException or
|
||||
UnauthorizedAccessException or
|
||||
ArgumentException or
|
||||
NotSupportedException)
|
||||
{
|
||||
lease?.Dispose();
|
||||
throw new PlayoutConfigurationException(
|
||||
"The approved Gate A scene could not be leased and verified.",
|
||||
exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (expectedHash is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(expectedHash);
|
||||
}
|
||||
|
||||
if (actualHash is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(actualHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearGateAEnvironment()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateACapabilityEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateAPgmProcessIdEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateAPgmStartTimeUtcTicksEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateAExpiresAtUtcTicksEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
|
||||
private static void ClearLiveAuthorizationEnvironment() =>
|
||||
Environment.SetEnvironmentVariable(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private readonly ITornadoProcessProbe _processProbe;
|
||||
private readonly IStaDispatcher? _dispatcher;
|
||||
private readonly ILiveAuthorization _liveAuthorization;
|
||||
private readonly PlayoutLaunchAuthorization _launchAuthorization;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Func<bool>? _finalConnectSafetyCheck;
|
||||
private readonly bool _abandonOnTargetMismatch;
|
||||
@@ -55,7 +56,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
bool startMonitor = true,
|
||||
Func<bool>? finalConnectSafetyCheck = null,
|
||||
bool abandonOnTargetMismatch = false,
|
||||
Action? beforeSdkDispatchClaim = null)
|
||||
Action? beforeSdkDispatchClaim = null,
|
||||
PlayoutLaunchAuthorization? launchAuthorization = null)
|
||||
{
|
||||
_options = options;
|
||||
_sessionFactory = sessionFactory;
|
||||
@@ -63,6 +65,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
_processProbe = processProbe;
|
||||
_dispatcher = dispatcher;
|
||||
_liveAuthorization = liveAuthorization;
|
||||
_launchAuthorization = launchAuthorization ??
|
||||
PlayoutLaunchAuthorization.Unrestricted;
|
||||
_timeProvider = timeProvider;
|
||||
_finalConnectSafetyCheck = finalConnectSafetyCheck;
|
||||
_abandonOnTargetMismatch = abandonOnTargetMismatch;
|
||||
@@ -102,7 +106,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return SerializedAsync(
|
||||
PlayoutOperation.Prepare,
|
||||
cancellationToken,
|
||||
token => PrepareCoreAsync(cue, token));
|
||||
token => PrepareCoreAsync(cue, token),
|
||||
cue);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default) =>
|
||||
@@ -116,7 +121,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return SerializedAsync(
|
||||
PlayoutOperation.Next,
|
||||
cancellationToken,
|
||||
token => NextCoreAsync(cue, token));
|
||||
token => NextCoreAsync(cue, token),
|
||||
cue);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> UpdateOnAirAsync(
|
||||
@@ -127,7 +133,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return SerializedAsync(
|
||||
PlayoutOperation.UpdateOnAir,
|
||||
cancellationToken,
|
||||
token => UpdateOnAirCoreAsync(cue, token));
|
||||
token => UpdateOnAirCoreAsync(cue, token),
|
||||
cue);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> TakeOutAsync(
|
||||
@@ -337,7 +344,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return;
|
||||
}
|
||||
|
||||
if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled ||
|
||||
if (_session is not null || !_connectionRequested ||
|
||||
!_launchAuthorization.AllowsAutomaticReconnect ||
|
||||
!_options.ReconnectEnabled ||
|
||||
_outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts ||
|
||||
_timeProvider.GetUtcNow() < _nextReconnectAt)
|
||||
{
|
||||
@@ -362,7 +371,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private async Task<PlayoutResult> SerializedAsync(
|
||||
PlayoutOperation operation,
|
||||
CancellationToken cancellationToken,
|
||||
Func<CancellationToken, Task<PlayoutResult>> callback)
|
||||
Func<CancellationToken, Task<PlayoutResult>> callback,
|
||||
PlayoutCue? cue = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -407,6 +417,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return ExistingOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
if (!_launchAuthorization.TryClaimEngineCommand(operation, cue))
|
||||
{
|
||||
return Result(
|
||||
operation,
|
||||
PlayoutResultCode.Rejected,
|
||||
_launchAuthorization.RejectionMessage(operation));
|
||||
}
|
||||
|
||||
return await callback(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
@@ -1412,6 +1430,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_session is not null && !_launchAuthorization.AllowsSdkDisconnect)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = _session;
|
||||
if (session?.HasPendingLifecycleCallbacks == true)
|
||||
{
|
||||
@@ -1526,6 +1550,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
IK3dSession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_launchAuthorization.AllowsSdkDisconnect)
|
||||
{
|
||||
throw new K3dConnectSafetyException();
|
||||
}
|
||||
|
||||
var dispatchLatch = new SdkDispatchLatch(_beforeSdkDispatchClaim);
|
||||
return _dispatcher!.InvokeAsync(
|
||||
() =>
|
||||
@@ -1759,7 +1788,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private bool CanTakeIn() => _options.Mode switch
|
||||
{
|
||||
PlayoutMode.Test => true,
|
||||
PlayoutMode.Live => IsLiveAuthorized(),
|
||||
PlayoutMode.Live =>
|
||||
IsLiveAuthorized() && _launchAuthorization.CanTakeIn,
|
||||
_ => false
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user