feat: add safe Tornado K3D playout adapter

This commit is contained in:
2026-07-10 06:41:48 +09:00
parent 39c4504b87
commit 5a8c7028dc
43 changed files with 7341 additions and 92 deletions

View 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);
}

View File

@@ -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;
}

View File

@@ -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)
{
}
}

View File

@@ -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)
{
}
}

View 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);
}
}
}

View 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;
}

View File

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

View 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);
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]

View File

@@ -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);
}
}
}

View 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.")
{
}
}

View 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;
}

View 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; }
}

File diff suppressed because it is too large Load Diff