feat: Avalonia 常驻通知应用(保活 + 弹窗 + 跨桌面)
- 常驻 Host:无主窗口保活 + 系统托盘菜单 - Toast 弹窗:堆叠 / 自动消失 / 悬停暂停 / 淡入淡出 / 点击关闭 - Sticky 常驻弹窗与 InputMode 边框配色 - 跨虚拟桌面显示(源生成 COM PinView,AOT 友好) - 设置窗口(Semi + Ursa)+ JSON 源生成持久化 - AOT 准备:IsAotCompatible 0 警告
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Notify.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.Styles>
|
||||
<StyleInclude Source="avares://Semi.Avalonia/Index.axaml" />
|
||||
<StyleInclude Source="avares://Ursa.Themes.Semi/Index.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/claude.ico" ToolTipText="Claude Code Notify">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="测试弹窗" Click="OnTestToastClick" />
|
||||
<NativeMenuItem Header="设置…" Click="OnOpenSettingsClick" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出" Click="OnExitClick" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
</TrayIcon>
|
||||
</TrayIcons>
|
||||
</TrayIcon.Icons>
|
||||
|
||||
</Application>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.Marshalling;
|
||||
|
||||
namespace Notify.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// 把窗口"钉"到所有虚拟桌面(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 下内置封送会被裁剪)
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次失败的诊断信息(临时排查用)
|
||||
/// </summary>
|
||||
public static string LastError { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 尝试把指定窗口钉到所有桌面;返回是否成功
|
||||
/// </summary>
|
||||
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<IApplicationViewCollection>(viewsPtr, "IApplicationViewCollection");
|
||||
|
||||
var pinnedGuid = IID_IVirtualDesktopPinnedApps;
|
||||
var clsidPinned = CLSID_VirtualDesktopPinnedApps;
|
||||
var pinnedPtr = shell.QueryService(ref clsidPinned, ref pinnedGuid);
|
||||
_pinned = WrapRequired<IVirtualDesktopPinnedApps>(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<T>(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);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 一次弹窗请求(后续由 hook / named pipe 投递)
|
||||
/// </summary>
|
||||
public sealed class ToastRequest
|
||||
{
|
||||
public required string Title { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// true = 需要输入(黄色边框),false = 任务完成(橙色边框)
|
||||
/// </summary>
|
||||
public bool InputMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// true = 常驻:不自动消失,只能点击 / ✕ 关闭
|
||||
/// </summary>
|
||||
public bool Sticky { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 屏幕角落,决定 toast 堆叠的起点与方向
|
||||
/// </summary>
|
||||
public enum ToastCorner
|
||||
{
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化的弹窗设置(纯数据模型,序列化到磁盘)
|
||||
/// </summary>
|
||||
public sealed class ToastSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动消失前的停留秒数
|
||||
/// </summary>
|
||||
public int DurationSeconds { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 出现的屏幕角落
|
||||
/// </summary>
|
||||
public ToastCorner Corner { get; set; } = ToastCorner.BottomRight;
|
||||
|
||||
/// <summary>
|
||||
/// 不透明度 0–1
|
||||
/// </summary>
|
||||
public double Opacity { get; set; } = 0.96;
|
||||
|
||||
/// <summary>
|
||||
/// 最多同时可见的 toast 数量,超出则排队
|
||||
/// </summary>
|
||||
public int MaxVisible { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 是否播放提示音
|
||||
/// </summary>
|
||||
public bool PlaySound { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// toast 宽度(DIP)
|
||||
/// </summary>
|
||||
public double Width { get; set; } = 340;
|
||||
|
||||
/// <summary>
|
||||
/// 淡入/淡出时长(毫秒)
|
||||
/// </summary>
|
||||
public int FadeMilliseconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// 跨所有虚拟桌面显示(未公开 API,失败自动退回单桌面)
|
||||
/// </summary>
|
||||
public bool ShowOnAllDesktops { get; set; } = true;
|
||||
|
||||
public ToastSettings Clone() => (ToastSettings)MemberwiseClone();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<ApplicationIcon>Assets\claude.ico</ApplicationIcon>
|
||||
<RootNamespace>Notify</RootNamespace>
|
||||
<AssemblyName>notify</AssemblyName>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="Semi.Avalonia" Version="12.0.3" />
|
||||
<PackageReference Include="Irihi.Ursa" Version="2.0.1" />
|
||||
<PackageReference Include="Irihi.Ursa.Themes.Semi" Version="2.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
[STAThread]
|
||||
public static void Main(string[] args) =>
|
||||
BuildAvaloniaApp()
|
||||
// OnExplicitShutdown = 持续保活:没有主窗口也不会退出,只有显式 Shutdown 才结束
|
||||
.StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Notify.Models;
|
||||
using Notify.Serialization;
|
||||
|
||||
namespace Notify.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 加载/保存弹窗设置,并在变更时通知订阅者
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// 设置被保存后触发
|
||||
/// </summary>
|
||||
public event Action<ToastSettings>? 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 在常驻进程内管理所有 toast 窗口:创建、按角落堆叠、关闭后重新排布
|
||||
/// 这是 Rust 版"每条通知一进程 + EnumWindows"的替代——进程内一个列表即可
|
||||
/// </summary>
|
||||
public sealed class ToastManager
|
||||
{
|
||||
private const int Margin = 12;
|
||||
private const int Gap = 8;
|
||||
|
||||
private readonly SettingsService _settings;
|
||||
private readonly List<ToastWindow> _active = [];
|
||||
private readonly Queue<ToastRequest> _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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把所有活动 toast 从指定角落沿垂直方向依次堆叠
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ToastCorner> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:u="https://irihi.tech/ursa"
|
||||
xmlns:vm="clr-namespace:Notify.ViewModels"
|
||||
xmlns:m="clr-namespace:Notify.Models"
|
||||
x:Class="Notify.Views.SettingsWindow"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Width="420"
|
||||
SizeToContent="Height"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Icon="/Assets/claude.ico"
|
||||
Title="弹窗设置">
|
||||
|
||||
<StackPanel Margin="20" Spacing="16">
|
||||
|
||||
<TextBlock Text="弹窗设置"
|
||||
FontSize="18"
|
||||
FontWeight="Bold" />
|
||||
|
||||
<u:Form LabelPosition="Left" LabelWidth="120">
|
||||
|
||||
<u:FormItem Label="停留时长(秒)">
|
||||
<u:NumericIntUpDown Value="{Binding DurationSeconds}" Minimum="1" Maximum="60" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="出现角落">
|
||||
<ComboBox ItemsSource="{Binding Corners}"
|
||||
SelectedItem="{Binding Corner}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="不透明度">
|
||||
<u:NumericDoubleUpDown Value="{Binding Opacity}" Minimum="0.3" Maximum="1.0" Step="0.05" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="最多同时显示">
|
||||
<u:NumericIntUpDown Value="{Binding MaxVisible}" Minimum="1" Maximum="10" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="宽度(DIP)">
|
||||
<u:NumericDoubleUpDown Value="{Binding Width}" Minimum="240" Maximum="600" Step="10" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="淡入淡出(毫秒)">
|
||||
<u:NumericIntUpDown Value="{Binding FadeMilliseconds}" Minimum="0" Maximum="2000" Step="50" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="播放提示音">
|
||||
<ToggleSwitch IsChecked="{Binding PlaySound}" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="跨所有桌面显示">
|
||||
<ToggleSwitch IsChecked="{Binding ShowOnAllDesktops}" />
|
||||
</u:FormItem>
|
||||
|
||||
</u:Form>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<TextBlock Text="{Binding StatusText}"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="#FF4CAF50" />
|
||||
<Button Content="测试弹窗" Command="{Binding TestToastCommand}" />
|
||||
<Button Content="保存"
|
||||
Classes="Primary"
|
||||
Command="{Binding SaveCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Notify.Views;
|
||||
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
public SettingsWindow() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:Notify.ViewModels"
|
||||
x:Class="Notify.Views.ToastWindow"
|
||||
x:DataType="vm:ToastViewModel"
|
||||
Width="340"
|
||||
Height="92"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
ShowActivated="False"
|
||||
Topmost="True"
|
||||
WindowDecorations="None"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Transparent">
|
||||
|
||||
<Border x:Name="Root"
|
||||
Background="#FF2B2B2B"
|
||||
CornerRadius="10"
|
||||
BorderThickness="2"
|
||||
BorderBrush="#FF4B64B2"
|
||||
BoxShadow="0 6 24 0 #80000000"
|
||||
Padding="14"
|
||||
PointerEntered="OnPointerEntered"
|
||||
PointerExited="OnPointerExited"
|
||||
PointerPressed="OnBodyPressed">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
|
||||
<Image Grid.Column="0"
|
||||
Width="44" Height="44"
|
||||
VerticalAlignment="Center"
|
||||
Source="/Assets/claude.ico" />
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontWeight="Bold"
|
||||
FontSize="14"
|
||||
Foreground="#FFFFFFFF"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Message}"
|
||||
FontSize="12"
|
||||
Foreground="#FFCCCCCC"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="2"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Content="✕"
|
||||
VerticalAlignment="Top"
|
||||
Padding="6,2"
|
||||
FontSize="12"
|
||||
Foreground="#FF888888"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Click="OnCloseClick" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口刚打开时 shell 可能还没给它登记 ApplicationView(GetViewForHwnd 报
|
||||
/// TYPE_E_ELEMENTNOTFOUND),故短间隔重试若干次直到成功
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// 淡出后再真正关闭
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="Notify.app" />
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -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 状态文件<br/>%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: 启动瞬间立即<br/>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<br/>立即抓前台窗口]
|
||||
B --> C[CoInitializeEx 初始化 COM]
|
||||
C --> D[解析参数 / 初始化日志]
|
||||
D --> E{模式分发}
|
||||
E -->|--save| F[校验 HWND 有效性<br/>无效则 fallback]
|
||||
E -->|--notify/--input| G[只解析 session,<br/>分离 spawn 后立即退出]
|
||||
E -->|--notify-show| H[加载状态→绘制 Toast<br/>阻塞直到关闭]
|
||||
|
||||
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<br/>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<br/>AskUserQuestion|ExitPlanMode"] --> I
|
||||
STOP[Stop] --> N["--notify"]
|
||||
SE[SessionEnd] --> C["--cleanup"]
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="Notify/Notify.csproj" />
|
||||
</Solution>
|
||||
Reference in New Issue
Block a user