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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +