Files
notify/Notify/Interop/VirtualDesktopPinner.cs
chuan 5ce2c8a982 feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows
Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
2026-06-22 18:05:15 +08:00

210 lines
7.7 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 说明:接口用源生成 COMGeneratedComInterface),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);
}