feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
@@ -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、SettingsViewModel(partial 源生成属性)
|
||||
├── 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 线程调用。
|
||||
@@ -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` |
|
||||
@@ -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 的两行省略号截断。
|
||||
@@ -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**(一串 int,SAFEARRAY)存入状态。
|
||||
- 点击时:激活 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 与静态渲染需在真机运行验证。
|
||||
Reference in New Issue
Block a user