feat: add safe Tornado K3D playout adapter
This commit is contained in:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -26,11 +26,23 @@ Config/appsettings.local.json
|
||||
Config/*.secrets.json
|
||||
Config/database.local.json
|
||||
**/database.local.json
|
||||
Config/playout.local.json
|
||||
**/playout.local.json
|
||||
*.local.ini
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Tornado/K3D vendor artifacts (generated/installed locally only)
|
||||
artifacts/K3DInterop/
|
||||
**/Interop.K3DAsyncEngineLib.dll
|
||||
**/K3DAsyncEngine.dll
|
||||
**/K3DAsyncEngine.tlb
|
||||
*.t2s
|
||||
*.k3s
|
||||
K3D*.lic
|
||||
Tornado*.lic
|
||||
|
||||
# Web tooling (if introduced later)
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
21
Config/playout.example.json
Normal file
21
Config/playout.example.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mode": "DryRun",
|
||||
"host": "127.0.0.1",
|
||||
"port": 30001,
|
||||
"tcpMode": 1,
|
||||
"clientPort": 0,
|
||||
"sceneDirectory": null,
|
||||
"outputChannel": null,
|
||||
"layoutIndex": 10,
|
||||
"testProcessWindowTitlePattern": null,
|
||||
"testSceneAllowlist": [],
|
||||
"trustedLiveOutputEnabled": false,
|
||||
"queueCapacity": 64,
|
||||
"connectTimeoutMilliseconds": 5000,
|
||||
"operationTimeoutMilliseconds": 5000,
|
||||
"disconnectTimeoutMilliseconds": 3000,
|
||||
"processPollIntervalMilliseconds": 1000,
|
||||
"reconnectDelayMilliseconds": 1000,
|
||||
"maximumReconnectAttempts": 3,
|
||||
"reconnectEnabled": true
|
||||
}
|
||||
@@ -42,6 +42,10 @@
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="Config\appsettings.example.json" />
|
||||
<Content Include="Config\playout.example.json" Condition="Exists('Config\playout.example.json')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="ThirdPartyNotices\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
@@ -72,6 +76,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
|
||||
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.33103.201
|
||||
@@ -21,90 +20,56 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.DbSmoke",
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Infrastructure.Tests", "tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj", "{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Playout", "src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj", "{8275871E-4ECB-465B-AA77-D09E61CC1D84}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Playout.Tests", "tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj", "{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.PlayoutSmoke", "tools\MBN_STOCK_WEBVIEW.PlayoutSmoke\MBN_STOCK_WEBVIEW.PlayoutSmoke.csproj", "{515E56D2-8F90-45A0-83BC-44BD541B10A6}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x86 = Debug|x86
|
||||
Release|x64 = Release|x64
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Build.0 = Debug|x64
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Deploy.0 = Debug|x64
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x86.ActiveCfg = Debug|x86
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x86.Build.0 = Debug|x86
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.ActiveCfg = Release|x64
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Build.0 = Release|x64
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Deploy.0 = Release|x64
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x86.ActiveCfg = Release|x86
|
||||
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x86.Build.0 = Release|x86
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Debug|x64.Build.0 = Debug|x64
|
||||
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Release|x64.ActiveCfg = Release|x64
|
||||
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Release|x64.Build.0 = Release|x64
|
||||
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Debug|x64.Build.0 = Debug|x64
|
||||
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Release|x64.ActiveCfg = Release|x64
|
||||
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Release|x64.Build.0 = Release|x64
|
||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.Build.0 = Debug|x64
|
||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.ActiveCfg = Release|x64
|
||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -115,6 +80,9 @@ Global
|
||||
{040E8620-035C-4F62-B408-C05C5BDC90FE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{8275871E-4ECB-465B-AA77-D09E61CC1D84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07}
|
||||
|
||||
503
MainWindow.Playout.cs
Normal file
503
MainWindow.Playout.cs
Normal file
@@ -0,0 +1,503 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private const int MaximumPlayoutRequestIdLength = 128;
|
||||
private const int MaximumPlayoutCodeLength = 64;
|
||||
private const int MaximumPlayoutTitleLength = 200;
|
||||
private const int MaximumPlayoutDetailLength = 500;
|
||||
|
||||
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
|
||||
private IPlayoutEngine? _playoutEngine;
|
||||
private string? _playoutInitializationError;
|
||||
private int _playoutCommandInFlight;
|
||||
private int _playoutShutdownStarted;
|
||||
|
||||
private void InitializePlayoutRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
_playoutEngine = PlayoutEngineFactory.CreateDefault();
|
||||
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_playoutInitializationError = exception is PlayoutConfigurationException
|
||||
? exception.Message
|
||||
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
QueuePlayoutStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await engine.ConnectAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
|
||||
}
|
||||
|
||||
QueuePlayoutStatus();
|
||||
}
|
||||
|
||||
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
|
||||
{
|
||||
if (Volatile.Read(ref _playoutCommandInFlight) != 0 ||
|
||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QueuePlayoutStatus();
|
||||
}
|
||||
|
||||
private void QueuePlayoutStatus()
|
||||
{
|
||||
if (DispatcherQueue.HasThreadAccess)
|
||||
{
|
||||
PostPlayoutStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherQueue.TryEnqueue(() => PostPlayoutStatus());
|
||||
}
|
||||
|
||||
private void HandlePlayoutStatusRequest(JsonElement payload)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId") ||
|
||||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
|
||||
{
|
||||
PostMessage("bridge-error", new { message = "올바르지 않은 송출 상태 요청입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
PostPlayoutStatus(requestId);
|
||||
}
|
||||
|
||||
private async Task HandlePlayoutCommandAsync(JsonElement payload)
|
||||
{
|
||||
var parsed = TryParsePlayoutCommand(payload, out var request, out var validationError);
|
||||
if (!parsed)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"INVALID_REQUEST",
|
||||
validationError,
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"PLAYOUT_UNAVAILABLE",
|
||||
_playoutInitializationError ?? "Tornado 송출 어댑터를 사용할 수 없습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _playoutCommandGate.WaitAsync(0))
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"PLAYOUT_BUSY",
|
||||
"다른 송출 명령을 처리하고 있습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||
var result = await ExecutePlayoutCommandAsync(engine, request, _lifetimeCancellation.Token);
|
||||
var status = engine.Status;
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
ToWireValue(result.Code),
|
||||
result.Message,
|
||||
IsRetryable(result.Code),
|
||||
result.Code == PlayoutResultCode.OutcomeUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
PostMessage("playout-command-result", new
|
||||
{
|
||||
requestId = request.RequestId,
|
||||
command = request.Command,
|
||||
succeeded = true,
|
||||
dryRun = result.IsDryRun,
|
||||
state = ToPlayoutPhase(status),
|
||||
message = result.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"CANCELLED",
|
||||
"송출 요청이 취소되었습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"PLAYOUT_FAILED",
|
||||
"송출 엔진 명령을 완료하지 못했습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<PlayoutResult> ExecutePlayoutCommandAsync(
|
||||
IPlayoutEngine engine,
|
||||
ValidatedPlayoutCommand request,
|
||||
CancellationToken cancellationToken) => request.Command switch
|
||||
{
|
||||
"prepare" => engine.PrepareAsync(request.Cue!, cancellationToken),
|
||||
"take-in" => engine.TakeInAsync(cancellationToken),
|
||||
"next" => engine.NextAsync(request.Cue!, cancellationToken),
|
||||
// Preserve the legacy MainForm TAKE OUT behavior (StopAll on the selected
|
||||
// scene player). Test/Live safety gates are enforced inside the engine.
|
||||
"take-out" => engine.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken),
|
||||
_ => throw new InvalidOperationException("검증되지 않은 송출 명령입니다.")
|
||||
};
|
||||
|
||||
private void PostPlayoutStatus(string? requestId = null)
|
||||
{
|
||||
var status = _playoutEngine?.Status;
|
||||
if (status is null)
|
||||
{
|
||||
var changedAt = DateTimeOffset.UtcNow;
|
||||
if (requestId is null)
|
||||
{
|
||||
PostMessage("playout-status", new
|
||||
{
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
liveTakeInAllowed = false,
|
||||
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
||||
preparedCode = (string?)null,
|
||||
onAirCode = (string?)null,
|
||||
changedAt
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
PostMessage("playout-status", new
|
||||
{
|
||||
requestId,
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
liveTakeInAllowed = false,
|
||||
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
||||
preparedCode = (string?)null,
|
||||
onAirCode = (string?)null,
|
||||
changedAt
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestId is null)
|
||||
{
|
||||
PostMessage("playout-status", CreatePlayoutStatusPayload(status));
|
||||
}
|
||||
else
|
||||
{
|
||||
PostMessage("playout-status", new
|
||||
{
|
||||
requestId,
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
processDetected = status.IsProcessRunning,
|
||||
connected = status.IsConnected,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
message = status.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName,
|
||||
changedAt = status.ChangedAtUtc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreatePlayoutStatusPayload(PlayoutStatus status) => new
|
||||
{
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
processDetected = status.IsProcessRunning,
|
||||
connected = status.IsConnected,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
message = status.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName,
|
||||
changedAt = status.ChangedAtUtc
|
||||
};
|
||||
|
||||
private void PostPlayoutCommandError(
|
||||
string requestId,
|
||||
string command,
|
||||
string code,
|
||||
string message,
|
||||
bool retryable,
|
||||
bool outcomeUnknown)
|
||||
{
|
||||
PostMessage("playout-command-error", new
|
||||
{
|
||||
requestId,
|
||||
command,
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
outcomeUnknown
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryParsePlayoutCommand(
|
||||
JsonElement payload,
|
||||
out ValidatedPlayoutCommand request,
|
||||
out string error)
|
||||
{
|
||||
var requestId = payload.ValueKind == JsonValueKind.Object
|
||||
? GetString(payload, "requestId") ?? string.Empty
|
||||
: string.Empty;
|
||||
var command = payload.ValueKind == JsonValueKind.Object
|
||||
? GetString(payload, "command") ?? string.Empty
|
||||
: string.Empty;
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId", "command", "cue"))
|
||||
{
|
||||
error = "송출 요청에 허용되지 않은 필드가 있습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId))
|
||||
{
|
||||
error = "송출 요청 식별자가 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (command is not ("prepare" or "take-in" or "next" or "take-out"))
|
||||
{
|
||||
error = "지원하지 않는 송출 명령입니다.";
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
var cueRequired = command is not "take-out";
|
||||
if (!payload.TryGetProperty("cue", out var cueElement))
|
||||
{
|
||||
if (!cueRequired)
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "이 송출 명령에는 그래픽 cue가 필요합니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParsePlayoutCue(cueElement, out var cue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new ValidatedPlayoutCommand(requestId, command, cue);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParsePlayoutCue(
|
||||
JsonElement payload,
|
||||
out PlayoutCue? cue,
|
||||
out string error)
|
||||
{
|
||||
cue = null;
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "code", "title", "detail") ||
|
||||
!TryGetSafeToken(payload, "code", MaximumPlayoutCodeLength, out var code) ||
|
||||
!TryGetBoundedText(payload, "title", MaximumPlayoutTitleLength, allowEmpty: false, out _) ||
|
||||
!TryGetBoundedText(payload, "detail", MaximumPlayoutDetailLength, allowEmpty: true, out _))
|
||||
{
|
||||
error = "그래픽 cue 형식이 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var sceneFile = $"{code}.t2s";
|
||||
if (!string.Equals(Path.GetFileName(sceneFile), sceneFile, StringComparison.Ordinal) ||
|
||||
sceneFile.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
error = "그래픽 cue 경로가 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
cue = new PlayoutCue(sceneFile, code);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetSafeToken(
|
||||
JsonElement payload,
|
||||
string propertyName,
|
||||
int maximumLength,
|
||||
out string value)
|
||||
{
|
||||
value = GetString(payload, propertyName) ?? string.Empty;
|
||||
return value.Length is > 0 && value.Length <= maximumLength &&
|
||||
value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');
|
||||
}
|
||||
|
||||
private static bool TryGetBoundedText(
|
||||
JsonElement payload,
|
||||
string propertyName,
|
||||
int maximumLength,
|
||||
bool allowEmpty,
|
||||
out string value)
|
||||
{
|
||||
value = GetString(payload, propertyName) ?? string.Empty;
|
||||
return value.Length <= maximumLength &&
|
||||
(allowEmpty || value.Length > 0) &&
|
||||
!value.Any(char.IsControl);
|
||||
}
|
||||
|
||||
private static bool HasOnlyProperties(JsonElement payload, params string[] allowed)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var property in payload.EnumerateObject())
|
||||
{
|
||||
if (!seen.Add(property.Name) ||
|
||||
!allowed.Contains(property.Name, StringComparer.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ToPlayoutPhase(PlayoutStatus status)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(status.OnAirSceneName))
|
||||
{
|
||||
return "PROGRAM";
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(status.PreparedSceneName) ? "PREPARED" : "IDLE";
|
||||
}
|
||||
|
||||
private static string ToWireValue(PlayoutMode mode) => mode switch
|
||||
{
|
||||
PlayoutMode.Disabled => "disabled",
|
||||
PlayoutMode.DryRun => "dry-run",
|
||||
PlayoutMode.Test => "test",
|
||||
PlayoutMode.Live => "live",
|
||||
_ => "disabled"
|
||||
};
|
||||
|
||||
private static string ToWireValue(PlayoutResultCode code) => code switch
|
||||
{
|
||||
PlayoutResultCode.Rejected => "REJECTED",
|
||||
PlayoutResultCode.Cancelled => "CANCELLED",
|
||||
PlayoutResultCode.TimedOut => "TIMED_OUT",
|
||||
PlayoutResultCode.Unavailable => "UNAVAILABLE",
|
||||
PlayoutResultCode.OutcomeUnknown => "OUTCOME_UNKNOWN",
|
||||
_ => "PLAYOUT_FAILED"
|
||||
};
|
||||
|
||||
private static bool IsRetryable(PlayoutResultCode code) => code is
|
||||
PlayoutResultCode.Cancelled or
|
||||
PlayoutResultCode.TimedOut or
|
||||
PlayoutResultCode.Unavailable or
|
||||
PlayoutResultCode.Failed or
|
||||
PlayoutResultCode.OutcomeUnknown;
|
||||
|
||||
private void ShutdownPlayoutRuntime()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var engine = Interlocked.Exchange(ref _playoutEngine, null);
|
||||
if (engine is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
engine.StatusChanged -= OnPlayoutStatusChanged;
|
||||
using var disconnectCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
try
|
||||
{
|
||||
var disconnect = engine.DisconnectAsync(disconnectCancellation.Token);
|
||||
if (!disconnect.Wait(TimeSpan.FromSeconds(4)))
|
||||
{
|
||||
disconnectCancellation.Cancel();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// App shutdown continues even when the external playout process is unavailable.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
engine.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal is best-effort and bounded during window shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ValidatedPlayoutCommand(
|
||||
string RequestId,
|
||||
string Command,
|
||||
PlayoutCue? Cue);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeDatabaseRuntime();
|
||||
InitializePlayoutRuntime();
|
||||
Root.Loaded += OnRootLoaded;
|
||||
Closed += OnClosed;
|
||||
}
|
||||
@@ -60,6 +61,7 @@ public sealed partial class MainWindow : Window
|
||||
if (_webViewReady)
|
||||
{
|
||||
_ = MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
|
||||
_ = ConnectPlayoutAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +126,9 @@ public sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (uri.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase) || uri.Scheme == "about")
|
||||
if ((uri.Scheme == Uri.UriSchemeHttps &&
|
||||
uri.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase)) ||
|
||||
uri.Scheme == "about")
|
||||
{
|
||||
LoadingOverlay.Visibility = Visibility.Visible;
|
||||
return;
|
||||
@@ -162,6 +166,13 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args)
|
||||
{
|
||||
if (!Uri.TryCreate(args.Source, UriKind.Absolute, out var source) ||
|
||||
source.Scheme != Uri.UriSchemeHttps ||
|
||||
!source.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = JsonSerializer.Deserialize<WebRequest>(args.WebMessageAsJson, JsonOptions);
|
||||
@@ -172,10 +183,17 @@ public sealed partial class MainWindow : Window
|
||||
case "request-app-info":
|
||||
PostAppInfo();
|
||||
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||
PostPlayoutStatus();
|
||||
break;
|
||||
case "request-database-status":
|
||||
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||
break;
|
||||
case "request-playout-status":
|
||||
HandlePlayoutStatusRequest(request.Payload);
|
||||
break;
|
||||
case "playout-command":
|
||||
_ = HandlePlayoutCommandAsync(request.Payload);
|
||||
break;
|
||||
case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object:
|
||||
_ = HandleMarketDataRequestAsync(request.Payload);
|
||||
break;
|
||||
@@ -501,6 +519,7 @@ public sealed partial class MainWindow : Window
|
||||
var wasWebViewReady = _webViewReady;
|
||||
_webViewReady = false;
|
||||
_lifetimeCancellation.Cancel();
|
||||
ShutdownPlayoutRuntime();
|
||||
var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null);
|
||||
requestCancellation?.Cancel();
|
||||
requestCancellation?.Dispose();
|
||||
|
||||
18
README.md
18
README.md
@@ -24,9 +24,12 @@
|
||||
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
|
||||
- Oracle/MariaDB 실제 비동기 `IDataQueryExecutor`와 소스별 health/retry/timeout
|
||||
- WebView 코스피·코스닥·NXT·5개 지수·해외 실데이터 조회와 장애 UI
|
||||
- COM 중립 `IPlayoutEngine`, x64 K3D late binding, 전용 STA 큐와 프로세스 감시
|
||||
- 네이티브 결과가 성공한 뒤에만 갱신되는 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` WebView 브리지
|
||||
- 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트
|
||||
- MSIX 패키지 매니페스트와 x64 게시 프로필
|
||||
|
||||
Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D 송출은 아직 어댑터를 연결하지 않았으며, 화면의 송출 버튼은 운영 흐름 검증용입니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
|
||||
Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D는 안전 기본값인 `DryRun`과 실제 어댑터 경계를 구현했습니다. 현재 장비에는 `PGM` Tornado만 실행 중이므로 그 프로세스에는 COM을 활성화하지 않았고, 실제 송출 호출 검증은 별도 Test 인스턴스·채널·씬이 준비된 뒤 진행합니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정과 롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
|
||||
|
||||
## Visual Studio 2026에서 실행
|
||||
|
||||
@@ -47,6 +50,15 @@ dotnet restore MBN_STOCK_WEBVIEW.sln -p:Platform=x64
|
||||
dotnet build MBN_STOCK_WEBVIEW.sln -c Debug -p:Platform=x64
|
||||
```
|
||||
|
||||
K3D 등록 상태와 COM을 열지 않는 dry-run 송출 흐름 확인:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\scripts\Inspect-K3DRegistration.ps1
|
||||
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
|
||||
-c Debug -p:Platform=x64 -- --dry-run
|
||||
```
|
||||
|
||||
MSIX 생성:
|
||||
|
||||
```powershell
|
||||
@@ -73,8 +85,10 @@ MainWindow.xaml(.cs) 네이티브 창, WebView 초기화, 보안 경
|
||||
Web/ FarPoint를 대체하는 로컬 운영 UI
|
||||
src/MBN_STOCK_WEBVIEW.Core/ 이관된 데이터 요청 및 공급자 비의존 Core
|
||||
src/MBN_STOCK_WEBVIEW.Infrastructure/ Oracle/MariaDB 공급자·설정·복원력·health
|
||||
tests/ Core/Infrastructure 단위 테스트
|
||||
src/MBN_STOCK_WEBVIEW.Playout/ x64 Tornado/K3D STA·COM 어댑터와 안전 게이트
|
||||
tests/ Core/Infrastructure/Playout 단위 테스트
|
||||
tools/MBN_STOCK_WEBVIEW.DbSmoke/ 비밀값을 출력하지 않는 실제 DB 스모크
|
||||
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/ COM 비활성 등록 probe와 dry-run 스모크
|
||||
Config/ 비밀값 없는 구성 템플릿
|
||||
ThirdPartyNotices/ 공급자 재배포 라이선스 고지
|
||||
docs/ 마이그레이션 기록과 다음 단계
|
||||
|
||||
388
Web/app.js
388
Web/app.js
@@ -36,7 +36,23 @@
|
||||
selectedRows: new Set(),
|
||||
currentIndex: -1,
|
||||
dragIndex: -1,
|
||||
playout: "IDLE",
|
||||
playout: {
|
||||
mode: "unknown",
|
||||
engineState: "IDLE",
|
||||
processDetected: false,
|
||||
connected: false,
|
||||
liveTakeInAllowed: false,
|
||||
message: "송출 어댑터 상태를 확인하고 있습니다.",
|
||||
preparedCode: null,
|
||||
onAirCode: null,
|
||||
preparedCue: null,
|
||||
changedAt: null,
|
||||
changedAtMs: 0,
|
||||
latestStatusRequestId: null,
|
||||
pending: null,
|
||||
error: null,
|
||||
lastSignature: ""
|
||||
},
|
||||
database: { sources: [], loading: true, lastSignature: "" },
|
||||
liveData: {
|
||||
requestId: null,
|
||||
@@ -66,7 +82,22 @@
|
||||
playlistBody: document.querySelector("#playlistBody"),
|
||||
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
|
||||
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
|
||||
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
|
||||
playoutState: document.querySelector("#playoutState"),
|
||||
playoutSummary: document.querySelector("#playoutSummary"),
|
||||
playoutSummaryIcon: document.querySelector("#playoutSummaryIcon"),
|
||||
playoutSummaryDetail: document.querySelector("#playoutSummaryDetail"),
|
||||
playoutSummaryBadge: document.querySelector("#playoutSummaryBadge"),
|
||||
tornadoProcessDot: document.querySelector("#tornadoProcessDot"),
|
||||
tornadoProcessState: document.querySelector("#tornadoProcessState"),
|
||||
playoutConnectionDot: document.querySelector("#playoutConnectionDot"),
|
||||
playoutConnectionState: document.querySelector("#playoutConnectionState"),
|
||||
playoutSafetyMode: document.querySelector("#playoutSafetyMode"),
|
||||
playoutStatusMessage: document.querySelector("#playoutStatusMessage"),
|
||||
playoutError: document.querySelector("#playoutError"),
|
||||
playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
|
||||
prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
|
||||
nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
|
||||
eventLog: document.querySelector("#eventLog"),
|
||||
toast: document.querySelector("#toast")
|
||||
};
|
||||
|
||||
@@ -207,10 +238,12 @@
|
||||
if (!item) {
|
||||
elements.previewTitle.textContent = "선택된 그래픽 없음";
|
||||
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
|
||||
renderPlayout();
|
||||
return;
|
||||
}
|
||||
elements.previewTitle.textContent = item.title;
|
||||
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`;
|
||||
renderPlayout();
|
||||
}
|
||||
|
||||
function onDragStart(event) {
|
||||
@@ -294,34 +327,312 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setPlayout(nextState, message) {
|
||||
state.playout = nextState;
|
||||
elements.playoutState.textContent = nextState;
|
||||
elements.playoutState.classList.toggle("neutral", nextState === "IDLE");
|
||||
addLog(message);
|
||||
function normalizePlayoutState(value) {
|
||||
const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, "");
|
||||
if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED";
|
||||
if (["program", "onair", "takein", "live"].includes(normalized)) return "PROGRAM";
|
||||
if (["dryrun", "preview", "simulation", "simulated"].includes(normalized)) return "DRY RUN";
|
||||
return "IDLE";
|
||||
}
|
||||
|
||||
function prepare() {
|
||||
if (!state.playlist[state.currentIndex]) return showToast("먼저 그래픽 항목을 선택하세요.");
|
||||
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} PREPARE`);
|
||||
function normalizePlayoutMode(value) {
|
||||
return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
|
||||
}
|
||||
|
||||
function takeIn() {
|
||||
if (state.playout !== "PREPARED") return showToast("PREPARE가 먼저 필요합니다.");
|
||||
setPlayout("PROGRAM", `${state.playlist[state.currentIndex].code} TAKE IN · Tornado 어댑터 대기`);
|
||||
showToast("UI 동작 확인 완료 — 실제 송출 어댑터는 아직 연결되지 않았습니다.");
|
||||
function isDryRunMode(mode = state.playout.mode) {
|
||||
return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (!state.playlist.length) return showToast("플레이리스트가 비어 있습니다.");
|
||||
state.currentIndex = (state.currentIndex + 1) % state.playlist.length;
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} NEXT / PREPARE`);
|
||||
function isLiveMode(mode = state.playout.mode) {
|
||||
return /live|program/.test(normalizePlayoutMode(mode));
|
||||
}
|
||||
|
||||
function takeOut() {
|
||||
setPlayout("IDLE", "TAKE OUT");
|
||||
function isTestMode(mode = state.playout.mode) {
|
||||
return /test/.test(normalizePlayoutMode(mode));
|
||||
}
|
||||
|
||||
function playoutDisplayState() {
|
||||
return isDryRunMode() ? "DRY RUN" : state.playout.engineState;
|
||||
}
|
||||
|
||||
function setStatusDot(element, status) {
|
||||
element.classList.remove("pending", "healthy", "error", "warn", "ok", "unconfigured");
|
||||
element.classList.add(status);
|
||||
}
|
||||
|
||||
function cueFromItem(item) {
|
||||
if (!item) return null;
|
||||
return { code: String(item.code), title: String(item.title), detail: String(item.detail) };
|
||||
}
|
||||
|
||||
function findCueByCode(code) {
|
||||
if (!code) return null;
|
||||
const current = state.playlist[state.currentIndex];
|
||||
if (String(current?.code) === String(code)) return cueFromItem(current);
|
||||
return cueFromItem(state.playlist.find(item => String(item.code) === String(code)));
|
||||
}
|
||||
|
||||
function renderPlayout() {
|
||||
const displayState = playoutDisplayState();
|
||||
const pending = state.playout.pending;
|
||||
const dryRun = isDryRunMode();
|
||||
const liveMode = isLiveMode();
|
||||
const testMode = isTestMode();
|
||||
const bridgeReady = Boolean(nativeBridge);
|
||||
const outcomeUnknown = state.playout.error?.outcomeUnknown === true;
|
||||
const commandReady = bridgeReady && !pending && !outcomeUnknown && (state.playout.connected || dryRun);
|
||||
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
|
||||
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
|
||||
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
|
||||
|
||||
elements.playoutState.textContent = displayState;
|
||||
elements.playoutState.classList.remove("neutral", "live", "dry-run", "error");
|
||||
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
|
||||
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
|
||||
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
|
||||
|
||||
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
|
||||
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
|
||||
elements.playoutConnectionState.textContent = state.playout.connected ? "연결됨" : "미연결";
|
||||
setStatusDot(elements.playoutConnectionDot, state.playout.connected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
|
||||
|
||||
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
|
||||
safetyContainer.classList.remove("live-allowed", "live-locked");
|
||||
if (dryRun) {
|
||||
elements.playoutSafetyMode.textContent = "DRY RUN · PROGRAM 차단";
|
||||
} else if (testMode && state.playout.liveTakeInAllowed) {
|
||||
elements.playoutSafetyMode.textContent = "TEST · 전용 출력 허용";
|
||||
safetyContainer.classList.add("live-allowed");
|
||||
} else if (testMode) {
|
||||
elements.playoutSafetyMode.textContent = "TEST TAKE IN 잠금";
|
||||
safetyContainer.classList.add("live-locked");
|
||||
} else if (liveMode && state.playout.liveTakeInAllowed) {
|
||||
elements.playoutSafetyMode.textContent = "LIVE 허용";
|
||||
safetyContainer.classList.add("live-allowed");
|
||||
} else if (liveMode) {
|
||||
elements.playoutSafetyMode.textContent = "LIVE TAKE IN 잠금";
|
||||
safetyContainer.classList.add("live-locked");
|
||||
} else {
|
||||
elements.playoutSafetyMode.textContent = bridgeReady ? "모드 확인 중" : "미리보기 전용";
|
||||
}
|
||||
|
||||
const changedLabel = state.playout.changedAtMs
|
||||
? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}`
|
||||
: "";
|
||||
elements.playoutStatusMessage.textContent = pending
|
||||
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
|
||||
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`;
|
||||
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
|
||||
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error));
|
||||
|
||||
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
|
||||
if (dryRun) {
|
||||
elements.playoutSummary.classList.add("dry-run");
|
||||
elements.playoutSummaryBadge.textContent = "DRY RUN";
|
||||
elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용";
|
||||
} else if (state.playout.connected) {
|
||||
elements.playoutSummary.classList.add("ready");
|
||||
elements.playoutSummaryBadge.textContent = testMode
|
||||
? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED")
|
||||
: (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED");
|
||||
elements.playoutSummaryDetail.textContent = testMode
|
||||
? "테스트 전용 출력 · 안전 게이트 적용"
|
||||
: (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지");
|
||||
} else {
|
||||
elements.playoutSummary.classList.add("error");
|
||||
elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW";
|
||||
elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
|
||||
}
|
||||
|
||||
elements.prepareButton.disabled = !commandReady || !currentCue;
|
||||
elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe;
|
||||
elements.nextButton.disabled = !commandReady || state.playlist.length === 0;
|
||||
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
|
||||
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
|
||||
button.setAttribute("aria-busy", String(Boolean(pending)));
|
||||
}
|
||||
|
||||
elements.playoutError.hidden = !state.playout.error;
|
||||
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
|
||||
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "확인 후 잠금 해제" : "닫기";
|
||||
}
|
||||
|
||||
function clearPlayoutError(render = true) {
|
||||
state.playout.error = null;
|
||||
if (render) renderPlayout();
|
||||
}
|
||||
|
||||
function showPlayoutError({ code = "PLAYOUT_ERROR", message, retryable = false, outcomeUnknown = false }) {
|
||||
const details = [message || "송출 명령을 완료하지 못했습니다."];
|
||||
if (code) details.push(`[${code}]`);
|
||||
if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요.");
|
||||
else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다.");
|
||||
state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown };
|
||||
renderPlayout();
|
||||
showToast(state.playout.error.message);
|
||||
}
|
||||
|
||||
function resolveCommand(command) {
|
||||
const current = state.playlist[state.currentIndex];
|
||||
if (command === "prepare") {
|
||||
return current ? { cue: cueFromItem(current), targetIndex: state.currentIndex } : null;
|
||||
}
|
||||
if (command === "take-in") {
|
||||
const cue = state.playout.preparedCue || findCueByCode(state.playout.preparedCode);
|
||||
return cue ? { cue, targetIndex: state.currentIndex } : null;
|
||||
}
|
||||
if (command === "next") {
|
||||
if (!state.playlist.length) return null;
|
||||
const targetIndex = state.currentIndex < 0 ? 0 : (state.currentIndex + 1) % state.playlist.length;
|
||||
return { cue: cueFromItem(state.playlist[targetIndex]), targetIndex };
|
||||
}
|
||||
if (command === "take-out") return { cue: null, targetIndex: state.currentIndex };
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestPlayoutCommand(command) {
|
||||
if (!nativeBridge) {
|
||||
showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
if (state.playout.pending) return;
|
||||
|
||||
const resolved = resolveCommand(command);
|
||||
if (!resolved) {
|
||||
const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요.";
|
||||
showToast(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = createId();
|
||||
const pending = {
|
||||
requestId,
|
||||
command,
|
||||
cue: resolved.cue,
|
||||
targetIndex: resolved.targetIndex,
|
||||
timeoutId: null
|
||||
};
|
||||
state.playout.pending = pending;
|
||||
clearPlayoutError(false);
|
||||
renderPlayout();
|
||||
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
|
||||
|
||||
const payload = { requestId, command };
|
||||
if (resolved.cue) payload.cue = resolved.cue;
|
||||
postNative("playout-command", payload);
|
||||
|
||||
pending.timeoutId = setTimeout(() => {
|
||||
if (state.playout.pending?.requestId !== requestId) return;
|
||||
state.playout.pending = null;
|
||||
showPlayoutError({
|
||||
code: "WEB_TIMEOUT",
|
||||
message: "송출 엔진 응답 시간이 초과되었습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: true
|
||||
});
|
||||
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
|
||||
requestPlayoutStatus();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function requestPlayoutStatus() {
|
||||
if (!nativeBridge) return;
|
||||
const requestId = createId();
|
||||
state.playout.latestStatusRequestId = requestId;
|
||||
postNative("request-playout-status", { requestId });
|
||||
}
|
||||
|
||||
function parseChangedAt(value) {
|
||||
const parsed = typeof value === "string" || typeof value === "number" ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function handlePlayoutStatus(payload) {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return;
|
||||
|
||||
const changedAtMs = parseChangedAt(payload.changedAt);
|
||||
if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return;
|
||||
|
||||
state.playout.mode = normalizePlayoutMode(payload.mode);
|
||||
state.playout.engineState = normalizePlayoutState(payload.state);
|
||||
state.playout.processDetected = payload.processDetected === true;
|
||||
state.playout.connected = payload.connected === true;
|
||||
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
|
||||
state.playout.message = typeof payload.message === "string" && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: "Tornado 송출 상태를 수신했습니다.";
|
||||
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
|
||||
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
|
||||
state.playout.preparedCue = findCueByCode(state.playout.preparedCode);
|
||||
state.playout.changedAt = payload.changedAt || null;
|
||||
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
|
||||
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
|
||||
|
||||
renderPlayout();
|
||||
const signature = [state.playout.mode, state.playout.engineState, state.playout.processDetected, state.playout.connected, state.playout.liveTakeInAllowed].join("|");
|
||||
if (signature !== state.playout.lastSignature) {
|
||||
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
|
||||
state.playout.lastSignature = signature;
|
||||
}
|
||||
}
|
||||
|
||||
function finishPendingCommand(payload) {
|
||||
const pending = state.playout.pending;
|
||||
if (!pending || payload?.requestId !== pending.requestId || payload?.command !== pending.command) return null;
|
||||
clearTimeout(pending.timeoutId);
|
||||
state.playout.pending = null;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function handlePlayoutCommandResult(payload) {
|
||||
const pending = finishPendingCommand(payload);
|
||||
if (!pending) return;
|
||||
if (payload.succeeded !== true) {
|
||||
showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true });
|
||||
addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`);
|
||||
requestPlayoutStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
clearPlayoutError(false);
|
||||
if (payload.dryRun === true) state.playout.mode = "dry-run";
|
||||
state.playout.engineState = normalizePlayoutState(payload.state);
|
||||
state.playout.message = typeof payload.message === "string" && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: `${pending.command.toUpperCase()} 완료`;
|
||||
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
|
||||
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
|
||||
|
||||
if (pending.command === "prepare" || pending.command === "next") {
|
||||
state.playout.preparedCue = pending.cue;
|
||||
} else if (!state.playout.preparedCode) {
|
||||
state.playout.preparedCue = null;
|
||||
}
|
||||
if (pending.command === "next") {
|
||||
state.currentIndex = pending.targetIndex;
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
renderPlayout();
|
||||
const safetyLabel = payload.dryRun === true ? "DRY RUN · " : "";
|
||||
addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`);
|
||||
showToast(state.playout.message);
|
||||
requestPlayoutStatus();
|
||||
}
|
||||
|
||||
function handlePlayoutCommandError(payload) {
|
||||
const pending = finishPendingCommand(payload);
|
||||
if (!pending) return;
|
||||
showPlayoutError({
|
||||
code: payload.code,
|
||||
message: payload.message,
|
||||
retryable: payload.retryable === true,
|
||||
outcomeUnknown: payload.outcomeUnknown === true
|
||||
});
|
||||
addLog(`${pending.command.toUpperCase()} 오류 · ${payload.code || "PLAYOUT_ERROR"} · ${payload.message || "명령 실패"}`);
|
||||
requestPlayoutStatus();
|
||||
}
|
||||
|
||||
function addLog(message) {
|
||||
@@ -715,6 +1026,15 @@
|
||||
case "market-data-error":
|
||||
handleMarketDataError(message.payload);
|
||||
break;
|
||||
case "playout-status":
|
||||
handlePlayoutStatus(message.payload);
|
||||
break;
|
||||
case "playout-command-result":
|
||||
handlePlayoutCommandResult(message.payload);
|
||||
break;
|
||||
case "playout-command-error":
|
||||
handlePlayoutCommandError(message.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,14 +1065,16 @@
|
||||
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
|
||||
document.querySelector("#saveButton").addEventListener("click", savePlaylist);
|
||||
document.querySelector("#loadButton").addEventListener("click", loadPlaylist);
|
||||
document.querySelector("#prepareButton").addEventListener("click", prepare);
|
||||
document.querySelector("#takeInButton").addEventListener("click", takeIn);
|
||||
document.querySelector("#nextButton").addEventListener("click", next);
|
||||
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
|
||||
elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare"));
|
||||
elements.takeInButton.addEventListener("click", () => requestPlayoutCommand("take-in"));
|
||||
elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next"));
|
||||
elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out"));
|
||||
document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError());
|
||||
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
|
||||
document.querySelector("#requestInfoButton").addEventListener("click", () => {
|
||||
postNative("request-app-info");
|
||||
requestDatabaseStatus();
|
||||
requestPlayoutStatus();
|
||||
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
|
||||
});
|
||||
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
|
||||
@@ -764,9 +1086,9 @@
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault(); elements.catalogSearch.focus(); return;
|
||||
}
|
||||
if (event.key === "F2") { event.preventDefault(); prepare(); }
|
||||
if (event.key === "F8") { event.preventDefault(); takeIn(); }
|
||||
if (event.key === "Escape") { event.preventDefault(); takeOut(); }
|
||||
if (event.key === "F2") { event.preventDefault(); requestPlayoutCommand("prepare"); }
|
||||
if (event.key === "F8") { event.preventDefault(); requestPlayoutCommand("take-in"); }
|
||||
if (event.key === "Escape") { event.preventDefault(); requestPlayoutCommand("take-out"); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -776,6 +1098,7 @@
|
||||
renderPlaylist();
|
||||
renderDatabaseStatus();
|
||||
renderLiveData();
|
||||
renderPlayout();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
addLog("WebView 운영 화면 시작");
|
||||
@@ -785,11 +1108,16 @@
|
||||
postNative("ready");
|
||||
postNative("request-app-info");
|
||||
requestDatabaseStatus();
|
||||
requestPlayoutStatus();
|
||||
} else {
|
||||
elements.bridgeState.textContent = "브라우저 미리보기";
|
||||
state.database.loading = false;
|
||||
state.playout.mode = "preview-dry-run";
|
||||
state.playout.engineState = "IDLE";
|
||||
state.playout.message = "Native Bridge 없음 · 미리보기 전용 DRY RUN (PROGRAM 출력 차단)";
|
||||
renderPlayout();
|
||||
renderDatabaseStatus();
|
||||
addLog("Native Bridge 없이 브라우저 모드로 실행");
|
||||
addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<article><span class="metric-icon mint">✓</span><div><strong>WebView UI</strong><small>FarPoint 대체 기반</small></div><b>READY</b></article>
|
||||
<article><span class="metric-icon blue">71</span><div><strong>데이터 요청</strong><small>기존 SQL 계층 이관</small></div><b>MIGRATED</b></article>
|
||||
<article id="databaseSummary"><span id="databaseSummaryIcon" class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small id="databaseSummaryDetail">연결 상태 확인 중</small></div><b id="databaseSummaryBadge">CHECKING</b></article>
|
||||
<article><span class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small>x64 COM 검증 대기</small></div><b>PENDING</b></article>
|
||||
<article id="playoutSummary" class="playout-summary"><span id="playoutSummaryIcon" class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small id="playoutSummaryDetail">상태 확인 중</small></div><b id="playoutSummaryBadge">CHECKING</b></article>
|
||||
</section>
|
||||
|
||||
<div class="console-grid">
|
||||
@@ -121,6 +121,27 @@
|
||||
<div><p class="panel-kicker">PROGRAM / PREVIEW</p><h2>송출 제어</h2></div>
|
||||
<span id="playoutState" class="badge neutral">IDLE</span>
|
||||
</div>
|
||||
<div class="playout-health" aria-label="Tornado 송출 연결 상태">
|
||||
<div class="playout-health-item">
|
||||
<span id="tornadoProcessDot" class="status-dot pending"></span>
|
||||
<small>Tornado2 Process</small>
|
||||
<strong id="tornadoProcessState">확인 중</strong>
|
||||
</div>
|
||||
<div class="playout-health-item">
|
||||
<span id="playoutConnectionDot" class="status-dot pending"></span>
|
||||
<small>Engine</small>
|
||||
<strong id="playoutConnectionState">확인 중</strong>
|
||||
</div>
|
||||
<div class="playout-health-item safety">
|
||||
<small>Safety Mode</small>
|
||||
<strong id="playoutSafetyMode">확인 중</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div id="playoutStatusMessage" class="playout-status-message" role="status" aria-live="polite">송출 어댑터 상태를 확인하고 있습니다.</div>
|
||||
<div id="playoutError" class="playout-error" role="alert" hidden>
|
||||
<div><strong>송출 명령 오류</strong><span id="playoutErrorMessage"></span></div>
|
||||
<button id="clearPlayoutErrorButton" type="button" aria-label="송출 오류 닫기">닫기</button>
|
||||
</div>
|
||||
<div class="preview-stage" id="previewStage">
|
||||
<div class="safe-area">
|
||||
<span>PREVIEW</span>
|
||||
|
||||
@@ -133,6 +133,12 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
|
||||
.database-summary.healthy > b { color: var(--mint); }
|
||||
.database-summary.partial > b { color: var(--amber); }
|
||||
.database-summary.error > b { color: var(--red); }
|
||||
.playout-summary.ready { border-color: rgba(50,213,164,.22); }
|
||||
.playout-summary.dry-run { border-color: rgba(255,186,85,.22); }
|
||||
.playout-summary.error { border-color: rgba(255,102,128,.24); }
|
||||
.playout-summary.ready > b { color: var(--mint); }
|
||||
.playout-summary.dry-run > b { color: var(--amber); }
|
||||
.playout-summary.error > b { color: var(--red); }
|
||||
|
||||
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
|
||||
|
||||
@@ -142,6 +148,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
|
||||
.badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; }
|
||||
.badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); }
|
||||
.badge.live { border-color: rgba(50,213,164,.36); background: rgba(50,213,164,.16); box-shadow: 0 0 0 3px rgba(50,213,164,.04); }
|
||||
.badge.dry-run { border-color: rgba(255,186,85,.32); background: rgba(255,186,85,.11); color: var(--amber); }
|
||||
|
||||
.search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; }
|
||||
.search-box > span { color: var(--muted); font-size: 17px; }
|
||||
@@ -239,6 +246,23 @@ input[type="checkbox"] { accent-color: var(--mint); }
|
||||
.empty-state span { color: var(--muted-2); font-size: 9px; }
|
||||
|
||||
.playout-panel { padding-bottom: 12px; }
|
||||
.playout-health { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; margin: 10px 13px 0; }
|
||||
.playout-health-item { display: grid; grid-template-columns: 7px minmax(0, 1fr); align-items: center; min-width: 0; min-height: 38px; column-gap: 6px; padding: 6px 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.72); }
|
||||
.playout-health-item small { overflow: hidden; color: var(--muted-2); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playout-health-item strong { grid-column: 2; overflow: hidden; color: #b9c7d8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playout-health-item.safety { grid-template-columns: 1fr; }
|
||||
.playout-health-item.safety strong { grid-column: 1; color: var(--amber); }
|
||||
.playout-health-item.safety.live-allowed strong { color: var(--mint); }
|
||||
.playout-health-item.safety.live-locked strong { color: var(--red); }
|
||||
.playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; }
|
||||
.playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; }
|
||||
.playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
|
||||
.playout-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 13px 0; padding: 8px 9px; border: 1px solid rgba(255,102,128,.28); border-radius: 7px; background: rgba(255,102,128,.07); }
|
||||
.playout-error[hidden] { display: none; }
|
||||
.playout-error strong, .playout-error span { display: block; }
|
||||
.playout-error strong { margin-bottom: 2px; color: #ff9bad; font-size: 8px; }
|
||||
.playout-error span { color: #c98995; font-size: 8px; line-height: 1.4; }
|
||||
.playout-error button { min-height: 24px; flex: 0 0 auto; padding: 0 7px; border: 1px solid rgba(255,102,128,.25); border-radius: 5px; background: transparent; color: #e38c9c; font-size: 8px; cursor: pointer; }
|
||||
.preview-stage { position: relative; display: grid; min-height: 255px; margin: 13px; place-items: center; overflow: hidden; border: 1px solid #29405a; border-radius: 9px; background: radial-gradient(circle at 50% 45%, #193552, #08121f 60%); }
|
||||
.preview-stage::after { position: absolute; inset: 0; background-image: linear-gradient(rgba(65,101,133,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(65,101,133,.08) 1px, transparent 1px); background-size: 24px 24px; content: ""; }
|
||||
.safe-area { position: relative; z-index: 1; width: 78%; padding: 31px 20px; border: 1px dashed rgba(137,167,195,.24); color: var(--muted); text-align: center; }
|
||||
@@ -253,6 +277,8 @@ input[type="checkbox"] { accent-color: var(--mint); }
|
||||
.playout-controls button { display: flex; min-width: 0; height: 42px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: #b5c4d5; font-size: 9px; font-weight: 700; cursor: pointer; }
|
||||
.playout-controls button span { color: var(--muted-2); font: 8px Consolas, monospace; }
|
||||
.playout-controls button:hover { filter: brightness(1.15); }
|
||||
.playout-controls button:disabled { filter: none; cursor: not-allowed; opacity: .4; }
|
||||
.playout-controls button[aria-busy="true"] { border-color: rgba(86,168,255,.36); box-shadow: inset 0 0 0 1px rgba(86,168,255,.08); opacity: .72; }
|
||||
.playout-controls .prepare { border-color: rgba(86,168,255,.34); background: rgba(86,168,255,.1); color: #8ec4ff; }
|
||||
.playout-controls .take-in { border-color: rgba(50,213,164,.38); background: var(--mint-soft); color: var(--mint); }
|
||||
.playout-controls .take-out { border-color: rgba(255,102,128,.28); background: rgba(255,102,128,.08); color: #ff8fa2; }
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
| 동기 Oracle/MySQL 연결 | 비동기 Oracle/MariaDB 공급자, 취소·timeout·선별 재시도 |
|
||||
| DB 실패 시 `Application.Exit()` | 앱 유지 + WebView 소스별 health/오류/재조회 |
|
||||
| 종목·지수 선택 데이터 | WebView의 KRX/NXT 종목 및 5개 지수 실데이터 표 |
|
||||
| 직접 K3D COM 호출 | COM 중립 `IPlayoutEngine` + x64 late-bound 어댑터 |
|
||||
| UI thread의 Tornado 호출 | bounded STA FIFO, timeout 격리, 재연결·프로세스 감시 |
|
||||
| 버튼 즉시 상태 변경 | requestId 기반 WebView bridge와 실제 결과 후 상태 갱신 |
|
||||
| AnyCPU/x86 혼재 | 솔루션 및 게시 프로필 x64 단일화 |
|
||||
| 상대 경로 Web 파일 | MSIX Content 및 안전한 가상 호스트 매핑 |
|
||||
|
||||
@@ -43,11 +46,14 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일
|
||||
|
||||
### Tornado/K3D
|
||||
|
||||
- 설치된 x64 COM 타입 라이브러리에서 재현 가능한 Interop 생성
|
||||
- `IPlayoutEngine` 경계로 COM 형식 격리
|
||||
- STA 호출 직렬화와 프로세스 재연결
|
||||
- MSIX 환경의 COM 활성화 및 벤더 재배포 조건 확인
|
||||
- 기존 `StartsWith("Tornado2")` 프로세스 탐지 변경 반영
|
||||
- 완료: Registry64의 K3D TypeLib/CLSID/ProgID/AMD64/Apartment 등록 검사
|
||||
- 완료: 빌드 입력에 Interop DLL을 두지 않는 late binding과 선택적 진단용 `TlbImp` 스크립트
|
||||
- 완료: `IPlayoutEngine` 경계, bounded STA FIFO/message pump, timeout 후 `OutcomeUnknown` 격리
|
||||
- 완료: 연결/해제, 명시적 재연결(no replay), `Tornado2` 접두사 프로세스 감시
|
||||
- 완료: `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` WebView 메시지 및 상태/오류 UI 연결
|
||||
- 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널·씬 allowlist, Live 이중 승인
|
||||
- 확인 대기: 현재 `PGM`과 분리된 테스트 Tornado 인스턴스·출력 채널·테스트 씬의 실제 호출
|
||||
- 확인 대기: 승인된 Test 환경에서 MSIX 컨텍스트의 실제 COM 활성화와 출력 관찰
|
||||
|
||||
### 화면 기능
|
||||
|
||||
|
||||
187
docs/PLAYOUT.md
Normal file
187
docs/PLAYOUT.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Tornado/K3D x64 송출 운영 가이드
|
||||
|
||||
## 안전 기준
|
||||
|
||||
기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다.
|
||||
|
||||
이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다.
|
||||
|
||||
## 확인된 x64 COM 등록
|
||||
|
||||
현재 개발 장비에서 확인할 등록 기준은 다음과 같습니다.
|
||||
|
||||
| 항목 | 기대값 |
|
||||
|---|---|
|
||||
| 레지스트리 뷰 | `HKLM\SOFTWARE\Classes`의 `Registry64`; 관련 HKCU override 없음 |
|
||||
| TypeLib GUID | `{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}` |
|
||||
| TypeLib 버전/대상 | `1.0` / `0\win64` |
|
||||
| KAEngine CLSID | `{D756CDBE-AA31-42B2-9CC7-018753CA61BF}` |
|
||||
| ProgID | `K3DAsyncEngine.KAEngine.1` |
|
||||
| KAEventHandler CLSID / ProgID | `{39828C77-EFF0-4E59-979B-8673C028C718}` / `K3DAsyncEngine.KAEventHandler.1` |
|
||||
| ThreadingModel | `Apartment` |
|
||||
| DLL PE 대상 | TypeLib, KAEngine, KAEventHandler 모두 `AMD64` |
|
||||
|
||||
[Inspect-K3DRegistration.ps1](../scripts/Inspect-K3DRegistration.ps1)은 64비트 HKLM 등록에서 GUID, 버전과 `win64` TypeLib을 확인합니다. KAEngine과 KAEventHandler 각각의 CLSID→ProgID 및 ProgID→CLSID 양방향 매핑, `Apartment` 모델, `InprocServer32` 존재와 AMD64 PE 헤더도 검사합니다. HKCU 64비트 `Software\Classes`에 같은 TypeLib GUID, CLSID 또는 ProgID override가 하나라도 있으면 fail-closed합니다. 레지스트리와 파일을 읽기만 하며 COM을 활성화하거나 등록을 변경하지 않습니다.
|
||||
|
||||
`MBN_STOCK_WEBVIEW.PlayoutSmoke --probe`는 런타임과 같은 검사기를 통해 KAEngine과 KAEventHandler 등록을 함께 확인합니다. 이 probe도 COM 객체를 생성하지 않습니다.
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\scripts\Inspect-K3DRegistration.ps1
|
||||
```
|
||||
|
||||
검사가 실패하면 x86 등록으로 대체하거나 DLL을 앱 폴더에 복사하지 않습니다. 벤더가 제공한 x64 설치 프로그램과 라이선스 절차로 장비 상태를 복구한 뒤 다시 검사합니다.
|
||||
|
||||
## 런타임 참조 방식
|
||||
|
||||
앱은 빌드 시점의 `Interop.K3DAsyncEngineLib.dll`을 참조하지 않습니다. HKLM 64비트 등록과 HKCU override 부재를 검증한 뒤 고정된 KAEngine/KAEventHandler CLSID로만 런타임 late binding하고, COM 객체와 호출 세부 사항은 `IPlayoutEngine` 구현 안에 격리합니다. 따라서 `obj` 폴더에 남은 Interop DLL 또는 특정 개발 장비의 벤더 설치 경로가 빌드 입력이 되지 않습니다.
|
||||
|
||||
타입 정보를 조사해야 할 때만 [Generate-K3DInterop.ps1](../scripts/Generate-K3DInterop.ps1)을 선택적으로 사용합니다. 생성 전 Inspect와 같은 HKLM 양방향 매핑·두 COM 서버 AMD64·HKCU override 부재 검사를 통과해야 합니다. 그 뒤 알려진 Windows SDK의 x64 `TlbImp.exe` 위치를 선택하거나 명시적으로 받은 x64 경로를 검증하고 `/machine:X64`로 생성합니다. 입력 TypeLib과 도구 및 결과물 모두 AMD64인지 확인하며, 결과는 Git에서 제외된 다음 위치에만 씁니다.
|
||||
|
||||
```text
|
||||
artifacts\K3DInterop\Interop.K3DAsyncEngineLib.dll
|
||||
```
|
||||
|
||||
실행 전 계획만 확인:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\scripts\Generate-K3DInterop.ps1 -WhatIf
|
||||
```
|
||||
|
||||
진단용 Interop 생성:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\scripts\Generate-K3DInterop.ps1
|
||||
```
|
||||
|
||||
SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-TlbImpPath`에 지정합니다. 생성물은 진단용일 뿐 프로젝트 참조나 MSIX 콘텐츠로 추가하지 않습니다. 이 스크립트도 COM 활성화와 등록 변경은 하지 않습니다.
|
||||
|
||||
## 로컬 설정
|
||||
|
||||
런타임 설정 기본 경로는 다음과 같습니다.
|
||||
|
||||
```text
|
||||
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json
|
||||
```
|
||||
|
||||
[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 외부 씬 루트와 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. 테스트 장비의 호스트, 채널, 창 제목 패턴, 외부 씬 루트와 허용할 씬 이름은 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.
|
||||
|
||||
| 속성 | 의미 |
|
||||
|---|---|
|
||||
| `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
|
||||
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
|
||||
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
|
||||
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 |
|
||||
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
|
||||
| `layoutIndex` | 씬 플레이어 layout 위치 |
|
||||
| `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 |
|
||||
| `testSceneAllowlist` | `Test`에서 허용한 테스트 scene name(code) 목록. 경로나 확장자는 넣지 않음 |
|
||||
| `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 |
|
||||
| `queueCapacity` | 직렬 STA 명령 큐의 최대 대기 항목 수 |
|
||||
| `*TimeoutMilliseconds` | 연결, 작업 및 해제 제한 시간 |
|
||||
| `processPollIntervalMilliseconds` | `Tornado2` 접두사 프로세스 감시 주기 |
|
||||
| `reconnect*` | 재연결 지연, 최대 횟수 및 활성화 여부 |
|
||||
|
||||
JSON보다 다음 환경 변수가 우선합니다.
|
||||
|
||||
```text
|
||||
MBN_STOCK_PLAYOUT_MODE
|
||||
MBN_STOCK_PLAYOUT_HOST
|
||||
MBN_STOCK_PLAYOUT_PORT
|
||||
MBN_STOCK_PLAYOUT_TCP_MODE
|
||||
MBN_STOCK_PLAYOUT_CLIENT_PORT
|
||||
MBN_STOCK_PLAYOUT_SCENE_DIRECTORY
|
||||
MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL
|
||||
MBN_STOCK_PLAYOUT_LAYOUT_INDEX
|
||||
MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN
|
||||
MBN_STOCK_PLAYOUT_QUEUE_CAPACITY
|
||||
MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS
|
||||
MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS
|
||||
MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS
|
||||
MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS
|
||||
MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS
|
||||
MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS
|
||||
MBN_STOCK_PLAYOUT_RECONNECT_ENABLED
|
||||
```
|
||||
|
||||
`testSceneAllowlist`와 `trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 Test allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
|
||||
|
||||
## 모드와 안전 게이트
|
||||
|
||||
`Disabled`는 모든 송출 명령을 거부하는 운영 롤백 모드입니다. `DryRun`은 COM 없이 WebView 동작을 성공 결과로 모의합니다. `Test`와 `Live`만 등록된 COM을 사용할 수 있습니다.
|
||||
|
||||
`Test` 전환 전 다음 조건을 모두 사람이 확인합니다.
|
||||
|
||||
1. Tornado 인스턴스가 현재 PGM과 물리적·논리적으로 분리되어 있고 Test의 `host`가 로컬 loopback IP literal(`127.0.0.1` 또는 `::1`)입니다. `localhost`를 포함한 호스트 이름은 허용하지 않습니다.
|
||||
2. `testProcessWindowTitlePattern`이 전용 테스트 인스턴스 하나만 식별합니다.
|
||||
3. `outputChannel`이 라우터/엔진에서 테스트 출력임을 확인했습니다.
|
||||
4. `sceneDirectory`가 승인된 테스트 자산 루트이고 사용할 scene name이 `testSceneAllowlist`에 있으며 운영 씬이 아닙니다.
|
||||
5. 같은 플레이리스트로 `DryRun`의 `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` 메시지와 화면 상태를 먼저 확인했습니다.
|
||||
|
||||
프로세스 감시는 이름이 `Tornado2`로 시작하는 모든 프로세스를 대소문자 구분 없이 찾습니다. 이는 가용성 신호일 뿐 송출 권한이 아닙니다. Test에서는 Tornado2 프로세스가 정확히 하나이고 그 창 제목이 테스트 규칙에만 일치해야 하며, `PGM`/`PROGRAM` 창이 하나라도 있거나 여러 인스턴스가 모호하면 COM을 활성화하지 않습니다. 즉 현재 방송 PGM이 실행 중인 장비에서는 Test 연결을 거부합니다.
|
||||
|
||||
Test에서 로컬 프로세스/창 제목 검사를 원격 KTAP endpoint의 신원 증명으로 사용하지 않습니다. 따라서 Test 모드는 loopback endpoint만 허용하며, 원격 host는 아래의 Live 이중 승인을 모두 통과한 경우에만 사용할 수 있습니다.
|
||||
|
||||
`Live`에는 별도의 이중 승인이 필요합니다.
|
||||
|
||||
1. 로컬 JSON의 `trustedLiveOutputEnabled`를 명시적으로 `true`로 설정합니다.
|
||||
2. 그 회차에만 프로세스 환경 변수 `MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT`을 정확히 `I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH`로 설정합니다.
|
||||
|
||||
둘 중 하나라도 없으면 라이브 출력을 거부해야 합니다. 이 값은 사용자/시스템 영구 환경 변수로 저장하지 않으며 앱 종료 후 제거합니다. 현재 작업에는 이 승인이 주어지지 않았으므로 `Live` 및 실제 PGM `TAKE IN` 검증을 수행하지 않습니다.
|
||||
|
||||
## 안전한 스모크 절차
|
||||
|
||||
먼저 `mode`가 `DryRun`이고 라이브 환경 변수가 없는지 확인합니다.
|
||||
|
||||
```powershell
|
||||
Remove-Item Env:MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT -ErrorAction SilentlyContinue
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\scripts\Inspect-K3DRegistration.ps1
|
||||
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
|
||||
-c Debug -p:Platform=x64 -- --probe
|
||||
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
|
||||
-c Debug -p:Platform=x64 -- --dry-run
|
||||
dotnet test .\MBN_STOCK_WEBVIEW.sln -c Debug -p:Platform=x64
|
||||
dotnet test .\MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64
|
||||
```
|
||||
|
||||
`PlayoutSmoke --probe`의 기본 출력은 등록 준비 여부·issue 이름, Tornado2 프로세스 수와 PROGRAM 감지 여부만 제공합니다. 벤더 DLL 전체 경로, 프로세스 ID/이름 및 창 제목은 출력하거나 로그에 남기지 않습니다. 정확한 로컬 등록 경로가 필요한 관리자는 `Inspect-K3DRegistration.ps1` 결과를 해당 장비에서만 확인하고 지원 첨부파일에 포함하지 않습니다.
|
||||
|
||||
앱을 실행한 뒤 다음을 확인합니다.
|
||||
|
||||
1. 시작 상태가 `DRY RUN`으로 보이고 앱이 COM 또는 Tornado 없이 종료되지 않습니다.
|
||||
2. WebView의 `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT`이 native bridge를 통과해 명확한 결과를 표시합니다.
|
||||
3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다.
|
||||
4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다.
|
||||
|
||||
실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → TAKE IN → NEXT → TAKE OUT` 결과를 관찰합니다. PGM/운영 출력에 변화가 보이면 즉시 앱을 종료하고 롤백합니다. 명령 timeout 뒤 결과가 불명확하면 명령을 자동 또는 수동으로 반복하지 말고 테스트 출력 상태를 먼저 확인합니다.
|
||||
|
||||
패키지 스모크에서는 벤더 x64 COM이 장비에 정식 등록되어 있어야 합니다. MSIX에 벤더 DLL을 복사해 활성화 오류를 우회하지 않습니다. 패키지 컨텍스트에서 COM 활성화가 막히면 `DryRun` 또는 `Disabled`를 유지하고 HRESULT와 등록 검사 결과만 보고합니다.
|
||||
|
||||
## 장애 및 롤백
|
||||
|
||||
가장 빠른 롤백은 로컬 설정을 다음처럼 바꾸고 앱을 재시작하는 것입니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "Disabled"
|
||||
}
|
||||
```
|
||||
|
||||
그 다음 회차별 라이브 환경 변수를 제거하고 테스트/운영 라우팅 상태를 사람이 확인합니다. 필요하면 조직 배포 절차로 직전 서명 MSIX로 되돌립니다. COM 등록 해제, 벤더 설치 폴더 삭제 또는 DLL 교체는 앱 롤백 절차가 아니므로 수행하지 않습니다. 진단용 `artifacts\K3DInterop`을 삭제해도 런타임에는 영향이 없습니다.
|
||||
|
||||
연결 장애나 Tornado 재시작은 화면의 `Disconnected`, `Reconnecting`, `Faulted` 또는 `OutcomeUnknown` 상태로 판단합니다. 재연결 횟수를 모두 소진하거나 결과가 불명확한 송출 명령이 있으면 자동 재송출하지 않고 `Disabled`로 전환한 뒤 운영자가 테스트 출력 상태를 확인합니다.
|
||||
|
||||
## 저장소 반입 금지
|
||||
|
||||
다음 항목은 Git과 MSIX에 넣지 않습니다.
|
||||
|
||||
- 벤더 COM DLL 및 생성한 Interop DLL
|
||||
- Tornado/K3D 라이선스 파일, 키 또는 인증서
|
||||
- 실제 `.t2s` 씬, 이미지, 영상 및 방송 자산
|
||||
- 운영 호스트, 채널, 창 제목 및 로컬 허용 목록이 든 설정
|
||||
- 실제 출력 캡처나 비밀정보가 포함된 진단 로그
|
||||
|
||||
벤더 바이너리와 자산은 승인된 설치·배포 위치에서 관리하고, 앱 저장소에는 COM 중립 인터페이스와 안전한 설정 예시만 유지합니다.
|
||||
440
scripts/Generate-K3DInterop.ps1
Normal file
440
scripts/Generate-K3DInterop.ps1
Normal file
@@ -0,0 +1,440 @@
|
||||
#Requires -Version 5.1
|
||||
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
|
||||
param(
|
||||
[string] $TlbImpPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$expectedTypeLibId = '{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}'
|
||||
$expectedTypeLibVersion = '1.0'
|
||||
$expectedThreadingModel = 'Apartment'
|
||||
$amd64Machine = 0x8664
|
||||
$expectedClasses = @(
|
||||
[pscustomobject]@{
|
||||
Name = 'KAEngine'
|
||||
ClassId = '{D756CDBE-AA31-42B2-9CC7-018753CA61BF}'
|
||||
ProgId = 'K3DAsyncEngine.KAEngine.1'
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Name = 'KAEventHandler'
|
||||
ClassId = '{39828C77-EFF0-4E59-979B-8673C028C718}'
|
||||
ProgId = 'K3DAsyncEngine.KAEventHandler.1'
|
||||
}
|
||||
)
|
||||
|
||||
function Get-RegistryValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $BaseKey,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $SubKey,
|
||||
|
||||
[string] $Name = ''
|
||||
)
|
||||
|
||||
$key = $BaseKey.OpenSubKey($SubKey, $false)
|
||||
if ($null -eq $key) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return $key.GetValue(
|
||||
$Name,
|
||||
$null,
|
||||
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
}
|
||||
finally {
|
||||
$key.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-TextEqual {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $Actual,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Expected,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
if ($null -eq $Actual -or
|
||||
-not [string]::Equals(
|
||||
[string] $Actual,
|
||||
$Expected,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-GuidEqual {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $Actual,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Expected,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
$actualGuid = [Guid]::Empty
|
||||
$expectedGuid = [Guid]::Empty
|
||||
if ($null -eq $Actual -or
|
||||
-not [Guid]::TryParse([string] $Actual, [ref] $actualGuid) -or
|
||||
-not [Guid]::TryParse($Expected, [ref] $expectedGuid) -or
|
||||
$actualGuid -ne $expectedGuid) {
|
||||
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-RegisteredFilePath {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $RegistryValue,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
if ($null -eq $RegistryValue -or [string]::IsNullOrWhiteSpace([string] $RegistryValue)) {
|
||||
throw "$Description is not registered."
|
||||
}
|
||||
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables([string] $RegistryValue)
|
||||
$expanded = $expanded.Trim().Trim('"')
|
||||
if (-not [IO.Path]::IsPathRooted($expanded)) {
|
||||
throw "$Description is not an absolute path."
|
||||
}
|
||||
|
||||
$fullPath = [IO.Path]::GetFullPath($expanded)
|
||||
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
||||
throw "$Description does not exist: $fullPath"
|
||||
}
|
||||
|
||||
return $fullPath
|
||||
}
|
||||
|
||||
function Get-PeMachine {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
$stream = [IO.File]::Open(
|
||||
$Path,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
|
||||
$reader = [IO.BinaryReader]::new($stream)
|
||||
|
||||
try {
|
||||
if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) {
|
||||
throw "Not a PE file: $Path"
|
||||
}
|
||||
|
||||
$stream.Position = 0x3C
|
||||
$peOffset = $reader.ReadInt32()
|
||||
if ($peOffset -lt 0 -or ($peOffset + 6) -gt $stream.Length) {
|
||||
throw "Invalid PE header offset: $Path"
|
||||
}
|
||||
|
||||
$stream.Position = $peOffset
|
||||
if ($reader.ReadUInt32() -ne 0x00004550) {
|
||||
throw "Invalid PE signature: $Path"
|
||||
}
|
||||
|
||||
$machineCode = $reader.ReadUInt16()
|
||||
$machineName = switch ($machineCode) {
|
||||
0x014C { 'I386' }
|
||||
0x8664 { 'AMD64' }
|
||||
0xAA64 { 'ARM64' }
|
||||
default { 'Unknown' }
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Code = $machineCode
|
||||
Name = $machineName
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Amd64File {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
$machine = Get-PeMachine -Path $Path
|
||||
if ($machine.Code -ne $amd64Machine) {
|
||||
throw "$Description is not AMD64. PE machine is $($machine.Name) (0x$('{0:X4}' -f $machine.Code))."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryKeyExists {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $BaseKey,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $SubKey
|
||||
)
|
||||
|
||||
$key = $BaseKey.OpenSubKey($SubKey, $false)
|
||||
if ($null -eq $key) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$key.Dispose()
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-NoCurrentUserOverrides {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $CurrentUserRoot
|
||||
)
|
||||
|
||||
$paths = @("Software\Classes\TypeLib\$expectedTypeLibId")
|
||||
foreach ($registration in $expectedClasses) {
|
||||
$paths += "Software\Classes\CLSID\$($registration.ClassId)"
|
||||
$paths += "Software\Classes\$($registration.ProgId)"
|
||||
}
|
||||
|
||||
foreach ($path in $paths) {
|
||||
if (Test-RegistryKeyExists -BaseKey $CurrentUserRoot -SubKey $path) {
|
||||
throw "A per-user Registry64 K3D COM override is present at HKCU:\$path. Remove the override through the approved vendor recovery procedure before use."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ComClassRegistration {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $MachineRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object] $Registration
|
||||
)
|
||||
|
||||
$classesPrefix = 'SOFTWARE\Classes'
|
||||
$progIdClassId = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\$($Registration.ProgId)\CLSID"
|
||||
Assert-GuidEqual `
|
||||
-Actual $progIdClassId `
|
||||
-Expected $Registration.ClassId `
|
||||
-Description "$($Registration.Name) ProgID to CLSID mapping"
|
||||
|
||||
$classProgId = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\ProgID"
|
||||
Assert-TextEqual `
|
||||
-Actual $classProgId `
|
||||
-Expected $Registration.ProgId `
|
||||
-Description "$($Registration.Name) CLSID to ProgID mapping"
|
||||
|
||||
$classTypeLibId = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\TypeLib"
|
||||
Assert-GuidEqual `
|
||||
-Actual $classTypeLibId `
|
||||
-Expected $expectedTypeLibId `
|
||||
-Description "$($Registration.Name) TypeLib GUID"
|
||||
|
||||
$classTypeLibVersion = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\Version"
|
||||
if ($null -ne $classTypeLibVersion -and
|
||||
-not [string]::IsNullOrWhiteSpace([string] $classTypeLibVersion)) {
|
||||
Assert-TextEqual `
|
||||
-Actual $classTypeLibVersion `
|
||||
-Expected $expectedTypeLibVersion `
|
||||
-Description "$($Registration.Name) TypeLib version"
|
||||
}
|
||||
|
||||
$inprocKey = "$classesPrefix\CLSID\$($Registration.ClassId)\InprocServer32"
|
||||
$serverPath = Resolve-RegisteredFilePath `
|
||||
-RegistryValue (Get-RegistryValue -BaseKey $MachineRoot -SubKey $inprocKey) `
|
||||
-Description "$($Registration.Name) InprocServer32 path"
|
||||
$threadingModel = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey $inprocKey `
|
||||
-Name 'ThreadingModel'
|
||||
Assert-TextEqual `
|
||||
-Actual $threadingModel `
|
||||
-Expected $expectedThreadingModel `
|
||||
-Description "$($Registration.Name) threading model"
|
||||
Assert-Amd64File -Path $serverPath -Description "$($Registration.Name) COM server DLL"
|
||||
}
|
||||
|
||||
function Resolve-X64TlbImpPath {
|
||||
param(
|
||||
[AllowEmptyString()]
|
||||
[string] $ExplicitPath
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
|
||||
if (-not [IO.Path]::IsPathRooted($ExplicitPath)) {
|
||||
throw 'TlbImpPath must be an absolute path.'
|
||||
}
|
||||
|
||||
$resolvedExplicitPath = [IO.Path]::GetFullPath($ExplicitPath)
|
||||
if (-not (Test-Path -LiteralPath $resolvedExplicitPath -PathType Leaf)) {
|
||||
throw "TlbImp.exe was not found: $resolvedExplicitPath"
|
||||
}
|
||||
|
||||
if (-not [string]::Equals(
|
||||
[IO.Path]::GetFileName($resolvedExplicitPath),
|
||||
'TlbImp.exe',
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'TlbImpPath must identify TlbImp.exe.'
|
||||
}
|
||||
|
||||
Assert-Amd64File -Path $resolvedExplicitPath -Description 'TlbImp.exe'
|
||||
return $resolvedExplicitPath
|
||||
}
|
||||
|
||||
$programFilesX86 = [Environment]::GetFolderPath(
|
||||
[Environment+SpecialFolder]::ProgramFilesX86)
|
||||
$sdkBin = Join-Path $programFilesX86 'Microsoft SDKs\Windows\v10.0A\bin'
|
||||
$candidates = @(
|
||||
(Join-Path $sdkBin 'NETFX 4.8.1 Tools\x64\TlbImp.exe'),
|
||||
(Join-Path $sdkBin 'NETFX 4.8 Tools\x64\TlbImp.exe'),
|
||||
(Join-Path $sdkBin 'NETFX 4.7.2 Tools\x64\TlbImp.exe'),
|
||||
(Join-Path $sdkBin 'NETFX 4.6.2 Tools\x64\TlbImp.exe')
|
||||
)
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||
Assert-Amd64File -Path $candidate -Description 'TlbImp.exe'
|
||||
return [IO.Path]::GetFullPath($candidate)
|
||||
}
|
||||
}
|
||||
|
||||
$candidateList = $candidates -join [Environment]::NewLine
|
||||
throw "An x64 TlbImp.exe was not found in the known Windows SDK locations. Install the .NET Framework SDK or pass -TlbImpPath with an x64 SDK path. Checked:$([Environment]::NewLine)$candidateList"
|
||||
}
|
||||
|
||||
if (-not [Environment]::Is64BitOperatingSystem) {
|
||||
throw 'K3D x64 interop generation requires a 64-bit Windows operating system.'
|
||||
}
|
||||
|
||||
$machineRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
$currentUserRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::CurrentUser,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
|
||||
try {
|
||||
Assert-NoCurrentUserOverrides -CurrentUserRoot $currentUserRoot
|
||||
|
||||
$typeLibKey = "SOFTWARE\Classes\TypeLib\$expectedTypeLibId\$expectedTypeLibVersion\0\win64"
|
||||
$registeredTypeLibPath = Resolve-RegisteredFilePath `
|
||||
-RegistryValue (Get-RegistryValue -BaseKey $machineRoot -SubKey $typeLibKey) `
|
||||
-Description 'K3D x64 type library path'
|
||||
Assert-Amd64File -Path $registeredTypeLibPath -Description 'K3D type library DLL'
|
||||
|
||||
foreach ($registration in $expectedClasses) {
|
||||
Assert-ComClassRegistration `
|
||||
-MachineRoot $machineRoot `
|
||||
-Registration $registration
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$currentUserRoot.Dispose()
|
||||
$machineRoot.Dispose()
|
||||
}
|
||||
|
||||
$resolvedTlbImpPath = Resolve-X64TlbImpPath -ExplicitPath $TlbImpPath
|
||||
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$outputDirectory = [IO.Path]::GetFullPath(
|
||||
(Join-Path $repositoryRoot 'artifacts\K3DInterop'))
|
||||
$outputPath = Join-Path $outputDirectory 'Interop.K3DAsyncEngineLib.dll'
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess(
|
||||
$outputPath,
|
||||
'Generate an x64 diagnostic interop assembly from the registered K3D type library')) {
|
||||
[pscustomobject][ordered]@{
|
||||
Status = 'NotGenerated'
|
||||
TypeLibId = $expectedTypeLibId
|
||||
TypeLibVersion = $expectedTypeLibVersion
|
||||
TypeLibPath = $registeredTypeLibPath
|
||||
RegistrationHive = 'HKLM'
|
||||
CurrentUserOverrides = 'None'
|
||||
ValidatedClasses = @($expectedClasses.Name)
|
||||
TlbImpPath = $resolvedTlbImpPath
|
||||
OutputPath = $outputPath
|
||||
Machine = 'AMD64'
|
||||
ComActivated = $false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
[void] (New-Item -ItemType Directory -Path $outputDirectory -Force)
|
||||
$stagingDirectory = Join-Path `
|
||||
$outputDirectory `
|
||||
('.staging-{0}-{1}' -f $PID, [Guid]::NewGuid().ToString('N'))
|
||||
$stagedOutputPath = Join-Path $stagingDirectory 'Interop.K3DAsyncEngineLib.dll'
|
||||
[void] (New-Item -ItemType Directory -Path $stagingDirectory)
|
||||
|
||||
try {
|
||||
$tlbImpArguments = @(
|
||||
$registeredTypeLibPath,
|
||||
"/out:$stagedOutputPath",
|
||||
'/namespace:K3DAsyncEngineLib',
|
||||
'/machine:X64',
|
||||
'/silent'
|
||||
)
|
||||
|
||||
$tlbImpOutput = & $resolvedTlbImpPath @tlbImpArguments 2>&1
|
||||
$tlbImpExitCode = $LASTEXITCODE
|
||||
if ($tlbImpExitCode -ne 0) {
|
||||
$diagnosticText = ($tlbImpOutput | Out-String).Trim()
|
||||
throw "TlbImp.exe failed with exit code $tlbImpExitCode. $diagnosticText"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $stagedOutputPath -PathType Leaf)) {
|
||||
throw 'TlbImp.exe reported success but did not create the interop assembly.'
|
||||
}
|
||||
|
||||
Assert-Amd64File -Path $stagedOutputPath -Description 'Generated interop assembly'
|
||||
[IO.File]::Copy($stagedOutputPath, $outputPath, $true)
|
||||
Assert-Amd64File -Path $outputPath -Description 'Final interop assembly'
|
||||
|
||||
$hash = Get-FileHash -LiteralPath $outputPath -Algorithm SHA256
|
||||
[pscustomobject][ordered]@{
|
||||
Status = 'Generated'
|
||||
TypeLibId = $expectedTypeLibId
|
||||
TypeLibVersion = $expectedTypeLibVersion
|
||||
TypeLibPath = $registeredTypeLibPath
|
||||
RegistrationHive = 'HKLM'
|
||||
CurrentUserOverrides = 'None'
|
||||
ValidatedClasses = @($expectedClasses.Name)
|
||||
TlbImpPath = $resolvedTlbImpPath
|
||||
OutputPath = $outputPath
|
||||
Machine = 'AMD64'
|
||||
Sha256 = $hash.Hash
|
||||
ComActivated = $false
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $stagingDirectory) {
|
||||
Remove-Item -LiteralPath $stagingDirectory -Recurse -Force
|
||||
}
|
||||
}
|
||||
338
scripts/Inspect-K3DRegistration.ps1
Normal file
338
scripts/Inspect-K3DRegistration.ps1
Normal file
@@ -0,0 +1,338 @@
|
||||
#Requires -Version 5.1
|
||||
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$expectedTypeLibId = '{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}'
|
||||
$expectedTypeLibVersion = '1.0'
|
||||
$expectedThreadingModel = 'Apartment'
|
||||
$amd64Machine = 0x8664
|
||||
$expectedClasses = @(
|
||||
[pscustomobject]@{
|
||||
Name = 'KAEngine'
|
||||
ClassId = '{D756CDBE-AA31-42B2-9CC7-018753CA61BF}'
|
||||
ProgId = 'K3DAsyncEngine.KAEngine.1'
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Name = 'KAEventHandler'
|
||||
ClassId = '{39828C77-EFF0-4E59-979B-8673C028C718}'
|
||||
ProgId = 'K3DAsyncEngine.KAEventHandler.1'
|
||||
}
|
||||
)
|
||||
|
||||
function Get-RegistryValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $BaseKey,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $SubKey,
|
||||
|
||||
[string] $Name = ''
|
||||
)
|
||||
|
||||
$key = $BaseKey.OpenSubKey($SubKey, $false)
|
||||
if ($null -eq $key) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return $key.GetValue(
|
||||
$Name,
|
||||
$null,
|
||||
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
}
|
||||
finally {
|
||||
$key.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-TextEqual {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $Actual,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Expected,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
if ($null -eq $Actual -or
|
||||
-not [string]::Equals(
|
||||
[string] $Actual,
|
||||
$Expected,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-GuidEqual {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $Actual,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Expected,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
$actualGuid = [Guid]::Empty
|
||||
$expectedGuid = [Guid]::Empty
|
||||
if ($null -eq $Actual -or
|
||||
-not [Guid]::TryParse([string] $Actual, [ref] $actualGuid) -or
|
||||
-not [Guid]::TryParse($Expected, [ref] $expectedGuid) -or
|
||||
$actualGuid -ne $expectedGuid) {
|
||||
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-RegisteredFilePath {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $RegistryValue,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
if ($null -eq $RegistryValue -or [string]::IsNullOrWhiteSpace([string] $RegistryValue)) {
|
||||
throw "$Description is not registered."
|
||||
}
|
||||
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables([string] $RegistryValue)
|
||||
$expanded = $expanded.Trim().Trim('"')
|
||||
if (-not [IO.Path]::IsPathRooted($expanded)) {
|
||||
throw "$Description is not an absolute path."
|
||||
}
|
||||
|
||||
$fullPath = [IO.Path]::GetFullPath($expanded)
|
||||
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
||||
throw "$Description does not exist: $fullPath"
|
||||
}
|
||||
|
||||
return $fullPath
|
||||
}
|
||||
|
||||
function Get-PeMachine {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
$stream = [IO.File]::Open(
|
||||
$Path,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
|
||||
$reader = [IO.BinaryReader]::new($stream)
|
||||
|
||||
try {
|
||||
if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) {
|
||||
throw "Not a PE file: $Path"
|
||||
}
|
||||
|
||||
$stream.Position = 0x3C
|
||||
$peOffset = $reader.ReadInt32()
|
||||
if ($peOffset -lt 0 -or ($peOffset + 6) -gt $stream.Length) {
|
||||
throw "Invalid PE header offset: $Path"
|
||||
}
|
||||
|
||||
$stream.Position = $peOffset
|
||||
if ($reader.ReadUInt32() -ne 0x00004550) {
|
||||
throw "Invalid PE signature: $Path"
|
||||
}
|
||||
|
||||
$machineCode = $reader.ReadUInt16()
|
||||
$machineName = switch ($machineCode) {
|
||||
0x014C { 'I386' }
|
||||
0x8664 { 'AMD64' }
|
||||
0xAA64 { 'ARM64' }
|
||||
default { 'Unknown' }
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Code = $machineCode
|
||||
Name = $machineName
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Amd64File {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
$machine = Get-PeMachine -Path $Path
|
||||
if ($machine.Code -ne $amd64Machine) {
|
||||
throw "$Description is not AMD64. PE machine is $($machine.Name) (0x$('{0:X4}' -f $machine.Code))."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryKeyExists {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $BaseKey,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $SubKey
|
||||
)
|
||||
|
||||
$key = $BaseKey.OpenSubKey($SubKey, $false)
|
||||
if ($null -eq $key) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$key.Dispose()
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-NoCurrentUserOverrides {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $CurrentUserRoot
|
||||
)
|
||||
|
||||
$paths = @("Software\Classes\TypeLib\$expectedTypeLibId")
|
||||
foreach ($registration in $expectedClasses) {
|
||||
$paths += "Software\Classes\CLSID\$($registration.ClassId)"
|
||||
$paths += "Software\Classes\$($registration.ProgId)"
|
||||
}
|
||||
|
||||
foreach ($path in $paths) {
|
||||
if (Test-RegistryKeyExists -BaseKey $CurrentUserRoot -SubKey $path) {
|
||||
throw "A per-user Registry64 K3D COM override is present at HKCU:\$path. Remove the override through the approved vendor recovery procedure before use."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ComClassRegistration {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Microsoft.Win32.RegistryKey] $MachineRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object] $Registration
|
||||
)
|
||||
|
||||
$classesPrefix = 'SOFTWARE\Classes'
|
||||
$progIdClassId = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\$($Registration.ProgId)\CLSID"
|
||||
Assert-GuidEqual `
|
||||
-Actual $progIdClassId `
|
||||
-Expected $Registration.ClassId `
|
||||
-Description "$($Registration.Name) ProgID to CLSID mapping"
|
||||
|
||||
$classProgId = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\ProgID"
|
||||
Assert-TextEqual `
|
||||
-Actual $classProgId `
|
||||
-Expected $Registration.ProgId `
|
||||
-Description "$($Registration.Name) CLSID to ProgID mapping"
|
||||
|
||||
$classTypeLibId = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\TypeLib"
|
||||
Assert-GuidEqual `
|
||||
-Actual $classTypeLibId `
|
||||
-Expected $expectedTypeLibId `
|
||||
-Description "$($Registration.Name) TypeLib GUID"
|
||||
|
||||
$classTypeLibVersion = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\Version"
|
||||
if ($null -ne $classTypeLibVersion -and
|
||||
-not [string]::IsNullOrWhiteSpace([string] $classTypeLibVersion)) {
|
||||
Assert-TextEqual `
|
||||
-Actual $classTypeLibVersion `
|
||||
-Expected $expectedTypeLibVersion `
|
||||
-Description "$($Registration.Name) TypeLib version"
|
||||
}
|
||||
|
||||
$inprocKey = "$classesPrefix\CLSID\$($Registration.ClassId)\InprocServer32"
|
||||
$serverPath = Resolve-RegisteredFilePath `
|
||||
-RegistryValue (Get-RegistryValue -BaseKey $MachineRoot -SubKey $inprocKey) `
|
||||
-Description "$($Registration.Name) InprocServer32 path"
|
||||
$threadingModel = Get-RegistryValue `
|
||||
-BaseKey $MachineRoot `
|
||||
-SubKey $inprocKey `
|
||||
-Name 'ThreadingModel'
|
||||
Assert-TextEqual `
|
||||
-Actual $threadingModel `
|
||||
-Expected $expectedThreadingModel `
|
||||
-Description "$($Registration.Name) threading model"
|
||||
Assert-Amd64File -Path $serverPath -Description "$($Registration.Name) COM server DLL"
|
||||
|
||||
return [pscustomobject][ordered]@{
|
||||
Name = $Registration.Name
|
||||
ClassId = $Registration.ClassId
|
||||
ProgId = $Registration.ProgId
|
||||
ServerPath = $serverPath
|
||||
ServerMachine = 'AMD64'
|
||||
ThreadingModel = $threadingModel
|
||||
ReciprocalMapping = $true
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [Environment]::Is64BitOperatingSystem) {
|
||||
throw 'K3D x64 registration requires a 64-bit Windows operating system.'
|
||||
}
|
||||
|
||||
$machineRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
$currentUserRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::CurrentUser,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
|
||||
try {
|
||||
Assert-NoCurrentUserOverrides -CurrentUserRoot $currentUserRoot
|
||||
|
||||
$typeLibKey = "SOFTWARE\Classes\TypeLib\$expectedTypeLibId\$expectedTypeLibVersion\0\win64"
|
||||
$typeLibPath = Resolve-RegisteredFilePath `
|
||||
-RegistryValue (Get-RegistryValue -BaseKey $machineRoot -SubKey $typeLibKey) `
|
||||
-Description 'K3D x64 type library path'
|
||||
Assert-Amd64File -Path $typeLibPath -Description 'K3D type library DLL'
|
||||
|
||||
$classReports = @()
|
||||
foreach ($registration in $expectedClasses) {
|
||||
$classReports += Assert-ComClassRegistration `
|
||||
-MachineRoot $machineRoot `
|
||||
-Registration $registration
|
||||
}
|
||||
|
||||
[pscustomobject][ordered]@{
|
||||
Status = 'Valid'
|
||||
RegistryHive = 'HKLM'
|
||||
RegistryView = 'Registry64'
|
||||
CurrentUserOverrides = 'None'
|
||||
TypeLibId = $expectedTypeLibId
|
||||
TypeLibVersion = $expectedTypeLibVersion
|
||||
TypeLibTarget = 'win64'
|
||||
TypeLibPath = $typeLibPath
|
||||
TypeLibMachine = 'AMD64'
|
||||
Classes = $classReports
|
||||
PowerShellProcessIs64Bit = [Environment]::Is64BitProcess
|
||||
ComActivated = $false
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$currentUserRoot.Dispose()
|
||||
$machineRoot.Dispose()
|
||||
}
|
||||
134
src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
Normal file
134
src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
#nullable enable
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
|
||||
public enum PlayoutMode
|
||||
{
|
||||
Disabled,
|
||||
DryRun,
|
||||
Test,
|
||||
Live
|
||||
}
|
||||
|
||||
public enum PlayoutConnectionState
|
||||
{
|
||||
Disabled,
|
||||
DryRunReady,
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Disconnecting,
|
||||
Faulted,
|
||||
OutcomeUnknown,
|
||||
Disposed
|
||||
}
|
||||
|
||||
public enum PlayoutOperation
|
||||
{
|
||||
Connect,
|
||||
Disconnect,
|
||||
Prepare,
|
||||
TakeIn,
|
||||
Next,
|
||||
TakeOut
|
||||
}
|
||||
|
||||
public enum PlayoutResultCode
|
||||
{
|
||||
Success,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
Unavailable,
|
||||
Failed,
|
||||
OutcomeUnknown
|
||||
}
|
||||
|
||||
public enum PlayoutTakeOutScope
|
||||
{
|
||||
Layout,
|
||||
All
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a COM-neutral scene object update performed before QueryVariables.
|
||||
/// </summary>
|
||||
public sealed record PlayoutField(
|
||||
string ObjectName,
|
||||
string? Value = null,
|
||||
bool? IsVisible = null);
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a scene and the neutral object changes needed to prepare it.
|
||||
/// SceneFile is an input only and is never copied into user-facing status/error text.
|
||||
/// </summary>
|
||||
public sealed record PlayoutCue(
|
||||
string SceneFile,
|
||||
string SceneName,
|
||||
IReadOnlyList<PlayoutField>? Fields = null,
|
||||
int FadeDuration = 0);
|
||||
|
||||
public sealed record PlayoutStatus(
|
||||
PlayoutMode Mode,
|
||||
PlayoutConnectionState State,
|
||||
bool IsConnected,
|
||||
bool IsProcessRunning,
|
||||
bool IsCommandAvailable,
|
||||
bool LiveTakeInAllowed,
|
||||
string? PreparedSceneName,
|
||||
string? OnAirSceneName,
|
||||
string Message,
|
||||
DateTimeOffset ChangedAtUtc,
|
||||
long Sequence);
|
||||
|
||||
public sealed class PlayoutStatusChangedEventArgs : EventArgs
|
||||
{
|
||||
public PlayoutStatusChangedEventArgs(PlayoutStatus previous, PlayoutStatus current)
|
||||
{
|
||||
Previous = previous;
|
||||
Current = current;
|
||||
}
|
||||
|
||||
public PlayoutStatus Previous { get; }
|
||||
|
||||
public PlayoutStatus Current { get; }
|
||||
}
|
||||
|
||||
public sealed record PlayoutResult(
|
||||
PlayoutOperation Operation,
|
||||
PlayoutResultCode Code,
|
||||
bool IsDryRun,
|
||||
string Message,
|
||||
DateTimeOffset CompletedAtUtc)
|
||||
{
|
||||
public bool IsSuccess => Code == PlayoutResultCode.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// COM-free application boundary for all Tornado/K3D playout operations.
|
||||
/// </summary>
|
||||
public interface IPlayoutEngine : IAsyncDisposable
|
||||
{
|
||||
PlayoutStatus Status { get; }
|
||||
|
||||
event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged;
|
||||
|
||||
Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PlayoutResult> PrepareAsync(
|
||||
PlayoutCue cue,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PlayoutResult> NextAsync(
|
||||
PlayoutCue cue,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PlayoutResult> TakeOutAsync(
|
||||
PlayoutTakeOutScope scope,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
public sealed class PlayoutOptions
|
||||
{
|
||||
public PlayoutMode Mode { get; set; } = PlayoutMode.DryRun;
|
||||
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
|
||||
public int Port { get; set; } = 30001;
|
||||
|
||||
/// <summary>KTAPConnect bTCP argument. The legacy application uses 1.</summary>
|
||||
public int TcpMode { get; set; } = 1;
|
||||
|
||||
/// <summary>KTAPConnect nClientPort argument. Zero lets the SDK choose.</summary>
|
||||
public int ClientPort { get; set; }
|
||||
|
||||
public int? OutputChannel { get; set; }
|
||||
|
||||
public int LayoutIndex { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// External absolute root containing vendor .t2s scenes. Required in Test and Live modes.
|
||||
/// </summary>
|
||||
public string? SceneDirectory { get; set; }
|
||||
|
||||
public string? TestProcessWindowTitlePattern { get; set; }
|
||||
|
||||
public List<string> TestSceneAllowlist { get; set; } = [];
|
||||
|
||||
public bool TrustedLiveOutputEnabled { get; set; }
|
||||
|
||||
public int QueueCapacity { get; set; } = 64;
|
||||
|
||||
public int ConnectTimeoutMilliseconds { get; set; } = 5_000;
|
||||
|
||||
public int OperationTimeoutMilliseconds { get; set; } = 5_000;
|
||||
|
||||
public int DisconnectTimeoutMilliseconds { get; set; } = 3_000;
|
||||
|
||||
public int ProcessPollIntervalMilliseconds { get; set; } = 1_000;
|
||||
|
||||
public int ReconnectDelayMilliseconds { get; set; } = 1_000;
|
||||
|
||||
public int MaximumReconnectAttempts { get; set; } = 3;
|
||||
|
||||
public bool ReconnectEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
public static class PlayoutOptionsLoader
|
||||
{
|
||||
public const string LiveAuthorizationEnvironmentVariable =
|
||||
"MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT";
|
||||
|
||||
public const string RequiredLiveAuthorizationValue =
|
||||
"I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH";
|
||||
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"Config",
|
||||
"playout.local.json");
|
||||
|
||||
public static PlayoutOptions Load(string? path = null)
|
||||
{
|
||||
var options = LoadJson(path ?? DefaultPath);
|
||||
ApplyEnvironment(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
internal static bool IsLiveAuthorizedForThisLaunch() =>
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable(LiveAuthorizationEnvironmentVariable),
|
||||
RequiredLiveAuthorizationValue,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static PlayoutOptions LoadJson(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new PlayoutOptions();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<PlayoutOptions>(json, JsonOptions())
|
||||
?? new PlayoutOptions();
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"송출 설정 파일 형식이 올바르지 않습니다.", exception);
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"송출 설정 파일을 읽을 수 없습니다.", exception);
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"송출 설정 파일에 접근할 수 없습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyEnvironment(PlayoutOptions options)
|
||||
{
|
||||
ApplyEnum("MBN_STOCK_PLAYOUT_MODE", value => options.Mode = value);
|
||||
ApplyString("MBN_STOCK_PLAYOUT_HOST", value => options.Host = value);
|
||||
ApplyInt("MBN_STOCK_PLAYOUT_PORT", value => options.Port = value);
|
||||
ApplyInt("MBN_STOCK_PLAYOUT_TCP_MODE", value => options.TcpMode = value);
|
||||
ApplyInt("MBN_STOCK_PLAYOUT_CLIENT_PORT", value => options.ClientPort = value);
|
||||
ApplyNullableInt("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", value => options.OutputChannel = value);
|
||||
ApplyInt("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", value => options.LayoutIndex = value);
|
||||
ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value);
|
||||
ApplyString(
|
||||
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
|
||||
value => options.TestProcessWindowTitlePattern = value);
|
||||
ApplyInt("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", value => options.QueueCapacity = value);
|
||||
ApplyInt(
|
||||
"MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS",
|
||||
value => options.ConnectTimeoutMilliseconds = value);
|
||||
ApplyInt(
|
||||
"MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS",
|
||||
value => options.OperationTimeoutMilliseconds = value);
|
||||
ApplyInt(
|
||||
"MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS",
|
||||
value => options.DisconnectTimeoutMilliseconds = value);
|
||||
ApplyInt(
|
||||
"MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS",
|
||||
value => options.ProcessPollIntervalMilliseconds = value);
|
||||
ApplyInt(
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS",
|
||||
value => options.ReconnectDelayMilliseconds = value);
|
||||
ApplyInt(
|
||||
"MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS",
|
||||
value => options.MaximumReconnectAttempts = value);
|
||||
ApplyBool(
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_ENABLED",
|
||||
value => options.ReconnectEnabled = value);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions JsonOptions()
|
||||
{
|
||||
var result = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
result.Converters.Add(new JsonStringEnumConverter());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyString(string name, Action<string> apply)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
apply(value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyInt(string name, Action<int> apply)
|
||||
{
|
||||
var text = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(text, out var value))
|
||||
{
|
||||
throw InvalidEnvironment(name);
|
||||
}
|
||||
|
||||
apply(value);
|
||||
}
|
||||
|
||||
private static void ApplyNullableInt(string name, Action<int?> apply)
|
||||
{
|
||||
var text = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(text, out var value))
|
||||
{
|
||||
throw InvalidEnvironment(name);
|
||||
}
|
||||
|
||||
apply(value);
|
||||
}
|
||||
|
||||
private static void ApplyBool(string name, Action<bool> apply)
|
||||
{
|
||||
var text = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bool.TryParse(text, out var value))
|
||||
{
|
||||
throw InvalidEnvironment(name);
|
||||
}
|
||||
|
||||
apply(value);
|
||||
}
|
||||
|
||||
private static void ApplyEnum(string name, Action<PlayoutMode> apply)
|
||||
{
|
||||
var text = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<PlayoutMode>(text, true, out var value))
|
||||
{
|
||||
throw InvalidEnvironment(name);
|
||||
}
|
||||
|
||||
apply(value);
|
||||
}
|
||||
|
||||
private static PlayoutConfigurationException InvalidEnvironment(string name) =>
|
||||
new($"환경 변수 {name} 값이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
public sealed class PlayoutConfigurationException : Exception
|
||||
{
|
||||
public PlayoutConfigurationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public PlayoutConfigurationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Net;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
internal sealed record ValidatedPlayoutOptions(
|
||||
PlayoutMode Mode,
|
||||
string Host,
|
||||
int Port,
|
||||
int TcpMode,
|
||||
int ClientPort,
|
||||
int? OutputChannel,
|
||||
int LayoutIndex,
|
||||
string? SceneDirectory,
|
||||
Regex? TestProcessWindowTitleRegex,
|
||||
IReadOnlySet<string> TestSceneAllowlist,
|
||||
bool TrustedLiveOutputEnabled,
|
||||
int QueueCapacity,
|
||||
TimeSpan ConnectTimeout,
|
||||
TimeSpan OperationTimeout,
|
||||
TimeSpan DisconnectTimeout,
|
||||
TimeSpan ProcessPollInterval,
|
||||
TimeSpan ReconnectDelay,
|
||||
int MaximumReconnectAttempts,
|
||||
bool ReconnectEnabled)
|
||||
{
|
||||
public static ValidatedPlayoutOptions Create(PlayoutOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!Enum.IsDefined(options.Mode))
|
||||
{
|
||||
throw Invalid(nameof(options.Mode));
|
||||
}
|
||||
|
||||
var host = RequiredText(options.Host, nameof(options.Host));
|
||||
RequireRange(options.Port, 1, 65_535, nameof(options.Port));
|
||||
RequireRange(options.TcpMode, 0, 1, nameof(options.TcpMode));
|
||||
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && options.TcpMode != 1)
|
||||
{
|
||||
throw Invalid(nameof(options.TcpMode));
|
||||
}
|
||||
RequireRange(options.ClientPort, 0, 65_535, nameof(options.ClientPort));
|
||||
if (options.OutputChannel is { } outputChannel)
|
||||
{
|
||||
RequireRange(outputChannel, 0, int.MaxValue, nameof(options.OutputChannel));
|
||||
}
|
||||
|
||||
if (options.LayoutIndex != K3dComConstants.LegacyLayoutIndex)
|
||||
{
|
||||
throw Invalid(nameof(options.LayoutIndex));
|
||||
}
|
||||
|
||||
string? sceneDirectory = null;
|
||||
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live)
|
||||
{
|
||||
sceneDirectory = ValidateSceneDirectory(options.SceneDirectory);
|
||||
}
|
||||
|
||||
RequireRange(options.QueueCapacity, 1, 4_096, nameof(options.QueueCapacity));
|
||||
RequireRange(
|
||||
options.ConnectTimeoutMilliseconds,
|
||||
100,
|
||||
300_000,
|
||||
nameof(options.ConnectTimeoutMilliseconds));
|
||||
RequireRange(
|
||||
options.OperationTimeoutMilliseconds,
|
||||
100,
|
||||
300_000,
|
||||
nameof(options.OperationTimeoutMilliseconds));
|
||||
RequireRange(
|
||||
options.DisconnectTimeoutMilliseconds,
|
||||
100,
|
||||
300_000,
|
||||
nameof(options.DisconnectTimeoutMilliseconds));
|
||||
RequireRange(
|
||||
options.ProcessPollIntervalMilliseconds,
|
||||
100,
|
||||
60_000,
|
||||
nameof(options.ProcessPollIntervalMilliseconds));
|
||||
RequireRange(
|
||||
options.ReconnectDelayMilliseconds,
|
||||
0,
|
||||
300_000,
|
||||
nameof(options.ReconnectDelayMilliseconds));
|
||||
RequireRange(
|
||||
options.MaximumReconnectAttempts,
|
||||
0,
|
||||
1_000,
|
||||
nameof(options.MaximumReconnectAttempts));
|
||||
|
||||
Regex? titleRegex = null;
|
||||
var allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in options.TestSceneAllowlist ?? [])
|
||||
{
|
||||
var value = item?.Trim();
|
||||
if (!IsSafeSceneCode(value))
|
||||
{
|
||||
throw Invalid(nameof(options.TestSceneAllowlist));
|
||||
}
|
||||
|
||||
allowlist.Add(value!);
|
||||
}
|
||||
|
||||
if (options.Mode == PlayoutMode.Test)
|
||||
{
|
||||
if (!IsLiteralLoopback(host))
|
||||
{
|
||||
throw Invalid(nameof(options.Host));
|
||||
}
|
||||
|
||||
if (options.OutputChannel is null)
|
||||
{
|
||||
throw Invalid(nameof(options.OutputChannel));
|
||||
}
|
||||
|
||||
if (allowlist.Count == 0)
|
||||
{
|
||||
throw Invalid(nameof(options.TestSceneAllowlist));
|
||||
}
|
||||
|
||||
var pattern = RequiredText(
|
||||
options.TestProcessWindowTitlePattern,
|
||||
nameof(options.TestProcessWindowTitlePattern));
|
||||
try
|
||||
{
|
||||
titleRegex = new Regex(
|
||||
pattern,
|
||||
RegexOptions.IgnoreCase |
|
||||
RegexOptions.CultureInvariant |
|
||||
RegexOptions.NonBacktracking,
|
||||
TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception);
|
||||
}
|
||||
|
||||
if (CanMatchProgramTitle(titleRegex))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"테스트 창 제목 규칙은 PROGRAM 출력을 가리킬 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidatedPlayoutOptions(
|
||||
options.Mode,
|
||||
host,
|
||||
options.Port,
|
||||
options.TcpMode,
|
||||
options.ClientPort,
|
||||
options.OutputChannel,
|
||||
options.LayoutIndex,
|
||||
sceneDirectory,
|
||||
titleRegex,
|
||||
allowlist,
|
||||
options.TrustedLiveOutputEnabled,
|
||||
options.QueueCapacity,
|
||||
TimeSpan.FromMilliseconds(options.ConnectTimeoutMilliseconds),
|
||||
TimeSpan.FromMilliseconds(options.OperationTimeoutMilliseconds),
|
||||
TimeSpan.FromMilliseconds(options.DisconnectTimeoutMilliseconds),
|
||||
TimeSpan.FromMilliseconds(options.ProcessPollIntervalMilliseconds),
|
||||
TimeSpan.FromMilliseconds(options.ReconnectDelayMilliseconds),
|
||||
options.MaximumReconnectAttempts,
|
||||
options.ReconnectEnabled);
|
||||
}
|
||||
|
||||
public bool IsTestSceneAllowed(PlayoutCue cue)
|
||||
{
|
||||
var sceneName = cue.SceneName?.Trim();
|
||||
return !string.IsNullOrEmpty(sceneName) && TestSceneAllowlist.Contains(sceneName);
|
||||
}
|
||||
|
||||
public PlayoutCue ResolveCue(PlayoutCue cue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cue);
|
||||
var sceneName = cue.SceneName?.Trim();
|
||||
if (!IsSafeSceneCode(sceneName))
|
||||
{
|
||||
throw new PlayoutRequestException("장면 이름이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (cue.FadeDuration is < 0 or > 60)
|
||||
{
|
||||
throw new PlayoutRequestException("장면 전환 시간이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
foreach (var field in cue.Fields ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field.ObjectName) ||
|
||||
field.ObjectName.Length > 128 ||
|
||||
field.ObjectName.Any(char.IsControl) ||
|
||||
(field.Value?.Length ?? 0) > 16_384)
|
||||
{
|
||||
throw new PlayoutRequestException("장면 필드 값이 올바르지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
var relativePath = cue.SceneFile?.Trim();
|
||||
if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath))
|
||||
{
|
||||
throw new PlayoutRequestException("장면 파일 이름이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!string.Equals(relativePath, Path.GetFileName(relativePath), StringComparison.Ordinal) ||
|
||||
relativePath.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
throw new PlayoutRequestException("장면 파일 이름이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(relativePath), ".t2s", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new PlayoutRequestException("장면 파일 형식이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
var fileSceneName = Path.GetFileNameWithoutExtension(relativePath);
|
||||
if (!string.Equals(fileSceneName, sceneName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new PlayoutRequestException("장면 이름과 장면 파일이 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
if (SceneDirectory is null)
|
||||
{
|
||||
throw new PlayoutRequestException("장면 폴더가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
|
||||
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
|
||||
? SceneDirectory
|
||||
: SceneDirectory + Path.DirectorySeparatorChar;
|
||||
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(candidate) ||
|
||||
HasReparsePointBetween(SceneDirectory, candidate))
|
||||
{
|
||||
throw new PlayoutRequestException("허용된 장면 파일을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return cue with
|
||||
{
|
||||
SceneFile = candidate,
|
||||
SceneName = sceneName!
|
||||
};
|
||||
}
|
||||
|
||||
private static bool CanMatchProgramTitle(Regex regex)
|
||||
{
|
||||
string[] protectedTitles =
|
||||
[
|
||||
"PGM",
|
||||
"PROGRAM",
|
||||
"Tornado2 PGM",
|
||||
"Tornado2 PROGRAM",
|
||||
"PGM OUTPUT",
|
||||
"PROGRAM OUTPUT"
|
||||
];
|
||||
return protectedTitles.Any(regex.IsMatch);
|
||||
}
|
||||
|
||||
private static bool ContainsPathSyntax(string value) =>
|
||||
value.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':']) >= 0;
|
||||
|
||||
private static bool IsSafeSceneCode(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length > 64 || ContainsPathSyntax(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (!((character >= 'A' && character <= 'Z') ||
|
||||
(character >= 'a' && character <= 'z') ||
|
||||
(character >= '0' && character <= '9') ||
|
||||
character is '_' or '-'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsLiteralLoopback(string host) =>
|
||||
string.Equals(host, IPAddress.Loopback.ToString(), StringComparison.Ordinal) ||
|
||||
string.Equals(host, IPAddress.IPv6Loopback.ToString(), StringComparison.Ordinal);
|
||||
|
||||
private static string ValidateSceneDirectory(string? value)
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrEmpty(value) || !Path.IsPathFullyQualified(value))
|
||||
{
|
||||
throw Invalid(nameof(PlayoutOptions.SceneDirectory));
|
||||
}
|
||||
|
||||
var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value));
|
||||
if (!Directory.Exists(fullPath) || IsReparsePoint(fullPath))
|
||||
{
|
||||
throw Invalid(nameof(PlayoutOptions.SceneDirectory));
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private static bool HasReparsePointBetween(string root, string file)
|
||||
{
|
||||
if (IsReparsePoint(file))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(file);
|
||||
while (!string.IsNullOrEmpty(directory) &&
|
||||
!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (IsReparsePoint(directory))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsReparsePoint(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequiredText(string? value, string name)
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrEmpty(value) || value.Length > 512)
|
||||
{
|
||||
throw Invalid(name);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void RequireRange(int value, int minimum, int maximum, string name)
|
||||
{
|
||||
if (value < minimum || value > maximum)
|
||||
{
|
||||
throw Invalid(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayoutConfigurationException Invalid(string name) =>
|
||||
new($"송출 설정 {name} 값이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
internal sealed class PlayoutRequestException : Exception
|
||||
{
|
||||
public PlayoutRequestException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
352
src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs
Normal file
352
src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
|
||||
internal interface IK3dSession : IDisposable
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
|
||||
void Connect(ValidatedPlayoutOptions options);
|
||||
|
||||
void Disconnect();
|
||||
|
||||
void Prepare(PlayoutCue cue, int layoutIndex);
|
||||
|
||||
void Play(int layoutIndex);
|
||||
|
||||
void TakeOut(int layoutIndex, PlayoutTakeOutScope scope);
|
||||
}
|
||||
|
||||
internal interface IK3dSessionFactory
|
||||
{
|
||||
IK3dSession Create();
|
||||
}
|
||||
|
||||
internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
|
||||
{
|
||||
private readonly ILateBoundComActivator _activator;
|
||||
|
||||
public DynamicK3dSessionFactory()
|
||||
: this(new RegisteredComActivator())
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(ILateBoundComActivator activator)
|
||||
{
|
||||
_activator = activator;
|
||||
}
|
||||
|
||||
public IK3dSession Create() => new DynamicK3dSession(_activator);
|
||||
}
|
||||
|
||||
internal interface ILateBoundComActivator
|
||||
{
|
||||
object Create(Guid classId);
|
||||
}
|
||||
|
||||
internal sealed class RegisteredComActivator : ILateBoundComActivator
|
||||
{
|
||||
public object Create(Guid classId)
|
||||
{
|
||||
var type = Type.GetTypeFromCLSID(classId, throwOnError: true)
|
||||
?? throw new InvalidOperationException("The registered COM type is unavailable.");
|
||||
return Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException("The registered COM object could not be created.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DynamicK3dSession : IK3dSession
|
||||
{
|
||||
private readonly ILateBoundComActivator _activator;
|
||||
private object? _engine;
|
||||
private object? _eventHandler;
|
||||
private object? _player;
|
||||
private object? _scene;
|
||||
private int? _ownerThreadId;
|
||||
private bool _disposed;
|
||||
|
||||
public DynamicK3dSession(ILateBoundComActivator activator)
|
||||
{
|
||||
_activator = activator;
|
||||
}
|
||||
|
||||
public bool IsConnected => _engine is not null && _player is not null;
|
||||
|
||||
public void Connect(ValidatedPlayoutOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
EnsureThread();
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
|
||||
_engine = _activator.Create(K3dComConstants.KaEngineClassGuid);
|
||||
var result = Invoke(
|
||||
_engine,
|
||||
"KTAPConnect",
|
||||
options.TcpMode,
|
||||
options.Host,
|
||||
options.Port,
|
||||
options.ClientPort,
|
||||
_eventHandler);
|
||||
var hresult = result is null
|
||||
? 0
|
||||
: Convert.ToInt32(result, CultureInfo.InvariantCulture);
|
||||
if (hresult != 1)
|
||||
{
|
||||
throw new InvalidOperationException("The K3D connection was rejected.");
|
||||
}
|
||||
|
||||
_player = options.OutputChannel is { } channel
|
||||
? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel)
|
||||
: InvokeRequired(_engine, "GetScenePlayer");
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseAll();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
EnsureThread();
|
||||
if (_engine is null)
|
||||
{
|
||||
ReleaseAll();
|
||||
return;
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
try
|
||||
{
|
||||
Invoke(_engine, "Disconnect");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failure = exception;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseAll();
|
||||
}
|
||||
|
||||
if (failure is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
}
|
||||
|
||||
public void Prepare(PlayoutCue cue, int layoutIndex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cue);
|
||||
EnsureThread();
|
||||
EnsureConnected();
|
||||
var engine = _engine!;
|
||||
var player = _player!;
|
||||
object? nextScene = null;
|
||||
var transactionStarted = false;
|
||||
|
||||
try
|
||||
{
|
||||
nextScene = InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName);
|
||||
Invoke(
|
||||
nextScene,
|
||||
"SetSceneEffectType",
|
||||
layoutIndex,
|
||||
K3dComConstants.SceneChangeEffectFade,
|
||||
cue.FadeDuration);
|
||||
|
||||
Invoke(engine, "BeginTransaction");
|
||||
transactionStarted = true;
|
||||
foreach (var field in cue.Fields ?? [])
|
||||
{
|
||||
ApplyField(nextScene, field);
|
||||
}
|
||||
|
||||
Invoke(nextScene, "QueryVariables");
|
||||
Invoke(engine, "EndTransaction");
|
||||
transactionStarted = false;
|
||||
Invoke(player, "Prepare", layoutIndex, nextScene);
|
||||
|
||||
ReleaseComObject(_scene);
|
||||
_scene = nextScene;
|
||||
nextScene = null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transactionStarted)
|
||||
{
|
||||
try
|
||||
{
|
||||
Invoke(engine, "RollbackTransaction");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Preserve the original SDK failure. The engine will recycle this session.
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(nextScene);
|
||||
}
|
||||
}
|
||||
|
||||
public void Play(int layoutIndex)
|
||||
{
|
||||
EnsureThread();
|
||||
EnsureConnected();
|
||||
Invoke(_player!, "Play", layoutIndex);
|
||||
}
|
||||
|
||||
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
|
||||
{
|
||||
EnsureThread();
|
||||
EnsureConnected();
|
||||
if (scope == PlayoutTakeOutScope.All)
|
||||
{
|
||||
Invoke(_player!, "StopAll");
|
||||
}
|
||||
else
|
||||
{
|
||||
Invoke(_player!, "CutOut", layoutIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureThread();
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_engine is not null)
|
||||
{
|
||||
Invoke(_engine, "Disconnect");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Dispose must still release every RCW on the owning STA.
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseAll();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyField(object scene, PlayoutField field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field.ObjectName))
|
||||
{
|
||||
throw new InvalidOperationException("A scene object name is required.");
|
||||
}
|
||||
|
||||
var sceneObject = InvokeRequired(scene, "GetObject", field.ObjectName);
|
||||
try
|
||||
{
|
||||
if (field.Value is not null)
|
||||
{
|
||||
Invoke(sceneObject, "SetValue", field.Value);
|
||||
}
|
||||
|
||||
if (field.IsVisible is { } visible)
|
||||
{
|
||||
Invoke(sceneObject, "SetVisible", visible ? 1 : 0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(sceneObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureThread()
|
||||
{
|
||||
var currentThreadId = Environment.CurrentManagedThreadId;
|
||||
if (_ownerThreadId is null)
|
||||
{
|
||||
if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA)
|
||||
{
|
||||
throw new InvalidOperationException("K3D access requires an STA thread.");
|
||||
}
|
||||
|
||||
_ownerThreadId = currentThreadId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ownerThreadId.Value != currentThreadId)
|
||||
{
|
||||
throw new InvalidOperationException("K3D access changed STA threads.");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!IsConnected)
|
||||
{
|
||||
throw new InvalidOperationException("The K3D session is not connected.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseAll()
|
||||
{
|
||||
ReleaseComObject(_scene);
|
||||
_scene = null;
|
||||
ReleaseComObject(_player);
|
||||
_player = null;
|
||||
ReleaseComObject(_engine);
|
||||
_engine = null;
|
||||
ReleaseComObject(_eventHandler);
|
||||
_eventHandler = null;
|
||||
}
|
||||
|
||||
private static object InvokeRequired(object target, string method, params object?[] arguments) =>
|
||||
Invoke(target, method, arguments)
|
||||
?? throw new InvalidOperationException("The K3D operation returned no object.");
|
||||
|
||||
private static object? Invoke(object target, string method, params object?[] arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
return target.GetType().InvokeMember(
|
||||
method,
|
||||
BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
target,
|
||||
arguments,
|
||||
CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (TargetInvocationException exception) when (exception.InnerException is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReleaseComObject(object? value)
|
||||
{
|
||||
if (value is not null && Marshal.IsComObject(value))
|
||||
{
|
||||
Marshal.FinalReleaseComObject(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs
Normal file
19
src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
|
||||
public static class K3dComConstants
|
||||
{
|
||||
public const string TypeLibraryId = "{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}";
|
||||
public const string TypeLibraryVersion = "1.0";
|
||||
|
||||
public const string KaEngineClassId = "{D756CDBE-AA31-42B2-9CC7-018753CA61BF}";
|
||||
public const string KaEngineProgId = "K3DAsyncEngine.KAEngine.1";
|
||||
public static readonly Guid KaEngineClassGuid = Guid.Parse(KaEngineClassId);
|
||||
|
||||
public const string KaEventHandlerClassId = "{39828C77-EFF0-4E59-979B-8673C028C718}";
|
||||
public const string KaEventHandlerProgId = "K3DAsyncEngine.KAEventHandler.1";
|
||||
public static readonly Guid KaEventHandlerClassGuid = Guid.Parse(KaEventHandlerClassId);
|
||||
|
||||
public const string ApartmentThreadingModel = "Apartment";
|
||||
public const int LegacyLayoutIndex = 10;
|
||||
public const int SceneChangeEffectFade = 7;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RootNamespace>MBN_STOCK_WEBVIEW.Playout</RootNamespace>
|
||||
<AssemblyName>MBN_STOCK_WEBVIEW.Playout</AssemblyName>
|
||||
<Platforms>x64</Platforms>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
31
src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs
Normal file
31
src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout;
|
||||
|
||||
public static class PlayoutEngineFactory
|
||||
{
|
||||
public static IPlayoutEngine CreateDefault() =>
|
||||
Create(PlayoutOptionsLoader.Load());
|
||||
|
||||
public static IPlayoutEngine Create(PlayoutOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var validated = ValidatedPlayoutOptions.Create(options);
|
||||
IStaDispatcher? dispatcher = validated.Mode is PlayoutMode.Test or PlayoutMode.Live
|
||||
? new StaDispatcher(validated.QueueCapacity)
|
||||
: null;
|
||||
return new TornadoPlayoutEngine(
|
||||
validated,
|
||||
new DynamicK3dSessionFactory(),
|
||||
new K3dRegistrationProbe(),
|
||||
new TornadoProcessProbe(),
|
||||
dispatcher,
|
||||
new EnvironmentLiveAuthorization(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
}
|
||||
3
src/MBN_STOCK_WEBVIEW.Playout/Properties/AssemblyInfo.cs
Normal file
3
src/MBN_STOCK_WEBVIEW.Playout/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]
|
||||
@@ -0,0 +1,387 @@
|
||||
using Microsoft.Win32;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
|
||||
[Flags]
|
||||
public enum K3dRegistrationIssue
|
||||
{
|
||||
None = 0,
|
||||
ProcessIsNot64Bit = 1 << 0,
|
||||
TypeLibraryMissing = 1 << 1,
|
||||
Win64BinaryMissing = 1 << 2,
|
||||
Win64BinaryIsNotAmd64 = 1 << 3,
|
||||
EngineClassMissing = 1 << 4,
|
||||
EngineProgIdMismatch = 1 << 5,
|
||||
EngineThreadingModelMismatch = 1 << 6,
|
||||
EngineTypeLibraryMismatch = 1 << 7,
|
||||
EventHandlerClassMissing = 1 << 8,
|
||||
EventHandlerProgIdMismatch = 1 << 9,
|
||||
EventHandlerThreadingModelMismatch = 1 << 10,
|
||||
EventHandlerTypeLibraryMismatch = 1 << 11,
|
||||
EngineProgIdClassIdMismatch = 1 << 12,
|
||||
EngineServerIsNotAmd64 = 1 << 13,
|
||||
EventHandlerProgIdClassIdMismatch = 1 << 14,
|
||||
EventHandlerServerIsNotAmd64 = 1 << 15,
|
||||
CurrentUserOverridePresent = 1 << 16,
|
||||
RegistryReadFailed = 1 << 17
|
||||
}
|
||||
|
||||
public sealed record K3dRegistrationReport(
|
||||
K3dRegistrationIssue Issues,
|
||||
bool Is64BitProcess,
|
||||
bool HasWin64TypeLibrary,
|
||||
bool IsWin64BinaryAmd64,
|
||||
bool IsEngineClassReady,
|
||||
bool IsEventHandlerClassReady,
|
||||
string Message)
|
||||
{
|
||||
public bool IsReady => Issues == K3dRegistrationIssue.None;
|
||||
}
|
||||
|
||||
internal interface IK3dRegistrationProbe
|
||||
{
|
||||
K3dRegistrationReport Probe();
|
||||
}
|
||||
|
||||
internal sealed record K3dClassRegistrationSnapshot(
|
||||
bool ClassKeyPresent,
|
||||
string? ClassProgId,
|
||||
string? ProgIdClassId,
|
||||
string? InprocServerPath,
|
||||
string? ThreadingModel,
|
||||
string? TypeLibraryId);
|
||||
|
||||
internal sealed record K3dRegistrySnapshot(
|
||||
bool Is64BitProcess,
|
||||
string? Win64TypeLibraryPath,
|
||||
K3dClassRegistrationSnapshot Engine,
|
||||
K3dClassRegistrationSnapshot EventHandler,
|
||||
bool CurrentUserOverridePresent);
|
||||
|
||||
internal interface IK3dRegistrySnapshotSource
|
||||
{
|
||||
K3dRegistrySnapshot Capture();
|
||||
}
|
||||
|
||||
internal readonly record struct PeBinaryInspection(bool Exists, bool IsAmd64);
|
||||
|
||||
internal interface IPeBinaryInspector
|
||||
{
|
||||
PeBinaryInspection Inspect(string? path);
|
||||
}
|
||||
|
||||
public sealed class K3dRegistrationProbe : IK3dRegistrationProbe
|
||||
{
|
||||
private readonly IK3dRegistrySnapshotSource _snapshotSource;
|
||||
private readonly IPeBinaryInspector _binaryInspector;
|
||||
|
||||
public K3dRegistrationProbe()
|
||||
: this(new WindowsK3dRegistrySnapshotSource(), new FilePeBinaryInspector())
|
||||
{
|
||||
}
|
||||
|
||||
internal K3dRegistrationProbe(
|
||||
IK3dRegistrySnapshotSource snapshotSource,
|
||||
IPeBinaryInspector binaryInspector)
|
||||
{
|
||||
_snapshotSource = snapshotSource;
|
||||
_binaryInspector = binaryInspector;
|
||||
}
|
||||
|
||||
public K3dRegistrationReport Probe()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ProbeCore(_snapshotSource.Capture());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new K3dRegistrationReport(
|
||||
K3dRegistrationIssue.RegistryReadFailed,
|
||||
Environment.Is64BitProcess,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
SafeMessage(K3dRegistrationIssue.RegistryReadFailed));
|
||||
}
|
||||
}
|
||||
|
||||
private K3dRegistrationReport ProbeCore(K3dRegistrySnapshot snapshot)
|
||||
{
|
||||
var issues = K3dRegistrationIssue.None;
|
||||
if (!snapshot.Is64BitProcess)
|
||||
{
|
||||
issues |= K3dRegistrationIssue.ProcessIsNot64Bit;
|
||||
}
|
||||
|
||||
var typeLibraryBinary = _binaryInspector.Inspect(snapshot.Win64TypeLibraryPath);
|
||||
var hasWin64TypeLibrary = !string.IsNullOrWhiteSpace(snapshot.Win64TypeLibraryPath);
|
||||
if (!hasWin64TypeLibrary)
|
||||
{
|
||||
issues |= K3dRegistrationIssue.TypeLibraryMissing;
|
||||
}
|
||||
|
||||
if (!typeLibraryBinary.Exists)
|
||||
{
|
||||
issues |= K3dRegistrationIssue.Win64BinaryMissing;
|
||||
}
|
||||
else if (!typeLibraryBinary.IsAmd64)
|
||||
{
|
||||
issues |= K3dRegistrationIssue.Win64BinaryIsNotAmd64;
|
||||
}
|
||||
|
||||
var engineReady = CheckClass(
|
||||
snapshot.Engine,
|
||||
K3dComConstants.KaEngineClassGuid,
|
||||
K3dComConstants.KaEngineProgId,
|
||||
K3dRegistrationIssue.EngineClassMissing,
|
||||
K3dRegistrationIssue.EngineProgIdMismatch,
|
||||
K3dRegistrationIssue.EngineProgIdClassIdMismatch,
|
||||
K3dRegistrationIssue.EngineServerIsNotAmd64,
|
||||
K3dRegistrationIssue.EngineThreadingModelMismatch,
|
||||
K3dRegistrationIssue.EngineTypeLibraryMismatch,
|
||||
ref issues);
|
||||
|
||||
var eventHandlerReady = CheckClass(
|
||||
snapshot.EventHandler,
|
||||
K3dComConstants.KaEventHandlerClassGuid,
|
||||
K3dComConstants.KaEventHandlerProgId,
|
||||
K3dRegistrationIssue.EventHandlerClassMissing,
|
||||
K3dRegistrationIssue.EventHandlerProgIdMismatch,
|
||||
K3dRegistrationIssue.EventHandlerProgIdClassIdMismatch,
|
||||
K3dRegistrationIssue.EventHandlerServerIsNotAmd64,
|
||||
K3dRegistrationIssue.EventHandlerThreadingModelMismatch,
|
||||
K3dRegistrationIssue.EventHandlerTypeLibraryMismatch,
|
||||
ref issues);
|
||||
|
||||
if (snapshot.CurrentUserOverridePresent)
|
||||
{
|
||||
issues |= K3dRegistrationIssue.CurrentUserOverridePresent;
|
||||
engineReady = false;
|
||||
eventHandlerReady = false;
|
||||
}
|
||||
|
||||
return new K3dRegistrationReport(
|
||||
issues,
|
||||
snapshot.Is64BitProcess,
|
||||
hasWin64TypeLibrary,
|
||||
typeLibraryBinary.Exists && typeLibraryBinary.IsAmd64,
|
||||
engineReady,
|
||||
eventHandlerReady,
|
||||
SafeMessage(issues));
|
||||
}
|
||||
|
||||
private bool CheckClass(
|
||||
K3dClassRegistrationSnapshot registration,
|
||||
Guid expectedClassId,
|
||||
string expectedProgId,
|
||||
K3dRegistrationIssue missingIssue,
|
||||
K3dRegistrationIssue classProgIdIssue,
|
||||
K3dRegistrationIssue progIdClassIdIssue,
|
||||
K3dRegistrationIssue architectureIssue,
|
||||
K3dRegistrationIssue threadingIssue,
|
||||
K3dRegistrationIssue typeLibraryIssue,
|
||||
ref K3dRegistrationIssue issues)
|
||||
{
|
||||
if (!registration.ClassKeyPresent)
|
||||
{
|
||||
issues |= missingIssue;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
registration.ClassProgId,
|
||||
expectedProgId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues |= classProgIdIssue;
|
||||
}
|
||||
|
||||
if (!MatchesGuid(registration.ProgIdClassId, expectedClassId))
|
||||
{
|
||||
issues |= progIdClassIdIssue;
|
||||
}
|
||||
|
||||
var classBinary = _binaryInspector.Inspect(registration.InprocServerPath);
|
||||
if (!classBinary.Exists)
|
||||
{
|
||||
issues |= missingIssue;
|
||||
}
|
||||
else if (!classBinary.IsAmd64)
|
||||
{
|
||||
issues |= architectureIssue;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
registration.ThreadingModel,
|
||||
K3dComConstants.ApartmentThreadingModel,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues |= threadingIssue;
|
||||
}
|
||||
|
||||
if (!MatchesGuid(registration.TypeLibraryId, Guid.Parse(K3dComConstants.TypeLibraryId)))
|
||||
{
|
||||
issues |= typeLibraryIssue;
|
||||
}
|
||||
|
||||
var classIssues =
|
||||
missingIssue |
|
||||
classProgIdIssue |
|
||||
progIdClassIdIssue |
|
||||
architectureIssue |
|
||||
threadingIssue |
|
||||
typeLibraryIssue;
|
||||
return (issues & classIssues) == 0;
|
||||
}
|
||||
|
||||
private static bool MatchesGuid(string? value, Guid expected) =>
|
||||
Guid.TryParse(value, out var actual) && actual == expected;
|
||||
|
||||
private static string SafeMessage(K3dRegistrationIssue issues) => issues switch
|
||||
{
|
||||
K3dRegistrationIssue.None => "x64 K3D 송출 구성 요소가 준비되었습니다.",
|
||||
_ when issues.HasFlag(K3dRegistrationIssue.CurrentUserOverridePresent) =>
|
||||
"사용자별 K3D 등록 재정의가 감지되어 송출 연결을 차단했습니다.",
|
||||
_ when issues.HasFlag(K3dRegistrationIssue.RegistryReadFailed) =>
|
||||
"K3D 송출 구성 요소 등록을 확인할 수 없습니다.",
|
||||
_ when issues.HasFlag(K3dRegistrationIssue.ProcessIsNot64Bit) =>
|
||||
"송출 어댑터를 x64 프로세스로 실행해야 합니다.",
|
||||
_ when issues.HasFlag(K3dRegistrationIssue.TypeLibraryMissing) ||
|
||||
issues.HasFlag(K3dRegistrationIssue.Win64BinaryMissing) ||
|
||||
issues.HasFlag(K3dRegistrationIssue.Win64BinaryIsNotAmd64) ||
|
||||
issues.HasFlag(K3dRegistrationIssue.EngineServerIsNotAmd64) ||
|
||||
issues.HasFlag(K3dRegistrationIssue.EventHandlerServerIsNotAmd64) =>
|
||||
"x64 K3D 송출 구성 요소를 확인할 수 없습니다.",
|
||||
_ => "K3D 송출 구성 요소 등록이 올바르지 않습니다."
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class WindowsK3dRegistrySnapshotSource : IK3dRegistrySnapshotSource
|
||||
{
|
||||
internal static IReadOnlyList<string> CurrentUserOverridePaths { get; } = Array.AsReadOnly(
|
||||
[
|
||||
$"CLSID\\{K3dComConstants.KaEngineClassId}",
|
||||
$"CLSID\\{K3dComConstants.KaEventHandlerClassId}",
|
||||
K3dComConstants.KaEngineProgId,
|
||||
K3dComConstants.KaEventHandlerProgId,
|
||||
$"TypeLib\\{K3dComConstants.TypeLibraryId}"
|
||||
]);
|
||||
|
||||
public K3dRegistrySnapshot Capture()
|
||||
{
|
||||
using var localMachineBase = RegistryKey.OpenBaseKey(
|
||||
RegistryHive.LocalMachine,
|
||||
RegistryView.Registry64);
|
||||
using var localMachineClasses = localMachineBase.OpenSubKey("SOFTWARE\\Classes");
|
||||
using var currentUserBase = RegistryKey.OpenBaseKey(
|
||||
RegistryHive.CurrentUser,
|
||||
RegistryView.Registry64);
|
||||
using var currentUserClasses = currentUserBase.OpenSubKey("Software\\Classes");
|
||||
|
||||
return new K3dRegistrySnapshot(
|
||||
Environment.Is64BitProcess,
|
||||
ReadDefault(
|
||||
localMachineClasses,
|
||||
$"TypeLib\\{K3dComConstants.TypeLibraryId}\\{K3dComConstants.TypeLibraryVersion}\\0\\win64"),
|
||||
ReadClass(
|
||||
localMachineClasses,
|
||||
K3dComConstants.KaEngineClassId,
|
||||
K3dComConstants.KaEngineProgId),
|
||||
ReadClass(
|
||||
localMachineClasses,
|
||||
K3dComConstants.KaEventHandlerClassId,
|
||||
K3dComConstants.KaEventHandlerProgId),
|
||||
HasCurrentUserOverride(currentUserClasses));
|
||||
}
|
||||
|
||||
private static K3dClassRegistrationSnapshot ReadClass(
|
||||
RegistryKey? classes,
|
||||
string classId,
|
||||
string progId)
|
||||
{
|
||||
using var classKey = classes?.OpenSubKey($"CLSID\\{classId}");
|
||||
using var classProgId = classKey?.OpenSubKey("ProgID");
|
||||
using var inprocServer = classKey?.OpenSubKey("InprocServer32");
|
||||
using var typeLibrary = classKey?.OpenSubKey("TypeLib");
|
||||
using var progIdClass = classes?.OpenSubKey($"{progId}\\CLSID");
|
||||
|
||||
return new K3dClassRegistrationSnapshot(
|
||||
classKey is not null,
|
||||
classProgId?.GetValue(null) as string,
|
||||
progIdClass?.GetValue(null) as string,
|
||||
inprocServer?.GetValue(null) as string,
|
||||
inprocServer?.GetValue("ThreadingModel") as string,
|
||||
typeLibrary?.GetValue(null) as string);
|
||||
}
|
||||
|
||||
private static string? ReadDefault(RegistryKey? root, string relativePath)
|
||||
{
|
||||
using var key = root?.OpenSubKey(relativePath);
|
||||
return key?.GetValue(null) as string;
|
||||
}
|
||||
|
||||
private static bool HasCurrentUserOverride(RegistryKey? currentUserClasses)
|
||||
{
|
||||
foreach (var path in CurrentUserOverridePaths)
|
||||
{
|
||||
using var key = currentUserClasses?.OpenSubKey(path);
|
||||
if (key is not null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FilePeBinaryInspector : IPeBinaryInspector
|
||||
{
|
||||
private const ushort ImageFileMachineAmd64 = 0x8664;
|
||||
private const uint PortableExecutableSignature = 0x00004550;
|
||||
|
||||
public PeBinaryInspection Inspect(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return new PeBinaryInspection(false, false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
if (stream.Length < 64)
|
||||
{
|
||||
return new PeBinaryInspection(true, false);
|
||||
}
|
||||
|
||||
stream.Position = 0x3c;
|
||||
var headerOffset = reader.ReadInt32();
|
||||
if (headerOffset < 0 || headerOffset > stream.Length - 6)
|
||||
{
|
||||
return new PeBinaryInspection(true, false);
|
||||
}
|
||||
|
||||
stream.Position = headerOffset;
|
||||
var isAmd64 = reader.ReadUInt32() == PortableExecutableSignature &&
|
||||
reader.ReadUInt16() == ImageFileMachineAmd64;
|
||||
return new PeBinaryInspection(true, isAmd64);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return new PeBinaryInspection(true, false);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return new PeBinaryInspection(true, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
422
src/MBN_STOCK_WEBVIEW.Playout/Runtime/StaDispatcher.cs
Normal file
422
src/MBN_STOCK_WEBVIEW.Playout/Runtime/StaDispatcher.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
internal interface IStaDispatcher : IAsyncDisposable
|
||||
{
|
||||
bool IsQuarantined { get; }
|
||||
|
||||
int? ManagedThreadId { get; }
|
||||
|
||||
Task<T> InvokeAsync<T>(
|
||||
Func<T> callback,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken,
|
||||
Action<T>? abandonedResultCleanup = null,
|
||||
Action? abandonedFailureCleanup = null);
|
||||
}
|
||||
|
||||
internal interface IStaDispatcherFactory
|
||||
{
|
||||
IStaDispatcher Create(int capacity);
|
||||
}
|
||||
|
||||
internal sealed class StaDispatcherFactory : IStaDispatcherFactory
|
||||
{
|
||||
public IStaDispatcher Create(int capacity) => new StaDispatcher(capacity);
|
||||
}
|
||||
|
||||
internal sealed class StaDispatcher : IStaDispatcher
|
||||
{
|
||||
private const uint QsAllInput = 0x04ff;
|
||||
private const uint MwmoInputAvailable = 0x0004;
|
||||
private const uint PmRemove = 0x0001;
|
||||
private const uint WaitObject0 = 0;
|
||||
private const uint WaitFailed = 0xffffffff;
|
||||
|
||||
private readonly ConcurrentQueue<StaWorkItem> _queue = new();
|
||||
private readonly SemaphoreSlim _slots;
|
||||
private readonly AutoResetEvent _workAvailable = new(false);
|
||||
private readonly Thread _thread;
|
||||
private int _state;
|
||||
private int _managedThreadId;
|
||||
|
||||
public StaDispatcher(int capacity)
|
||||
{
|
||||
if (capacity < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
}
|
||||
|
||||
_slots = new SemaphoreSlim(capacity, capacity);
|
||||
_thread = new Thread(ThreadMain)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "MBN K3D STA Dispatcher"
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public bool IsQuarantined => Volatile.Read(ref _state) == 1;
|
||||
|
||||
public int? ManagedThreadId
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = Volatile.Read(ref _managedThreadId);
|
||||
return value == 0 ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> InvokeAsync<T>(
|
||||
Func<T> callback,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken,
|
||||
Action<T>? abandonedResultCleanup = null,
|
||||
Action? abandonedFailureCleanup = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout));
|
||||
}
|
||||
|
||||
ThrowIfUnavailable();
|
||||
await _slots.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var item = new StaWorkItem<T>(
|
||||
callback,
|
||||
abandonedResultCleanup,
|
||||
abandonedFailureCleanup);
|
||||
var queued = false;
|
||||
try
|
||||
{
|
||||
ThrowIfUnavailable();
|
||||
using var registration = cancellationToken.Register(
|
||||
static state => ((StaWorkItem)state!).CancelBeforeExecution(),
|
||||
item);
|
||||
|
||||
_queue.Enqueue(item);
|
||||
queued = true;
|
||||
_workAvailable.Set();
|
||||
|
||||
var completion = item.Completion;
|
||||
var timeoutTask = Task.Delay(timeout);
|
||||
var completed = await Task.WhenAny(completion, timeoutTask).ConfigureAwait(false);
|
||||
if (completed == completion || completion.IsCompleted)
|
||||
{
|
||||
return await completion.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (item.TryTimeout())
|
||||
{
|
||||
Quarantine();
|
||||
}
|
||||
|
||||
return await completion.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!queued)
|
||||
{
|
||||
_slots.Release();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _state, 2) == 2)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
FailQueuedItems(new ObjectDisposedException(nameof(StaDispatcher)));
|
||||
_workAvailable.Set();
|
||||
if (Thread.CurrentThread != _thread)
|
||||
{
|
||||
_thread.Join(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
_slots.Dispose();
|
||||
_workAvailable.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private void ThreadMain()
|
||||
{
|
||||
Volatile.Write(ref _managedThreadId, Environment.CurrentManagedThreadId);
|
||||
var waitHandles = new[] { _workAvailable.SafeWaitHandle.DangerousGetHandle() };
|
||||
|
||||
while (Volatile.Read(ref _state) != 2)
|
||||
{
|
||||
PumpMessages();
|
||||
if (_queue.TryDequeue(out var item))
|
||||
{
|
||||
_slots.Release();
|
||||
if (Volatile.Read(ref _state) == 0)
|
||||
{
|
||||
item.Execute();
|
||||
}
|
||||
else
|
||||
{
|
||||
item.Fail(new StaDispatcherQuarantinedException());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var waitResult = MsgWaitForMultipleObjectsEx(
|
||||
1,
|
||||
waitHandles,
|
||||
250,
|
||||
QsAllInput,
|
||||
MwmoInputAvailable);
|
||||
if (waitResult == WaitFailed)
|
||||
{
|
||||
Quarantine();
|
||||
}
|
||||
else if (waitResult == WaitObject0 + 1)
|
||||
{
|
||||
PumpMessages();
|
||||
}
|
||||
}
|
||||
|
||||
PumpMessages();
|
||||
}
|
||||
|
||||
private void Quarantine()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _state, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FailQueuedItems(new StaDispatcherQuarantinedException());
|
||||
_workAvailable.Set();
|
||||
}
|
||||
|
||||
private void FailQueuedItems(Exception exception)
|
||||
{
|
||||
while (_queue.TryDequeue(out var queued))
|
||||
{
|
||||
_slots.Release();
|
||||
queued.Fail(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfUnavailable()
|
||||
{
|
||||
var state = Volatile.Read(ref _state);
|
||||
if (state == 1)
|
||||
{
|
||||
throw new StaDispatcherQuarantinedException();
|
||||
}
|
||||
|
||||
ObjectDisposedException.ThrowIf(state == 2, this);
|
||||
}
|
||||
|
||||
private static void PumpMessages()
|
||||
{
|
||||
while (PeekMessage(out var message, IntPtr.Zero, 0, 0, PmRemove))
|
||||
{
|
||||
TranslateMessage(in message);
|
||||
DispatchMessage(in message);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint MsgWaitForMultipleObjectsEx(
|
||||
uint count,
|
||||
[In] IntPtr[] handles,
|
||||
uint milliseconds,
|
||||
uint wakeMask,
|
||||
uint flags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PeekMessage(
|
||||
out NativeMessage message,
|
||||
IntPtr window,
|
||||
uint filterMinimum,
|
||||
uint filterMaximum,
|
||||
uint removeMessage);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TranslateMessage(in NativeMessage message);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(in NativeMessage message);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeMessage
|
||||
{
|
||||
public IntPtr Window;
|
||||
public uint Message;
|
||||
public UIntPtr WParam;
|
||||
public IntPtr LParam;
|
||||
public uint Time;
|
||||
public NativePoint Point;
|
||||
public uint Private;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
private abstract class StaWorkItem
|
||||
{
|
||||
// 0 queued, 1 running, 2 completed/cancelled/timed out
|
||||
private int _executionState;
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _executionState, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ExecuteCore();
|
||||
}
|
||||
|
||||
public void CancelBeforeExecution()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _executionState, 2, 0) == 0)
|
||||
{
|
||||
CancelCore();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryTimeout()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var state = Volatile.Read(ref _executionState);
|
||||
if (state == 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _executionState, 2, state) == state)
|
||||
{
|
||||
TimeoutCore();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Fail(Exception exception)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _executionState, 2) != 2)
|
||||
{
|
||||
FailCore(exception);
|
||||
}
|
||||
}
|
||||
|
||||
protected bool TryCompleteExecution() =>
|
||||
Interlocked.CompareExchange(ref _executionState, 2, 1) == 1;
|
||||
|
||||
protected abstract void ExecuteCore();
|
||||
|
||||
protected abstract void CancelCore();
|
||||
|
||||
protected abstract void TimeoutCore();
|
||||
|
||||
protected abstract void FailCore(Exception exception);
|
||||
}
|
||||
|
||||
private sealed class StaWorkItem<T> : StaWorkItem
|
||||
{
|
||||
private readonly Func<T> _callback;
|
||||
private readonly Action<T>? _abandonedResultCleanup;
|
||||
private readonly Action? _abandonedFailureCleanup;
|
||||
private readonly TaskCompletionSource<T> _completion =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public StaWorkItem(
|
||||
Func<T> callback,
|
||||
Action<T>? abandonedResultCleanup,
|
||||
Action? abandonedFailureCleanup)
|
||||
{
|
||||
_callback = callback;
|
||||
_abandonedResultCleanup = abandonedResultCleanup;
|
||||
_abandonedFailureCleanup = abandonedFailureCleanup;
|
||||
}
|
||||
|
||||
public Task<T> Completion => _completion.Task;
|
||||
|
||||
protected override void ExecuteCore()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = _callback();
|
||||
if (TryCompleteExecution())
|
||||
{
|
||||
_completion.TrySetResult(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
_abandonedResultCleanup?.Invoke(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Late cleanup is best effort and must not terminate the STA pump.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (TryCompleteExecution())
|
||||
{
|
||||
_completion.TrySetException(exception);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
_abandonedFailureCleanup?.Invoke();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Late cleanup is best effort and must not terminate the STA pump.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void CancelCore() =>
|
||||
_completion.TrySetCanceled();
|
||||
|
||||
protected override void TimeoutCore() =>
|
||||
_completion.TrySetException(new StaOperationTimedOutException());
|
||||
|
||||
protected override void FailCore(Exception exception) =>
|
||||
_completion.TrySetException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StaOperationTimedOutException : TimeoutException
|
||||
{
|
||||
public StaOperationTimedOutException()
|
||||
: base("The STA operation timed out and its outcome is unknown.")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StaDispatcherQuarantinedException : InvalidOperationException
|
||||
{
|
||||
public StaDispatcherQuarantinedException()
|
||||
: base("The STA dispatcher is quarantined.")
|
||||
{
|
||||
}
|
||||
}
|
||||
125
src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs
Normal file
125
src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System.Diagnostics;
|
||||
using System.ComponentModel;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
internal sealed record TornadoProcessSnapshot(
|
||||
int TotalProcessCount,
|
||||
int EligibleProcessCount,
|
||||
int ProgramProcessCount)
|
||||
{
|
||||
public bool AnyTornadoProcess => TotalProcessCount > 0;
|
||||
|
||||
public bool HasSingleEligibleProcess => EligibleProcessCount == 1;
|
||||
|
||||
public bool UnsafeProgramWindowMatched => ProgramProcessCount > 0;
|
||||
}
|
||||
|
||||
internal interface ITornadoProcessProbe
|
||||
{
|
||||
TornadoProcessSnapshot Capture(Regex? testWindowPattern);
|
||||
}
|
||||
|
||||
internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||
{
|
||||
internal const string ProcessNamePrefix = "Tornado2";
|
||||
|
||||
public TornadoProcessSnapshot Capture(Regex? testWindowPattern)
|
||||
{
|
||||
var totalCount = 0;
|
||||
var eligibleCount = 0;
|
||||
var programCount = 0;
|
||||
Process[] processes;
|
||||
|
||||
try
|
||||
{
|
||||
processes = Process.GetProcesses();
|
||||
}
|
||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||
{
|
||||
return new TornadoProcessSnapshot(0, 0, 0);
|
||||
}
|
||||
|
||||
foreach (var process in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
string processName;
|
||||
try
|
||||
{
|
||||
processName = process.ProcessName;
|
||||
}
|
||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!processName.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCount++;
|
||||
if (testWindowPattern is null)
|
||||
{
|
||||
eligibleCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string title;
|
||||
try
|
||||
{
|
||||
title = process.MainWindowTitle;
|
||||
}
|
||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsProgramTitle(title))
|
||||
{
|
||||
programCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
bool titleMatches;
|
||||
try
|
||||
{
|
||||
titleMatches = testWindowPattern.IsMatch(title);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
titleMatches = false;
|
||||
}
|
||||
|
||||
if (!titleMatches)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
eligibleCount++;
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return new TornadoProcessSnapshot(totalCount, eligibleCount, programCount);
|
||||
}
|
||||
|
||||
internal static bool IsTornadoProcessName(string? processName) =>
|
||||
processName?.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
internal static bool IsProgramTitle(string? title) =>
|
||||
!string.IsNullOrWhiteSpace(title) &&
|
||||
(title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsProcessInspectionException(Exception exception) =>
|
||||
exception is InvalidOperationException or
|
||||
Win32Exception or
|
||||
NotSupportedException or
|
||||
UnauthorizedAccessException;
|
||||
}
|
||||
18
src/MBN_STOCK_WEBVIEW.Playout/Safety/LiveAuthorization.cs
Normal file
18
src/MBN_STOCK_WEBVIEW.Playout/Safety/LiveAuthorization.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
internal interface ILiveAuthorization
|
||||
{
|
||||
bool IsAuthorizedForThisLaunch { get; }
|
||||
}
|
||||
|
||||
internal sealed class EnvironmentLiveAuthorization : ILiveAuthorization
|
||||
{
|
||||
public EnvironmentLiveAuthorization()
|
||||
{
|
||||
// Capture once. Changing a process environment variable after launch cannot arm output.
|
||||
IsAuthorizedForThisLaunch =
|
||||
Configuration.PlayoutOptionsLoader.IsLiveAuthorizedForThisLaunch();
|
||||
}
|
||||
|
||||
public bool IsAuthorizedForThisLaunch { get; }
|
||||
}
|
||||
1068
src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs
Normal file
1068
src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs
Normal file
File diff suppressed because it is too large
Load Diff
3
tests/MBN_STOCK_WEBVIEW.Playout.Tests/AssemblyInfo.cs
Normal file
3
tests/MBN_STOCK_WEBVIEW.Playout.Tests/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using Xunit;
|
||||
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,152 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class DryRunPlayoutEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DefaultOptions_AreDryRunAndNeverReportARealConnection()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode);
|
||||
Assert.Equal(PlayoutConnectionState.DryRunReady, engine.Status.State);
|
||||
Assert.True(engine.Status.IsCommandAvailable);
|
||||
Assert.False(engine.Status.IsConnected);
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.IsDryRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledMode_RejectsEveryOperationWithoutChangingState()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.Disabled);
|
||||
var cue = Cue("disabled-scene");
|
||||
|
||||
var results = new[]
|
||||
{
|
||||
await engine.ConnectAsync(CancellationToken.None),
|
||||
await engine.PrepareAsync(cue, CancellationToken.None),
|
||||
await engine.TakeInAsync(CancellationToken.None),
|
||||
await engine.NextAsync(cue, CancellationToken.None),
|
||||
await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
CancellationToken.None),
|
||||
await engine.DisconnectAsync(CancellationToken.None)
|
||||
};
|
||||
|
||||
Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code));
|
||||
Assert.All(results, result => Assert.False(result.IsDryRun));
|
||||
Assert.Equal(PlayoutConnectionState.Disabled, engine.Status.State);
|
||||
Assert.False(engine.Status.IsConnected);
|
||||
Assert.False(engine.Status.IsCommandAvailable);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_TransitionsPreparedAndOnAirSceneStateInCommandOrder()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
var statuses = new List<PlayoutStatus>();
|
||||
engine.StatusChanged += (_, args) => statuses.Add(args.Current);
|
||||
|
||||
var connect = await engine.ConnectAsync(CancellationToken.None);
|
||||
var prepare = await engine.PrepareAsync(
|
||||
Cue("first-scene"),
|
||||
CancellationToken.None);
|
||||
Assert.Equal("first-scene", engine.Status.PreparedSceneName);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
|
||||
var takeIn = await engine.TakeInAsync(CancellationToken.None);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Equal("first-scene", engine.Status.OnAirSceneName);
|
||||
|
||||
var next = await engine.NextAsync(
|
||||
Cue("next-scene"),
|
||||
CancellationToken.None);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Equal("next-scene", engine.Status.OnAirSceneName);
|
||||
|
||||
var takeOut = await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.Layout,
|
||||
CancellationToken.None);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
|
||||
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.All(
|
||||
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
|
||||
result => Assert.Equal(PlayoutResultCode.Success, result.Code));
|
||||
Assert.All(
|
||||
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
|
||||
result => Assert.True(result.IsDryRun));
|
||||
Assert.Equal(6, statuses.Count);
|
||||
Assert.True(statuses.Zip(statuses.Skip(1), (left, right) => left.Sequence < right.Sequence).All(x => x));
|
||||
Assert.All(statuses, status => Assert.Equal(PlayoutConnectionState.DryRunReady, status.State));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_TakeInWithoutPreparedSceneIsRejected()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
|
||||
var result = await engine.TakeInAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelledDryRunCommand_DoesNotChangeStatus()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
var initialStatus = engine.Status;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
var result = await engine.PrepareAsync(Cue("cancelled-scene"), cancellation.Token);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
|
||||
Assert.Same(initialStatus, engine.Status);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_PublicStatusAndResultDoNotExposeScenePath()
|
||||
{
|
||||
const string secretPath = "C:\\private-vendor-assets\\do-not-log\\secret.t2s";
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
var cue = new PlayoutCue(secretPath, "C:\\unsafe-scene-name");
|
||||
|
||||
var result = await engine.PrepareAsync(cue, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("장면", engine.Status.PreparedSceneName);
|
||||
Assert.DoesNotContain(secretPath, result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(secretPath, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(cue.SceneName, result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(cue.SceneName, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PlayoutCue Cue(string sceneName) => new(
|
||||
$"C:\\test-only\\{sceneName}.t2s",
|
||||
sceneName,
|
||||
[new PlayoutField("headline", "safe value", true)]);
|
||||
|
||||
private static TornadoPlayoutEngine CreateEngine(PlayoutMode mode)
|
||||
{
|
||||
var options = ValidatedPlayoutOptions.Create(new PlayoutOptions { Mode = mode });
|
||||
return new TornadoPlayoutEngine(
|
||||
options,
|
||||
new FakeK3dSessionFactory(),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(0, 0, 0)),
|
||||
null,
|
||||
new FakeLiveAuthorization(false),
|
||||
TimeProvider.System,
|
||||
startMonitor: false);
|
||||
}
|
||||
}
|
||||
280
tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs
Normal file
280
tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class DynamicK3dSessionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public async Task Connect_WhenKtapDoesNotReturnOne_FailsClosed(int connectResult)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
||||
var log = new FakeComLog();
|
||||
var engine = new FakeEngine(
|
||||
log,
|
||||
new FakePlayer(log),
|
||||
new FakeScene(log),
|
||||
connectResult);
|
||||
var activator = new FakeActivator(log, engine);
|
||||
await using var dispatcher = new StaDispatcher(capacity: 1);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(
|
||||
() => dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
using var session = new DynamicK3dSession(activator);
|
||||
session.Connect(options);
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(5),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.DoesNotContain(options.Host, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(connectResult.ToString(), exception.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("GetScenePlayer", log.Names);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PlayoutTakeOutScope.All, "StopAll")]
|
||||
[InlineData(PlayoutTakeOutScope.Layout, "CutOut:10")]
|
||||
public async Task FakeLateBoundCom_UsesLegacyCallOrderOnOneStaThread(
|
||||
PlayoutTakeOutScope takeOutScope,
|
||||
string expectedTakeOutCall)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
||||
var cue = options.ResolveCue(new PlayoutCue(
|
||||
"test-scene.t2s",
|
||||
"test-scene",
|
||||
[
|
||||
new PlayoutField("headline", "safe value", true),
|
||||
new PlayoutField("badge", null, false)
|
||||
],
|
||||
FadeDuration: 12));
|
||||
var log = new FakeComLog();
|
||||
var player = new FakePlayer(log);
|
||||
var scene = new FakeScene(log);
|
||||
var engine = new FakeEngine(log, player, scene);
|
||||
var activator = new FakeActivator(log, engine);
|
||||
await using var dispatcher = new StaDispatcher(capacity: 4);
|
||||
|
||||
await dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
using var session = new DynamicK3dSession(activator);
|
||||
session.Connect(options);
|
||||
session.Prepare(cue, options.LayoutIndex);
|
||||
session.Play(options.LayoutIndex);
|
||||
session.TakeOut(options.LayoutIndex, takeOutScope);
|
||||
session.Disconnect();
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(5),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
||||
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
||||
"KTAPConnect:1:127.0.0.1:30001:0",
|
||||
"GetScenePlayerOnChannel:9",
|
||||
"LoadScene:test-scene",
|
||||
"SetSceneEffectType:10:7:12",
|
||||
"BeginTransaction",
|
||||
"GetObject:headline",
|
||||
"SetValue:headline:safe value",
|
||||
"SetVisible:headline:1",
|
||||
"GetObject:badge",
|
||||
"SetVisible:badge:0",
|
||||
"QueryVariables",
|
||||
"EndTransaction",
|
||||
"Prepare:10",
|
||||
"Play:10",
|
||||
expectedTakeOutCall,
|
||||
"Disconnect"
|
||||
},
|
||||
log.Names);
|
||||
Assert.Single(log.ThreadIds.Distinct());
|
||||
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateBoundActivatorContract_AcceptsOnlyAClsid()
|
||||
{
|
||||
var create = typeof(ILateBoundComActivator).GetMethod(nameof(ILateBoundComActivator.Create));
|
||||
|
||||
Assert.NotNull(create);
|
||||
Assert.Equal(typeof(Guid), Assert.Single(create.GetParameters()).ParameterType);
|
||||
Assert.Equal(typeof(object), create.ReturnType);
|
||||
}
|
||||
|
||||
private static PlayoutOptions TestOptions(string sceneDirectory) => new()
|
||||
{
|
||||
Mode = PlayoutMode.Test,
|
||||
SceneDirectory = sceneDirectory,
|
||||
OutputChannel = 9,
|
||||
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
|
||||
TestSceneAllowlist = ["test-scene"]
|
||||
};
|
||||
|
||||
public sealed class FakeComLog
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _names = new();
|
||||
private readonly ConcurrentQueue<int> _threadIds = new();
|
||||
private readonly ConcurrentQueue<ApartmentState> _apartmentStates = new();
|
||||
|
||||
public string[] Names => _names.ToArray();
|
||||
|
||||
public int[] ThreadIds => _threadIds.ToArray();
|
||||
|
||||
public ApartmentState[] ApartmentStates => _apartmentStates.ToArray();
|
||||
|
||||
public void Add(string name)
|
||||
{
|
||||
_names.Enqueue(name);
|
||||
_threadIds.Enqueue(Environment.CurrentManagedThreadId);
|
||||
_apartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeActivator : ILateBoundComActivator
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
private readonly FakeEngine _engine;
|
||||
private readonly object _eventHandler = new();
|
||||
|
||||
public FakeActivator(FakeComLog log, FakeEngine engine)
|
||||
{
|
||||
_log = log;
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
public object Create(Guid classId)
|
||||
{
|
||||
_log.Add($"Create:{classId:B}");
|
||||
return classId switch
|
||||
{
|
||||
var value when value == K3dComConstants.KaEventHandlerClassGuid => _eventHandler,
|
||||
var value when value == K3dComConstants.KaEngineClassGuid => _engine,
|
||||
_ => throw new InvalidOperationException("Unexpected fake CLSID.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeEngine
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
private readonly FakePlayer _player;
|
||||
private readonly FakeScene _scene;
|
||||
private readonly int _connectResult;
|
||||
|
||||
public FakeEngine(
|
||||
FakeComLog log,
|
||||
FakePlayer player,
|
||||
FakeScene scene,
|
||||
int connectResult = 1)
|
||||
{
|
||||
_log = log;
|
||||
_player = player;
|
||||
_scene = scene;
|
||||
_connectResult = connectResult;
|
||||
}
|
||||
|
||||
public int KTAPConnect(
|
||||
int tcpMode,
|
||||
string host,
|
||||
int hostPort,
|
||||
int clientPort,
|
||||
object eventHandler)
|
||||
{
|
||||
Assert.NotNull(eventHandler);
|
||||
_log.Add($"KTAPConnect:{tcpMode}:{host}:{hostPort}:{clientPort}");
|
||||
return _connectResult;
|
||||
}
|
||||
|
||||
public object GetScenePlayerOnChannel(int channel)
|
||||
{
|
||||
_log.Add($"GetScenePlayerOnChannel:{channel}");
|
||||
return _player;
|
||||
}
|
||||
|
||||
public object LoadScene(string sceneFile, string sceneName)
|
||||
{
|
||||
Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile));
|
||||
_log.Add($"LoadScene:{sceneName}");
|
||||
return _scene;
|
||||
}
|
||||
|
||||
public void BeginTransaction() => _log.Add("BeginTransaction");
|
||||
|
||||
public void EndTransaction() => _log.Add("EndTransaction");
|
||||
|
||||
public void Disconnect() => _log.Add("Disconnect");
|
||||
}
|
||||
|
||||
public sealed class FakePlayer
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
|
||||
public FakePlayer(FakeComLog log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Prepare(int layoutIndex, object scene)
|
||||
{
|
||||
Assert.NotNull(scene);
|
||||
_log.Add($"Prepare:{layoutIndex}");
|
||||
}
|
||||
|
||||
public void Play(int layoutIndex) => _log.Add($"Play:{layoutIndex}");
|
||||
|
||||
public void StopAll() => _log.Add("StopAll");
|
||||
|
||||
public void CutOut(int layoutIndex) => _log.Add($"CutOut:{layoutIndex}");
|
||||
}
|
||||
|
||||
public sealed class FakeScene
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
|
||||
public FakeScene(FakeComLog log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void SetSceneEffectType(int layoutIndex, int effectType, int duration) =>
|
||||
_log.Add($"SetSceneEffectType:{layoutIndex}:{effectType}:{duration}");
|
||||
|
||||
public object GetObject(string objectName)
|
||||
{
|
||||
_log.Add($"GetObject:{objectName}");
|
||||
return new FakeSceneObject(_log, objectName);
|
||||
}
|
||||
|
||||
public void QueryVariables() => _log.Add("QueryVariables");
|
||||
}
|
||||
|
||||
public sealed class FakeSceneObject
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
private readonly string _name;
|
||||
|
||||
public FakeSceneObject(FakeComLog log, string name)
|
||||
{
|
||||
_log = log;
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public void SetValue(string value) => _log.Add($"SetValue:{_name}:{value}");
|
||||
|
||||
public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}");
|
||||
}
|
||||
}
|
||||
4
tests/MBN_STOCK_WEBVIEW.Playout.Tests/GlobalUsings.cs
Normal file
4
tests/MBN_STOCK_WEBVIEW.Playout.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
global using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
global using MBN_STOCK_WEBVIEW.Playout;
|
||||
global using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,204 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class K3dRegistrationProbeTests
|
||||
{
|
||||
private const string TypeLibraryPath = "C:\\vendor-secret\\typelib-x64.dll";
|
||||
private const string EnginePath = "C:\\vendor-secret\\engine-x64.dll";
|
||||
private const string EventHandlerPath = "C:\\vendor-secret\\events-x64.dll";
|
||||
|
||||
[Fact]
|
||||
public void Probe_WithReciprocalAmd64MachineRegistration_IsReady()
|
||||
{
|
||||
var binaries = ReadyBinaries();
|
||||
var probe = CreateProbe(ReadySnapshot(), binaries);
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.True(report.IsReady);
|
||||
Assert.Equal(K3dRegistrationIssue.None, report.Issues);
|
||||
Assert.True(report.IsEngineClassReady);
|
||||
Assert.True(report.IsEventHandlerClassReady);
|
||||
Assert.Equal(
|
||||
new[] { TypeLibraryPath, EnginePath, EventHandlerPath },
|
||||
binaries.InspectedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenEitherDirectionOfReciprocalMappingDiffers_FailsClosed()
|
||||
{
|
||||
var snapshot = ReadySnapshot() with
|
||||
{
|
||||
Engine = ReadySnapshot().Engine with
|
||||
{
|
||||
ClassProgId = "Unexpected.Engine.1",
|
||||
ProgIdClassId = K3dComConstants.KaEventHandlerClassId
|
||||
},
|
||||
EventHandler = ReadySnapshot().EventHandler with
|
||||
{
|
||||
ProgIdClassId = K3dComConstants.KaEngineClassId
|
||||
}
|
||||
};
|
||||
var probe = CreateProbe(snapshot, ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineProgIdMismatch));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineProgIdClassIdMismatch));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerProgIdClassIdMismatch));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenEitherClassServerIsNotAmd64_FailsClosed()
|
||||
{
|
||||
var binaries = ReadyBinaries();
|
||||
binaries.Set(EnginePath, exists: true, isAmd64: false);
|
||||
binaries.Set(EventHandlerPath, exists: true, isAmd64: false);
|
||||
var probe = CreateProbe(ReadySnapshot(), binaries);
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineServerIsNotAmd64));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerServerIsNotAmd64));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenCurrentUserOverrideExists_FailsClosedWithoutDetails()
|
||||
{
|
||||
var probe = CreateProbe(
|
||||
ReadySnapshot() with { CurrentUserOverridePresent = true },
|
||||
ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.CurrentUserOverridePresent));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
Assert.Contains("재정의", report.Message, StringComparison.Ordinal);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentUserOverrideBoundary_CoversBothClassesProgIdsAndTypeLibrary()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
$"CLSID\\{K3dComConstants.KaEngineClassId}",
|
||||
$"CLSID\\{K3dComConstants.KaEventHandlerClassId}",
|
||||
K3dComConstants.KaEngineProgId,
|
||||
K3dComConstants.KaEventHandlerProgId,
|
||||
$"TypeLib\\{K3dComConstants.TypeLibraryId}"
|
||||
},
|
||||
WindowsK3dRegistrySnapshotSource.CurrentUserOverridePaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenRegistrySnapshotCannotBeRead_ReturnsSafeFailure()
|
||||
{
|
||||
var probe = new K3dRegistrationProbe(
|
||||
new ThrowingSnapshotSource(),
|
||||
ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.Equal(K3dRegistrationIssue.RegistryReadFailed, report.Issues);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
private static K3dRegistrationProbe CreateProbe(
|
||||
K3dRegistrySnapshot snapshot,
|
||||
FakeBinaryInspector binaries) =>
|
||||
new(new FakeSnapshotSource(snapshot), binaries);
|
||||
|
||||
private static K3dRegistrySnapshot ReadySnapshot() => new(
|
||||
Is64BitProcess: true,
|
||||
Win64TypeLibraryPath: TypeLibraryPath,
|
||||
Engine: new K3dClassRegistrationSnapshot(
|
||||
ClassKeyPresent: true,
|
||||
ClassProgId: K3dComConstants.KaEngineProgId,
|
||||
ProgIdClassId: K3dComConstants.KaEngineClassId,
|
||||
InprocServerPath: EnginePath,
|
||||
ThreadingModel: K3dComConstants.ApartmentThreadingModel,
|
||||
TypeLibraryId: K3dComConstants.TypeLibraryId),
|
||||
EventHandler: new K3dClassRegistrationSnapshot(
|
||||
ClassKeyPresent: true,
|
||||
ClassProgId: K3dComConstants.KaEventHandlerProgId,
|
||||
ProgIdClassId: K3dComConstants.KaEventHandlerClassId,
|
||||
InprocServerPath: EventHandlerPath,
|
||||
ThreadingModel: K3dComConstants.ApartmentThreadingModel,
|
||||
TypeLibraryId: K3dComConstants.TypeLibraryId),
|
||||
CurrentUserOverridePresent: false);
|
||||
|
||||
private static FakeBinaryInspector ReadyBinaries() => new FakeBinaryInspector()
|
||||
.Set(TypeLibraryPath, exists: true, isAmd64: true)
|
||||
.Set(EnginePath, exists: true, isAmd64: true)
|
||||
.Set(EventHandlerPath, exists: true, isAmd64: true);
|
||||
|
||||
private static void AssertSafe(K3dRegistrationReport report)
|
||||
{
|
||||
Assert.DoesNotContain(TypeLibraryPath, report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(EnginePath, report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(EventHandlerPath, report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("HKEY", report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("CLSID\\", report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class FakeSnapshotSource : IK3dRegistrySnapshotSource
|
||||
{
|
||||
private readonly K3dRegistrySnapshot _snapshot;
|
||||
|
||||
public FakeSnapshotSource(K3dRegistrySnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public K3dRegistrySnapshot Capture() => _snapshot;
|
||||
}
|
||||
|
||||
private sealed class ThrowingSnapshotSource : IK3dRegistrySnapshotSource
|
||||
{
|
||||
public K3dRegistrySnapshot Capture() =>
|
||||
throw new UnauthorizedAccessException("HKEY secret registry detail");
|
||||
}
|
||||
|
||||
private sealed class FakeBinaryInspector : IPeBinaryInspector
|
||||
{
|
||||
private readonly Dictionary<string, PeBinaryInspection> _inspections =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<string> _inspectedPaths = [];
|
||||
|
||||
public IReadOnlyList<string> InspectedPaths => _inspectedPaths;
|
||||
|
||||
public FakeBinaryInspector Set(string path, bool exists, bool isAmd64)
|
||||
{
|
||||
_inspections[path] = new PeBinaryInspection(exists, isAmd64);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PeBinaryInspection Inspect(string? path)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
return new PeBinaryInspection(false, false);
|
||||
}
|
||||
|
||||
_inspectedPaths.Add(path);
|
||||
return _inspections.TryGetValue(path, out var inspection)
|
||||
? inspection
|
||||
: new PeBinaryInspection(false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class LiveAuthorizationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH ", false)]
|
||||
[InlineData("i_authorize_live_program_output_for_this_launch", false)]
|
||||
[InlineData("wrong", false)]
|
||||
[InlineData(
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
|
||||
true)]
|
||||
public void EnvironmentAuthorization_RequiresExactPerLaunchValue(
|
||||
string? value,
|
||||
bool expected)
|
||||
{
|
||||
using var environment = new EnvironmentScope()
|
||||
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, value);
|
||||
|
||||
var authorization = new EnvironmentLiveAuthorization();
|
||||
|
||||
Assert.Equal(expected, authorization.IsAuthorizedForThisLaunch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentAuthorization_IsCapturedOnceAndCannotBeArmedAfterConstruction()
|
||||
{
|
||||
using var environment = new EnvironmentScope()
|
||||
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null);
|
||||
var authorization = new EnvironmentLiveAuthorization();
|
||||
|
||||
environment.Set(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
||||
|
||||
Assert.False(authorization.IsAuthorizedForThisLaunch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Platforms>x64</Platforms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
195
tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs
Normal file
195
tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
internal sealed class FakeK3dSessionFactory : IK3dSessionFactory
|
||||
{
|
||||
private readonly Func<FakeK3dSession> _create;
|
||||
private int _createCount;
|
||||
|
||||
public FakeK3dSessionFactory(Func<FakeK3dSession>? create = null)
|
||||
{
|
||||
_create = create ?? (() => new FakeK3dSession());
|
||||
}
|
||||
|
||||
public int CreateCount => Volatile.Read(ref _createCount);
|
||||
|
||||
public ConcurrentQueue<FakeK3dSession> Sessions { get; } = new();
|
||||
|
||||
public IK3dSession Create()
|
||||
{
|
||||
Interlocked.Increment(ref _createCount);
|
||||
var session = _create();
|
||||
Sessions.Enqueue(session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeK3dSession : IK3dSession
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public ConcurrentQueue<string> Calls { get; } = new();
|
||||
|
||||
public ConcurrentQueue<int> ThreadIds { get; } = new();
|
||||
|
||||
public ConcurrentQueue<ApartmentState> ApartmentStates { get; } = new();
|
||||
|
||||
public Action<ValidatedPlayoutOptions>? ConnectAction { get; set; }
|
||||
|
||||
public Action? DisconnectAction { get; set; }
|
||||
|
||||
public Action<PlayoutCue, int>? PrepareAction { get; set; }
|
||||
|
||||
public Action<int>? PlayAction { get; set; }
|
||||
|
||||
public Action<int, PlayoutTakeOutScope>? TakeOutAction { get; set; }
|
||||
|
||||
public void Connect(ValidatedPlayoutOptions options)
|
||||
{
|
||||
Record("Connect");
|
||||
ConnectAction?.Invoke(options);
|
||||
IsConnected = true;
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
Record("Disconnect");
|
||||
DisconnectAction?.Invoke();
|
||||
IsConnected = false;
|
||||
}
|
||||
|
||||
public void Prepare(PlayoutCue cue, int layoutIndex)
|
||||
{
|
||||
Record($"Prepare:{cue.SceneName}");
|
||||
PrepareAction?.Invoke(cue, layoutIndex);
|
||||
}
|
||||
|
||||
public void Play(int layoutIndex)
|
||||
{
|
||||
Record("Play");
|
||||
PlayAction?.Invoke(layoutIndex);
|
||||
}
|
||||
|
||||
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
|
||||
{
|
||||
Record($"TakeOut:{scope}");
|
||||
TakeOutAction?.Invoke(layoutIndex, scope);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
Record("Dispose");
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Record(string name)
|
||||
{
|
||||
Calls.Enqueue(name);
|
||||
ThreadIds.Enqueue(Environment.CurrentManagedThreadId);
|
||||
ApartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeRegistrationProbe : IK3dRegistrationProbe
|
||||
{
|
||||
private int _probeCount;
|
||||
|
||||
public FakeRegistrationProbe(K3dRegistrationReport? report = null)
|
||||
{
|
||||
Report = report ?? ReadyReport;
|
||||
}
|
||||
|
||||
public static K3dRegistrationReport ReadyReport { get; } = new(
|
||||
K3dRegistrationIssue.None,
|
||||
Is64BitProcess: true,
|
||||
HasWin64TypeLibrary: true,
|
||||
IsWin64BinaryAmd64: true,
|
||||
IsEngineClassReady: true,
|
||||
IsEventHandlerClassReady: true,
|
||||
Message: "fake K3D registration ready");
|
||||
|
||||
public K3dRegistrationReport Report { get; set; }
|
||||
|
||||
public int ProbeCount => Volatile.Read(ref _probeCount);
|
||||
|
||||
public K3dRegistrationReport Probe()
|
||||
{
|
||||
Interlocked.Increment(ref _probeCount);
|
||||
return Report;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeTornadoProcessProbe : ITornadoProcessProbe
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Queue<TornadoProcessSnapshot> _snapshots = new();
|
||||
private TornadoProcessSnapshot _last;
|
||||
|
||||
public FakeTornadoProcessProbe(params TornadoProcessSnapshot[] snapshots)
|
||||
{
|
||||
_last = snapshots.LastOrDefault()
|
||||
?? new TornadoProcessSnapshot(0, 0, 0);
|
||||
foreach (var snapshot in snapshots)
|
||||
{
|
||||
_snapshots.Enqueue(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public int CaptureCount { get; private set; }
|
||||
|
||||
public Regex? LastPattern { get; private set; }
|
||||
|
||||
public TornadoProcessSnapshot Capture(Regex? testWindowPattern)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
CaptureCount++;
|
||||
LastPattern = testWindowPattern;
|
||||
if (_snapshots.Count > 0)
|
||||
{
|
||||
_last = _snapshots.Dequeue();
|
||||
}
|
||||
|
||||
return _last;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeLiveAuthorization : ILiveAuthorization
|
||||
{
|
||||
public FakeLiveAuthorization(bool isAuthorizedForThisLaunch)
|
||||
{
|
||||
IsAuthorizedForThisLaunch = isAuthorizedForThisLaunch;
|
||||
}
|
||||
|
||||
public bool IsAuthorizedForThisLaunch { get; }
|
||||
}
|
||||
|
||||
internal sealed class TrackingStaDispatcherFactory : IStaDispatcherFactory
|
||||
{
|
||||
private int _createCount;
|
||||
|
||||
public int CreateCount => Volatile.Read(ref _createCount);
|
||||
|
||||
public ConcurrentQueue<IStaDispatcher> Dispatchers { get; } = new();
|
||||
|
||||
public IStaDispatcher Create(int capacity)
|
||||
{
|
||||
Interlocked.Increment(ref _createCount);
|
||||
var dispatcher = new StaDispatcher(capacity);
|
||||
Dispatchers.Enqueue(dispatcher);
|
||||
return dispatcher;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PlayoutOptionsLoaderTests
|
||||
{
|
||||
private static readonly string[] EnvironmentNames =
|
||||
[
|
||||
"MBN_STOCK_PLAYOUT_MODE",
|
||||
"MBN_STOCK_PLAYOUT_HOST",
|
||||
"MBN_STOCK_PLAYOUT_PORT",
|
||||
"MBN_STOCK_PLAYOUT_TCP_MODE",
|
||||
"MBN_STOCK_PLAYOUT_CLIENT_PORT",
|
||||
"MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL",
|
||||
"MBN_STOCK_PLAYOUT_LAYOUT_INDEX",
|
||||
"MBN_STOCK_PLAYOUT_SCENE_DIRECTORY",
|
||||
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
|
||||
"MBN_STOCK_PLAYOUT_QUEUE_CAPACITY",
|
||||
"MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS",
|
||||
"MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS",
|
||||
"MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS",
|
||||
"MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS",
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS",
|
||||
"MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS",
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_ENABLED",
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
"MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST",
|
||||
"MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenFileIsMissing_UsesSafeDryRunDefaults()
|
||||
{
|
||||
using var environment = ClearedEnvironment();
|
||||
using var file = TemporaryJsonFile.Missing();
|
||||
|
||||
var options = PlayoutOptionsLoader.Load(file.Path);
|
||||
|
||||
Assert.Equal(PlayoutMode.DryRun, options.Mode);
|
||||
Assert.Equal("127.0.0.1", options.Host);
|
||||
Assert.Equal(30001, options.Port);
|
||||
Assert.Equal(1, options.TcpMode);
|
||||
Assert.Equal(0, options.ClientPort);
|
||||
Assert.Null(options.OutputChannel);
|
||||
Assert.Null(options.SceneDirectory);
|
||||
Assert.Empty(options.TestSceneAllowlist);
|
||||
Assert.False(options.TrustedLiveOutputEnabled);
|
||||
Assert.True(options.ReconnectEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
|
||||
{
|
||||
using var environment = ClearedEnvironment()
|
||||
.Set("MBN_STOCK_PLAYOUT_MODE", "DryRun")
|
||||
.Set("MBN_STOCK_PLAYOUT_HOST", "env-host")
|
||||
.Set("MBN_STOCK_PLAYOUT_PORT", "32001")
|
||||
.Set("MBN_STOCK_PLAYOUT_TCP_MODE", "2")
|
||||
.Set("MBN_STOCK_PLAYOUT_CLIENT_PORT", "32002")
|
||||
.Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "12")
|
||||
.Set("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", "13")
|
||||
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\env-scenes")
|
||||
.Set("MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "ENV TEST")
|
||||
.Set("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "14")
|
||||
.Set("MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS", "1501")
|
||||
.Set("MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS", "1502")
|
||||
.Set("MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS", "1503")
|
||||
.Set("MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS", "1504")
|
||||
.Set("MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS", "1505")
|
||||
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "4")
|
||||
.Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "false")
|
||||
.Set("MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST", "C:\\env\\must-not-apply.t2s")
|
||||
.Set("MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED", "false");
|
||||
using var file = TemporaryJsonFile.Create(
|
||||
"""
|
||||
{
|
||||
"mode": "Test",
|
||||
"host": "json-host",
|
||||
"port": 31001,
|
||||
"tcpMode": 1,
|
||||
"clientPort": 31002,
|
||||
"outputChannel": 3,
|
||||
"layoutIndex": 10,
|
||||
"sceneDirectory": "C:\\json-scenes",
|
||||
"testProcessWindowTitlePattern": "JSON TEST",
|
||||
"testSceneAllowlist": ["C:\\test-scenes\\allowed.t2s"],
|
||||
"trustedLiveOutputEnabled": true,
|
||||
"queueCapacity": 64,
|
||||
"connectTimeoutMilliseconds": 5000,
|
||||
"operationTimeoutMilliseconds": 5000,
|
||||
"disconnectTimeoutMilliseconds": 3000,
|
||||
"processPollIntervalMilliseconds": 1000,
|
||||
"reconnectDelayMilliseconds": 1000,
|
||||
"maximumReconnectAttempts": 3,
|
||||
"reconnectEnabled": true
|
||||
}
|
||||
""");
|
||||
|
||||
var options = PlayoutOptionsLoader.Load(file.Path);
|
||||
|
||||
Assert.Equal(PlayoutMode.DryRun, options.Mode);
|
||||
Assert.Equal("env-host", options.Host);
|
||||
Assert.Equal(32001, options.Port);
|
||||
Assert.Equal(2, options.TcpMode);
|
||||
Assert.Equal(32002, options.ClientPort);
|
||||
Assert.Equal(12, options.OutputChannel);
|
||||
Assert.Equal(13, options.LayoutIndex);
|
||||
Assert.Equal("C:\\env-scenes", options.SceneDirectory);
|
||||
Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern);
|
||||
Assert.Equal(14, options.QueueCapacity);
|
||||
Assert.Equal(1501, options.ConnectTimeoutMilliseconds);
|
||||
Assert.Equal(1502, options.OperationTimeoutMilliseconds);
|
||||
Assert.Equal(1503, options.DisconnectTimeoutMilliseconds);
|
||||
Assert.Equal(1504, options.ProcessPollIntervalMilliseconds);
|
||||
Assert.Equal(1505, options.ReconnectDelayMilliseconds);
|
||||
Assert.Equal(4, options.MaximumReconnectAttempts);
|
||||
Assert.False(options.ReconnectEnabled);
|
||||
Assert.Equal(["C:\\test-scenes\\allowed.t2s"], options.TestSceneAllowlist);
|
||||
Assert.True(options.TrustedLiveOutputEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_InvalidJson_ReturnsOnlySafeConfigurationMessage()
|
||||
{
|
||||
using var environment = ClearedEnvironment();
|
||||
using var file = TemporaryJsonFile.Create("{ invalid-json: true }");
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => PlayoutOptionsLoader.Load(file.Path));
|
||||
|
||||
Assert.Equal("송출 설정 파일 형식이 올바르지 않습니다.", exception.Message);
|
||||
Assert.DoesNotContain(file.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("invalid-json", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_PORT", "not-a-number")]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "not-a-bool")]
|
||||
public void Load_InvalidEnvironmentValue_IdentifiesVariableWithoutLeakingValue(
|
||||
string name,
|
||||
string invalidValue)
|
||||
{
|
||||
using var environment = ClearedEnvironment().Set(name, invalidValue);
|
||||
using var file = TemporaryJsonFile.Missing();
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => PlayoutOptionsLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains(name, exception.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(invalidValue, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static EnvironmentScope ClearedEnvironment()
|
||||
{
|
||||
var result = new EnvironmentScope();
|
||||
foreach (var name in EnvironmentNames)
|
||||
{
|
||||
result.Set(name, null);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PlayoutSafetyValidationTests : IDisposable
|
||||
{
|
||||
private readonly TemporarySceneDirectory _scenes =
|
||||
TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tornado2", true)]
|
||||
[InlineData("tornado2-test", true)]
|
||||
[InlineData("TORNADO2Preview", true)]
|
||||
[InlineData("Tornado20", true)]
|
||||
[InlineData("Tornado", false)]
|
||||
[InlineData("PreviewTornado2", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsTornadoProcessName_UsesCaseInsensitiveTornado2Prefix(
|
||||
string? processName,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TornadoProcessProbe.IsTornadoProcessName(processName));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tornado2 TEST", false)]
|
||||
[InlineData("Tornado2 PGM", true)]
|
||||
[InlineData("PROGRAM OUTPUT", true)]
|
||||
[InlineData("preview pgm backup", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsProgramTitle_RejectsPgmAndProgramWindows(string? title, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TornadoProcessProbe.IsProgramTitle(title));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(10, false)]
|
||||
public void ProcessSnapshot_RequiresExactlyOneEligibleInstance(
|
||||
int eligibleProcessCount,
|
||||
bool expected)
|
||||
{
|
||||
var snapshot = new TornadoProcessSnapshot(
|
||||
TotalProcessCount: eligibleProcessCount,
|
||||
EligibleProcessCount: eligibleProcessCount,
|
||||
ProgramProcessCount: 0);
|
||||
|
||||
Assert.Equal(expected, snapshot.HasSingleEligibleProcess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TestModeRequiresExplicitOutputChannel()
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.OutputChannel = null;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(nameof(PlayoutOptions.OutputChannel), exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TestModeRequiresSceneAllowlist()
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.TestSceneAllowlist = [];
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(nameof(PlayoutOptions.TestSceneAllowlist), exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("localhost")]
|
||||
[InlineData("test-tornado.local")]
|
||||
[InlineData("192.168.0.10")]
|
||||
public void Validate_TestModeRequiresLiteralLoopbackAddress(string host)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.Host = host;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(nameof(PlayoutOptions.Host), exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".*")]
|
||||
[InlineData("PGM")]
|
||||
[InlineData("PROGRAM")]
|
||||
[InlineData("^Tornado2 (TEST|PGM)$")]
|
||||
public void Validate_TestWindowPatternThatCanMatchProgramOutput_IsRejected(string pattern)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.TestProcessWindowTitlePattern = pattern;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains("PROGRAM", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidTestWindowPattern_IsRejectedWithoutEchoingPattern()
|
||||
{
|
||||
const string invalidPattern = "(?<secret>";
|
||||
var options = ValidTestOptions();
|
||||
options.TestProcessWindowTitlePattern = invalidPattern;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Equal("테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception.Message);
|
||||
Assert.DoesNotContain(invalidPattern, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TestSceneAllowlistMatchesSceneNameOnlyAndIgnoresCase()
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
|
||||
Assert.True(validated.IsTestSceneAllowed(
|
||||
new PlayoutCue("test-scene.t2s", "TEST-SCENE")));
|
||||
Assert.False(validated.IsTestSceneAllowed(
|
||||
new PlayoutCue("test-scene.t2s", "OTHER-SCENE")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_UsesCanonicalFileUnderConfiguredSceneDirectory()
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
|
||||
var resolved = validated.ResolveCue(new PlayoutCue(
|
||||
"test-scene.t2s",
|
||||
"TEST-SCENE",
|
||||
FadeDuration: 5));
|
||||
|
||||
Assert.Equal(
|
||||
System.IO.Path.Combine(_scenes.Path, "test-scene.t2s"),
|
||||
resolved.SceneFile,
|
||||
ignoreCase: true);
|
||||
Assert.Equal("TEST-SCENE", resolved.SceneName);
|
||||
Assert.Equal(5, resolved.FadeDuration);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("..\\outside.t2s", "outside")]
|
||||
[InlineData("test-scene.txt", "test-scene")]
|
||||
[InlineData("test-scene.t2s", "different-name")]
|
||||
[InlineData("missing.t2s", "missing")]
|
||||
public void ResolveCue_RejectsUnsafeOrMissingFilesWithoutLeakingInput(
|
||||
string sceneFile,
|
||||
string sceneName)
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
|
||||
var exception = Assert.Throws<PlayoutRequestException>(
|
||||
() => validated.ResolveCue(new PlayoutCue(sceneFile, sceneName)));
|
||||
|
||||
Assert.DoesNotContain(sceneFile, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_RejectsRootedScenePathWithoutLeakingIt()
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
var rootedPath = System.IO.Path.Combine(_scenes.Path, "test-scene.t2s");
|
||||
|
||||
var exception = Assert.Throws<PlayoutRequestException>(
|
||||
() => validated.ResolveCue(new PlayoutCue(rootedPath, "test-scene")));
|
||||
|
||||
Assert.Equal("장면 파일 이름이 올바르지 않습니다.", exception.Message);
|
||||
Assert.DoesNotContain(rootedPath, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 5000, 64, nameof(PlayoutOptions.Port))]
|
||||
[InlineData(30001, 99, 64, nameof(PlayoutOptions.OperationTimeoutMilliseconds))]
|
||||
[InlineData(30001, 5000, 0, nameof(PlayoutOptions.QueueCapacity))]
|
||||
public void Validate_OutOfRangeValuesAreRejected(
|
||||
int port,
|
||||
int operationTimeoutMilliseconds,
|
||||
int queueCapacity,
|
||||
string expectedProperty)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.Port = port;
|
||||
options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds;
|
||||
options.QueueCapacity = queueCapacity;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(expectedProperty, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public void Dispose() => _scenes.Dispose();
|
||||
|
||||
private PlayoutOptions ValidTestOptions() => new()
|
||||
{
|
||||
Mode = PlayoutMode.Test,
|
||||
Host = "127.0.0.1",
|
||||
Port = 30001,
|
||||
TcpMode = 1,
|
||||
ClientPort = 0,
|
||||
OutputChannel = 9,
|
||||
LayoutIndex = 10,
|
||||
SceneDirectory = _scenes.Path,
|
||||
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
|
||||
TestSceneAllowlist = ["test-scene"],
|
||||
TrustedLiveOutputEnabled = false,
|
||||
QueueCapacity = 64,
|
||||
ConnectTimeoutMilliseconds = 5000,
|
||||
OperationTimeoutMilliseconds = 5000,
|
||||
DisconnectTimeoutMilliseconds = 3000,
|
||||
ProcessPollIntervalMilliseconds = 1000,
|
||||
ReconnectDelayMilliseconds = 1000,
|
||||
MaximumReconnectAttempts = 3,
|
||||
ReconnectEnabled = true
|
||||
};
|
||||
}
|
||||
169
tests/MBN_STOCK_WEBVIEW.Playout.Tests/StaDispatcherTests.cs
Normal file
169
tests/MBN_STOCK_WEBVIEW.Playout.Tests/StaDispatcherTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class StaDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RunsCallbacksOnOneStaThreadInFifoOrder()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 4);
|
||||
using var firstStarted = new ManualResetEventSlim();
|
||||
using var releaseFirst = new ManualResetEventSlim();
|
||||
var calls = new ConcurrentQueue<(int Order, int ThreadId, ApartmentState Apartment)>();
|
||||
|
||||
var first = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
calls.Enqueue((
|
||||
1,
|
||||
Environment.CurrentManagedThreadId,
|
||||
Thread.CurrentThread.GetApartmentState()));
|
||||
firstStarted.Set();
|
||||
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
var second = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
calls.Enqueue((
|
||||
2,
|
||||
Environment.CurrentManagedThreadId,
|
||||
Thread.CurrentThread.GetApartmentState()));
|
||||
return 2;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
var third = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
calls.Enqueue((
|
||||
3,
|
||||
Environment.CurrentManagedThreadId,
|
||||
Thread.CurrentThread.GetApartmentState()));
|
||||
return 3;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
|
||||
releaseFirst.Set();
|
||||
var results = await Task.WhenAll(first, second, third);
|
||||
Assert.Equal(new[] { 1, 2, 3 }, results);
|
||||
|
||||
var recorded = calls.ToArray();
|
||||
Assert.Equal([1, 2, 3], recorded.Select(call => call.Order));
|
||||
Assert.Single(recorded.Select(call => call.ThreadId).Distinct());
|
||||
Assert.All(recorded, call => Assert.Equal(ApartmentState.STA, call.Apartment));
|
||||
Assert.Equal(recorded[0].ThreadId, dispatcher.ManagedThreadId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_CancelledWhileQueued_DoesNotRunCallback()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 2);
|
||||
using var firstStarted = new ManualResetEventSlim();
|
||||
using var releaseFirst = new ManualResetEventSlim();
|
||||
var cancelledCallbackRan = false;
|
||||
|
||||
var first = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
firstStarted.Set();
|
||||
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var cancelled = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
cancelledCallbackRan = true;
|
||||
return 2;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
cancellation.Token);
|
||||
cancellation.Cancel();
|
||||
releaseFirst.Set();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
|
||||
Assert.Equal(1, await first);
|
||||
Assert.False(cancelledCallbackRan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenBoundedQueueIsFull_CanCancelWithoutRunning()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 1);
|
||||
using var firstStarted = new ManualResetEventSlim();
|
||||
using var releaseFirst = new ManualResetEventSlim();
|
||||
var thirdCallbackRan = false;
|
||||
|
||||
var first = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
firstStarted.Set();
|
||||
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
var second = dispatcher.InvokeAsync(
|
||||
() => 2,
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
|
||||
var third = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
thirdCallbackRan = true;
|
||||
return 3;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
cancellation.Token);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => third);
|
||||
Assert.False(thirdCallbackRan);
|
||||
releaseFirst.Set();
|
||||
var results = await Task.WhenAll(first, second);
|
||||
Assert.Equal(new[] { 1, 2 }, results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_TimeoutQuarantinesDispatcherAndRejectsLaterWork()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 2);
|
||||
using var callbackStarted = new ManualResetEventSlim();
|
||||
using var releaseCallback = new ManualResetEventSlim();
|
||||
|
||||
var timedOut = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
callbackStarted.Set();
|
||||
Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromMilliseconds(100),
|
||||
CancellationToken.None);
|
||||
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
await Assert.ThrowsAsync<StaOperationTimedOutException>(() => timedOut);
|
||||
Assert.True(dispatcher.IsQuarantined);
|
||||
await Assert.ThrowsAsync<StaDispatcherQuarantinedException>(
|
||||
() => dispatcher.InvokeAsync(
|
||||
() => 2,
|
||||
TimeSpan.FromSeconds(1),
|
||||
CancellationToken.None));
|
||||
|
||||
releaseCallback.Set();
|
||||
}
|
||||
}
|
||||
118
tests/MBN_STOCK_WEBVIEW.Playout.Tests/TestScopes.cs
Normal file
118
tests/MBN_STOCK_WEBVIEW.Playout.Tests/TestScopes.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
internal sealed class EnvironmentScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.Ordinal);
|
||||
|
||||
public EnvironmentScope Set(string name, string? value)
|
||||
{
|
||||
if (!_originalValues.ContainsKey(name))
|
||||
{
|
||||
_originalValues.Add(name, Environment.GetEnvironmentVariable(name));
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var pair in _originalValues)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TemporaryJsonFile : IDisposable
|
||||
{
|
||||
private TemporaryJsonFile(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public static TemporaryJsonFile Create(string json)
|
||||
{
|
||||
var directory = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW.Playout.Tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var path = System.IO.Path.Combine(directory, "playout.local.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryJsonFile(path);
|
||||
}
|
||||
|
||||
public static TemporaryJsonFile Missing()
|
||||
{
|
||||
var directory = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW.Playout.Tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
return new TemporaryJsonFile(
|
||||
System.IO.Path.Combine(directory, "missing-playout.local.json"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var directory = System.IO.Path.GetDirectoryName(Path);
|
||||
if (directory is not null && Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TemporarySceneDirectory : IDisposable
|
||||
{
|
||||
private TemporarySceneDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public static TemporarySceneDirectory Create(params string[] relativeFiles)
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW.Playout.Tests",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Scenes");
|
||||
Directory.CreateDirectory(path);
|
||||
var result = new TemporarySceneDirectory(path);
|
||||
foreach (var relativeFile in relativeFiles)
|
||||
{
|
||||
result.AddFile(relativeFile);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public string AddFile(string relativePath)
|
||||
{
|
||||
var fullPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, relativePath));
|
||||
var rootPrefix = Path.EndsWith(System.IO.Path.DirectorySeparatorChar)
|
||||
? Path
|
||||
: Path + System.IO.Path.DirectorySeparatorChar;
|
||||
if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Test file must remain under the temporary scene directory.", nameof(relativePath));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!);
|
||||
File.WriteAllText(fullPath, "fake test scene; never loaded by a real COM server");
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var parent = Directory.GetParent(Path)?.FullName;
|
||||
if (parent is not null && Directory.Exists(parent))
|
||||
{
|
||||
Directory.Delete(parent, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class TornadoPlayoutEngineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
public async Task DefaultDryRun_MonitorsProcessWithoutCreatingComSession(
|
||||
int totalProcessCount,
|
||||
bool expectedRunning)
|
||||
{
|
||||
var sessionFactory = new FakeK3dSessionFactory();
|
||||
var registration = new FakeRegistrationProbe();
|
||||
var process = new FakeTornadoProcessProbe(
|
||||
new TornadoProcessSnapshot(totalProcessCount, totalProcessCount, 0));
|
||||
await using var engine = CreateEngine(
|
||||
new PlayoutOptions(),
|
||||
sessionFactory,
|
||||
registration,
|
||||
process,
|
||||
liveAuthorized: false);
|
||||
|
||||
await engine.PollProcessOnceAsync(CancellationToken.None);
|
||||
var connect = await engine.ConnectAsync(CancellationToken.None);
|
||||
var prepare = await engine.PrepareAsync(
|
||||
new PlayoutCue("never-resolved.t2s", "dry-run-scene"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode);
|
||||
Assert.Equal(expectedRunning, engine.Status.IsProcessRunning);
|
||||
Assert.Equal(PlayoutResultCode.Success, connect.Code);
|
||||
Assert.Equal(PlayoutResultCode.Success, prepare.Code);
|
||||
Assert.Equal(0, sessionFactory.CreateCount);
|
||||
Assert.Equal(0, registration.ProbeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelledCommandBeforeSerialization_DoesNotProbeOrCreateSession()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var sessionFactory = new FakeK3dSessionFactory();
|
||||
var registration = new FakeRegistrationProbe();
|
||||
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
sessionFactory,
|
||||
registration,
|
||||
process,
|
||||
liveAuthorized: false);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
var result = await engine.ConnectAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
|
||||
Assert.Equal(0, sessionFactory.CreateCount);
|
||||
Assert.Equal(0, registration.ProbeCount);
|
||||
Assert.Equal(0, process.CaptureCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0, false, "찾을 수 없습니다")]
|
||||
[InlineData(2, 1, 0, false, "여러 개")]
|
||||
[InlineData(1, 0, 1, false, "PROGRAM")]
|
||||
[InlineData(1, 1, 0, true, null)]
|
||||
public async Task TestConnect_RequiresExactlyOneEligibleNonProgramInstance(
|
||||
int totalProcessCount,
|
||||
int eligibleProcessCount,
|
||||
int programProcessCount,
|
||||
bool expectedSuccess,
|
||||
string? expectedFailureText)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var sessionFactory = new FakeK3dSessionFactory();
|
||||
var registration = new FakeRegistrationProbe();
|
||||
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(
|
||||
totalProcessCount,
|
||||
eligibleProcessCount,
|
||||
programProcessCount));
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
sessionFactory,
|
||||
registration,
|
||||
process,
|
||||
liveAuthorized: false);
|
||||
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(expectedSuccess, result.IsSuccess);
|
||||
Assert.Equal(expectedSuccess ? 1 : 0, sessionFactory.CreateCount);
|
||||
Assert.Equal(expectedSuccess ? 1 : 0, registration.ProbeCount);
|
||||
Assert.NotNull(process.LastPattern);
|
||||
if (expectedSuccess)
|
||||
{
|
||||
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
|
||||
Assert.True(engine.Status.LiveTakeInAllowed);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(PlayoutResultCode.Unavailable, result.Code);
|
||||
Assert.Contains(expectedFailureText!, result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(engine.Status.LiveTakeInAllowed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestWorkflow_ExecutesFakeSessionInCommandOrderOnSta()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("first.t2s", "next.t2s");
|
||||
var session = new FakeK3dSession();
|
||||
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
||||
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "first", "next"),
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
process,
|
||||
liveAuthorized: false);
|
||||
|
||||
var connect = await engine.ConnectAsync(CancellationToken.None);
|
||||
Assert.True(engine.Status.LiveTakeInAllowed);
|
||||
var prepare = await engine.PrepareAsync(Cue("first"), CancellationToken.None);
|
||||
var takeIn = await engine.TakeInAsync(CancellationToken.None);
|
||||
var next = await engine.NextAsync(Cue("next"), CancellationToken.None);
|
||||
var takeOut = await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
CancellationToken.None);
|
||||
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.All(
|
||||
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
|
||||
result => Assert.Equal(PlayoutResultCode.Success, result.Code));
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"Connect",
|
||||
"Prepare:first",
|
||||
"Play",
|
||||
"Prepare:next",
|
||||
"Play",
|
||||
"TakeOut:All",
|
||||
"Disconnect",
|
||||
"Dispose"
|
||||
},
|
||||
session.Calls);
|
||||
Assert.Single(session.ThreadIds.Distinct());
|
||||
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
||||
Assert.Equal(PlayoutConnectionState.Disconnected, engine.Status.State);
|
||||
Assert.False(engine.Status.LiveTakeInAllowed);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSceneOutsideAllowlist_IsRejectedBeforeSessionCall()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("allowed.t2s", "blocked.t2s");
|
||||
var session = new FakeK3dSession();
|
||||
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "allowed"),
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false);
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
|
||||
var prepare = await engine.PrepareAsync(Cue("blocked"), CancellationToken.None);
|
||||
var next = await engine.NextAsync(Cue("blocked"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Rejected, prepare.Code);
|
||||
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
|
||||
Assert.DoesNotContain(session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("blocked.t2s", prepare.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(scenes.Path, prepare.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
public async Task LiveWithoutBothAuthorizations_RejectsCommandsBeforeCom(
|
||||
bool trustedLiveOutputEnabled,
|
||||
bool launchAuthorized)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
|
||||
var sessionFactory = new FakeK3dSessionFactory();
|
||||
var registration = new FakeRegistrationProbe();
|
||||
await using var engine = CreateEngine(
|
||||
LiveOptions(scenes.Path, trustedLiveOutputEnabled),
|
||||
sessionFactory,
|
||||
registration,
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
launchAuthorized);
|
||||
|
||||
var results = new[]
|
||||
{
|
||||
await engine.ConnectAsync(CancellationToken.None),
|
||||
await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None),
|
||||
await engine.TakeInAsync(CancellationToken.None),
|
||||
await engine.NextAsync(Cue("live-scene"), CancellationToken.None),
|
||||
await engine.TakeOutAsync(PlayoutTakeOutScope.All, CancellationToken.None)
|
||||
};
|
||||
|
||||
Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code));
|
||||
Assert.Equal(0, sessionFactory.CreateCount);
|
||||
Assert.Equal(0, registration.ProbeCount);
|
||||
Assert.False(engine.Status.LiveTakeInAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveWithBothAuthorizations_CanUseFakeSessionOnly()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
|
||||
var session = new FakeK3dSession();
|
||||
await using var engine = CreateEngine(
|
||||
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: true);
|
||||
|
||||
var connect = await engine.ConnectAsync(CancellationToken.None);
|
||||
var prepare = await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None);
|
||||
var takeIn = await engine.TakeInAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(connect.IsSuccess);
|
||||
Assert.True(prepare.IsSuccess);
|
||||
Assert.True(takeIn.IsSuccess);
|
||||
Assert.True(engine.Status.LiveTakeInAllowed);
|
||||
Assert.Contains("Play", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimedOutTakeIn_QuarantinesEngineAndNeverReconnectsOrReplays()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var playStarted = new ManualResetEventSlim();
|
||||
using var releasePlay = new ManualResetEventSlim();
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
PlayAction = _ =>
|
||||
{
|
||||
playStarted.Set();
|
||||
Assert.True(releasePlay.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
};
|
||||
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
||||
var options = TestOptions(scenes.Path, "test-scene");
|
||||
options.OperationTimeoutMilliseconds = 100;
|
||||
options.ReconnectDelayMilliseconds = 0;
|
||||
await using var engine = CreateEngine(
|
||||
options,
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess);
|
||||
try
|
||||
{
|
||||
var takeInTask = engine.TakeInAsync(CancellationToken.None);
|
||||
Assert.True(playStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
var takeIn = await takeInTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code);
|
||||
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
||||
Assert.False(engine.Status.IsCommandAvailable);
|
||||
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
||||
|
||||
releasePlay.Set();
|
||||
await engine.PollProcessOnceAsync(CancellationToken.None);
|
||||
var takeOut = await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code);
|
||||
Assert.Equal(1, sessionFactory.CreateCount);
|
||||
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
||||
Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
releasePlay.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutcomeUnknown_PreservesLatchAndNeverReconnectsOrReplays()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var secretPath = System.IO.Path.Combine(scenes.Path, "test-scene.t2s");
|
||||
var first = new FakeK3dSession
|
||||
{
|
||||
PlayAction = _ => throw new COMException(
|
||||
$"vendor HRESULT 0x80004005 at {secretPath}",
|
||||
unchecked((int)0x80004005))
|
||||
};
|
||||
var second = new FakeK3dSession();
|
||||
var created = 0;
|
||||
var sessionFactory = new FakeK3dSessionFactory(
|
||||
() => Interlocked.Increment(ref created) == 1 ? first : second);
|
||||
var options = TestOptions(scenes.Path, "test-scene");
|
||||
options.ReconnectDelayMilliseconds = 0;
|
||||
await using var engine = CreateEngine(
|
||||
options,
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess);
|
||||
var takeIn = await engine.TakeInAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code);
|
||||
Assert.DoesNotContain("0x80004005", takeIn.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(secretPath, takeIn.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("0x80004005", engine.Status.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(secretPath, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(1, first.Calls.Count(call => call == "Play"));
|
||||
|
||||
await engine.PollProcessOnceAsync(CancellationToken.None);
|
||||
var laterCommand = await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, sessionFactory.CreateCount);
|
||||
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterCommand.Code);
|
||||
Assert.False(engine.Status.IsCommandAvailable);
|
||||
Assert.DoesNotContain("Play", second.Calls);
|
||||
Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedPrepare_ReconnectsWithoutReplayingTheFailedCommand()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var first = new FakeK3dSession
|
||||
{
|
||||
PrepareAction = (_, _) => throw new InvalidOperationException("fake prepare failure")
|
||||
};
|
||||
var second = new FakeK3dSession();
|
||||
var created = 0;
|
||||
var sessionFactory = new FakeK3dSessionFactory(
|
||||
() => Interlocked.Increment(ref created) == 1 ? first : second);
|
||||
var options = TestOptions(scenes.Path, "test-scene");
|
||||
options.ReconnectDelayMilliseconds = 0;
|
||||
await using var engine = CreateEngine(
|
||||
options,
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var prepare = await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Failed, prepare.Code);
|
||||
Assert.Equal(1, first.Calls.Count(call => call == "Prepare:test-scene"));
|
||||
Assert.Equal(PlayoutConnectionState.Reconnecting, engine.Status.State);
|
||||
|
||||
await engine.PollProcessOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, sessionFactory.CreateCount);
|
||||
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
|
||||
Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("Play", second.Calls);
|
||||
}
|
||||
|
||||
private static TornadoPlayoutEngine CreateEngine(
|
||||
PlayoutOptions rawOptions,
|
||||
FakeK3dSessionFactory sessionFactory,
|
||||
FakeRegistrationProbe registrationProbe,
|
||||
FakeTornadoProcessProbe processProbe,
|
||||
bool liveAuthorized)
|
||||
{
|
||||
var options = ValidatedPlayoutOptions.Create(rawOptions);
|
||||
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
|
||||
? new StaDispatcher(options.QueueCapacity)
|
||||
: null;
|
||||
return new TornadoPlayoutEngine(
|
||||
options,
|
||||
sessionFactory,
|
||||
registrationProbe,
|
||||
processProbe,
|
||||
dispatcher,
|
||||
new FakeLiveAuthorization(liveAuthorized),
|
||||
TimeProvider.System,
|
||||
startMonitor: false);
|
||||
}
|
||||
|
||||
private static PlayoutOptions TestOptions(
|
||||
string sceneDirectory,
|
||||
params string[] allowedScenes) => new()
|
||||
{
|
||||
Mode = PlayoutMode.Test,
|
||||
SceneDirectory = sceneDirectory,
|
||||
OutputChannel = 9,
|
||||
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
|
||||
TestSceneAllowlist = [.. allowedScenes],
|
||||
ConnectTimeoutMilliseconds = 500,
|
||||
OperationTimeoutMilliseconds = 500,
|
||||
DisconnectTimeoutMilliseconds = 500,
|
||||
ProcessPollIntervalMilliseconds = 100,
|
||||
ReconnectDelayMilliseconds = 0,
|
||||
MaximumReconnectAttempts = 3
|
||||
};
|
||||
|
||||
private static PlayoutOptions LiveOptions(
|
||||
string sceneDirectory,
|
||||
bool trustedLiveOutputEnabled) => new()
|
||||
{
|
||||
Mode = PlayoutMode.Live,
|
||||
SceneDirectory = sceneDirectory,
|
||||
TrustedLiveOutputEnabled = trustedLiveOutputEnabled,
|
||||
ConnectTimeoutMilliseconds = 500,
|
||||
OperationTimeoutMilliseconds = 500,
|
||||
DisconnectTimeoutMilliseconds = 500,
|
||||
ProcessPollIntervalMilliseconds = 100,
|
||||
ReconnectDelayMilliseconds = 0,
|
||||
MaximumReconnectAttempts = 3
|
||||
};
|
||||
|
||||
private static PlayoutCue Cue(string sceneName) => new(
|
||||
$"{sceneName}.t2s",
|
||||
sceneName,
|
||||
[new PlayoutField("headline", "fake", true)]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Platforms>x64</Platforms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
124
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs
Normal file
124
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
|
||||
var command = args.Length == 0 ? "--probe" : args[0].ToLowerInvariant();
|
||||
|
||||
return command switch
|
||||
{
|
||||
"--probe" => Probe(),
|
||||
"--dry-run" => await DryRunAsync(),
|
||||
_ => Usage()
|
||||
};
|
||||
|
||||
static int Probe()
|
||||
{
|
||||
var runtimeRegistration = new K3dRegistrationProbe().Probe();
|
||||
var processInspectionAvailable = true;
|
||||
var tornadoProcessCount = 0;
|
||||
var programWindowDetected = false;
|
||||
Process[] processes;
|
||||
try
|
||||
{
|
||||
processes = Process.GetProcesses();
|
||||
}
|
||||
catch
|
||||
{
|
||||
processes = [];
|
||||
processInspectionAvailable = false;
|
||||
}
|
||||
|
||||
foreach (var process in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.ProcessName.StartsWith("Tornado2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tornadoProcessCount++;
|
||||
var title = process.MainWindowTitle;
|
||||
if (title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
programWindowDetected = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A process may exit or deny inspection between enumeration and reading.
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
architecture = Environment.Is64BitProcess ? "x64" : "x86",
|
||||
runtimeRegistration = new
|
||||
{
|
||||
ready = runtimeRegistration.IsReady,
|
||||
issues = runtimeRegistration.Issues.ToString(),
|
||||
engineClassReady = runtimeRegistration.IsEngineClassReady,
|
||||
eventHandlerClassReady = runtimeRegistration.IsEventHandlerClassReady,
|
||||
message = runtimeRegistration.Message
|
||||
},
|
||||
tornado = new
|
||||
{
|
||||
inspectionAvailable = processInspectionAvailable,
|
||||
processCount = tornadoProcessCount,
|
||||
programWindowDetected
|
||||
},
|
||||
safety = programWindowDetected
|
||||
? "PROGRAM window detected; no COM activation was attempted."
|
||||
: "Read-only probe complete; no COM activation was attempted."
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
return runtimeRegistration.IsReady && Environment.Is64BitProcess
|
||||
? 0
|
||||
: 2;
|
||||
}
|
||||
|
||||
static async Task<int> DryRunAsync()
|
||||
{
|
||||
var options = new PlayoutOptions { Mode = PlayoutMode.DryRun };
|
||||
await using var engine = PlayoutEngineFactory.Create(options);
|
||||
var cue = new PlayoutCue("DRY_RUN_ONLY.t2s", "DRY_RUN_ONLY");
|
||||
|
||||
var results = new[]
|
||||
{
|
||||
await engine.ConnectAsync(),
|
||||
await engine.PrepareAsync(cue),
|
||||
await engine.TakeInAsync(),
|
||||
await engine.NextAsync(cue with { SceneName = "DRY_RUN_NEXT" }),
|
||||
await engine.TakeOutAsync(PlayoutTakeOutScope.All),
|
||||
await engine.DisconnectAsync()
|
||||
};
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
mode = engine.Status.Mode.ToString(),
|
||||
state = engine.Status.State.ToString(),
|
||||
commands = results.Select(result => new
|
||||
{
|
||||
operation = result.Operation.ToString(),
|
||||
code = result.Code.ToString(),
|
||||
message = result.Message
|
||||
})
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
return results.All(result => result.IsSuccess) ? 0 : 3;
|
||||
}
|
||||
|
||||
static int Usage()
|
||||
{
|
||||
Console.Error.WriteLine("Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run]");
|
||||
Console.Error.WriteLine("This tool never enables Test or Live mode and never activates K3D COM in dry-run.");
|
||||
return 64;
|
||||
}
|
||||
Reference in New Issue
Block a user