diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs b/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs index aeaa365..2a73d49 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.ComponentModel; using System.Globalization; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -66,9 +67,34 @@ internal interface ITornadoProcessProbe TornadoProcessSnapshot Capture(Regex? testWindowPattern); } +internal sealed record TornadoWindowTitleSnapshot( + IReadOnlyList Titles, + bool InspectionSucceeded); + +internal readonly record struct TornadoWindowClassification( + bool IsProgram, + bool IsEligible); + +internal interface ITornadoWindowTitleProbe +{ + TornadoWindowTitleSnapshot Capture(int processId); +} + internal sealed class TornadoProcessProbe : ITornadoProcessProbe { internal const string ProcessNamePrefix = "Tornado2"; + private readonly ITornadoWindowTitleProbe _windowTitleProbe; + + internal TornadoProcessProbe() + : this(new Win32TornadoWindowTitleProbe()) + { + } + + internal TornadoProcessProbe(ITornadoWindowTitleProbe windowTitleProbe) + { + ArgumentNullException.ThrowIfNull(windowTitleProbe); + _windowTitleProbe = windowTitleProbe; + } public TornadoProcessSnapshot Capture(Regex? testWindowPattern) { @@ -119,10 +145,10 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe } totalCount++; - string title; + TornadoWindowTitleSnapshot windowSnapshot; try { - title = process.MainWindowTitle; + windowSnapshot = _windowTitleProbe.Capture(process.Id); } catch (Exception exception) when (IsProcessInspectionException(exception)) { @@ -130,32 +156,26 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe continue; } - var isProgram = IsProgramTitle(title); - if (isProgram) + if (!TryClassifyWindowSnapshot( + windowSnapshot, + testWindowPattern, + out var classification)) + { + identityInspectionSucceeded = false; + continue; + } + + if (classification.IsProgram) { programCount++; } - var isEligible = testWindowPattern is null; - if (testWindowPattern is not null && !isProgram) - { - try - { - isEligible = HasExplicitTestMarker(title) && - testWindowPattern.IsMatch(title); - } - catch (RegexMatchTimeoutException) - { - isEligible = false; - } - } - - if (isEligible) + if (classification.IsEligible) { eligibleCount++; } - if (!isEligible && !isProgram) + if (!classification.IsEligible && !classification.IsProgram) { continue; } @@ -166,12 +186,12 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe continue; } - if (isEligible) + if (classification.IsEligible) { eligibleIdentities.Add(identity); } - if (isProgram) + if (classification.IsProgram) { programIdentities.Add(identity); } @@ -197,6 +217,53 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe internal static bool IsTornadoProcessName(string? processName) => processName?.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) == true; + internal static bool TryClassifyWindowSnapshot( + TornadoWindowTitleSnapshot snapshot, + Regex? testWindowPattern, + out TornadoWindowClassification classification) + { + if (!snapshot.InspectionSucceeded || + !snapshot.Titles.Any(title => !string.IsNullOrWhiteSpace(title))) + { + classification = default; + return false; + } + + classification = ClassifyWindowTitles(snapshot.Titles, testWindowPattern); + return true; + } + + internal static TornadoWindowClassification ClassifyWindowTitles( + IEnumerable titles, + Regex? testWindowPattern) + { + ArgumentNullException.ThrowIfNull(titles); + + var meaningfulTitles = titles + .Where(title => !string.IsNullOrWhiteSpace(title)) + .Select(title => title!) + .ToArray(); + var isProgram = meaningfulTitles.Length == 0 || + meaningfulTitles.Any(IsProgramTitle); + var isEligible = testWindowPattern is null; + + if (testWindowPattern is not null && !isProgram) + { + try + { + isEligible = meaningfulTitles.Any(title => + HasExplicitTestMarker(title) && + testWindowPattern.IsMatch(title)); + } + catch (RegexMatchTimeoutException) + { + isEligible = false; + } + } + + return new TornadoWindowClassification(isProgram, isEligible); + } + internal static bool IsProgramTitle(string? title) => string.IsNullOrWhiteSpace(title) || title.Equals(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) || @@ -258,3 +325,117 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe private static bool IsExitedProcessInspectionException(Exception exception) => exception is InvalidOperationException or ArgumentException; } + +internal sealed class Win32TornadoWindowTitleProbe : ITornadoWindowTitleProbe +{ + private const int ErrorInvalidWindowHandle = 1400; + + public TornadoWindowTitleSnapshot Capture(int processId) + { + if (processId <= 0) + { + return FailedSnapshot(); + } + + var titles = new List(); + var inspectionSucceeded = true; + EnumWindowsCallback callback = (windowHandle, _) => + { + try + { + Marshal.SetLastPInvokeError(0); + var threadId = GetWindowThreadProcessId(windowHandle, out var windowProcessId); + if (threadId == 0) + { + if (Marshal.GetLastPInvokeError() == ErrorInvalidWindowHandle) + { + // A top-level window can disappear while EnumWindows is walking the list. + return true; + } + + inspectionSucceeded = false; + return false; + } + + if (windowProcessId != unchecked((uint)processId)) + { + return true; + } + + Marshal.SetLastPInvokeError(0); + var titleLength = GetWindowTextLength(windowHandle); + if (titleLength == 0) + { + if (Marshal.GetLastPInvokeError() != 0) + { + inspectionSucceeded = false; + return false; + } + + return true; + } + + var titleBuilder = new StringBuilder(titleLength + 1); + if (GetWindowText(windowHandle, titleBuilder, titleBuilder.Capacity) == 0) + { + inspectionSucceeded = false; + return false; + } + + var title = titleBuilder.ToString(); + if (!string.IsNullOrWhiteSpace(title)) + { + titles.Add(title); + } + + return true; + } + catch (Exception) + { + inspectionSucceeded = false; + return false; + } + }; + + try + { + if (!EnumWindows(callback, nint.Zero) || !inspectionSucceeded) + { + return FailedSnapshot(); + } + } + catch (Exception) + { + return FailedSnapshot(); + } + + return new TornadoWindowTitleSnapshot(titles, InspectionSucceeded: true); + } + + private static TornadoWindowTitleSnapshot FailedSnapshot() => + new(Array.Empty(), InspectionSucceeded: false); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + [return: MarshalAs(UnmanagedType.Bool)] + private delegate bool EnumWindowsCallback(nint windowHandle, nint parameter); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows( + EnumWindowsCallback callback, + nint parameter); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId( + nint windowHandle, + out uint processId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int GetWindowTextLength(nint windowHandle); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int GetWindowText( + nint windowHandle, + StringBuilder title, + int maximumCharacterCount); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs index e7960cd..0a5be9e 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs @@ -1,5 +1,6 @@ using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Runtime; +using System.Text.RegularExpressions; namespace MBN_STOCK_WEBVIEW.Playout.Tests; @@ -53,6 +54,136 @@ public sealed class PlayoutSafetyValidationTests : IDisposable Assert.Equal(expected, TornadoProcessProbe.HasExplicitTestMarker(title)); } + [Fact] + public void ClassifyWindowTitles_EditorAndPgmRemainTheSameProgramGeneration_WhenFocusOrderChanges() + { + string[] editorFirst = ["Tornado2 - [5001.t2s]", "PGM"]; + string[] pgmFirst = ["PGM", "Tornado2 - [5001.t2s]"]; + + var first = TornadoProcessProbe.ClassifyWindowTitles(editorFirst, testWindowPattern: null); + var second = TornadoProcessProbe.ClassifyWindowTitles(pgmFirst, testWindowPattern: null); + + Assert.True(first.IsProgram); + Assert.True(first.IsEligible); + Assert.Equal(first, second); + + var identity = new TornadoProcessIdentity(42, 123456789); + var firstGeneration = TornadoProcessGeneration.FromIdentities( + "program", + first.IsProgram ? [identity] : []); + var secondGeneration = TornadoProcessGeneration.FromIdentities( + "program", + second.IsProgram ? [identity] : []); + + Assert.Equal(firstGeneration, secondGeneration); + } + + [Fact] + public void ClassifyWindowTitles_TestModeAcceptsExplicitTestTitleFromAnyTopLevelWindow() + { + var pattern = new Regex( + "^Tornado2 TEST$", + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + var classification = TornadoProcessProbe.ClassifyWindowTitles( + ["Tornado2 - [test-editor]", "Tornado2 TEST"], + pattern); + + Assert.False(classification.IsProgram); + Assert.True(classification.IsEligible); + } + + [Fact] + public void ClassifyWindowTitles_TestModeRejectsProcessThatAlsoOwnsPgmWindow() + { + var pattern = new Regex( + "^Tornado2 TEST$", + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + var classification = TornadoProcessProbe.ClassifyWindowTitles( + ["Tornado2 TEST", "PGM"], + pattern); + + Assert.True(classification.IsProgram); + Assert.False(classification.IsEligible); + } + + [Fact] + public void ClassifyWindowTitles_NoMeaningfulTitlesFailsClosedAsProgram() + { + var pattern = new Regex( + "^Tornado2 TEST$", + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + var classification = TornadoProcessProbe.ClassifyWindowTitles( + [null, "", " "], + pattern); + + Assert.True(classification.IsProgram); + Assert.False(classification.IsEligible); + } + + [Fact] + public void TryClassifyWindowSnapshot_InspectionFailureFailsClosed() + { + var snapshot = new TornadoWindowTitleSnapshot( + ["PGM"], + InspectionSucceeded: false); + + var succeeded = TornadoProcessProbe.TryClassifyWindowSnapshot( + snapshot, + testWindowPattern: null, + out var classification); + + Assert.False(succeeded); + Assert.Equal(default, classification); + } + + [Fact] + public void TryClassifyWindowSnapshot_NoMeaningfulTitlesFailsIdentityInspection() + { + var snapshot = new TornadoWindowTitleSnapshot( + ["", " "], + InspectionSucceeded: true); + + var succeeded = TornadoProcessProbe.TryClassifyWindowSnapshot( + snapshot, + testWindowPattern: null, + out var classification); + + Assert.False(succeeded); + Assert.Equal(default, classification); + } + + [Fact] + public void TryClassifyWindowSnapshot_PgmTitleSucceedsForLiveIdentityInspection() + { + var snapshot = new TornadoWindowTitleSnapshot( + ["Tornado2 - [5001.t2s]", "PGM"], + InspectionSucceeded: true); + + var succeeded = TornadoProcessProbe.TryClassifyWindowSnapshot( + snapshot, + testWindowPattern: null, + out var classification); + + Assert.True(succeeded); + Assert.True(classification.IsProgram); + Assert.True(classification.IsEligible); + } + + [Fact] + public void WindowTitleProbe_InvalidProcessIdFailsClosedWithoutNativeInspection() + { + var snapshot = new Win32TornadoWindowTitleProbe().Capture(0); + + Assert.False(snapshot.InspectionSucceeded); + Assert.Empty(snapshot.Titles); + } + [Theory] [InlineData(0, false)] [InlineData(1, true)]