feat: 点击 toast 激活原窗口(P1a)

- WindowActivator:ALT 模拟 + AttachThreadInput + SetForegroundWindow 组合技抢前台
- save 抓的前台句柄经 PipeMessage/ToastRequest 一路带到 toast
- 点击 toast 主体激活目标窗口后再关闭
- Win32 互操作用 LibraryImport(AOT 友好)
- 新增 acttest 自测子命令
This commit is contained in:
2026-06-22 14:48:11 +08:00
Unverified
parent 37fc06e274
commit 13fe87da99
9 changed files with 176 additions and 4 deletions
+1
View File
@@ -58,6 +58,7 @@ public partial class App : Application
Message = message.Message,
InputMode = message.InputMode,
Sticky = message.Sticky,
TargetHwnd = message.TargetHwnd,
}));
}
+29
View File
@@ -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 <hwnd>,尝试激活指定窗口并把结果写入临时日志
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()
{
+54
View File
@@ -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;
}
+72
View File
@@ -0,0 +1,72 @@
using System;
namespace Notify.Interop;
/// <summary>
/// 把目标窗口拉回前台
///
/// Windows 限制后台进程抢焦点,这里用一套组合技绕过:ALT 键模拟 +
/// AttachThreadInput 把当前线程与前台/目标线程的输入队列挂接 +
/// SetWindowPos/BringWindowToTop/SwitchToThisWindow/SetForegroundWindow 多管齐下
/// </summary>
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;
}
}
}
+3
View File
@@ -17,4 +17,7 @@ public sealed class PipeMessage
// 触发该通知的会话 id,便于 Host 后续按会话激活窗口
public string? SessionId { get; set; }
// 点击 toast 时要激活的目标窗口句柄,0 表示无
public long TargetHwnd { get; set; }
}
+5
View File
@@ -18,4 +18,9 @@ public sealed class ToastRequest
/// true = 常驻:不自动消失,只能点击 / ✕ 关闭
/// </summary>
public bool Sticky { get; init; }
/// <summary>
/// 点击 toast 主体时要激活的窗口句柄,0 表示不激活
/// </summary>
public long TargetHwnd { get; init; }
}
+1
View File
@@ -31,6 +31,7 @@ internal static class Program
"notify" => CliRunner.Notify(),
"input" => CliRunner.Input(),
"cleanup" => CliRunner.Cleanup(),
"acttest" => CliRunner.ActTest(args),
_ => RunHost(args),
};
}
+1 -1
View File
@@ -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);
+10 -3
View File
@@ -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();
}
}