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