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
commit 5ce2c8a982
53 changed files with 3889 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
namespace Notify.Ipc;
internal static class IpcConstants
{
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
public const string HostMutexName = "ClaudeCodeNotifyHost";
}
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using Notify.Serialization;
namespace Notify.Ipc;
/// <summary>
/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费
///
/// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code
/// </summary>
public static class NotificationSpool
{
public static readonly string Dir =
Path.Combine(Path.GetTempPath(), "claude-notify-spool");
// CLI 侧:写入一条请求,必要时拉起 Host,全程不阻塞
public static void Deliver(NotifyMessage message)
{
try
{
Directory.CreateDirectory(Dir);
// 先写 .tmp 再原子改名为 .json,避免 Host 读到半截文件
var id = Guid.NewGuid().ToString("N");
var tmp = Path.Combine(Dir, id + ".tmp");
var final = Path.Combine(Dir, id + ".json");
File.WriteAllText(tmp, JsonSerializer.Serialize(message, AppJsonContext.Default.NotifyMessage));
File.Move(tmp, final);
}
catch
{
// 写入失败则放弃这条通知,绝不影响调用方
}
EnsureHostRunning();
}
// Host 未运行则拉起(不等待);运行中则什么都不做
private static void EnsureHostRunning()
{
try
{
if (Mutex.TryOpenExisting(IpcConstants.HostMutexName, out var existing))
{
existing.Dispose();
return;
}
}
catch
{
// 打不开就当作未运行,继续尝试拉起
}
try
{
var exe = Environment.ProcessPath;
if (exe is null)
{
return;
}
// 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄
// 否则常驻 host 会继承并攥住钩子的 stdout 管道,导致 Claude Code
// 等不到管道 EOF 而卡在 "running stop hook"
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "host",
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch
{
// 拉起失败则该通知会在下次 Host 启动时由 DrainExisting 补弹
}
}
// Host 侧:消费单个 spool 文件并删除
public static NotifyMessage? ReadAndRemove(string path)
{
try
{
var json = File.ReadAllText(path);
File.Delete(path);
return JsonSerializer.Deserialize(json, AppJsonContext.Default.NotifyMessage);
}
catch
{
return null;
}
}
// Host 启动时把已有的 spool 文件补弹一遍
public static void DrainExisting(Action<NotifyMessage> handler)
{
try
{
if (!Directory.Exists(Dir))
{
return;
}
foreach (var path in Directory.GetFiles(Dir, "*.json"))
{
if (ReadAndRemove(path) is { } msg)
{
handler(msg);
}
}
}
catch
{
// 忽略
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace Notify.Ipc;
/// <summary>
/// 一条投递给 Host 的弹窗请求,经 spool 文件传递
/// </summary>
public sealed class NotifyMessage
{
public string Title { get; set; } = "";
public string Message { get; set; } = "";
// true = 需要输入(青色边框)
public bool InputMode { get; set; }
// true = 常驻,不自动消失
public bool Sticky { get; set; }
// 触发该通知的会话 id
public string? SessionId { get; set; }
// 点击 toast 时要激活的目标窗口句柄,0 表示无
public long TargetHwnd { get; set; }
// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
public string? WtRuntimeId { get; set; }
// 调用方 App 的 exe 路径,用于显示其图标
public string? IconPath { get; set; }
}
+43
View File
@@ -0,0 +1,43 @@
using System;
using System.IO;
namespace Notify.Ipc;
/// <summary>
/// Host 侧:监视 spool 目录,新文件出现即消费并回调
/// </summary>
public sealed class SpoolWatcher
{
private readonly Action<NotifyMessage> _onMessage;
private FileSystemWatcher? _watcher;
public SpoolWatcher(Action<NotifyMessage> onMessage) => _onMessage = onMessage;
public void Start()
{
Directory.CreateDirectory(NotificationSpool.Dir);
// 先补弹启动前堆积的请求
NotificationSpool.DrainExisting(_onMessage);
_watcher = new FileSystemWatcher(NotificationSpool.Dir, "*.json")
{
NotifyFilter = NotifyFilters.FileName,
EnableRaisingEvents = true,
};
_watcher.Created += OnCreated;
_watcher.Renamed += OnRenamed;
}
private void OnCreated(object sender, FileSystemEventArgs e) => Handle(e.FullPath);
private void OnRenamed(object sender, RenamedEventArgs e) => Handle(e.FullPath);
private void Handle(string path)
{
if (NotificationSpool.ReadAndRemove(path) is { } msg)
{
_onMessage(msg);
}
}
}