feat: add safe Tornado K3D playout adapter
This commit is contained in:
@@ -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
Reference in New Issue
Block a user