feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)

为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows
Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
2026-06-22 18:05:15 +08:00
Unverified
commit 5ce2c8a982
53 changed files with 3889 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
# 架构
## 进程模型:CLI 子命令 + 常驻 Host
整个程序是**单个 exe `notify.exe`**,靠命令行第一个参数分流成两类角色:
| 角色 | 触发方式 | 是否加载 Avalonia | 生命周期 |
|------|----------|------------------|----------|
| **CLI 子命令** | `notify save\|notify\|input\|cleanup` | 否(纯互操作 / 落盘) | 即起即退(~100ms |
| **Host** | `notify``notify host` | 是 | 常驻(单例,无主窗口) |
这样设计的原因:hook 在你每次发消息时都会被拉起,必须**极快返回、绝不阻塞 Claude Code**;而真正画 UI 的 Avalonia 较重,放在一个**只初始化一次**的常驻进程里。
```mermaid
flowchart TD
M["Main(args)"] --> SW{"args[0]"}
SW -->|save| CSAVE[CliRunner.Save]
SW -->|notify| CNOTIFY[CliRunner.Notify]
SW -->|input| CINPUT[CliRunner.Input]
SW -->|cleanup| CCLEAN[CliRunner.Cleanup]
SW -->|其它 / host| RUN[RunHost:单例互斥量 + Avalonia]
CSAVE -. 不加载 Avalonia .-> X1[退出]
CNOTIFY -. 不加载 Avalonia .-> X2[退出]
RUN --> APP[App:托盘 + SpoolWatcher + ToastManager]
```
- **单例**`RunHost` 用命名互斥量 `ClaudeCodeNotifyHost` 保证只有一个 Host;第二个实例直接退出。
- **保活**`ShutdownMode.OnExplicitShutdown`,没有主窗口也不退,只有托盘"退出"才结束。
## 两条数据通道
1. **状态文件**(每会话)`%TEMP%\claude-notify-{session_id}.json`
- `save` 写入:前台窗口句柄、prompt、WT 标签 RuntimeId、调用方 exe 路径。
- `notify`/`input` 读取,拼成通知;`cleanup` 删除。
2. **spool 队列** `%TEMP%\claude-notify-spool\*.json`
- `notify`/`input` 把一条 `NotifyMessage` 原子落盘,Host 用 `FileSystemWatcher` 消费。
- 取代命名管道,**让 CLI 写完即走、不等 Host**(见 README 的「非阻塞投递」时序)。
## 组件 / 目录
```
Notify/
├── Program.cs 入口:子命令分流 + Host 单例
├── App.axaml(.cs) Avalonia 应用:托盘、SpoolWatcher、收到消息弹 toast
├── Cli/
│ ├── HookInput.cs stdin JSON 的 DTO(源生成反序列化)
│ └── CliRunner.cs save/notify/input/cleanup 实现 + 消息清洗
├── Ipc/
│ ├── NotifyMessage.cs 投递给 Host 的弹窗请求 DTO
│ ├── NotificationSpool.cs 落盘投递 + 拉起 Host(客户端)/ 消费(Host)
│ ├── SpoolWatcher.cs Host 侧目录监视
│ └── IpcConstants.cs 互斥量名等
├── Models/
│ ├── ToastSettings.cs 持久化设置 + 枚举(HEdge/VEdge
│ ├── ToastRequest.cs Host 内部的一次弹窗请求
│ └── StateData.cs 每会话状态
├── Services/
│ ├── SettingsService.cs 设置读写
│ ├── StateStore.cs 状态文件读写
│ └── ToastManager.cs toast 创建 / 堆叠定位 / 排队
├── ViewModels/ ToastViewModel、SettingsViewModelpartial 源生成属性)
├── Views/ ToastWindow、SettingsWindow
├── Interop/ 原生互操作(见 interop.md
└── Serialization/
└── AppJsonContext.cs System.Text.Json 源生成上下文
```
## 线程模型
- CLI 子命令在单线程跑完即退。
- Host 里:`FileSystemWatcher` 回调在线程池线程触发 → `App.OnNotify` 里先做一次"是否前台"判断和提示音,再 `Dispatcher.UIThread.Post` 切回 UI 线程创建 toast。
- 所有 Avalonia 对象操作都在 UI 线程;GDI / COM 互操作可在任意线程,但本项目主要在 UI 线程调用。
+59
View File
@@ -0,0 +1,59 @@
# 构建与安装
## 安装(用户)
```bash
claude plugin marketplace add https://git.pchuan.top/cc-tools/notify
claude plugin install claude-code-notify@claude-code-notify
```
重启 Claude Code 后生效。插件的 `hooks/hooks.json` 指向 `${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd`,首次触发钩子时该脚本会从 Release 下载单文件 `notify.exe``bin/`,之后常驻。
引导脚本(`scripts/notify.cmd``scripts/notify.sh`)顶部的 `DOWNLOAD_URL` 决定从哪拉取 exe;下载用临时文件 + 原子改名 + mkdir 锁,并发触发不会重复下载。
## 从源码构建(开发)
框架依赖型,依赖已安装的 .NET 10 运行时:
```bash
cd Notify
dotnet build -c Release
# 产物:Notify/bin/Release/net10.0-windows/notify.exe + 同目录依赖 DLL
```
exe 必须和这些 DLL 在一起(.NET 从 exe 所在目录加载依赖)。
## 发布单文件(维护者)
NativeAOT 静态链接 Skia / HarfBuzz / ANGLE,产出**单个无依赖 exe**。原生链接需要 MSVC 工具链。
```bash
# 从 "Developer Command Prompt for VS" 运行,或用脚本(自动用 vswhere 配 vcvars
scripts\build.bat
# 产物:bin\notify.exe(单文件,~40MB
```
然后把它作为 Release 资产发布,并确保引导脚本的 `DOWNLOAD_URL` 指向它:
```bash
gh release create v0.1.0 bin/notify.exe
```
> AOT 配置在 `Notify/Notify.csproj``PublishAot` 条件块 + `CoreUtils.*.Static` 静态库包 + 发布后清理)。源生成 COM / UIAutomation 与静态渲染需真机运行验证,详见 [interop.md](interop.md)。
## 不接 Claude 的手动烟雾测试
```bash
notify host & # 起 Host(托盘出现)
echo {"session_id":"x","prompt":"hi"} | notify save
echo {"session_id":"x"} | notify notify # 弹窗
```
## 排错
| 现象 | 处理 |
|------|------|
| 首次触发慢 1~2 秒 | 首次会下载 exe + Host 冷启动(一次性),之后常驻、瞬时 |
| 没弹窗 | 先用手动烟雾测试确认程序本身正常;看托盘有没有图标 |
| 弹双份 | 同时装了原版 Rust 插件,删除其一 |
| 停止 Host | 托盘右键 → 退出,或 `taskkill /F /IM notify.exe` |
+60
View File
@@ -0,0 +1,60 @@
# Hook 与 CLI
## Hook → 子命令映射
| Claude Code 事件 | 子命令 | 作用 |
|------------------|--------|------|
| `UserPromptSubmit` | `notify save` | 记录前台窗口、prompt、WT 标签、调用方图标路径 |
| `Stop` | `notify notify` | 弹"任务完成"通知(自动消失,聚焦时更短) |
| `Notification` | `notify input` | 弹"需要输入"通知(常驻),按类型分标题 |
| `PreToolUse``AskUserQuestion`/`ExitPlanMode` | `notify input` | 提问 / 出 Plan 时弹常驻通知 |
| `SessionEnd` | `notify cleanup` | 删除该会话状态文件 |
`hooks/hooks.json`(插件形式)里命令为 `${CLAUDE_PLUGIN_ROOT}/bin/notify.exe <子命令>`;直连 `settings.json` 时可写 `notify <子命令>` 或绝对路径。
## stdin JSON
Claude Code 通过 **stdin** 把事件数据以 JSON 传入。`HookInput` 关心这几个字段:
| 字段 | 用途 |
|------|------|
| `session_id` | 状态文件隔离;为空则忽略本次 |
| `prompt` | UserPromptSubmit 的用户输入,用作"完成"通知正文 |
| `notification_type` | Notification 类型:`permission_prompt` / `idle_prompt` / `elicitation_dialog` / … |
| `message` | Notification / 提问的文本 |
| `tool_name` | PreToolUse 工具名:`AskUserQuestion` / `ExitPlanMode` |
> **注意**stdin 用 `OpenStandardInput()` 读**原始字节**再按 **UTF-8** 解码。不能用 `Console.In`——WinExe 下它不可靠,且会用控制台代码页(中文系统是 GBK)把中文解成乱码。
## 标题分流(input
| 条件 | 标题 |
|------|------|
| `tool_name == AskUserQuestion` | Claude is Asking |
| `tool_name == ExitPlanMode` | Plan Ready for Approval |
| `notification_type == permission_prompt` | Permission Required |
| `notification_type == idle_prompt` | Claude is Waiting |
| `notification_type == elicitation_dialog` | MCP Asks |
| 其它 | Input Required |
被**过滤**(不弹)的类型:`auth_success` / `elicitation_complete` / `elicitation_response`
## 状态文件
路径:`%TEMP%\claude-notify-{session_id}.json``session_id` 做了文件名安全过滤)。
```jsonc
{
"Hwnd": 329712, // 触发时前台窗口句柄
"Prompt": "重构通知模块", // 完成通知正文
"WtRuntimeId": "42.288...",// WT 当前标签 RuntimeId(非 WT 为空)
"CallerExePath": "...\\WindowsTerminal.exe" // 调用方 App,用于取图标
}
```
- `notify`/`input` 读它来填 `NotifyMessage`(含点击要激活的 `TargetHwnd`、要切的标签、要显示的图标)。
- `cleanup` 删它。若 `SessionEnd` 没触发(崩溃等),文件会残留在 `%TEMP%`,无害。
## 消息清洗
`notify`/`input` 投递前会把正文里的换行 / 制表 / 多余空格折叠成单行,避免撑乱 toast 布局;超长部分由 toast 的两行省略号截断。
+39
View File
@@ -0,0 +1,39 @@
# 原生互操作与 AOT
所有 Win32 / COM 互操作都为 **NativeAOT** 准备:用 `LibraryImport`(源生成 P/Invoke)和 `[GeneratedComInterface]`(源生成 COM),**不用** `System.Drawing`、经典 `[ComImport]`、反射式封送(它们在 AOT 下不可用)。
| 文件 | 职责 | 关键点 |
|------|------|--------|
| `Win32.cs` | 基础 P/Invoke | `GetForegroundWindow``GetClassName`、工具窗口样式等 |
| `WindowActivator.cs` | 抢前台激活 | ALT 模拟 + `AttachThreadInput` + 多 API 组合,绕过防焦点抢占 |
| `WinTerminalTabs.cs` | WT 切标签 | 源生成 COM 调 UIAutomation,按 RuntimeId 定位标签 |
| `VirtualDesktopPinner.cs` | 跨虚拟桌面 | 未公开 COM `IVirtualDesktopPinnedApps.PinView` |
| `ProcessTree.cs` | 进程树上溯 | Toolhelp 快照,跳过 shell/运行时找调用方 App |
| `AppIcon.cs` | 取图标 | `ExtractIconEx` + GDI 读 BGRA 像素 → Avalonia 位图 |
| `Sound.cs` | 提示音 | winmm `PlaySound` 从内存异步播放 |
## 窗口激活(WindowActivator
Windows 限制后台进程抢焦点。组合技:还原最小化 → 模拟一次 ALT 抬起 → `AttachThreadInput` 把当前线程与前台/目标线程输入队列挂接 → `AllowSetForegroundWindow` + `SetWindowPos`/`BringWindowToTop`/`SwitchToThisWindow`/`SetForegroundWindow` 多管齐下 → 解除挂接。
## Windows Terminal 切标签(WinTerminalTabs
- `save` 时:检测前台窗口类是否 `CASCADIA_HOSTING_WINDOW_CLASS`;是则用 UIAutomation 找当前选中的 `TabItem`,取其 **RuntimeId**(一串 intSAFEARRAY)存入状态。
- 点击时:激活 WT 窗口后,枚举标签找到 RuntimeId 匹配的,调 `SelectionItemPattern.Select()`
- **AOT 要点**:接口用 `[GeneratedComInterface]`;未用到的 vtable 槽用占位方法按 SDK 头文件顺序补齐(顺序 / GUID 均取自 `UIAutomationClient.h`);`IApplicationView` 以裸 `IntPtr` 传递。
## 跨虚拟桌面(VirtualDesktopPinner
-`CoCreateInstance``CLSCTX_LOCAL_SERVER`)拿 ImmersiveShell → `IApplicationViewCollection.GetViewForHwnd``IVirtualDesktopPinnedApps.PinView`
- GUID 取自 Win11 24H2;整段 try/catch,失败自动退回"仅当前桌面"。
- 窗口刚打开时 view 可能尚未就绪,短间隔重试直到成功。
## 取图标(AppIcon
`ExtractIconEx` 拿 HICON → `GetIconInfo` 取彩色位图 → `GetDIBits` 以 32bpp 自上而下读出 BGRA → 构造 `Avalonia.Media.Imaging.Bitmap`。老图标无 alpha(全 0)时补成不透明,避免整块透明。取不到则回退默认 Claude 图标。
## AOT
- `csproj``IsAotCompatible``PublishAot` 条件块 + `TrimmerRootAssembly`Ursa / Semi 整体保留)+ `CoreUtils.*.Static` 静态链接 Skia / HarfBuzz / ANGLE。
- 原生链接需要 MSVC 工具链(从 "Developer Command Prompt for VS" 跑,或用配好 vcvars 的脚本)。
- 源生成 COM / UIAutomation 与静态渲染需在真机运行验证。