fix: track Tornado program windows across focus changes
This commit is contained in:
@@ -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<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 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<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) =>
|
||||
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<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.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)]
|
||||
|
||||
Reference in New Issue
Block a user