diff --git a/Notify/App.axaml b/Notify/App.axaml
new file mode 100644
index 0000000..6c82bfc
--- /dev/null
+++ b/Notify/App.axaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs
new file mode 100644
index 0000000..d350c92
--- /dev/null
+++ b/Notify/App.axaml.cs
@@ -0,0 +1,98 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Notify.Models;
+using Notify.Services;
+using Notify.ViewModels;
+using Notify.Views;
+
+namespace Notify;
+
+public partial class App : Application
+{
+ private SettingsWindow? _settingsWindow;
+
+ public static new App Current => (App)Application.Current!;
+
+ public SettingsService Settings { get; } = new();
+
+ public ToastManager Toasts { get; private set; } = null!;
+
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ Settings.Load();
+ Toasts = new ToastManager(Settings);
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // 无主窗口的常驻进程:仅托盘存在,靠托盘菜单或外部请求驱动
+ desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
+
+ // --demo:启动即弹一条 toast 并打开设置,便于无托盘交互地验证
+ if (desktop.Args is { Length: > 0 } args && System.Array.IndexOf(args, "--demo") >= 0)
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(RunDemo);
+ }
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private void RunDemo()
+ {
+ // 普通:会自动消失
+ Toasts.Show(new ToastRequest { Title = "Claude Code", Message = "任务已完成 — 4 秒后自动消失" });
+ // 常驻:InputMode 且 Sticky,不点不消失
+ Toasts.Show(new ToastRequest
+ {
+ Title = "需要你的输入",
+ Message = "权限请求 — 常驻,点击 / ✕ 才关闭",
+ InputMode = true,
+ Sticky = true,
+ });
+ }
+
+ private int _counter;
+
+ private void OnTestToastClick(object? sender, EventArgs e)
+ {
+ _counter++;
+ var inputMode = _counter % 2 == 0;
+ Toasts.Show(new ToastRequest
+ {
+ Title = inputMode ? "需要你的输入" : "Claude Code",
+ Message = inputMode
+ ? $"权限请求 #{_counter} — 点击跳回终端"
+ : $"任务已完成 #{_counter}",
+ InputMode = inputMode,
+ });
+ }
+
+ private void OnOpenSettingsClick(object? sender, EventArgs e)
+ {
+ if (_settingsWindow is { } w)
+ {
+ w.Activate();
+ return;
+ }
+
+ _settingsWindow = new SettingsWindow
+ {
+ DataContext = new SettingsViewModel(Settings, Toasts),
+ };
+ _settingsWindow.Closed += (_, _) => _settingsWindow = null;
+ _settingsWindow.Show();
+ }
+
+ private void OnExitClick(object? sender, EventArgs e)
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.Shutdown();
+ }
+ }
+}
diff --git a/Notify/Assets/JetBrainsMono-ExtraBold.ttf b/Notify/Assets/JetBrainsMono-ExtraBold.ttf
new file mode 100644
index 0000000..435d7a7
Binary files /dev/null and b/Notify/Assets/JetBrainsMono-ExtraBold.ttf differ
diff --git a/Notify/Assets/claude.ico b/Notify/Assets/claude.ico
new file mode 100644
index 0000000..5df0a49
Binary files /dev/null and b/Notify/Assets/claude.ico differ
diff --git a/Notify/Assets/notification.wav b/Notify/Assets/notification.wav
new file mode 100644
index 0000000..174122a
Binary files /dev/null and b/Notify/Assets/notification.wav differ
diff --git a/Notify/Interop/VirtualDesktopPinner.cs b/Notify/Interop/VirtualDesktopPinner.cs
new file mode 100644
index 0000000..f7483d8
--- /dev/null
+++ b/Notify/Interop/VirtualDesktopPinner.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.Marshalling;
+
+namespace Notify.Interop;
+
+///
+/// 把窗口"钉"到所有虚拟桌面(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 说明:接口用源生成 COM(GeneratedComInterface),ImmersiveShell 用
+/// CoCreateInstance 直接拿 IUnknown 指针并经 StrategyBasedComWrappers 包装,
+/// 不再依赖内置 COM 封送(NativeAOT 下内置封送会被裁剪)
+///
+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;
+
+ ///
+ /// 最近一次失败的诊断信息(临时排查用)
+ ///
+ public static string LastError { get; private set; } = "";
+
+ ///
+ /// 尝试把指定窗口钉到所有桌面;返回是否成功
+ ///
+ 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(viewsPtr, "IApplicationViewCollection");
+
+ var pinnedGuid = IID_IVirtualDesktopPinnedApps;
+ var clsidPinned = CLSID_VirtualDesktopPinnedApps;
+ var pinnedPtr = shell.QueryService(ref clsidPinned, ref pinnedGuid);
+ _pinned = WrapRequired(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(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);
+}
diff --git a/Notify/Models/ToastRequest.cs b/Notify/Models/ToastRequest.cs
new file mode 100644
index 0000000..fd47ec5
--- /dev/null
+++ b/Notify/Models/ToastRequest.cs
@@ -0,0 +1,21 @@
+namespace Notify.Models;
+
+///
+/// 一次弹窗请求(后续由 hook / named pipe 投递)
+///
+public sealed class ToastRequest
+{
+ public required string Title { get; init; }
+
+ public required string Message { get; init; }
+
+ ///
+ /// true = 需要输入(黄色边框),false = 任务完成(橙色边框)
+ ///
+ public bool InputMode { get; init; }
+
+ ///
+ /// true = 常驻:不自动消失,只能点击 / ✕ 关闭
+ ///
+ public bool Sticky { get; init; }
+}
diff --git a/Notify/Models/ToastSettings.cs b/Notify/Models/ToastSettings.cs
new file mode 100644
index 0000000..ceeccd7
--- /dev/null
+++ b/Notify/Models/ToastSettings.cs
@@ -0,0 +1,60 @@
+namespace Notify.Models;
+
+///
+/// 屏幕角落,决定 toast 堆叠的起点与方向
+///
+public enum ToastCorner
+{
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight,
+}
+
+///
+/// 持久化的弹窗设置(纯数据模型,序列化到磁盘)
+///
+public sealed class ToastSettings
+{
+ ///
+ /// 自动消失前的停留秒数
+ ///
+ public int DurationSeconds { get; set; } = 4;
+
+ ///
+ /// 出现的屏幕角落
+ ///
+ public ToastCorner Corner { get; set; } = ToastCorner.BottomRight;
+
+ ///
+ /// 不透明度 0–1
+ ///
+ public double Opacity { get; set; } = 0.96;
+
+ ///
+ /// 最多同时可见的 toast 数量,超出则排队
+ ///
+ public int MaxVisible { get; set; } = 5;
+
+ ///
+ /// 是否播放提示音
+ ///
+ public bool PlaySound { get; set; } = true;
+
+ ///
+ /// toast 宽度(DIP)
+ ///
+ public double Width { get; set; } = 340;
+
+ ///
+ /// 淡入/淡出时长(毫秒)
+ ///
+ public int FadeMilliseconds { get; set; } = 300;
+
+ ///
+ /// 跨所有虚拟桌面显示(未公开 API,失败自动退回单桌面)
+ ///
+ public bool ShowOnAllDesktops { get; set; } = true;
+
+ public ToastSettings Clone() => (ToastSettings)MemberwiseClone();
+}
diff --git a/Notify/Notify.csproj b/Notify/Notify.csproj
new file mode 100644
index 0000000..9f23930
--- /dev/null
+++ b/Notify/Notify.csproj
@@ -0,0 +1,32 @@
+
+
+
+ WinExe
+ net10.0-windows
+ enable
+ latest
+ true
+ app.manifest
+ true
+ Assets\claude.ico
+ Notify
+ notify
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Notify/Program.cs b/Notify/Program.cs
new file mode 100644
index 0000000..2fc41b3
--- /dev/null
+++ b/Notify/Program.cs
@@ -0,0 +1,21 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+
+namespace Notify;
+
+internal static class Program
+{
+ // Avalonia configuration, don't remove; also used by the visual designer.
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+
+ [STAThread]
+ public static void Main(string[] args) =>
+ BuildAvaloniaApp()
+ // OnExplicitShutdown = 持续保活:没有主窗口也不会退出,只有显式 Shutdown 才结束
+ .StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
+}
diff --git a/Notify/Serialization/AppJsonContext.cs b/Notify/Serialization/AppJsonContext.cs
new file mode 100644
index 0000000..e4df96f
--- /dev/null
+++ b/Notify/Serialization/AppJsonContext.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+using Notify.Models;
+
+namespace Notify.Serialization;
+
+// System.Text.Json 源生成:为后续 NativeAOT 准备,避免反射序列化被裁剪
+[JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true)]
+[JsonSerializable(typeof(ToastSettings))]
+internal partial class AppJsonContext : JsonSerializerContext;
diff --git a/Notify/Services/SettingsService.cs b/Notify/Services/SettingsService.cs
new file mode 100644
index 0000000..169f5fc
--- /dev/null
+++ b/Notify/Services/SettingsService.cs
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using Notify.Models;
+using Notify.Serialization;
+
+namespace Notify.Services;
+
+///
+/// 加载/保存弹窗设置,并在变更时通知订阅者
+///
+public sealed class SettingsService
+{
+ private static readonly string Dir =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeCodeNotify");
+
+ private static readonly string FilePath = Path.Combine(Dir, "settings.json");
+
+ public ToastSettings Current { get; private set; } = new();
+
+ ///
+ /// 设置被保存后触发
+ ///
+ public event Action? Changed;
+
+ public void Load()
+ {
+ try
+ {
+ if (File.Exists(FilePath))
+ {
+ var json = File.ReadAllText(FilePath);
+ var loaded = JsonSerializer.Deserialize(json, AppJsonContext.Default.ToastSettings);
+ if (loaded is not null)
+ {
+ Current = loaded;
+ }
+ }
+ }
+ catch
+ {
+ // 配置损坏则回退到默认值,不影响保活
+ Current = new ToastSettings();
+ }
+ }
+
+ public void Save(ToastSettings settings)
+ {
+ Current = settings;
+ try
+ {
+ Directory.CreateDirectory(Dir);
+ var json = JsonSerializer.Serialize(settings, AppJsonContext.Default.ToastSettings);
+ File.WriteAllText(FilePath, json);
+ }
+ catch
+ {
+ // 落盘失败不致命,内存里仍生效
+ }
+
+ Changed?.Invoke(Current);
+ }
+}
diff --git a/Notify/Services/ToastManager.cs b/Notify/Services/ToastManager.cs
new file mode 100644
index 0000000..50d3c59
--- /dev/null
+++ b/Notify/Services/ToastManager.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Platform;
+using Notify.Models;
+using Notify.ViewModels;
+using Notify.Views;
+
+namespace Notify.Services;
+
+///
+/// 在常驻进程内管理所有 toast 窗口:创建、按角落堆叠、关闭后重新排布
+/// 这是 Rust 版"每条通知一进程 + EnumWindows"的替代——进程内一个列表即可
+///
+public sealed class ToastManager
+{
+ private const int Margin = 12;
+ private const int Gap = 8;
+
+ private readonly SettingsService _settings;
+ private readonly List _active = [];
+ private readonly Queue _pending = new();
+
+ public ToastManager(SettingsService settings) => _settings = settings;
+
+ public void Show(ToastRequest request)
+ {
+ var settings = _settings.Current;
+
+ if (_active.Count >= settings.MaxVisible)
+ {
+ _pending.Enqueue(request);
+ return;
+ }
+
+ var vm = new ToastViewModel(request);
+ var window = new ToastWindow(vm, settings, request.Sticky);
+ window.Closed += OnToastClosed;
+
+ _active.Add(window);
+ // 先显示(拿到尺寸/屏幕信息),再排布
+ window.Show();
+ Arrange();
+ }
+
+ private void OnToastClosed(object? sender, System.EventArgs e)
+ {
+ if (sender is ToastWindow w)
+ {
+ w.Closed -= OnToastClosed;
+ _active.Remove(w);
+ }
+
+ Arrange();
+
+ if (_pending.Count > 0 && _active.Count < _settings.Current.MaxVisible)
+ {
+ Show(_pending.Dequeue());
+ }
+ }
+
+ ///
+ /// 把所有活动 toast 从指定角落沿垂直方向依次堆叠
+ ///
+ private void Arrange()
+ {
+ if (_active.Count == 0)
+ {
+ return;
+ }
+
+ var anchor = _active[0];
+ var screen = anchor.Screens.ScreenFromWindow(anchor) ?? anchor.Screens.Primary;
+ if (screen is null)
+ {
+ return;
+ }
+
+ var settings = _settings.Current;
+ var wa = screen.WorkingArea; // 物理像素
+ var scale = anchor.RenderScaling;
+ var margin = (int)(Margin * scale);
+ var gap = (int)(Gap * scale);
+ var bottom = settings.Corner is ToastCorner.BottomLeft or ToastCorner.BottomRight;
+ var right = settings.Corner is ToastCorner.TopRight or ToastCorner.BottomRight;
+
+ var cursor = bottom ? wa.Bottom - margin : wa.Y + margin;
+
+ foreach (var toast in _active)
+ {
+ var wPx = (int)(toast.Width * scale);
+ var hPx = (int)(toast.Bounds.Height * scale);
+ var x = right ? wa.Right - margin - wPx : wa.X + margin;
+
+ int y;
+ if (bottom)
+ {
+ cursor -= hPx;
+ y = cursor;
+ cursor -= gap;
+ }
+ else
+ {
+ y = cursor;
+ cursor += hPx + gap;
+ }
+
+ toast.Position = new PixelPoint(x, y);
+ }
+ }
+}
diff --git a/Notify/ViewModels/SettingsViewModel.cs b/Notify/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..0d8ea23
--- /dev/null
+++ b/Notify/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,89 @@
+using System.Collections.Generic;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Notify.Models;
+using Notify.Services;
+
+namespace Notify.ViewModels;
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly SettingsService _settings;
+ private readonly ToastManager _toasts;
+
+ public SettingsViewModel(SettingsService settings, ToastManager toasts)
+ {
+ _settings = settings;
+ _toasts = toasts;
+
+ var s = settings.Current;
+ DurationSeconds = s.DurationSeconds;
+ Corner = s.Corner;
+ Opacity = s.Opacity;
+ MaxVisible = s.MaxVisible;
+ PlaySound = s.PlaySound;
+ Width = s.Width;
+ FadeMilliseconds = s.FadeMilliseconds;
+ ShowOnAllDesktops = s.ShowOnAllDesktops;
+ }
+
+ public IReadOnlyList Corners { get; } =
+ [ToastCorner.TopLeft, ToastCorner.TopRight, ToastCorner.BottomLeft, ToastCorner.BottomRight];
+
+ [ObservableProperty]
+ public partial int DurationSeconds { get; set; }
+
+ [ObservableProperty]
+ public partial ToastCorner Corner { get; set; }
+
+ [ObservableProperty]
+ public partial double Opacity { get; set; }
+
+ [ObservableProperty]
+ public partial int MaxVisible { get; set; }
+
+ [ObservableProperty]
+ public partial bool PlaySound { get; set; }
+
+ [ObservableProperty]
+ public partial double Width { get; set; }
+
+ [ObservableProperty]
+ public partial int FadeMilliseconds { get; set; }
+
+ [ObservableProperty]
+ public partial bool ShowOnAllDesktops { get; set; }
+
+ [ObservableProperty]
+ public partial string StatusText { get; set; } = string.Empty;
+
+ [RelayCommand]
+ private void Save()
+ {
+ _settings.Save(new ToastSettings
+ {
+ DurationSeconds = DurationSeconds,
+ Corner = Corner,
+ Opacity = Opacity,
+ MaxVisible = MaxVisible,
+ PlaySound = PlaySound,
+ Width = Width,
+ FadeMilliseconds = FadeMilliseconds,
+ ShowOnAllDesktops = ShowOnAllDesktops,
+ });
+ StatusText = "已保存";
+ }
+
+ [RelayCommand]
+ private void TestToast()
+ {
+ // 用当前编辑中的值预览(先保存再弹,所见即所得)
+ Save();
+ _toasts.Show(new ToastRequest
+ {
+ Title = "预览弹窗",
+ Message = "这是一条测试通知 — 点击可关闭",
+ InputMode = false,
+ });
+ }
+}
diff --git a/Notify/ViewModels/ToastViewModel.cs b/Notify/ViewModels/ToastViewModel.cs
new file mode 100644
index 0000000..056339d
--- /dev/null
+++ b/Notify/ViewModels/ToastViewModel.cs
@@ -0,0 +1,23 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Notify.Models;
+
+namespace Notify.ViewModels;
+
+public partial class ToastViewModel : ObservableObject
+{
+ public ToastViewModel(ToastRequest request)
+ {
+ Title = request.Title;
+ Message = request.Message;
+ InputMode = request.InputMode;
+ }
+
+ [ObservableProperty]
+ public partial string Title { get; set; }
+
+ [ObservableProperty]
+ public partial string Message { get; set; }
+
+ [ObservableProperty]
+ public partial bool InputMode { get; set; }
+}
diff --git a/Notify/Views/SettingsWindow.axaml b/Notify/Views/SettingsWindow.axaml
new file mode 100644
index 0000000..8a5fc9e
--- /dev/null
+++ b/Notify/Views/SettingsWindow.axaml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Notify/Views/SettingsWindow.axaml.cs b/Notify/Views/SettingsWindow.axaml.cs
new file mode 100644
index 0000000..b8cac2a
--- /dev/null
+++ b/Notify/Views/SettingsWindow.axaml.cs
@@ -0,0 +1,8 @@
+using Avalonia.Controls;
+
+namespace Notify.Views;
+
+public partial class SettingsWindow : Window
+{
+ public SettingsWindow() => InitializeComponent();
+}
diff --git a/Notify/Views/ToastWindow.axaml b/Notify/Views/ToastWindow.axaml
new file mode 100644
index 0000000..0974828
--- /dev/null
+++ b/Notify/Views/ToastWindow.axaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Notify/Views/ToastWindow.axaml.cs b/Notify/Views/ToastWindow.axaml.cs
new file mode 100644
index 0000000..d12c8ab
--- /dev/null
+++ b/Notify/Views/ToastWindow.axaml.cs
@@ -0,0 +1,156 @@
+using System;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using Notify.Models;
+using Notify.ViewModels;
+
+namespace Notify.Views;
+
+public partial class ToastWindow : Window
+{
+ private static readonly Color BorderNormal = Color.Parse("#FF4B64B2");
+ private static readonly Color BorderInput = Color.Parse("#FF00CFCF");
+
+ private readonly ToastSettings _settings;
+ private readonly DispatcherTimer _dismissTimer;
+ private readonly bool _sticky;
+ private bool _closing;
+
+ // 设计器需要的无参构造
+ public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false)
+ {
+ }
+
+ public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky)
+ {
+ _settings = settings;
+ // 常驻:请求显式 Sticky,或全局停留时长 <= 0
+ _sticky = sticky || settings.DurationSeconds <= 0;
+ InitializeComponent();
+ DataContext = vm;
+
+ Width = settings.Width;
+ Opacity = 0;
+
+ // Opacity 过渡用于淡入/淡出
+ Transitions =
+ [
+ new DoubleTransition
+ {
+ Property = OpacityProperty,
+ Duration = TimeSpan.FromMilliseconds(settings.FadeMilliseconds),
+ Easing = new Avalonia.Animation.Easings.CubicEaseOut(),
+ },
+ ];
+
+ Root.BorderBrush = new SolidColorBrush(vm.InputMode ? BorderInput : BorderNormal);
+
+ _dismissTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(Math.Max(1, settings.DurationSeconds)),
+ };
+ _dismissTimer.Tick += (_, _) => BeginClose();
+ }
+
+ protected override void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+ Opacity = _settings.Opacity; // 触发淡入
+
+ // 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
+ if (_settings.ShowOnAllDesktops && TryGetPlatformHandle()?.Handle is { } hwnd)
+ {
+ TryPinWithRetry(hwnd);
+ }
+
+ if (!_sticky)
+ {
+ _dismissTimer.Start();
+ }
+ }
+
+ ///
+ /// 窗口刚打开时 shell 可能还没给它登记 ApplicationView(GetViewForHwnd 报
+ /// TYPE_E_ELEMENTNOTFOUND),故短间隔重试若干次直到成功
+ ///
+ private void TryPinWithRetry(IntPtr hwnd)
+ {
+ if (Notify.Interop.VirtualDesktopPinner.TryPin(hwnd))
+ {
+ return;
+ }
+
+ var attempts = 0;
+ var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(60) };
+ timer.Tick += (_, _) =>
+ {
+ attempts++;
+ if (_closing || Notify.Interop.VirtualDesktopPinner.TryPin(hwnd) || attempts >= 15)
+ {
+ timer.Stop();
+ }
+ };
+ timer.Start();
+ }
+
+ private void OnPointerEntered(object? sender, PointerEventArgs e)
+ {
+ // 悬停时暂停自动消失
+ _dismissTimer.Stop();
+ if (!_closing)
+ {
+ Opacity = _settings.Opacity;
+ }
+ }
+
+ private void OnPointerExited(object? sender, PointerEventArgs e)
+ {
+ if (!_closing && !_sticky)
+ {
+ _dismissTimer.Start();
+ }
+ }
+
+ private void OnBodyPressed(object? sender, PointerPressedEventArgs e)
+ {
+ // 左键点击主体:后续接入"激活原窗口 + 切回标签页",当前先关闭
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ BeginClose();
+ }
+ }
+
+ private void OnCloseClick(object? sender, RoutedEventArgs e) => BeginClose();
+
+ ///
+ /// 淡出后再真正关闭
+ ///
+ private void BeginClose()
+ {
+ if (_closing)
+ {
+ return;
+ }
+
+ _closing = true;
+ _dismissTimer.Stop();
+ Opacity = 0;
+
+ var closeTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(_settings.FadeMilliseconds),
+ };
+ closeTimer.Tick += (_, _) =>
+ {
+ closeTimer.Stop();
+ Close();
+ };
+ closeTimer.Start();
+ }
+}
diff --git a/Notify/app.manifest b/Notify/app.manifest
new file mode 100644
index 0000000..495475b
--- /dev/null
+++ b/Notify/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ PerMonitorV2
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index e69de29..1452abc 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,139 @@
+# Claude Code Notify — 功能与时序说明
+
+为 Claude Code 提供 **原生 Windows Toast 通知** 的插件。当 Claude 完成任务或需要你输入时弹出系统通知,**点击通知即可跳回原终端/编辑器窗口**(并能切回正确的 Windows Terminal 标签页)。核心是一个用 Rust + Win32 API 编写的单一可执行程序 `ToastWindow.exe`,通过 Claude Code 的 hook 机制驱动。
+
+---
+
+## 一、实现的功能
+
+| 功能 | 说明 |
+|------|------|
+| 原生 Toast 通知 | 任务完成 / 需要输入时弹出系统集成风格的通知 |
+| 一键返回 | 左键点击通知 → 激活当初发起请求的窗口 |
+| 标签页感知 | 在 Windows Terminal 中能切回提问时所在的标签页 |
+| 自动取图标 | 沿进程树向上查找调用方 App(VSCode / Cursor / JetBrains / Windows Terminal …),显示其图标 |
+| 会话隔离 | 每个 `session_id` 独立保存状态于 `%TEMP%\claude-notify-{session_id}.txt`,多开互不干扰 |
+| 通知堆叠 | 多条通知纵向堆叠、平滑滑动、悬停暂停自动关闭 |
+| 非侵入显示 | `WS_EX_NOACTIVATE | TOPMOST | LAYERED`,不抢焦点、置顶、支持淡出 |
+
+### exe 的 5 种运行模式
+
+| 模式 | 触发 Hook | 作用 |
+|------|-----------|------|
+| `--save` | `UserPromptSubmit` | 保存当前窗口句柄、WT 标签 RuntimeId、调用方 exe 路径、prompt |
+| `--notify` | `Stop` | 弹"任务完成"通知(橙色边框) |
+| `--input` | `Notification` / `PreToolUse` | 弹"需要输入"通知(黄色边框),按类型区分标题 |
+| `--cleanup` | `SessionEnd` | 删除该 session 的状态文件 |
+| `--notify-show` | (由 `--notify`/`--input` 内部分离启动) | 实际加载状态、绘制并显示 Toast 窗口 |
+
+---
+
+## 二、整体生命周期时序
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User as 用户
+ participant CC as Claude Code
+ participant Save as ToastWindow --save
+ participant State as 状态文件
%TEMP%\claude-notify-{id}.txt
+ participant Notify as ToastWindow --notify/--input
+ participant Toast as ToastWindow --notify-show
+ participant Win as 目标窗口
+
+ User->>CC: 发送消息 (UserPromptSubmit)
+ activate CC
+ CC->>Save: 调用 exe,stdin 传 session_id + prompt
+ Note over Save: 启动瞬间立即
GetForegroundWindow() 抓住窗口
+ Save->>Save: 检测窗口类,若是 WT 则取标签 RuntimeId
+ Save->>Save: 沿进程树查找调用方 exe(取图标用)
+ Save->>State: 写入 HWND / RuntimeId / iconPath / prompt
+ deactivate CC
+
+ Note over CC: Claude 处理中…
+
+ alt 任务完成 (Stop)
+ CC->>Notify: ToastWindow --notify
+ else 需要输入 (Notification / PreToolUse)
+ CC->>Notify: ToastWindow --input(含 title/message)
+ end
+ Notify->>Toast: 分离式 spawn --notify-show
+ Note over Notify: 立即返回,不阻塞 hook
+
+ Toast->>State: 读取状态
+ Toast->>Toast: 取图标 / 载字体 / 播提示音
+ Toast-->>User: 显示 Toast(堆叠 / 淡入)
+
+ User->>Toast: 左键点击
+ Toast->>Win: 激活窗口(夺前台 + 切回标签页)
+ Win-->>User: 回到原窗口
+
+ User->>CC: 结束会话 (SessionEnd)
+ CC->>State: ToastWindow --cleanup 删除状态文件
+```
+
+---
+
+## 三、关键时序点(为什么这样设计)
+
+```mermaid
+flowchart TD
+ A[main 启动] -->|第一行代码| B[GetForegroundWindow
立即抓前台窗口]
+ B --> C[CoInitializeEx 初始化 COM]
+ C --> D[解析参数 / 初始化日志]
+ D --> E{模式分发}
+ E -->|--save| F[校验 HWND 有效性
无效则 fallback]
+ E -->|--notify/--input| G[只解析 session,
分离 spawn 后立即退出]
+ E -->|--notify-show| H[加载状态→绘制 Toast
阻塞直到关闭]
+
+ style B fill:#ffe9c7,stroke:#e08a00
+ style G fill:#d7f0ff,stroke:#0078d6
+```
+
+要点:
+
+1. **最早抓窗口**:`main()` 第一行就 `GetForegroundWindow()`,避免后续 COM/参数初始化期间前台窗口变化导致句柄失真。
+2. **hook 不阻塞**:`--notify` / `--input` 只做极少工作,随即 `spawn_detached` 出 `--notify-show` 子进程后立即返回,保证 Claude Code 的 hook 超时(5–10s)内完成。
+3. **真正显示在子进程**:`--notify-show` 才阻塞绘制 Toast,与 hook 主进程解耦,通知存活不依赖 hook。
+4. **过滤无意义通知**:`auth_success` / `elicitation_complete` / `elicitation_response` 等类型直接跳过,不弹 toast。
+
+---
+
+## 四、点击通知的窗口激活时序
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User as 用户
+ participant Toast as Toast 窗口
+ participant API as Win32 API
+ participant WT as Windows Terminal
+ participant Win as 目标窗口
+
+ User->>Toast: 左键点击
+ Toast->>API: AllowSetForegroundWindow(ASFW_ANY)
+ Toast->>API: 模拟 ALT 键 + AttachThreadInput
+ Toast->>API: SetWindowPos + BringWindowToTop
SwitchToThisWindow + SetForegroundWindow
+ API->>Win: 窗口拉回前台
+
+ opt 目标是 Windows Terminal
+ Toast->>WT: UIAutomation 枚举标签项
+ Toast->>WT: 匹配保存的 RuntimeId
+ Toast->>WT: SelectionItemPattern::Select() 切回标签
+ end
+
+ Win-->>User: 回到提问时的窗口与标签页
+```
+
+---
+
+## 五、Hook 注册一览(hooks/hooks.json)
+
+```mermaid
+graph LR
+ UPS[UserPromptSubmit] --> S["--save"]
+ NOT[Notification] --> I["--input"]
+ PRE["PreToolUse
AskUserQuestion|ExitPlanMode"] --> I
+ STOP[Stop] --> N["--notify"]
+ SE[SessionEnd] --> C["--cleanup"]
+```
diff --git a/Settings.XamlStyler b/Settings.XamlStyler
new file mode 100644
index 0000000..8dcd894
--- /dev/null
+++ b/Settings.XamlStyler
@@ -0,0 +1,47 @@
+{
+ "IndentSize": 4,
+ "IndentWithTabs": null,
+ "AttributesTolerance": 5,
+ "KeepFirstAttributeOnSameLine": false,
+ "MaxAttributeCharactersPerLine": 80,
+ "MaxAttributesPerLine": 0,
+ "NewlineExemptionElements": "RadialGradientBrush, GradientStop, LinearGradientBrush, ScaleTransform, SkewTransform, RotateTransform, TranslateTransform, Trigger, Condition, Setter",
+ "SeparateByGroups": true,
+ "AttributeIndentation": 0,
+ "AttributeIndentationStyle": "Spaces",
+ "RemoveDesignTimeReferences": false,
+ "EnableAttributeReordering": true,
+ "AttributeOrderingRuleGroups": [
+ "x:Class",
+ "xmlns, xmlns:x",
+ "xmlns:*",
+ "x:Key, Key, x:Name, Name, x:Uid, Uid, Title",
+ "Grid.Row, Grid.RowSpan, Grid.Column, Grid.ColumnSpan, Canvas.Left, Canvas.Top, Canvas.Right, Canvas.Bottom",
+ "Width, Height, MinWidth, MinHeight, MaxWidth, MaxHeight",
+ "Classes, Theme, Styles",
+ "Margin, Padding, HorizontalAlignment, VerticalAlignment, HorizontalContentAlignment, VerticalContentAlignment, Panel.ZIndex",
+ "*:*, *",
+ "PageSource, PageIndex, Offset, Color, TargetName, Property, Value, StartPoint, EndPoint",
+ "mc:Ignorable, d:IsDataSource, d:LayoutOverrides, d:IsStaticText",
+ "Storyboard.*, From, To, Duration"
+ ],
+ "FirstLineAttributes": "",
+ "OrderAttributesByName": true,
+ "IgnoreDesignTimeReferencePrefix": false,
+ "PutEndingBracketOnNewLine": false,
+ "RemoveEndingTagOfEmptyElement": true,
+ "SpaceBeforeClosingSlash": false,
+ "RootElementLineBreakRule": "Default",
+ "ReorderVSM": "Last",
+ "ReorderGridChildren": false,
+ "ReorderCanvasChildren": false,
+ "ReorderSetters": "None",
+ "FormatMarkupExtension": false,
+ "NoNewLineMarkupExtensions": "x:Bind, Binding",
+ "ThicknessSeparator": "Comma",
+ "ThicknessAttributes": "Margin, Padding, BorderThickness, ThumbnailClipMargin",
+ "FormatOnSave": true,
+ "SaveAndCloseOnFormat": true,
+ "CommentPadding": 2,
+ "SuppressProcessing": false
+}
diff --git a/notify.slnx b/notify.slnx
new file mode 100644
index 0000000..ac8799a
--- /dev/null
+++ b/notify.slnx
@@ -0,0 +1,3 @@
+
+
+