diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs index 45d6c17..07f9e01 100644 --- a/Notify/App.axaml.cs +++ b/Notify/App.axaml.cs @@ -58,6 +58,7 @@ public partial class App : Application Message = message.Message, InputMode = message.InputMode, Sticky = message.Sticky, + TargetHwnd = message.TargetHwnd, })); } diff --git a/Notify/Cli/CliRunner.cs b/Notify/Cli/CliRunner.cs index 25b7c7d..077863d 100644 --- a/Notify/Cli/CliRunner.cs +++ b/Notify/Cli/CliRunner.cs @@ -52,6 +52,7 @@ public static class CliRunner Message = message, InputMode = false, Sticky = false, + TargetHwnd = state?.Hwnd ?? 0, }); } @@ -71,6 +72,7 @@ public static class CliRunner } var (title, message) = Resolve(input); + var state = StateStore.Load(input.SessionId); return SendToHost(new PipeMessage { @@ -79,9 +81,36 @@ public static class CliRunner Message = message, InputMode = true, Sticky = true, + TargetHwnd = state?.Hwnd ?? 0, }); } + // 仅用于自测:acttest ,尝试激活指定窗口并把结果写入临时日志 + public static int ActTest(string[] args) + { + if (args.Length < 2 || !long.TryParse(args[1], out var h)) + { + return 1; + } + + var before = Win32.GetForegroundWindow().ToInt64(); + var ok = WindowActivator.Activate(new IntPtr(h)); + var after = Win32.GetForegroundWindow().ToInt64(); + + try + { + System.IO.File.WriteAllText( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "notify-acttest.log"), + $"target={h} before={before} after={after} ok={ok}\n"); + } + catch + { + // 忽略 + } + + return ok ? 0 : 1; + } + // SessionEnd:清理会话状态 public static int Cleanup() { diff --git a/Notify/Interop/Win32.cs b/Notify/Interop/Win32.cs index c680d32..7020953 100644 --- a/Notify/Interop/Win32.cs +++ b/Notify/Interop/Win32.cs @@ -7,4 +7,58 @@ internal static partial class Win32 { [LibraryImport("user32.dll")] internal static partial IntPtr GetForegroundWindow(); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetForegroundWindow(IntPtr hWnd); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool AllowSetForegroundWindow(uint dwProcessId); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach); + + [LibraryImport("user32.dll")] + internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool BringWindowToTop(IntPtr hWnd); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsIconic(IntPtr hWnd); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsWindow(IntPtr hWnd); + + [LibraryImport("user32.dll")] + internal static partial void SwitchToThisWindow(IntPtr hWnd, [MarshalAs(UnmanagedType.Bool)] bool fAltTab); + + [LibraryImport("user32.dll")] + internal static partial void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo); + + [LibraryImport("kernel32.dll")] + internal static partial uint GetCurrentThreadId(); + + // --- 常量 --- + internal const uint ASFW_ANY = 0xFFFFFFFF; + internal const int SW_RESTORE = 9; + internal const int SW_SHOW = 5; + internal const uint SWP_NOSIZE = 0x0001; + internal const uint SWP_NOMOVE = 0x0002; + internal const uint SWP_SHOWWINDOW = 0x0040; + internal const byte VK_MENU = 0x12; + internal const uint KEYEVENTF_KEYUP = 0x0002; } diff --git a/Notify/Interop/WindowActivator.cs b/Notify/Interop/WindowActivator.cs new file mode 100644 index 0000000..d39590e --- /dev/null +++ b/Notify/Interop/WindowActivator.cs @@ -0,0 +1,72 @@ +using System; + +namespace Notify.Interop; + +/// +/// 把目标窗口拉回前台 +/// +/// Windows 限制后台进程抢焦点,这里用一套组合技绕过:ALT 键模拟 + +/// AttachThreadInput 把当前线程与前台/目标线程的输入队列挂接 + +/// SetWindowPos/BringWindowToTop/SwitchToThisWindow/SetForegroundWindow 多管齐下 +/// +public static class WindowActivator +{ + public static bool Activate(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero || !Win32.IsWindow(hwnd)) + { + return false; + } + + try + { + // 最小化的先还原 + if (Win32.IsIconic(hwnd)) + { + Win32.ShowWindow(hwnd, Win32.SW_RESTORE); + } + + var foreground = Win32.GetForegroundWindow(); + var curThread = Win32.GetCurrentThreadId(); + var fgThread = Win32.GetWindowThreadProcessId(foreground, out _); + var targetThread = Win32.GetWindowThreadProcessId(hwnd, out _); + + // 模拟一次 ALT 抬起,满足 Windows 的"防焦点抢占"前置条件 + Win32.keybd_event(Win32.VK_MENU, 0, 0, IntPtr.Zero); + Win32.keybd_event(Win32.VK_MENU, 0, Win32.KEYEVENTF_KEYUP, IntPtr.Zero); + + if (fgThread != curThread) + { + Win32.AttachThreadInput(curThread, fgThread, true); + } + + if (targetThread != curThread && targetThread != fgThread) + { + Win32.AttachThreadInput(curThread, targetThread, true); + } + + Win32.AllowSetForegroundWindow(Win32.ASFW_ANY); + Win32.SetWindowPos(hwnd, IntPtr.Zero, 0, 0, 0, 0, Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_SHOWWINDOW); + Win32.BringWindowToTop(hwnd); + Win32.SwitchToThisWindow(hwnd, true); + Win32.SetForegroundWindow(hwnd); + Win32.ShowWindow(hwnd, Win32.SW_SHOW); + + if (targetThread != curThread && targetThread != fgThread) + { + Win32.AttachThreadInput(curThread, targetThread, false); + } + + if (fgThread != curThread) + { + Win32.AttachThreadInput(curThread, fgThread, false); + } + + return Win32.GetForegroundWindow() == hwnd; + } + catch + { + return false; + } + } +} diff --git a/Notify/Ipc/PipeMessage.cs b/Notify/Ipc/PipeMessage.cs index e3f122b..a6c5f36 100644 --- a/Notify/Ipc/PipeMessage.cs +++ b/Notify/Ipc/PipeMessage.cs @@ -17,4 +17,7 @@ public sealed class PipeMessage // 触发该通知的会话 id,便于 Host 后续按会话激活窗口 public string? SessionId { get; set; } + + // 点击 toast 时要激活的目标窗口句柄,0 表示无 + public long TargetHwnd { get; set; } } diff --git a/Notify/Models/ToastRequest.cs b/Notify/Models/ToastRequest.cs index fd47ec5..f696a59 100644 --- a/Notify/Models/ToastRequest.cs +++ b/Notify/Models/ToastRequest.cs @@ -18,4 +18,9 @@ public sealed class ToastRequest /// true = 常驻:不自动消失,只能点击 / ✕ 关闭 /// public bool Sticky { get; init; } + + /// + /// 点击 toast 主体时要激活的窗口句柄,0 表示不激活 + /// + public long TargetHwnd { get; init; } } diff --git a/Notify/Program.cs b/Notify/Program.cs index ab6e241..bcdaf5c 100644 --- a/Notify/Program.cs +++ b/Notify/Program.cs @@ -31,6 +31,7 @@ internal static class Program "notify" => CliRunner.Notify(), "input" => CliRunner.Input(), "cleanup" => CliRunner.Cleanup(), + "acttest" => CliRunner.ActTest(args), _ => RunHost(args), }; } diff --git a/Notify/Services/ToastManager.cs b/Notify/Services/ToastManager.cs index 50d3c59..afea853 100644 --- a/Notify/Services/ToastManager.cs +++ b/Notify/Services/ToastManager.cs @@ -33,7 +33,7 @@ public sealed class ToastManager } var vm = new ToastViewModel(request); - var window = new ToastWindow(vm, settings, request.Sticky); + var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd); window.Closed += OnToastClosed; _active.Add(window); diff --git a/Notify/Views/ToastWindow.axaml.cs b/Notify/Views/ToastWindow.axaml.cs index d12c8ab..0658551 100644 --- a/Notify/Views/ToastWindow.axaml.cs +++ b/Notify/Views/ToastWindow.axaml.cs @@ -20,16 +20,18 @@ public partial class ToastWindow : Window private readonly ToastSettings _settings; private readonly DispatcherTimer _dismissTimer; private readonly bool _sticky; + private readonly long _targetHwnd; private bool _closing; // 设计器需要的无参构造 - public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false) + public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0) { } - public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky) + public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd) { _settings = settings; + _targetHwnd = targetHwnd; // 常驻:请求显式 Sticky,或全局停留时长 <= 0 _sticky = sticky || settings.DurationSeconds <= 0; InitializeComponent(); @@ -119,9 +121,14 @@ public partial class ToastWindow : Window private void OnBodyPressed(object? sender, PointerPressedEventArgs e) { - // 左键点击主体:后续接入"激活原窗口 + 切回标签页",当前先关闭 + // 左键点击主体:激活原窗口后关闭 if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { + if (_targetHwnd != 0) + { + Notify.Interop.WindowActivator.Activate(new IntPtr(_targetHwnd)); + } + BeginClose(); } }