using System.Runtime.InteropServices;
using System.Text;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml.Media;
using Windows.Graphics;
using Windows.System;
using XFEWorkflow.Controls;
using XFEWorkflow.Infrastructure.Storage;
using XFEWorkflow.Plugins.Vision;
namespace XFEWorkflow.Services;
internal static class TrayMenuSmokeTest
{
internal static async Task ThemesAsync(string output)
{
var owner = App.MainWindow; var root = (FrameworkElement)owner.Content;
var previousTheme = root.RequestedTheme;
var tray = owner.Tray!;
var area = DisplayArea.GetFromWindowId(owner.AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea;
var point = new PointInt32(area.X + area.Width / 2, area.Y + area.Height / 2);
try
{
root.RequestedTheme = ElementTheme.Dark;
owner.AppWindow.Hide();
tray.ShowContextMenu(point);
var menu = tray.ContextMenu!;
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
if (owner.AppWindow.IsVisible || menu.AppWindow.IsShownInSwitchers) throw new InvalidOperationException("托盘菜单显示了主窗口或任务栏按钮");
if (MicaController.IsSupported() && menu.Menu.SystemBackdrop is not MicaBackdrop) throw new InvalidOperationException("菜单未使用真实 Mica 背景");
if (menu.Menu.ShouldConstrainToRootBounds || menu.Menu.Items.OfType<MenuFlyoutItem>().Count() != 4) throw new InvalidOperationException("WinUI 菜单结构错误");
if (menu.Presenter!.ActualTheme != ElementTheme.Dark) throw new InvalidOperationException("托盘菜单未使用深色主题");
var dark = await CaptureAsync(menu, Path.Combine(output, "tray-menu-dark.png"));
// The owner's theme must propagate even while it is hidden and
// the native tray popup is already open.
root.RequestedTheme = ElementTheme.Light;
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter?.ActualTheme == ElementTheme.Light);
var light = await CaptureAsync(menu, Path.Combine(output, "tray-menu-light.png"));
await AtomicJson.WriteAsync(Path.Combine(output, "tray-menu-material.json"), new { dark, light, micaSupported = MicaController.IsSupported() });
if (light.Brightness - dark.Brightness < 60) throw new InvalidOperationException($"云母背景未随主题切换:dark={dark.Brightness}, light={light.Brightness}");
menu.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(menu));
UiSmokeTest.PressKey(VirtualKey.Escape, window: menu);
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("关闭托盘菜单后主窗口意外出现");
foreach (var corner in new[] { new PointInt32(area.X, area.Y), new(area.X + area.Width - 1, area.Y), new(area.X, area.Y + area.Height - 1), new(area.X + area.Width - 1, area.Y + area.Height - 1) })
{
tray.ShowContextMenu(corner);
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
await Task.Delay(200);
var bounds = PopupBounds(menu);
var presenter = menu.Presenter!; var scale = presenter.XamlRoot.RasterizationScale;
// The external Fluent shadow belongs to the popup HWND and
// may cross the work-area edge; the menu itself must fit.
var shadowX = Math.Max(0, (bounds.Right - bounds.Left - presenter.ActualWidth * scale) / 2) + 2;
var shadowY = Math.Max(0, bounds.Bottom - bounds.Top - presenter.ActualHeight * scale) + 2;
if (bounds.Left < area.X - shadowX || bounds.Top < area.Y - shadowY || bounds.Right > area.X + area.Width + shadowX || bounds.Bottom > area.Y + area.Height + shadowY)
throw new InvalidOperationException($"托盘菜单超出显示器工作区:{bounds.Left},{bounds.Top},{bounds.Right},{bounds.Bottom}");
menu.Dismiss(); await Task.Delay(80);
}
tray.ShowContextMenu(point); await WaitAsync(() => menu.IsMenuOpen);
for (var i = 0; i < 6; i++) tray.ShowContextMenu(point);
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
tray.Show();
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible && owner.AppWindow.IsVisible);
}
finally { tray.ContextMenu?.Dismiss(); root.RequestedTheme = previousTheme; tray.Show(); }
}
internal static async Task CommandsAsync()
{
var owner = App.MainWindow; var tray = owner.Tray!; var scheduler = AppServices.Current.Scheduler;
var area = DisplayArea.GetFromWindowId(owner.AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea;
var point = new PointInt32(area.X + area.Width / 2, area.Y + area.Height / 2);
var wasSuspended = scheduler.IsSuspended;
async Task<TrayMenuWindow> Open()
{
tray.ShowContextMenu(point);
await WaitAsync(() => tray.ContextMenu?.IsMenuOpen == true && tray.ContextMenu.Presenter is { ActualWidth: > 100 });
return tray.ContextMenu!;
}
async Task Invoke(TrayMenuWindow menu, string command)
{
var item = menu.Menu.Items.OfType<MenuFlyoutItem>().Single(x => x.Tag?.ToString() == command);
if (!item.IsEnabled || !item.Focus(FocusState.Keyboard)) throw new InvalidOperationException("菜单命令无法聚焦:" + command);
UiSmokeTest.PressKey(VirtualKey.Enter, window: menu);
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
}
try
{
await scheduler.ResumeAsync(); owner.AppWindow.Hide();
var menu = await Open();
if (menu.Menu.Items.OfType<MenuFlyoutItem>().Single(x => x.Tag?.ToString() == "Resume").IsEnabled) throw new InvalidOperationException("正常调度时恢复命令应禁用");
var flow = Core.Runtime.WorkflowTemplates.CreateGraph();
flow.Graph.Nodes.Insert(1, new Abstractions.NodeDefinition { CapabilityId = "core.delay", Parameters = new() { ["milliseconds"] = 20000 } });
flow.Graph.Edges.Clear();
for (var i = 0; i < flow.Graph.Nodes.Count - 1; i++) flow.Graph.Edges.Add(new() { FromNode = flow.Graph.Nodes[i].Id, ToNode = flow.Graph.Nodes[i + 1].Id });
var running = AppServices.Current.Runs.RunAsync(flow);
await WaitAsync(() => AppServices.Current.Runs.IsRunning(flow.Id));
await Invoke(menu, "EmergencyStop");
await WaitAsync(() => scheduler.IsSuspended);
var stopped = await running.WaitAsync(TimeSpan.FromSeconds(8));
if (stopped.Status != Abstractions.RunStatus.Cancelled) throw new InvalidOperationException("托盘紧急停止未取消 Runner:" + stopped.Status);
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("紧急停止打开了主窗口");
menu = await Open(); await Invoke(menu, "Resume"); await WaitAsync(() => !scheduler.IsSuspended);
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("恢复调度打开了主窗口");
menu = await Open(); await Invoke(menu, "Open"); await WaitAsync(() => owner.AppWindow.IsVisible);
// Deactivation is the native light-dismiss path, including a
// click on another window. Use our own window to avoid external apps.
menu = await Open(); owner.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(owner));
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
}
finally { tray.ContextMenu?.Dismiss(); if (wasSuspended) scheduler.Suspend(); else await scheduler.ResumeAsync(); tray.Show(); }
}
private static async Task WaitAsync(Func<bool> condition)
{
for (var i = 0; i < 60; i++) { if (condition()) return; await Task.Delay(50); }
throw new InvalidOperationException("托盘 WinUI 菜单状态未按预期变化");
}
private sealed record MaterialSample(int Brightness, int Width, int Height);
private static async Task<MaterialSample> CaptureAsync(TrayMenuWindow menu, string path)
{
menu.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(menu));
await Task.Delay(250);
var rect = PopupBounds(menu);
var foreground = GetForegroundWindow(); GetWindowThreadProcessId(foreground, out var foregroundProcess);
if (!menu.IsMenuOpen || foregroundProcess != Environment.ProcessId)
{
var name = new StringBuilder(256); GetClassName(foreground, name, name.Capacity);
throw new InvalidOperationException($"托盘验收窗口失去焦点,已停止截图:open={menu.IsMenuOpen}, foreground={name}, process={foregroundProcess}");
}
GetWindowThreadProcessId(WindowFromPoint(new() { X = (rect.Left + rect.Right) / 2, Y = (rect.Top + rect.Bottom) / 2 }), out var process);
if (process != Environment.ProcessId) throw new InvalidOperationException("托盘菜单被其他窗口遮挡,已停止截图");
// Capture only the verified native popup belonging to this test
// process; RenderTargetBitmap does not include a system backdrop.
using var frame = ScreenCapture.Capture(new(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
frame.Bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png);
var pixel = frame.Bitmap.GetPixel(frame.Bitmap.Width - 64, frame.Bitmap.Height / 2);
return new((pixel.R + pixel.G + pixel.B) / 3, frame.Bitmap.Width, frame.Bitmap.Height);
}
private static NativeRect PopupBounds(TrayMenuWindow menu)
{
var anchor = WinRT.Interop.WindowNative.GetWindowHandle(menu);
var thread = GetWindowThreadProcessId(anchor, out _); var matches = new List<NativeRect>(); var trace = new List<string>();
EnumThreadWindows(thread, (handle, _) =>
{
if (!IsWindowVisible(handle)) return true;
GetWindowRect(handle, out var rect); var name = new StringBuilder(256); GetClassName(handle, name, name.Capacity);
GetWindowThreadProcessId(handle, out var process);
trace.Add($"{name}: {rect.Left},{rect.Top},{rect.Right},{rect.Bottom}");
if (process == Environment.ProcessId && handle != anchor && handle != WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow)
&& rect.Right - rect.Left > 100 && rect.Bottom - rect.Top > 50 && name.ToString().Contains("Popup", StringComparison.OrdinalIgnoreCase)) matches.Add(rect);
return true;
}, 0);
return matches.Count == 1 ? matches[0] : throw new InvalidOperationException("未找到唯一的原生 WinUI 菜单窗口:" + string.Join("; ", trace));
}
private delegate bool EnumWindowProc(nint window, nint data);
[StructLayout(LayoutKind.Sequential)] private struct NativeRect { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X, Y; }
[DllImport("user32.dll")] private static extern nint GetForegroundWindow();
[DllImport("user32.dll")] private static extern nint WindowFromPoint(NativePoint point);
[DllImport("user32.dll")] private static extern bool EnumThreadWindows(uint thread, EnumWindowProc callback, nint data);
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(nint window, out uint process);
[DllImport("user32.dll")] private static extern bool IsWindowVisible(nint window);
[DllImport("user32.dll")] private static extern bool GetWindowRect(nint window, out NativeRect rect);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(nint window, StringBuilder name, int size);
}
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml.Media;
using Windows.Graphics;
using Windows.System;
using XFEWorkflow.Controls;
using XFEWorkflow.Infrastructure.Storage;
using XFEWorkflow.Plugins.Vision;
namespace XFEWorkflow.Services;
internal static class TrayMenuSmokeTest
{
internal static async Task ThemesAsync(string output)
{
var owner = App.MainWindow; var root = (FrameworkElement)owner.Content;
var previousTheme = root.RequestedTheme;
var tray = owner.Tray!;
var area = DisplayArea.GetFromWindowId(owner.AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea;
var point = new PointInt32(area.X + area.Width / 2, area.Y + area.Height / 2);
try
{
root.RequestedTheme = ElementTheme.Dark;
owner.AppWindow.Hide();
tray.ShowContextMenu(point);
var menu = tray.ContextMenu!;
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
if (owner.AppWindow.IsVisible || menu.AppWindow.IsShownInSwitchers) throw new InvalidOperationException("托盘菜单显示了主窗口或任务栏按钮");
if (MicaController.IsSupported() && menu.Menu.SystemBackdrop is not MicaBackdrop) throw new InvalidOperationException("菜单未使用真实 Mica 背景");
if (menu.Menu.ShouldConstrainToRootBounds || menu.Menu.Items.OfType<MenuFlyoutItem>().Count() != 4) throw new InvalidOperationException("WinUI 菜单结构错误");
if (menu.Presenter!.ActualTheme != ElementTheme.Dark) throw new InvalidOperationException("托盘菜单未使用深色主题");
var dark = await CaptureAsync(menu, Path.Combine(output, "tray-menu-dark.png"));
// The owner's theme must propagate even while it is hidden and
// the native tray popup is already open.
root.RequestedTheme = ElementTheme.Light;
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter?.ActualTheme == ElementTheme.Light);
var light = await CaptureAsync(menu, Path.Combine(output, "tray-menu-light.png"));
await AtomicJson.WriteAsync(Path.Combine(output, "tray-menu-material.json"), new { dark, light, micaSupported = MicaController.IsSupported() });
if (light.Brightness - dark.Brightness < 60) throw new InvalidOperationException($"云母背景未随主题切换:dark={dark.Brightness}, light={light.Brightness}");
menu.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(menu));
UiSmokeTest.PressKey(VirtualKey.Escape, window: menu);
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("关闭托盘菜单后主窗口意外出现");
foreach (var corner in new[] { new PointInt32(area.X, area.Y), new(area.X + area.Width - 1, area.Y), new(area.X, area.Y + area.Height - 1), new(area.X + area.Width - 1, area.Y + area.Height - 1) })
{
tray.ShowContextMenu(corner);
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
await Task.Delay(200);
var bounds = PopupBounds(menu);
var presenter = menu.Presenter!; var scale = presenter.XamlRoot.RasterizationScale;
// The external Fluent shadow belongs to the popup HWND and
// may cross the work-area edge; the menu itself must fit.
var shadowX = Math.Max(0, (bounds.Right - bounds.Left - presenter.ActualWidth * scale) / 2) + 2;
var shadowY = Math.Max(0, bounds.Bottom - bounds.Top - presenter.ActualHeight * scale) + 2;
if (bounds.Left < area.X - shadowX || bounds.Top < area.Y - shadowY || bounds.Right > area.X + area.Width + shadowX || bounds.Bottom > area.Y + area.Height + shadowY)
throw new InvalidOperationException($"托盘菜单超出显示器工作区:{bounds.Left},{bounds.Top},{bounds.Right},{bounds.Bottom}");
menu.Dismiss(); await Task.Delay(80);
}
tray.ShowContextMenu(point); await WaitAsync(() => menu.IsMenuOpen);
for (var i = 0; i < 6; i++) tray.ShowContextMenu(point);
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
tray.Show();
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible && owner.AppWindow.IsVisible);
}
finally { tray.ContextMenu?.Dismiss(); root.RequestedTheme = previousTheme; tray.Show(); }
}
internal static async Task CommandsAsync()
{
var owner = App.MainWindow; var tray = owner.Tray!; var scheduler = AppServices.Current.Scheduler;
var area = DisplayArea.GetFromWindowId(owner.AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea;
var point = new PointInt32(area.X + area.Width / 2, area.Y + area.Height / 2);
var wasSuspended = scheduler.IsSuspended;
async Task<TrayMenuWindow> Open()
{
tray.ShowContextMenu(point);
await WaitAsync(() => tray.ContextMenu?.IsMenuOpen == true && tray.ContextMenu.Presenter is { ActualWidth: > 100 });
return tray.ContextMenu!;
}
async Task Invoke(TrayMenuWindow menu, string command)
{
var item = menu.Menu.Items.OfType<MenuFlyoutItem>().Single(x => x.Tag?.ToString() == command);
if (!item.IsEnabled || !item.Focus(FocusState.Keyboard)) throw new InvalidOperationException("菜单命令无法聚焦:" + command);
UiSmokeTest.PressKey(VirtualKey.Enter, window: menu);
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
}
try
{
await scheduler.ResumeAsync(); owner.AppWindow.Hide();
var menu = await Open();
if (menu.Menu.Items.OfType<MenuFlyoutItem>().Single(x => x.Tag?.ToString() == "Resume").IsEnabled) throw new InvalidOperationException("正常调度时恢复命令应禁用");
var flow = Core.Runtime.WorkflowTemplates.CreateGraph();
flow.Graph.Nodes.Insert(1, new Abstractions.NodeDefinition { CapabilityId = "core.delay", Parameters = new() { ["milliseconds"] = 20000 } });
flow.Graph.Edges.Clear();
for (var i = 0; i < flow.Graph.Nodes.Count - 1; i++) flow.Graph.Edges.Add(new() { FromNode = flow.Graph.Nodes[i].Id, ToNode = flow.Graph.Nodes[i + 1].Id });
var running = AppServices.Current.Runs.RunAsync(flow);
await WaitAsync(() => AppServices.Current.Runs.IsRunning(flow.Id));
await Invoke(menu, "EmergencyStop");
await WaitAsync(() => scheduler.IsSuspended);
var stopped = await running.WaitAsync(TimeSpan.FromSeconds(8));
if (stopped.Status != Abstractions.RunStatus.Cancelled) throw new InvalidOperationException("托盘紧急停止未取消 Runner:" + stopped.Status);
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("紧急停止打开了主窗口");
menu = await Open(); await Invoke(menu, "Resume"); await WaitAsync(() => !scheduler.IsSuspended);
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("恢复调度打开了主窗口");
menu = await Open(); await Invoke(menu, "Open"); await WaitAsync(() => owner.AppWindow.IsVisible);
// Deactivation is the native light-dismiss path, including a
// click on another window. Use our own window to avoid external apps.
menu = await Open(); owner.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(owner));
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
}
finally { tray.ContextMenu?.Dismiss(); if (wasSuspended) scheduler.Suspend(); else await scheduler.ResumeAsync(); tray.Show(); }
}
private static async Task WaitAsync(Func<bool> condition)
{
for (var i = 0; i < 60; i++) { if (condition()) return; await Task.Delay(50); }
throw new InvalidOperationException("托盘 WinUI 菜单状态未按预期变化");
}
private sealed record MaterialSample(int Brightness, int Width, int Height);
private static async Task<MaterialSample> CaptureAsync(TrayMenuWindow menu, string path)
{
menu.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(menu));
await Task.Delay(250);
var rect = PopupBounds(menu);
var foreground = GetForegroundWindow(); GetWindowThreadProcessId(foreground, out var foregroundProcess);
if (!menu.IsMenuOpen || foregroundProcess != Environment.ProcessId)
{
var name = new StringBuilder(256); GetClassName(foreground, name, name.Capacity);
throw new InvalidOperationException($"托盘验收窗口失去焦点,已停止截图:open={menu.IsMenuOpen}, foreground={name}, process={foregroundProcess}");
}
GetWindowThreadProcessId(WindowFromPoint(new() { X = (rect.Left + rect.Right) / 2, Y = (rect.Top + rect.Bottom) / 2 }), out var process);
if (process != Environment.ProcessId) throw new InvalidOperationException("托盘菜单被其他窗口遮挡,已停止截图");
// Capture only the verified native popup belonging to this test
// process; RenderTargetBitmap does not include a system backdrop.
using var frame = ScreenCapture.Capture(new(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
frame.Bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png);
var pixel = frame.Bitmap.GetPixel(frame.Bitmap.Width - 64, frame.Bitmap.Height / 2);
return new((pixel.R + pixel.G + pixel.B) / 3, frame.Bitmap.Width, frame.Bitmap.Height);
}
private static NativeRect PopupBounds(TrayMenuWindow menu)
{
var anchor = WinRT.Interop.WindowNative.GetWindowHandle(menu);
var thread = GetWindowThreadProcessId(anchor, out _); var matches = new List<NativeRect>(); var trace = new List<string>();
EnumThreadWindows(thread, (handle, _) =>
{
if (!IsWindowVisible(handle)) return true;
GetWindowRect(handle, out var rect); var name = new StringBuilder(256); GetClassName(handle, name, name.Capacity);
GetWindowThreadProcessId(handle, out var process);
trace.Add($"{name}: {rect.Left},{rect.Top},{rect.Right},{rect.Bottom}");
if (process == Environment.ProcessId && handle != anchor && handle != WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow)
&& rect.Right - rect.Left > 100 && rect.Bottom - rect.Top > 50 && name.ToString().Contains("Popup", StringComparison.OrdinalIgnoreCase)) matches.Add(rect);
return true;
}, 0);
return matches.Count == 1 ? matches[0] : throw new InvalidOperationException("未找到唯一的原生 WinUI 菜单窗口:" + string.Join("; ", trace));
}
private delegate bool EnumWindowProc(nint window, nint data);
[StructLayout(LayoutKind.Sequential)] private struct NativeRect { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X, Y; }
[DllImport("user32.dll")] private static extern nint GetForegroundWindow();
[DllImport("user32.dll")] private static extern nint WindowFromPoint(NativePoint point);
[DllImport("user32.dll")] private static extern bool EnumThreadWindows(uint thread, EnumWindowProc callback, nint data);
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(nint window, out uint process);
[DllImport("user32.dll")] private static extern bool IsWindowVisible(nint window);
[DllImport("user32.dll")] private static extern bool GetWindowRect(nint window, out NativeRect rect);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(nint window, StringBuilder name, int size);
}