5ce2c8a982
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
210 lines
7.7 KiB
C#
210 lines
7.7 KiB
C#
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);
|
||
}
|