diff --git a/scripts/windows/Invoke-M6Validation.ps1 b/scripts/windows/Invoke-M6Validation.ps1
index 350bca3..58acde3 100644
--- a/scripts/windows/Invoke-M6Validation.ps1
+++ b/scripts/windows/Invoke-M6Validation.ps1
@@ -1,4 +1,4 @@
-param(
+param(
[Parameter(Mandatory=$true)][string]$Executable,
[Parameter(Mandatory=$true)][string]$OutputDirectory
)
@@ -16,6 +16,8 @@ $cases = @(
@{ Name='smoke'; Args=@('smoke','--output',"$directory\smoke-ui-tree.json",'--timeout','30') },
@{ Name='recovery'; Args=@('recovery-smoke','--timeout','60') },
@{ Name='listener'; Args=@('listener-smoke','--output',"$directory\listener",'--timeout','60') },
+ @{ Name='listener-independent'; Args=@('listener-smoke','--independent','--output',"$directory\listener-independent",'--timeout','60') },
+ @{ Name='independent-ui'; Args=@('inspect-ui','--window-title','文件传输助手','--output',"$directory\independent-ui-tree.json",'--timeout','30') },
@{ Name='stability'; Args=@('stability-smoke','--seconds','30','--sample-seconds','5','--output',"$directory\stability",'--timeout','90') },
@{ Name='diagnose'; Args=@('diagnose','--baseline',"$directory\ui-tree.json",'--output',"$directory\diagnostics",'--timeout','60') }
)
diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs
index fef780e..4c6002c 100644
--- a/src/WxAgent.Host/Program.cs
+++ b/src/WxAgent.Host/Program.cs
@@ -65,7 +65,7 @@ try
case "inspect-ui":
{
var output = GetRequiredOption(args, "--output");
- var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken);
+ var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken, GetOption(args, "--window-title"));
WriteJson(new { output = Path.GetFullPath(output), nodes = CountNodes(snapshot), sanitized = true });
return 0;
}
@@ -101,7 +101,7 @@ try
}
case "listener-smoke":
{
- var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken);
+ var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken, HasOption(args, "--independent"));
WriteJson(result);
return result.Sent && result.MatchingEvents == 1 && result.EventsAfterRestart == 0
&& result.Snapshots >= 2 && result.CheckpointSaved ? 0 : 2;
@@ -322,7 +322,9 @@ try
await foreach (var messageEvent in WechatChatClient.ListenEventsAsync(
TimeSpan.FromSeconds(seconds),
stateFile,
- cancellationToken))
+ cancellationToken,
+ session: GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant,
+ independentWindow: HasOption(args, "--independent")))
{
Console.WriteLine(JsonSerializer.Serialize(ToEventOutput(messageEvent, includeContent), jsonLineOptions));
}
@@ -473,7 +475,7 @@ static int ValidateCommandLine(string[] values)
if (values[0] == "listener-smoke")
{
- ValidateOptions(values, 1, ["--output", "--timeout"], []);
+ ValidateOptions(values, 1, ["--output", "--timeout"], ["--independent"]);
return 60;
}
@@ -485,7 +487,7 @@ static int ValidateCommandLine(string[] values)
if (values[0] == "inspect-ui")
{
- ValidateOptions(values, 1, ["--output", "--timeout"], []);
+ ValidateOptions(values, 1, ["--output", "--window-title", "--timeout"], []);
return 30;
}
@@ -564,7 +566,7 @@ static int ValidateCommandLine(string[] values)
ValidateOptions(values, 2, ["--limit", "--scrolls", "--timeout"], ["--include-content"]);
return 60;
case "monitor":
- ValidateOptions(values, 2, ["--seconds", "--state-file", "--timeout"], ["--include-content"]);
+ ValidateOptions(values, 2, ["--seconds", "--state-file", "--session", "--timeout"], ["--include-content", "--independent"]);
return GetPositiveIntOption(values, "--seconds", 60, 86400) + 30;
case "listen":
ValidateOptions(values, 2, ["--seconds", "--timeout"], ["--include-content"]);
@@ -716,13 +718,13 @@ static void PrintHelp() => Console.WriteLine("""
WxAgent.Host commands:
doctor [--timeout 30]
diagnose --output
[--baseline ] [--timeout 60]
- inspect-ui --output [--timeout 30]
+ inspect-ui --output [--window-title ] [--timeout 30]
smoke [--output ] [--timeout 30]
m4-smoke [--timeout 60]
m5-smoke [--timeout 60]
tray-status [--timeout 30]
window-status [--timeout 30]
- listener-smoke --output [--timeout 60]
+ listener-smoke --output [--independent] [--timeout 60]
recovery-smoke [--timeout 30]
recover-ui [--timeout 30]
stability-smoke [--seconds 15] [--sample-seconds 5] [--output ]
@@ -737,7 +739,7 @@ WxAgent.Host commands:
chat send-image --path [--timeout 60]
chat read [--limit 20] [--include-content] [--timeout 30]
chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60]
- chat monitor [--seconds 60] [--state-file ] [--include-content] [--timeout ]
+ chat monitor [--seconds 60] [--session ] [--independent] [--state-file ] [--include-content] [--timeout ]
chat listen [--seconds 30] [--include-content] [--timeout ]
session list [--timeout 30]
session search --query [--exact] [--timeout 30]
diff --git a/src/WxAgent.Windows/WechatChatClient.Listening.cs b/src/WxAgent.Windows/WechatChatClient.Listening.cs
new file mode 100644
index 0000000..d448956
--- /dev/null
+++ b/src/WxAgent.Windows/WechatChatClient.Listening.cs
@@ -0,0 +1,46 @@
+using FlaUI.Core.AutomationElements;
+using FlaUI.Core.Definitions;
+using FlaUI.UIA3;
+using WxAgent.Core;
+
+namespace WxAgent.Windows;
+
+public static partial class WechatChatClient
+{
+ private static bool IsListeningSession(AutomationElement window, string session, bool independent) =>
+ string.Equals(independent ? SafeName(window) : SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)),
+ session, StringComparison.Ordinal);
+
+ // Called while holding CommandQueue. A dedicated chat window avoids changing the user's main chat on every poll.
+ private static async Task BindListeningWindowAsync(
+ UIA3Automation automation, string session, bool independent, CancellationToken cancellationToken)
+ {
+ if (!independent)
+ {
+ var main = AttachWindow(automation, cancellationToken);
+ await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false);
+ return main;
+ }
+
+ var window = WechatDoctor.FindNamedWechatWindow(automation, session, cancellationToken);
+ if (window is null)
+ {
+ var main = AttachWindow(automation, cancellationToken);
+ await OpenSessionInSubWindowCoreAsync(main, session, cancellationToken).ConfigureAwait(false);
+ }
+ else if (window.IsOffscreen)
+ {
+ window.Patterns.Window.Pattern.SetWindowVisualState(WindowVisualState.Normal);
+ }
+
+ for (var attempt = 0; attempt < 25; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ window = WechatDoctor.FindNamedWechatWindow(automation, session, cancellationToken);
+ if (window is not null && FindByAutomationId(window, WechatLocators.MessageList) is not null)
+ return window;
+ await Task.Delay(100, cancellationToken).ConfigureAwait(false);
+ }
+ throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Dedicated chat window did not expose its message list.");
+ }
+}
diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs
index 2c6d8bb..8d74c64 100644
--- a/src/WxAgent.Windows/WechatChatClient.Management.cs
+++ b/src/WxAgent.Windows/WechatChatClient.Management.cs
@@ -72,13 +72,18 @@ public static partial class WechatChatClient
public static Task OpenSessionInSubWindowAsync(string session, CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, _) =>
{
- await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false);
- var item = FindByAutomationId(main, $"session_item_{session}")
- ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session was not found.");
- item.DoubleClick();
+ await OpenSessionInSubWindowCoreAsync(main, session, cancellationToken).ConfigureAwait(false);
return true;
}, cancellationToken);
+ private static async Task OpenSessionInSubWindowCoreAsync(AutomationElement main, string session, CancellationToken cancellationToken)
+ {
+ await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false);
+ var item = FindByAutomationId(main, $"session_item_{session}")
+ ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session was not found.");
+ item.DoubleClick();
+ }
+
public static Task> GetRecentGroupsAsync(CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, _) =>
{
@@ -420,7 +425,7 @@ public static partial class WechatChatClient
if (direct is not null)
{
direct.Click();
- await WaitForSessionPageAsync(main, cancellationToken).ConfigureAwait(false);
+ await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false);
return;
}
var search = main.FindAllDescendants().FirstOrDefault(element =>
@@ -429,15 +434,16 @@ public static partial class WechatChatClient
search.AsTextBox().Text = session;
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
ClickNamed(main, session);
- await WaitForSessionPageAsync(main, cancellationToken).ConfigureAwait(false);
+ await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false);
}
- private static async Task WaitForSessionPageAsync(AutomationElement main, CancellationToken cancellationToken)
+ private static async Task WaitForSessionPageAsync(AutomationElement main, string session, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 25; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
- if (FindByAutomationId(main, WechatLocators.MessageList) is not null) return;
+ if (IsListeningSession(main, session, independent: false)
+ && FindByAutomationId(main, WechatLocators.MessageList) is not null) return;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs
index b65623e..04c1785 100644
--- a/src/WxAgent.Windows/WechatChatClient.cs
+++ b/src/WxAgent.Windows/WechatChatClient.cs
@@ -376,14 +376,16 @@ public static partial class WechatChatClient
string? checkpointPath,
[EnumeratorCancellation] CancellationToken cancellationToken,
IReadOnlyList>? callbacks = null,
- Action? snapshotObserved = null)
+ Action? snapshotObserved = null,
+ string session = WechatLocators.FileTransferAssistant,
+ bool independentWindow = false)
{
if (duration <= TimeSpan.Zero)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive.");
}
- const string session = WechatLocators.FileTransferAssistant;
+ WechatOperationPolicy.ValidateNames([session], nameof(session));
var checkpoint = await ListenerCheckpointStore.LoadAsync(checkpointPath, cancellationToken);
if (checkpoint is not null && !string.Equals(checkpoint.Session, session, StringComparison.Ordinal))
{
@@ -397,6 +399,7 @@ public static partial class WechatChatClient
AutomationElement? window = null;
IDisposable? subscription = null;
var connected = false;
+ nint subscribedWindow = 0;
var announceReconnect = recoveredScan;
var elapsed = System.Diagnostics.Stopwatch.StartNew();
using var readDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -413,23 +416,30 @@ public static partial class WechatChatClient
await CommandQueue.WaitAsync(readDeadline.Token);
try
{
- window = AttachWindow(automation, readDeadline.Token);
- if (!IsFileTransferAssistantOpen(window)) connected = false;
+ window = await BindListeningWindowAsync(automation, session, independentWindow, readDeadline.Token).ConfigureAwait(false);
+ var handle = window.Properties.NativeWindowHandle.Value;
+ if (subscribedWindow != 0 && handle != subscribedWindow)
+ {
+ connected = false;
+ announceReconnect = true;
+ }
if (!connected)
{
- await OpenFileTransferAssistantCoreAsync(window, readDeadline.Token);
subscription?.Dispose();
var list = FindByAutomationId(window, WechatLocators.MessageList)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Listener message list was not found.");
subscription = list.RegisterStructureChangedEvent(
TreeScope.Subtree,
(_, _, _) => signals.Writer.TryWrite(true));
+ subscribedWindow = handle;
connected = true;
}
- // Never attribute the currently selected contact's messages to File Transfer Assistant.
- if (!IsFileTransferAssistantOpen(window))
+ if (!IsListeningSession(window, session, independentWindow))
+ throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed before snapshot.");
+ var snapshot = ReadVisible(window);
+ if (!IsListeningSession(window, session, independentWindow))
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed during snapshot.");
- visible = ReadVisible(window);
+ visible = snapshot;
snapshotObserved?.Invoke();
}
finally
@@ -447,6 +457,7 @@ public static partial class WechatChatClient
}
catch
{
+ visible = null;
connected = false;
subscription?.Dispose();
subscription = null;
diff --git a/src/WxAgent.Windows/WechatDoctor.cs b/src/WxAgent.Windows/WechatDoctor.cs
index afe8dae..2b0453b 100644
--- a/src/WxAgent.Windows/WechatDoctor.cs
+++ b/src/WxAgent.Windows/WechatDoctor.cs
@@ -136,6 +136,25 @@ public static class WechatDoctor
return fallback;
}
+ internal static FlaUI.Core.AutomationElements.AutomationElement? FindNamedWechatWindow(
+ AutomationBase automation, string title, CancellationToken cancellationToken)
+ {
+ var matches = new List();
+ foreach (var native in WechatNativeWindow.Enumerate().Where(window => window.Visible))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var candidate = automation.FromHandle((nint)native.Handle);
+ if (string.Equals(candidate.Name, title, StringComparison.Ordinal)) matches.Add(candidate);
+ }
+ catch { /* A window may disappear during enumeration. */ }
+ }
+ if (matches.Count > 1)
+ throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple WeChat windows have the requested title; refusing an ambiguous target.");
+ return matches.SingleOrDefault();
+ }
+
private static ProcessDiagnostic CreateProcessDiagnostic(Process process, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
diff --git a/src/WxAgent.Windows/WechatListenerSmoke.cs b/src/WxAgent.Windows/WechatListenerSmoke.cs
index dfb4331..c766482 100644
--- a/src/WxAgent.Windows/WechatListenerSmoke.cs
+++ b/src/WxAgent.Windows/WechatListenerSmoke.cs
@@ -7,7 +7,7 @@ public sealed record WechatListenerSmokeResult(bool Sent, int MatchingEvents, in
public static partial class WechatChatClient
{
- public static async Task ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken)
+ public static async Task ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false)
{
var directory = Path.GetFullPath(outputDirectory);
Directory.CreateDirectory(directory);
@@ -26,7 +26,8 @@ public static partial class WechatChatClient
try
{
await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(30), checkpoint, listening.Token,
- snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); }))
+ snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); },
+ independentWindow: independentWindow))
{
if (item.Message?.Text != marker) continue;
Interlocked.Increment(ref matches);
@@ -49,7 +50,7 @@ public static partial class WechatChatClient
}
var afterRestart = 0;
await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(4), checkpoint, cancellationToken,
- snapshotObserved: () => Interlocked.Increment(ref snapshots)))
+ snapshotObserved: () => Interlocked.Increment(ref snapshots), independentWindow: independentWindow))
{
if (item.Message?.Text == marker) afterRestart++;
}
diff --git a/src/WxAgent.Windows/WechatUiInspector.cs b/src/WxAgent.Windows/WechatUiInspector.cs
index 2631af8..aa44f7d 100644
--- a/src/WxAgent.Windows/WechatUiInspector.cs
+++ b/src/WxAgent.Windows/WechatUiInspector.cs
@@ -7,13 +7,14 @@ namespace WxAgent.Windows;
public static class WechatUiInspector
{
- public static async Task CaptureAsync(string outputPath, CancellationToken cancellationToken)
+ public static async Task CaptureAsync(string outputPath, CancellationToken cancellationToken, string? windowTitle = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
cancellationToken.ThrowIfCancellationRequested();
using var automation = new UIA3Automation();
- var window = WechatDoctor.FindWechatWindow(automation)
+ var window = (windowTitle is null ? WechatDoctor.FindWechatWindow(automation)
+ : WechatDoctor.FindNamedWechatWindow(automation, windowTitle, cancellationToken))
?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat window was not found in the current interactive session.");
var guard = new UiTraversalGuard();