feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user