diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..01edfd4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 批处理脚本必须用 CRLF,否则 cmd 解析会出错 +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs index 07f9e01..fd27222 100644 --- a/Notify/App.axaml.cs +++ b/Notify/App.axaml.cs @@ -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, })); } diff --git a/Notify/Cli/CliRunner.cs b/Notify/Cli/CliRunner.cs index 077863d..e6b15e4 100644 --- a/Notify/Cli/CliRunner.cs +++ b/Notify/Cli/CliRunner.cs @@ -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 ,尝试激活指定窗口并把结果写入临时日志 @@ -111,6 +122,33 @@ public static class CliRunner return ok ? 0 : 1; } + // 仅用于自测:wttest ,切到该标签后回读当前选中项 + 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() diff --git a/Notify/Interop/Win32.cs b/Notify/Interop/Win32.cs index 7020953..e3d1c85 100644 --- a/Notify/Interop/Win32.cs +++ b/Notify/Interop/Win32.cs @@ -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; } diff --git a/Notify/Interop/WinTerminalTabs.cs b/Notify/Interop/WinTerminalTabs.cs new file mode 100644 index 0000000..0636f1a --- /dev/null +++ b/Notify/Interop/WinTerminalTabs.cs @@ -0,0 +1,299 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Text; + +namespace Notify.Interop; + +/// +/// Windows Terminal 标签页的捕获与切换 +/// +/// save 时记录当前选中标签的 RuntimeId,点击通知激活窗口后再据此切回该标签 +/// 全程走源生成 COM(GeneratedComInterface),保证 NativeAOT 兼容 +/// vtable 顺序与 GUID 均取自 Windows SDK UIAutomationClient.h +/// +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? 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(); +} diff --git a/Notify/Ipc/IpcConstants.cs b/Notify/Ipc/IpcConstants.cs index b238eb2..0fafb53 100644 --- a/Notify/Ipc/IpcConstants.cs +++ b/Notify/Ipc/IpcConstants.cs @@ -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"; } diff --git a/Notify/Ipc/NotificationSpool.cs b/Notify/Ipc/NotificationSpool.cs new file mode 100644 index 0000000..8b26758 --- /dev/null +++ b/Notify/Ipc/NotificationSpool.cs @@ -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; + +/// +/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费 +/// +/// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code +/// +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 handler) + { + try + { + if (!Directory.Exists(Dir)) + { + return; + } + + foreach (var path in Directory.GetFiles(Dir, "*.json")) + { + if (ReadAndRemove(path) is { } msg) + { + handler(msg); + } + } + } + catch + { + // 忽略 + } + } +} diff --git a/Notify/Ipc/PipeMessage.cs b/Notify/Ipc/NotifyMessage.cs similarity index 64% rename from Notify/Ipc/PipeMessage.cs rename to Notify/Ipc/NotifyMessage.cs index a6c5f36..e433bae 100644 --- a/Notify/Ipc/PipeMessage.cs +++ b/Notify/Ipc/NotifyMessage.cs @@ -1,9 +1,9 @@ namespace Notify.Ipc; /// -/// 瘦客户端经命名管道投递给 Host 的一条弹窗请求 +/// 一条投递给 Host 的弹窗请求,经 spool 文件传递 /// -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; } } diff --git a/Notify/Ipc/PipeClient.cs b/Notify/Ipc/PipeClient.cs deleted file mode 100644 index 3b2a3c7..0000000 --- a/Notify/Ipc/PipeClient.cs +++ /dev/null @@ -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; - -/// -/// 瘦客户端侧:把一条 PipeMessage 发给 Host,Host 不在则拉起后重试 -/// -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 - { - // 拉起失败则发送会重试超时后放弃 - } - } -} diff --git a/Notify/Ipc/PipeServer.cs b/Notify/Ipc/PipeServer.cs deleted file mode 100644 index 929a174..0000000 --- a/Notify/Ipc/PipeServer.cs +++ /dev/null @@ -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; - -/// -/// Host 侧命名管道监听:每个连接读取一条 PipeMessage 并回调 -/// -public sealed class PipeServer -{ - private readonly Action _onMessage; - - public PipeServer(Action 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 - { - // 单个连接出错不影响后续监听 - } - } - } -} diff --git a/Notify/Ipc/SpoolWatcher.cs b/Notify/Ipc/SpoolWatcher.cs new file mode 100644 index 0000000..8580fe2 --- /dev/null +++ b/Notify/Ipc/SpoolWatcher.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; + +namespace Notify.Ipc; + +/// +/// Host 侧:监视 spool 目录,新文件出现即消费并回调 +/// +public sealed class SpoolWatcher +{ + private readonly Action _onMessage; + private FileSystemWatcher? _watcher; + + public SpoolWatcher(Action 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); + } + } +} diff --git a/Notify/Models/StateData.cs b/Notify/Models/StateData.cs index 2fe282e..416fda0 100644 --- a/Notify/Models/StateData.cs +++ b/Notify/Models/StateData.cs @@ -10,4 +10,7 @@ public sealed class StateData // 用户当次输入的 prompt,用作"任务完成"通知的正文 public string Prompt { get; set; } = ""; + + // 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回 + public string WtRuntimeId { get; set; } = ""; } diff --git a/Notify/Models/ToastRequest.cs b/Notify/Models/ToastRequest.cs index f696a59..02c6685 100644 --- a/Notify/Models/ToastRequest.cs +++ b/Notify/Models/ToastRequest.cs @@ -23,4 +23,9 @@ public sealed class ToastRequest /// 点击 toast 主体时要激活的窗口句柄,0 表示不激活 /// public long TargetHwnd { get; init; } + + /// + /// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId + /// + public string? WtRuntimeId { get; init; } } diff --git a/Notify/Notify.csproj b/Notify/Notify.csproj index 9f23930..ad7a96f 100644 --- a/Notify/Notify.csproj +++ b/Notify/Notify.csproj @@ -15,10 +15,25 @@ true + + + win-x64 + true + false + Size + + + + + + + + + diff --git a/Notify/Program.cs b/Notify/Program.cs index bcdaf5c..d19bd13 100644 --- a/Notify/Program.cs +++ b/Notify/Program.cs @@ -32,6 +32,7 @@ internal static class Program "input" => CliRunner.Input(), "cleanup" => CliRunner.Cleanup(), "acttest" => CliRunner.ActTest(args), + "wttest" => CliRunner.WtTest(args), _ => RunHost(args), }; } diff --git a/Notify/Serialization/AppJsonContext.cs b/Notify/Serialization/AppJsonContext.cs index 585cf8a..0c7eed1 100644 --- a/Notify/Serialization/AppJsonContext.cs +++ b/Notify/Serialization/AppJsonContext.cs @@ -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; diff --git a/Notify/Services/ToastManager.cs b/Notify/Services/ToastManager.cs index afea853..ebac14e 100644 --- a/Notify/Services/ToastManager.cs +++ b/Notify/Services/ToastManager.cs @@ -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); diff --git a/Notify/Views/ToastWindow.axaml.cs b/Notify/Views/ToastWindow.axaml.cs index 0658551..18d1967 100644 --- a/Notify/Views/ToastWindow.axaml.cs +++ b/Notify/Views/ToastWindow.axaml.cs @@ -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(); diff --git a/scripts/build.bat b/scripts/build.bat new file mode 100644 index 0000000..6040b19 --- /dev/null +++ b/scripts/build.bat @@ -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