feat: WT 切标签、工具窗口化与非阻塞投递
- P1b: 点击通知切回 Windows Terminal 原标签(源生成 COM UIAutomation,AOT 友好) - Toast 设为工具窗口(WS_EX_TOOLWINDOW),从任务栏与 Alt+Tab 隐藏 - 投递改为落盘队列 spool + FileSystemWatcher,CLI 毫秒级返回不阻塞 Claude Code - 移除命名管道(PipeServer/PipeClient/PipeMessage),新增 NotificationSpool/SpoolWatcher - NativeAOT 发布配置 + scripts/build.bat(vcvars 自动配置 MSVC 工具链) - .gitattributes 保证 .bat 用 CRLF
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# 批处理脚本必须用 CRLF,否则 cmd 解析会出错
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
+7
-6
@@ -15,7 +15,7 @@ namespace Notify;
|
||||
public partial class App : Application
|
||||
{
|
||||
private SettingsWindow? _settingsWindow;
|
||||
private PipeServer? _pipeServer;
|
||||
private SpoolWatcher? _spoolWatcher;
|
||||
|
||||
public static new App Current => (App)Application.Current!;
|
||||
|
||||
@@ -30,9 +30,9 @@ public partial class App : Application
|
||||
Settings.Load();
|
||||
Toasts = new ToastManager(Settings);
|
||||
|
||||
// 监听命名管道,把瘦客户端投递的请求转成 toast
|
||||
_pipeServer = new PipeServer(OnPipeMessage);
|
||||
_pipeServer.Start();
|
||||
// 监视 spool 目录,把瘦客户端投递的请求转成 toast
|
||||
_spoolWatcher = new SpoolWatcher(OnNotify);
|
||||
_spoolWatcher.Start();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
@@ -49,8 +49,8 @@ public partial class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
// 管道线程收到请求,切回 UI 线程弹出 toast
|
||||
private void OnPipeMessage(PipeMessage message)
|
||||
// 监视线程收到请求,切回 UI 线程弹出 toast
|
||||
private void OnNotify(NotifyMessage message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => Toasts.Show(new ToastRequest
|
||||
{
|
||||
@@ -59,6 +59,7 @@ public partial class App : Application
|
||||
InputMode = message.InputMode,
|
||||
Sticky = message.Sticky,
|
||||
TargetHwnd = message.TargetHwnd,
|
||||
WtRuntimeId = message.WtRuntimeId,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+40
-4
@@ -25,10 +25,17 @@ public static class CliRunner
|
||||
}
|
||||
|
||||
var hwnd = Win32.GetForegroundWindow();
|
||||
|
||||
// 前台是 Windows Terminal 时,记录当前标签的 RuntimeId
|
||||
var wtRuntimeId = WinTerminalTabs.IsWindowsTerminal(hwnd)
|
||||
? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd)
|
||||
: "";
|
||||
|
||||
StateStore.Save(input.SessionId, new StateData
|
||||
{
|
||||
Hwnd = hwnd.ToInt64(),
|
||||
Prompt = input.Prompt ?? "",
|
||||
WtRuntimeId = wtRuntimeId,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
@@ -45,7 +52,7 @@ public static class CliRunner
|
||||
var state = StateStore.Load(input.SessionId);
|
||||
var message = !string.IsNullOrWhiteSpace(state?.Prompt) ? state!.Prompt : "Task completed";
|
||||
|
||||
return SendToHost(new PipeMessage
|
||||
NotificationSpool.Deliver(new NotifyMessage
|
||||
{
|
||||
SessionId = input.SessionId,
|
||||
Title = "Claude Code",
|
||||
@@ -53,7 +60,9 @@ public static class CliRunner
|
||||
InputMode = false,
|
||||
Sticky = false,
|
||||
TargetHwnd = state?.Hwnd ?? 0,
|
||||
WtRuntimeId = state?.WtRuntimeId,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Notification / PreToolUse:需要输入,常驻显示
|
||||
@@ -74,7 +83,7 @@ public static class CliRunner
|
||||
var (title, message) = Resolve(input);
|
||||
var state = StateStore.Load(input.SessionId);
|
||||
|
||||
return SendToHost(new PipeMessage
|
||||
NotificationSpool.Deliver(new NotifyMessage
|
||||
{
|
||||
SessionId = input.SessionId,
|
||||
Title = title,
|
||||
@@ -82,7 +91,9 @@ public static class CliRunner
|
||||
InputMode = true,
|
||||
Sticky = true,
|
||||
TargetHwnd = state?.Hwnd ?? 0,
|
||||
WtRuntimeId = state?.WtRuntimeId,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 仅用于自测:acttest <hwnd>,尝试激活指定窗口并把结果写入临时日志
|
||||
@@ -111,6 +122,33 @@ public static class CliRunner
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
// 仅用于自测:wttest <hwnd> <runtimeId>,切到该标签后回读当前选中项
|
||||
public static int WtTest(string[] args)
|
||||
{
|
||||
if (args.Length < 3 || !long.TryParse(args[1], out var h))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var hwnd = new IntPtr(h);
|
||||
var ok = WinTerminalTabs.SelectTab(hwnd, args[2]);
|
||||
System.Threading.Thread.Sleep(250);
|
||||
var now = WinTerminalTabs.GetSelectedTabRuntimeId(hwnd);
|
||||
|
||||
try
|
||||
{
|
||||
System.IO.File.WriteAllText(
|
||||
System.IO.Path.Combine(System.IO.Path.GetTempPath(), "notify-wttest.log"),
|
||||
$"select ok={ok} expected={args[2]} nowSelected={now} match={(now == args[2])}\n");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
// SessionEnd:清理会话状态
|
||||
public static int Cleanup()
|
||||
{
|
||||
@@ -148,8 +186,6 @@ public static class CliRunner
|
||||
return (title, message);
|
||||
}
|
||||
|
||||
private static int SendToHost(PipeMessage message) => PipeClient.Send(message) ? 0 : 1;
|
||||
|
||||
// 直接读原始字节并按 UTF-8 解码:WinExe 下 Console.In 不可靠,且其代码页
|
||||
// 会把中文解成乱码(GBK),这里绕开
|
||||
private static HookInput? ReadStdin()
|
||||
|
||||
@@ -52,6 +52,31 @@ internal static partial class Win32
|
||||
[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;
|
||||
@@ -61,4 +86,7 @@ internal static partial class Win32
|
||||
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();
|
||||
}
|
||||
@@ -2,9 +2,6 @@ namespace Notify.Ipc;
|
||||
|
||||
internal static class IpcConstants
|
||||
{
|
||||
// 瘦客户端与常驻 Host 之间的命名管道名
|
||||
public const string PipeName = "claude-code-notify";
|
||||
|
||||
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
|
||||
public const string HostMutexName = "ClaudeCodeNotifyHost";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using Notify.Serialization;
|
||||
|
||||
namespace Notify.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费
|
||||
///
|
||||
/// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code
|
||||
/// </summary>
|
||||
public static class NotificationSpool
|
||||
{
|
||||
public static readonly string Dir =
|
||||
Path.Combine(Path.GetTempPath(), "claude-notify-spool");
|
||||
|
||||
// CLI 侧:写入一条请求,必要时拉起 Host,全程不阻塞
|
||||
public static void Deliver(NotifyMessage message)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
|
||||
// 先写 .tmp 再原子改名为 .json,避免 Host 读到半截文件
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var tmp = Path.Combine(Dir, id + ".tmp");
|
||||
var final = Path.Combine(Dir, id + ".json");
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(message, AppJsonContext.Default.NotifyMessage));
|
||||
File.Move(tmp, final);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 写入失败则放弃这条通知,绝不影响调用方
|
||||
}
|
||||
|
||||
EnsureHostRunning();
|
||||
}
|
||||
|
||||
// Host 未运行则拉起(不等待);运行中则什么都不做
|
||||
private static void EnsureHostRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mutex.TryOpenExisting(IpcConstants.HostMutexName, out var existing))
|
||||
{
|
||||
existing.Dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 打不开就当作未运行,继续尝试拉起
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (exe is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
Arguments = "host",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 拉起失败则该通知会在下次 Host 启动时由 DrainExisting 补弹
|
||||
}
|
||||
}
|
||||
|
||||
// Host 侧:消费单个 spool 文件并删除
|
||||
public static NotifyMessage? ReadAndRemove(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
File.Delete(path);
|
||||
return JsonSerializer.Deserialize(json, AppJsonContext.Default.NotifyMessage);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Host 启动时把已有的 spool 文件补弹一遍
|
||||
public static void DrainExisting(Action<NotifyMessage> handler)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(Dir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in Directory.GetFiles(Dir, "*.json"))
|
||||
{
|
||||
if (ReadAndRemove(path) is { } msg)
|
||||
{
|
||||
handler(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace Notify.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// 瘦客户端经命名管道投递给 Host 的一条弹窗请求
|
||||
/// 一条投递给 Host 的弹窗请求,经 spool 文件传递
|
||||
/// </summary>
|
||||
public sealed class PipeMessage
|
||||
public sealed class NotifyMessage
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
@@ -15,9 +15,12 @@ public sealed class PipeMessage
|
||||
// true = 常驻,不自动消失
|
||||
public bool Sticky { get; set; }
|
||||
|
||||
// 触发该通知的会话 id,便于 Host 后续按会话激活窗口
|
||||
// 触发该通知的会话 id
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
// 点击 toast 时要激活的目标窗口句柄,0 表示无
|
||||
public long TargetHwnd { get; set; }
|
||||
|
||||
// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
|
||||
public string? WtRuntimeId { get; set; }
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using Notify.Serialization;
|
||||
|
||||
namespace Notify.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// 瘦客户端侧:把一条 PipeMessage 发给 Host,Host 不在则拉起后重试
|
||||
/// </summary>
|
||||
public static class PipeClient
|
||||
{
|
||||
public static bool Send(PipeMessage message)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(message, AppJsonContext.Default.PipeMessage);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
if (TrySend(bytes, 300))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Host 未运行:拉起后等待其管道就绪再重试,最多约 5 秒
|
||||
StartHost();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
if (TrySend(bytes, 300))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TrySend(byte[] bytes, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(".", IpcConstants.PipeName, PipeDirection.Out);
|
||||
client.Connect(timeoutMs);
|
||||
client.Write(bytes, 0, bytes.Length);
|
||||
client.Flush();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartHost()
|
||||
{
|
||||
try
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (exe is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
Arguments = "host",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 拉起失败则发送会重试超时后放弃
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Notify.Serialization;
|
||||
|
||||
namespace Notify.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Host 侧命名管道监听:每个连接读取一条 PipeMessage 并回调
|
||||
/// </summary>
|
||||
public sealed class PipeServer
|
||||
{
|
||||
private readonly Action<PipeMessage> _onMessage;
|
||||
|
||||
public PipeServer(Action<PipeMessage> onMessage) => _onMessage = onMessage;
|
||||
|
||||
public void Start() => Task.Run(RunLoopAsync);
|
||||
|
||||
private async Task RunLoopAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
IpcConstants.PipeName,
|
||||
PipeDirection.In,
|
||||
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await server.WaitForConnectionAsync().ConfigureAwait(false);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await server.CopyToAsync(ms).ConfigureAwait(false);
|
||||
|
||||
var json = Encoding.UTF8.GetString(ms.ToArray());
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var msg = JsonSerializer.Deserialize(json, AppJsonContext.Default.PipeMessage);
|
||||
if (msg is not null)
|
||||
{
|
||||
_onMessage(msg);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 单个连接出错不影响后续监听
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Notify.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Host 侧:监视 spool 目录,新文件出现即消费并回调
|
||||
/// </summary>
|
||||
public sealed class SpoolWatcher
|
||||
{
|
||||
private readonly Action<NotifyMessage> _onMessage;
|
||||
private FileSystemWatcher? _watcher;
|
||||
|
||||
public SpoolWatcher(Action<NotifyMessage> onMessage) => _onMessage = onMessage;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
Directory.CreateDirectory(NotificationSpool.Dir);
|
||||
|
||||
// 先补弹启动前堆积的请求
|
||||
NotificationSpool.DrainExisting(_onMessage);
|
||||
|
||||
_watcher = new FileSystemWatcher(NotificationSpool.Dir, "*.json")
|
||||
{
|
||||
NotifyFilter = NotifyFilters.FileName,
|
||||
EnableRaisingEvents = true,
|
||||
};
|
||||
_watcher.Created += OnCreated;
|
||||
_watcher.Renamed += OnRenamed;
|
||||
}
|
||||
|
||||
private void OnCreated(object sender, FileSystemEventArgs e) => Handle(e.FullPath);
|
||||
|
||||
private void OnRenamed(object sender, RenamedEventArgs e) => Handle(e.FullPath);
|
||||
|
||||
private void Handle(string path)
|
||||
{
|
||||
if (NotificationSpool.ReadAndRemove(path) is { } msg)
|
||||
{
|
||||
_onMessage(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,7 @@ public sealed class StateData
|
||||
|
||||
// 用户当次输入的 prompt,用作"任务完成"通知的正文
|
||||
public string Prompt { get; set; } = "";
|
||||
|
||||
// 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回
|
||||
public string WtRuntimeId { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -23,4 +23,9 @@ public sealed class ToastRequest
|
||||
/// 点击 toast 主体时要激活的窗口句柄,0 表示不激活
|
||||
/// </summary>
|
||||
public long TargetHwnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
|
||||
/// </summary>
|
||||
public string? WtRuntimeId { get; init; }
|
||||
}
|
||||
|
||||
@@ -15,10 +15,25 @@
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- NativeAOT 发布配置(仅 publish 生效):单文件原生 exe -->
|
||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
<DebuggerSupport>false</DebuggerSupport>
|
||||
<OptimizationPreference>Size</OptimizationPreference>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 这些 UI 库未完全标注 trim/AOT 安全,整体保留避免裁剪导致运行时异常 -->
|
||||
<ItemGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<TrimmerRootAssembly Include="Ursa" />
|
||||
<TrimmerRootAssembly Include="Ursa.Themes.Semi" />
|
||||
<TrimmerRootAssembly Include="Semi.Avalonia" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
|
||||
@@ -32,6 +32,7 @@ internal static class Program
|
||||
"input" => CliRunner.Input(),
|
||||
"cleanup" => CliRunner.Cleanup(),
|
||||
"acttest" => CliRunner.ActTest(args),
|
||||
"wttest" => CliRunner.WtTest(args),
|
||||
_ => RunHost(args),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@ namespace Notify.Serialization;
|
||||
[JsonSerializable(typeof(ToastSettings))]
|
||||
[JsonSerializable(typeof(StateData))]
|
||||
[JsonSerializable(typeof(HookInput))]
|
||||
[JsonSerializable(typeof(PipeMessage))]
|
||||
[JsonSerializable(typeof(NotifyMessage))]
|
||||
internal partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class ToastManager
|
||||
}
|
||||
|
||||
var vm = new ToastViewModel(request);
|
||||
var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd);
|
||||
var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId);
|
||||
window.Closed += OnToastClosed;
|
||||
|
||||
_active.Add(window);
|
||||
|
||||
@@ -21,17 +21,19 @@ public partial class ToastWindow : Window
|
||||
private readonly DispatcherTimer _dismissTimer;
|
||||
private readonly bool _sticky;
|
||||
private readonly long _targetHwnd;
|
||||
private readonly string? _wtRuntimeId;
|
||||
private bool _closing;
|
||||
|
||||
// 设计器需要的无参构造
|
||||
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0)
|
||||
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null)
|
||||
{
|
||||
}
|
||||
|
||||
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd)
|
||||
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId)
|
||||
{
|
||||
_settings = settings;
|
||||
_targetHwnd = targetHwnd;
|
||||
_wtRuntimeId = wtRuntimeId;
|
||||
// 常驻:请求显式 Sticky,或全局停留时长 <= 0
|
||||
_sticky = sticky || settings.DurationSeconds <= 0;
|
||||
InitializeComponent();
|
||||
@@ -65,10 +67,16 @@ public partial class ToastWindow : Window
|
||||
base.OnOpened(e);
|
||||
Opacity = _settings.Opacity; // 触发淡入
|
||||
|
||||
// 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
|
||||
if (_settings.ShowOnAllDesktops && TryGetPlatformHandle()?.Handle is { } hwnd)
|
||||
if (TryGetPlatformHandle()?.Handle is { } hwnd)
|
||||
{
|
||||
TryPinWithRetry(hwnd);
|
||||
// 工具窗口:从任务栏与 Alt+Tab 中隐藏
|
||||
Notify.Interop.Win32.MakeToolWindow(hwnd);
|
||||
|
||||
// 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
|
||||
if (_settings.ShowOnAllDesktops)
|
||||
{
|
||||
TryPinWithRetry(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_sticky)
|
||||
@@ -126,7 +134,14 @@ public partial class ToastWindow : Window
|
||||
{
|
||||
if (_targetHwnd != 0)
|
||||
{
|
||||
Notify.Interop.WindowActivator.Activate(new IntPtr(_targetHwnd));
|
||||
var target = new IntPtr(_targetHwnd);
|
||||
Notify.Interop.WindowActivator.Activate(target);
|
||||
|
||||
// 目标是 Windows Terminal 则切回原标签
|
||||
if (!string.IsNullOrEmpty(_wtRuntimeId))
|
||||
{
|
||||
Notify.Interop.WinTerminalTabs.SelectTab(target, _wtRuntimeId);
|
||||
}
|
||||
}
|
||||
|
||||
BeginClose();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
setlocal
|
||||
rem 切到仓库根目录(scripts 的上一级)
|
||||
cd /d "%~dp0\.."
|
||||
|
||||
rem NativeAOT 的原生链接需要 MSVC 工具链,先用 vswhere 找到 VS 并配置环境
|
||||
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if exist "%VSWHERE%" (
|
||||
for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i"
|
||||
)
|
||||
if defined VSPATH if exist "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" (
|
||||
echo === 配置 MSVC 环境: %VSPATH% ===
|
||||
call "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" >nul
|
||||
)
|
||||
|
||||
echo === NativeAOT publish (win-x64) -^> bin\notify.exe ===
|
||||
dotnet publish Notify -c Release -r win-x64 -p:PublishAot=true -o bin
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo *** 发布失败。若提示找不到 link.exe,请从 "Developer Command Prompt for VS" 运行本脚本 ***
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === 完成: %CD%\bin\notify.exe ===
|
||||
endlocal
|
||||
Reference in New Issue
Block a user