feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// 从 exe 提取图标并转成 Avalonia 位图
|
||||
///
|
||||
/// ExtractIconEx 拿 HICON,再用 GDI 读出 BGRA 像素构造 Bitmap;不依赖
|
||||
/// System.Drawing(其 AOT 不友好)
|
||||
/// </summary>
|
||||
internal static partial class AppIcon
|
||||
{
|
||||
public static Bitmap? Extract(string exePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(exePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hIcon = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (ExtractIconExW(exePath, 0, out hIcon, out _, 1) == 0 || hIcon == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return IconToBitmap(hIcon);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hIcon != IntPtr.Zero)
|
||||
{
|
||||
DestroyIcon(hIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Bitmap? IconToBitmap(IntPtr hIcon)
|
||||
{
|
||||
if (!GetIconInfo(hIcon, out var ii))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bm = default(BITMAP);
|
||||
if (GetObjectW(ii.hbmColor, Marshal.SizeOf<BITMAP>(), ref bm) == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var w = bm.bmWidth;
|
||||
var h = bm.bmHeight;
|
||||
var buffer = new byte[w * h * 4];
|
||||
|
||||
var bmi = new BITMAPINFOHEADER
|
||||
{
|
||||
biSize = (uint)Marshal.SizeOf<BITMAPINFOHEADER>(),
|
||||
biWidth = w,
|
||||
biHeight = -h, // 负数 = 自上而下,行序正常
|
||||
biPlanes = 1,
|
||||
biBitCount = 32,
|
||||
biCompression = 0,
|
||||
};
|
||||
|
||||
var hdc = GetDC(IntPtr.Zero);
|
||||
try
|
||||
{
|
||||
if (GetDIBits(hdc, ii.hbmColor, 0, (uint)h, buffer, ref bmi, 0) == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseDC(IntPtr.Zero, hdc);
|
||||
}
|
||||
|
||||
// 某些老图标无 alpha 通道(全 0),那样会整块透明,补成不透明
|
||||
var anyAlpha = false;
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
{
|
||||
if (buffer[i] != 0)
|
||||
{
|
||||
anyAlpha = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyAlpha)
|
||||
{
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
{
|
||||
buffer[i] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
return new Bitmap(
|
||||
PixelFormat.Bgra8888,
|
||||
AlphaFormat.Unpremul,
|
||||
handle.AddrOfPinnedObject(),
|
||||
new PixelSize(w, h),
|
||||
new Vector(96, 96),
|
||||
w * 4);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ii.hbmColor != IntPtr.Zero)
|
||||
{
|
||||
DeleteObject(ii.hbmColor);
|
||||
}
|
||||
|
||||
if (ii.hbmMask != IntPtr.Zero)
|
||||
{
|
||||
DeleteObject(ii.hbmMask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[LibraryImport("shell32.dll", EntryPoint = "ExtractIconExW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
private static partial uint ExtractIconExW(string lpszFile, int nIconIndex, out IntPtr phiconLarge, out IntPtr phiconSmall, uint nIcons);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool DestroyIcon(IntPtr hIcon);
|
||||
|
||||
[LibraryImport("gdi32.dll", EntryPoint = "GetObjectW")]
|
||||
private static partial int GetObjectW(IntPtr hgdiobj, int cbBuffer, ref BITMAP lpvObject);
|
||||
|
||||
[LibraryImport("gdi32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool DeleteObject(IntPtr hObject);
|
||||
|
||||
[LibraryImport("gdi32.dll")]
|
||||
private static partial int GetDIBits(IntPtr hdc, IntPtr hbmp, uint uStartScan, uint cScanLines, [Out] byte[] lpvBits, ref BITMAPINFOHEADER lpbi, uint uUsage);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial IntPtr GetDC(IntPtr hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial int ReleaseDC(IntPtr hWnd, IntPtr hDC);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct ICONINFO
|
||||
{
|
||||
public int fIcon;
|
||||
public uint xHotspot;
|
||||
public uint yHotspot;
|
||||
public IntPtr hbmMask;
|
||||
public IntPtr hbmColor;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct BITMAP
|
||||
{
|
||||
public int bmType;
|
||||
public int bmWidth;
|
||||
public int bmHeight;
|
||||
public int bmWidthBytes;
|
||||
public ushort bmPlanes;
|
||||
public ushort bmBitsPixel;
|
||||
public IntPtr bmBits;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct BITMAPINFOHEADER
|
||||
{
|
||||
public uint biSize;
|
||||
public int biWidth;
|
||||
public int biHeight;
|
||||
public ushort biPlanes;
|
||||
public ushort biBitCount;
|
||||
public uint biCompression;
|
||||
public uint biSizeImage;
|
||||
public int biXPelsPerMeter;
|
||||
public int biYPelsPerMeter;
|
||||
public uint biClrUsed;
|
||||
public uint biClrImportant;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// 沿父进程上溯,跳过 shell/运行时,找到真正的调用方 App(编辑器/终端)
|
||||
/// </summary>
|
||||
internal static partial class ProcessTree
|
||||
{
|
||||
// 这些进程是 shell / 运行时 / 包装器,不是用户面对的 App,跳过继续上溯
|
||||
private static readonly HashSet<string> SkipNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"cmd", "powershell", "pwsh", "bash", "sh", "zsh", "fish",
|
||||
"wsl", "wslhost", "conhost", "openconsole",
|
||||
"node", "deno", "bun", "python", "python3", "py",
|
||||
"uv", "uvx", "npm", "npx", "yarn", "pnpm",
|
||||
"claude", "dotnet", "git", "env", "busybox", "winpty", "sudo",
|
||||
"notify",
|
||||
};
|
||||
|
||||
public static string FindCallerExePath()
|
||||
{
|
||||
try
|
||||
{
|
||||
var parents = BuildParentMap();
|
||||
var pid = GetCurrentProcessId();
|
||||
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
if (!parents.TryGetValue(pid, out var info))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
pid = info.Parent;
|
||||
if (pid == 0 || !parents.TryGetValue(pid, out var anc))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var name = anc.Name;
|
||||
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name[..^4];
|
||||
}
|
||||
|
||||
if (SkipNames.Contains(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 第一个非 shell/运行时的祖先即调用方 App
|
||||
return GetFullPath(pid);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 取不到就回退默认图标
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static Dictionary<uint, (uint Parent, string Name)> BuildParentMap()
|
||||
{
|
||||
var map = new Dictionary<uint, (uint, string)>();
|
||||
var snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1))
|
||||
{
|
||||
return map;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = default(PROCESSENTRY32W);
|
||||
entry.dwSize = (uint)Marshal.SizeOf<PROCESSENTRY32W>();
|
||||
|
||||
if (Process32FirstW(snapshot, ref entry))
|
||||
{
|
||||
do
|
||||
{
|
||||
map[entry.th32ProcessID] = (entry.th32ParentProcessID, ReadExeName(ref entry));
|
||||
}
|
||||
while (Process32NextW(snapshot, ref entry));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(snapshot);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static unsafe string ReadExeName(ref PROCESSENTRY32W entry)
|
||||
{
|
||||
fixed (char* p = entry.szExeFile)
|
||||
{
|
||||
return new string(p);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFullPath(uint pid)
|
||||
{
|
||||
var h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
if (h == IntPtr.Zero)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var buf = new char[1024];
|
||||
var size = (uint)buf.Length;
|
||||
return QueryFullProcessImageName(h, 0, ref buf[0], ref size) ? new string(buf, 0, (int)size) : "";
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(h);
|
||||
}
|
||||
}
|
||||
|
||||
private const uint TH32CS_SNAPPROCESS = 0x00000002;
|
||||
private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial uint GetCurrentProcessId();
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool Process32FirstW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool Process32NextW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial IntPtr OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId);
|
||||
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool QueryFullProcessImageName(IntPtr hProcess, uint dwFlags, ref char lpExeName, ref uint lpdwSize);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal unsafe struct PROCESSENTRY32W
|
||||
{
|
||||
public uint dwSize;
|
||||
public uint cntUsage;
|
||||
public uint th32ProcessID;
|
||||
public nint th32DefaultHeapID;
|
||||
public uint th32ModuleID;
|
||||
public uint cntThreads;
|
||||
public uint th32ParentProcessID;
|
||||
public int pcPriClassBase;
|
||||
public uint dwFlags;
|
||||
public fixed char szExeFile[260];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// 播放打包的提示音 wav
|
||||
///
|
||||
/// 用 winmm 的 PlaySound 从内存异步播放;为配合 SND_ASYNC,wav 拷到不会被 GC
|
||||
/// 移动的非托管内存里常驻
|
||||
/// </summary>
|
||||
internal static partial class Sound
|
||||
{
|
||||
private const uint SND_ASYNC = 0x0001;
|
||||
private const uint SND_NODEFAULT = 0x0002;
|
||||
private const uint SND_MEMORY = 0x0004;
|
||||
|
||||
private static IntPtr _wavPtr;
|
||||
private static DateTime _lastPlay = DateTime.MinValue;
|
||||
|
||||
[LibraryImport("winmm.dll", EntryPoint = "PlaySoundW")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool PlaySound(IntPtr pszSound, IntPtr hmod, uint fdwSound);
|
||||
|
||||
public static void Play()
|
||||
{
|
||||
// 防连环音:300ms 内只响一次
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - _lastPlay).TotalMilliseconds < 300)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastPlay = now;
|
||||
|
||||
try
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (_wavPtr != IntPtr.Zero)
|
||||
{
|
||||
PlaySound(_wavPtr, IntPtr.Zero, SND_MEMORY | SND_ASYNC | SND_NODEFAULT);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 播放失败无所谓
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureLoaded()
|
||||
{
|
||||
if (_wavPtr != IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var s = AssetLoader.Open(new Uri("avares://notify/Assets/notification.wav"));
|
||||
using var ms = new MemoryStream();
|
||||
s.CopyTo(ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
_wavPtr = Marshal.AllocHGlobal(bytes.Length);
|
||||
Marshal.Copy(bytes, 0, _wavPtr, bytes.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.Marshalling;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// 把窗口"钉"到所有虚拟桌面(Win+Tab 切桌面后仍可见)
|
||||
///
|
||||
/// 走 Windows **未公开** 的 COM 接口:ImmersiveShell -> IApplicationViewCollection
|
||||
/// -> IVirtualDesktopPinnedApps.PinView。GUID/方法顺序随 build 变化,这里用 Win11 24H2
|
||||
/// (build 26100) 的定义(来自 MScholtes/VirtualDesktop),实测在 26200 上 GUID 仍匹配
|
||||
///
|
||||
/// 关键点:IApplicationView 是 IInspectable,而现代 .NET 不支持 IInspectable 封送,
|
||||
/// 因此这里把 view 当作 **裸 IntPtr** 在 GetViewForHwnd / PinView 之间传递,绕开封送
|
||||
/// 任何一步失败都被吞掉,退回"仅当前桌面显示"
|
||||
///
|
||||
/// AOT 说明:接口用源生成 COM(GeneratedComInterface),ImmersiveShell 用
|
||||
/// CoCreateInstance 直接拿 IUnknown 指针并经 StrategyBasedComWrappers 包装,
|
||||
/// 不再依赖内置 COM 封送(NativeAOT 下内置封送会被裁剪)
|
||||
/// </summary>
|
||||
public static partial class VirtualDesktopPinner
|
||||
{
|
||||
private static readonly StrategyBasedComWrappers ComWrappers = new();
|
||||
|
||||
private static bool _initialized;
|
||||
private static bool _available;
|
||||
private static IApplicationViewCollection? _views;
|
||||
private static IVirtualDesktopPinnedApps? _pinned;
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次失败的诊断信息(临时排查用)
|
||||
/// </summary>
|
||||
public static string LastError { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 尝试把指定窗口钉到所有桌面;返回是否成功
|
||||
/// </summary>
|
||||
public static bool TryPin(IntPtr hwnd)
|
||||
{
|
||||
if (hwnd == IntPtr.Zero)
|
||||
{
|
||||
LastError = "hwnd=0";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnsureInit();
|
||||
if (!_available || _views is null || _pinned is null)
|
||||
{
|
||||
LastError = "init failed: " + LastError;
|
||||
return false;
|
||||
}
|
||||
|
||||
var view = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
_views.GetViewForHwnd(hwnd, out view);
|
||||
if (view == IntPtr.Zero)
|
||||
{
|
||||
LastError = "GetViewForHwnd returned null";
|
||||
return false;
|
||||
}
|
||||
|
||||
_pinned.PinView(view);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = "pin: " + ex.GetType().Name + " 0x" + ex.HResult.ToString("X8") + " " + ex.Message;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (view != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Release(view); // GetViewForHwnd 返回的指针已 AddRef
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = "outer: " + ex.GetType().Name + " " + ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureInit()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
var shellPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
// 用 CoCreateInstance 直接拿 ImmersiveShell 的 IServiceProvider10 指针
|
||||
// 等价于经典写法 Activator.CreateInstance(GetTypeFromCLSID(...)) 但 AOT 友好
|
||||
var clsid = CLSID_ImmersiveShell;
|
||||
var iidServiceProvider = IID_IServiceProvider10;
|
||||
// ImmersiveShell 是本地服务器,必须用 CLSCTX_LOCAL_SERVER
|
||||
// 仅传 INPROC_SERVER 会得到 0x80040154 REGDB_E_CLASSNOTREG
|
||||
var hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_LOCAL_SERVER, ref iidServiceProvider, out shellPtr);
|
||||
if (hr < 0 || shellPtr == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("CoCreateInstance(ImmersiveShell) 0x" + hr.ToString("X8"));
|
||||
}
|
||||
|
||||
var shell = (IServiceProvider10)ComWrappers.GetOrCreateObjectForComInstance(shellPtr, CreateObjectFlags.None);
|
||||
|
||||
var viewCollectionGuid = IID_IApplicationViewCollection;
|
||||
var viewsPtr = shell.QueryService(ref viewCollectionGuid, ref viewCollectionGuid);
|
||||
_views = WrapRequired<IApplicationViewCollection>(viewsPtr, "IApplicationViewCollection");
|
||||
|
||||
var pinnedGuid = IID_IVirtualDesktopPinnedApps;
|
||||
var clsidPinned = CLSID_VirtualDesktopPinnedApps;
|
||||
var pinnedPtr = shell.QueryService(ref clsidPinned, ref pinnedGuid);
|
||||
_pinned = WrapRequired<IVirtualDesktopPinnedApps>(pinnedPtr, "IVirtualDesktopPinnedApps");
|
||||
|
||||
_available = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_available = false;
|
||||
LastError = "EnsureInit: " + ex.GetType().Name + " 0x" + ex.HResult.ToString("X8") + " " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shellPtr != IntPtr.Zero)
|
||||
{
|
||||
// GetOrCreateObjectForComInstance 持有了自己的引用,释放本地这一份
|
||||
Marshal.Release(shellPtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 把 QueryService 返回的裸 IUnknown 指针包装成托管 RCW,并释放本地引用
|
||||
private static T WrapRequired<T>(IntPtr unknown, string name)
|
||||
{
|
||||
if (unknown == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("QueryService(" + name + ") 返回 null");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return (T)ComWrappers.GetOrCreateObjectForComInstance(unknown, CreateObjectFlags.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.Release(unknown);
|
||||
}
|
||||
}
|
||||
|
||||
private const int CLSCTX_LOCAL_SERVER = 4;
|
||||
|
||||
// --- CLSIDs / IIDs ---
|
||||
private static readonly Guid CLSID_ImmersiveShell = new("C2F03A33-21F5-47FA-B4BB-156362A2F239");
|
||||
private static readonly Guid CLSID_VirtualDesktopPinnedApps = new("B5A399E7-1C87-46B8-88E9-FC5747B171BD");
|
||||
private static readonly Guid IID_IServiceProvider10 = new("6D5140C1-7436-11CE-8034-00AA006009FA");
|
||||
private static readonly Guid IID_IApplicationViewCollection = new("1841C6D7-4F9D-42C0-AF41-8747538F10E5");
|
||||
private static readonly Guid IID_IVirtualDesktopPinnedApps = new("4CE81583-1E4C-4632-A621-07A53543148F");
|
||||
|
||||
[LibraryImport("ole32.dll")]
|
||||
private static partial int CoCreateInstance(
|
||||
ref Guid rclsid,
|
||||
IntPtr pUnkOuter,
|
||||
int dwClsContext,
|
||||
ref Guid riid,
|
||||
out IntPtr ppv);
|
||||
}
|
||||
|
||||
// ImmersiveShell 的 IServiceProvider(与系统 IServiceProvider 不同)
|
||||
// QueryService 返回裸 IUnknown 指针(nint),由调用方用 ComWrappers 包装
|
||||
[GeneratedComInterface]
|
||||
[Guid("6D5140C1-7436-11CE-8034-00AA006009FA")]
|
||||
internal partial interface IServiceProvider10
|
||||
{
|
||||
IntPtr QueryService(ref Guid service, ref Guid riid);
|
||||
}
|
||||
|
||||
// 只声明到 GetViewForHwnd(第 4 个方法);view 用 IntPtr,避免 IInspectable 封送
|
||||
[GeneratedComInterface]
|
||||
[Guid("1841C6D7-4F9D-42C0-AF41-8747538F10E5")]
|
||||
internal partial interface IApplicationViewCollection
|
||||
{
|
||||
int GetViews(out IntPtr array);
|
||||
int GetViewsByZOrder(out IntPtr array);
|
||||
int GetViewsByAppUserModelId([MarshalAs(UnmanagedType.LPWStr)] string id, out IntPtr array);
|
||||
int GetViewForHwnd(IntPtr hwnd, out IntPtr view);
|
||||
}
|
||||
|
||||
// view 参数同样用 IntPtr
|
||||
[GeneratedComInterface]
|
||||
[Guid("4CE81583-1E4C-4632-A621-07A53543148F")]
|
||||
internal partial interface IVirtualDesktopPinnedApps
|
||||
{
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
bool IsAppIdPinned([MarshalAs(UnmanagedType.LPWStr)] string appId);
|
||||
void PinAppID([MarshalAs(UnmanagedType.LPWStr)] string appId);
|
||||
void UnpinAppID([MarshalAs(UnmanagedType.LPWStr)] string appId);
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
bool IsViewPinned(IntPtr applicationView);
|
||||
void PinView(IntPtr applicationView);
|
||||
void UnpinView(IntPtr applicationView);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
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();
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetClassNameW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int GetClassName(IntPtr hWnd, ref char lpClassName, int nMaxCount);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||
internal static partial IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
internal static partial IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||
|
||||
// 把窗口设为工具窗口:从任务栏与 Alt+Tab 中隐藏
|
||||
internal static void MakeToolWindow(IntPtr hWnd)
|
||||
{
|
||||
var ex = GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64();
|
||||
ex = (ex | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW;
|
||||
SetWindowLongPtr(hWnd, GWL_EXSTYLE, new IntPtr(ex));
|
||||
}
|
||||
|
||||
// 取窗口类名
|
||||
internal static string GetClassNameOf(IntPtr hWnd)
|
||||
{
|
||||
var buf = new char[256];
|
||||
var n = GetClassName(hWnd, ref buf[0], buf.Length);
|
||||
return n > 0 ? new string(buf, 0, n) : "";
|
||||
}
|
||||
|
||||
// --- 常量 ---
|
||||
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;
|
||||
internal const int GWL_EXSTYLE = -20;
|
||||
internal const long WS_EX_TOOLWINDOW = 0x00000080;
|
||||
internal const long WS_EX_APPWINDOW = 0x00040000;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.Marshalling;
|
||||
using System.Text;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Windows Terminal 标签页的捕获与切换
|
||||
///
|
||||
/// save 时记录当前选中标签的 RuntimeId,点击通知激活窗口后再据此切回该标签
|
||||
/// 全程走源生成 COM(GeneratedComInterface),保证 NativeAOT 兼容
|
||||
/// vtable 顺序与 GUID 均取自 Windows SDK UIAutomationClient.h
|
||||
/// </summary>
|
||||
public static partial class WinTerminalTabs
|
||||
{
|
||||
private const string WtClass = "CASCADIA_HOSTING_WINDOW_CLASS";
|
||||
|
||||
private const int TreeScopeDescendants = 4;
|
||||
private const int ControlTypePropertyId = 30003;
|
||||
private const int IsSelectedPropertyId = 30079;
|
||||
private const int TabItemControlTypeId = 50019;
|
||||
private const int SelectionItemPatternId = 10010;
|
||||
|
||||
private const int CLSCTX_INPROC_SERVER = 1;
|
||||
|
||||
private static readonly StrategyBasedComWrappers ComWrappers = new();
|
||||
private static IUIAutomation? _uia;
|
||||
private static bool _initTried;
|
||||
|
||||
public static bool IsWindowsTerminal(IntPtr hwnd) =>
|
||||
hwnd != IntPtr.Zero && Win32.GetClassNameOf(hwnd) == WtClass;
|
||||
|
||||
// 返回当前选中标签的 RuntimeId 串,失败返回空串
|
||||
public static string GetSelectedTabRuntimeId(IntPtr hwnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uia = EnsureUia();
|
||||
if (uia is null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var root = uia.ElementFromHandle(hwnd);
|
||||
var cond = uia.CreateTrueCondition();
|
||||
var all = root.FindAll(TreeScopeDescendants, cond);
|
||||
|
||||
var count = all.Length();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var el = all.GetElement(i);
|
||||
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GetBoolProperty(el, IsSelectedPropertyId))
|
||||
{
|
||||
return RuntimeIdOf(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 任何 COM 异常退回空串
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// 在已激活的 WT 窗口里找到匹配 RuntimeId 的标签并选中
|
||||
public static bool SelectTab(IntPtr hwnd, string runtimeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(runtimeId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var uia = EnsureUia();
|
||||
if (uia is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = uia.ElementFromHandle(hwnd);
|
||||
var cond = uia.CreateTrueCondition();
|
||||
var all = root.FindAll(TreeScopeDescendants, cond);
|
||||
|
||||
var count = all.Length();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var el = all.GetElement(i);
|
||||
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RuntimeIdOf(el) != runtimeId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pattern = el.GetCurrentPattern(SelectionItemPatternId);
|
||||
if (pattern is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pattern.Select();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 切换失败不影响窗口已被激活
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IUIAutomation? EnsureUia()
|
||||
{
|
||||
if (_initTried)
|
||||
{
|
||||
return _uia;
|
||||
}
|
||||
|
||||
_initTried = true;
|
||||
try
|
||||
{
|
||||
var clsid = new Guid("ff48dba4-60ef-4201-aa87-54103eef594e");
|
||||
var iid = typeof(IUIAutomation).GUID;
|
||||
var hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref iid, out var ptr);
|
||||
if (hr >= 0 && ptr != IntPtr.Zero)
|
||||
{
|
||||
_uia = (IUIAutomation)ComWrappers.GetOrCreateObjectForComInstance(ptr, CreateObjectFlags.None);
|
||||
Marshal.Release(ptr);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_uia = null;
|
||||
}
|
||||
|
||||
return _uia;
|
||||
}
|
||||
|
||||
private static int GetIntProperty(IUIAutomationElement el, int propertyId)
|
||||
{
|
||||
var v = el.GetCurrentPropertyValue(propertyId);
|
||||
return v.lVal;
|
||||
}
|
||||
|
||||
private static bool GetBoolProperty(IUIAutomationElement el, int propertyId)
|
||||
{
|
||||
var v = el.GetCurrentPropertyValue(propertyId);
|
||||
return v.boolVal != 0;
|
||||
}
|
||||
|
||||
// RuntimeId 是一个 int 数组(SAFEARRAY),拼成点分串用于比较
|
||||
private static string RuntimeIdOf(IUIAutomationElement el)
|
||||
{
|
||||
var psa = el.GetRuntimeId();
|
||||
if (psa == IntPtr.Zero)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (SafeArrayGetLBound(psa, 1, out var lb) < 0 || SafeArrayGetUBound(psa, 1, out var ub) < 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var idx = lb; idx <= ub; idx++)
|
||||
{
|
||||
var i = idx;
|
||||
if (SafeArrayGetElement(psa, ref i, out var val) < 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('.');
|
||||
}
|
||||
|
||||
sb.Append(val);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeArrayDestroy(psa);
|
||||
}
|
||||
}
|
||||
|
||||
[LibraryImport("ole32.dll")]
|
||||
private static partial int CoCreateInstance(ref Guid rclsid, IntPtr pUnkOuter, int dwClsContext, ref Guid riid, out IntPtr ppv);
|
||||
|
||||
[LibraryImport("oleaut32.dll")]
|
||||
private static partial int SafeArrayGetLBound(IntPtr psa, uint nDim, out int plLbound);
|
||||
|
||||
[LibraryImport("oleaut32.dll")]
|
||||
private static partial int SafeArrayGetUBound(IntPtr psa, uint nDim, out int plUbound);
|
||||
|
||||
[LibraryImport("oleaut32.dll")]
|
||||
private static partial int SafeArrayGetElement(IntPtr psa, ref int rgIndices, out int pv);
|
||||
|
||||
[LibraryImport("oleaut32.dll")]
|
||||
private static partial int SafeArrayDestroy(IntPtr psa);
|
||||
}
|
||||
|
||||
// VARIANT 的最小化布局(x64 为 24 字节),只读 VT_I4 / VT_BOOL
|
||||
[StructLayout(LayoutKind.Explicit, Size = 24)]
|
||||
internal struct VARIANT
|
||||
{
|
||||
[FieldOffset(0)]
|
||||
public ushort vt;
|
||||
|
||||
[FieldOffset(8)]
|
||||
public int lVal;
|
||||
|
||||
[FieldOffset(8)]
|
||||
public short boolVal;
|
||||
}
|
||||
|
||||
[GeneratedComInterface]
|
||||
[Guid("30cbe57d-d9d0-452a-ab13-7ac5ac4825ee")]
|
||||
internal partial interface IUIAutomation
|
||||
{
|
||||
void _CompareElements();
|
||||
void _CompareRuntimeIds();
|
||||
void _GetRootElement();
|
||||
IUIAutomationElement ElementFromHandle(IntPtr hwnd);
|
||||
void _ElementFromPoint();
|
||||
void _GetFocusedElement();
|
||||
void _GetRootElementBuildCache();
|
||||
void _ElementFromHandleBuildCache();
|
||||
void _ElementFromPointBuildCache();
|
||||
void _GetFocusedElementBuildCache();
|
||||
void _CreateTreeWalker();
|
||||
void _get_ControlViewWalker();
|
||||
void _get_ContentViewWalker();
|
||||
void _get_RawViewWalker();
|
||||
void _get_RawViewCondition();
|
||||
void _get_ControlViewCondition();
|
||||
void _get_ContentViewCondition();
|
||||
void _CreateCacheRequest();
|
||||
IUIAutomationCondition CreateTrueCondition();
|
||||
}
|
||||
|
||||
[GeneratedComInterface]
|
||||
[Guid("d22108aa-8ac5-49a5-837b-37bbb3d7591e")]
|
||||
internal partial interface IUIAutomationElement
|
||||
{
|
||||
void _SetFocus();
|
||||
IntPtr GetRuntimeId();
|
||||
void _FindFirst();
|
||||
IUIAutomationElementArray FindAll(int scope, IUIAutomationCondition condition);
|
||||
void _FindFirstBuildCache();
|
||||
void _FindAllBuildCache();
|
||||
void _BuildUpdatedCache();
|
||||
VARIANT GetCurrentPropertyValue(int propertyId);
|
||||
void _GetCurrentPropertyValueEx();
|
||||
void _GetCachedPropertyValue();
|
||||
void _GetCachedPropertyValueEx();
|
||||
void _GetCurrentPatternAs();
|
||||
void _GetCachedPatternAs();
|
||||
[return: MarshalUsing(typeof(UniqueComInterfaceMarshaller<IUIAutomationSelectionItemPattern>))]
|
||||
IUIAutomationSelectionItemPattern? GetCurrentPattern(int patternId);
|
||||
}
|
||||
|
||||
[GeneratedComInterface]
|
||||
[Guid("14314595-b4bc-4055-95f2-58f2e42c9855")]
|
||||
internal partial interface IUIAutomationElementArray
|
||||
{
|
||||
int Length();
|
||||
IUIAutomationElement GetElement(int index);
|
||||
}
|
||||
|
||||
[GeneratedComInterface]
|
||||
[Guid("352ffba8-0973-437c-a61f-f64cafd81df9")]
|
||||
internal partial interface IUIAutomationCondition
|
||||
{
|
||||
}
|
||||
|
||||
[GeneratedComInterface]
|
||||
[Guid("a8efa66a-0fda-421a-9194-38021f3578ea")]
|
||||
internal partial interface IUIAutomationSelectionItemPattern
|
||||
{
|
||||
void Select();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user