fix: track Tornado program windows across focus changes

This commit is contained in:
2026-07-11 13:15:26 +09:00
parent 8864440ac7
commit c71ea425e8
2 changed files with 334 additions and 22 deletions

View File

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

View File

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