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