XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
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);
}