fix: track Tornado program windows across focus changes
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
@@ -66,9 +67,34 @@ internal interface ITornadoProcessProbe
|
|||||||
TornadoProcessSnapshot Capture(Regex? testWindowPattern);
|
TornadoProcessSnapshot Capture(Regex? testWindowPattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record TornadoWindowTitleSnapshot(
|
||||||
|
IReadOnlyList<string> 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 sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||||
{
|
{
|
||||||
internal const string ProcessNamePrefix = "Tornado2";
|
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)
|
public TornadoProcessSnapshot Capture(Regex? testWindowPattern)
|
||||||
{
|
{
|
||||||
@@ -119,10 +145,10 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalCount++;
|
totalCount++;
|
||||||
string title;
|
TornadoWindowTitleSnapshot windowSnapshot;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
title = process.MainWindowTitle;
|
windowSnapshot = _windowTitleProbe.Capture(process.Id);
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||||
{
|
{
|
||||||
@@ -130,32 +156,26 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isProgram = IsProgramTitle(title);
|
if (!TryClassifyWindowSnapshot(
|
||||||
if (isProgram)
|
windowSnapshot,
|
||||||
|
testWindowPattern,
|
||||||
|
out var classification))
|
||||||
|
{
|
||||||
|
identityInspectionSucceeded = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification.IsProgram)
|
||||||
{
|
{
|
||||||
programCount++;
|
programCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEligible = testWindowPattern is null;
|
if (classification.IsEligible)
|
||||||
if (testWindowPattern is not null && !isProgram)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
isEligible = HasExplicitTestMarker(title) &&
|
|
||||||
testWindowPattern.IsMatch(title);
|
|
||||||
}
|
|
||||||
catch (RegexMatchTimeoutException)
|
|
||||||
{
|
|
||||||
isEligible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEligible)
|
|
||||||
{
|
{
|
||||||
eligibleCount++;
|
eligibleCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEligible && !isProgram)
|
if (!classification.IsEligible && !classification.IsProgram)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -166,12 +186,12 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEligible)
|
if (classification.IsEligible)
|
||||||
{
|
{
|
||||||
eligibleIdentities.Add(identity);
|
eligibleIdentities.Add(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isProgram)
|
if (classification.IsProgram)
|
||||||
{
|
{
|
||||||
programIdentities.Add(identity);
|
programIdentities.Add(identity);
|
||||||
}
|
}
|
||||||
@@ -197,6 +217,53 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
|||||||
internal static bool IsTornadoProcessName(string? processName) =>
|
internal static bool IsTornadoProcessName(string? processName) =>
|
||||||
processName?.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) == true;
|
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<string?> 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) =>
|
internal static bool IsProgramTitle(string? title) =>
|
||||||
string.IsNullOrWhiteSpace(title) ||
|
string.IsNullOrWhiteSpace(title) ||
|
||||||
title.Equals(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) ||
|
title.Equals(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||||
@@ -258,3 +325,117 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
|||||||
private static bool IsExitedProcessInspectionException(Exception exception) =>
|
private static bool IsExitedProcessInspectionException(Exception exception) =>
|
||||||
exception is InvalidOperationException or ArgumentException;
|
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<string>();
|
||||||
|
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<string>(), 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||||
|
|
||||||
@@ -53,6 +54,136 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
|
|||||||
Assert.Equal(expected, TornadoProcessProbe.HasExplicitTestMarker(title));
|
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]
|
[Theory]
|
||||||
[InlineData(0, false)]
|
[InlineData(0, false)]
|
||||||
[InlineData(1, true)]
|
[InlineData(1, true)]
|
||||||
|
|||||||
Reference in New Issue
Block a user