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
+63
View File
@@ -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);
}
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using Notify.Models;
using Notify.Serialization;
namespace Notify.Services;
/// <summary>
/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json
/// </summary>
public static class StateStore
{
private static string FilePath(string sessionId) =>
Path.Combine(Path.GetTempPath(), $"claude-notify-{Sanitize(sessionId)}.json");
public static void Save(string sessionId, StateData data)
{
try
{
File.WriteAllText(FilePath(sessionId), JsonSerializer.Serialize(data, AppJsonContext.Default.StateData));
}
catch
{
// 落盘失败不致命
}
}
public static StateData? Load(string sessionId)
{
try
{
var path = FilePath(sessionId);
if (File.Exists(path))
{
return JsonSerializer.Deserialize(File.ReadAllText(path), AppJsonContext.Default.StateData);
}
}
catch
{
// 损坏或不可读则当作无状态
}
return null;
}
public static void Delete(string sessionId)
{
try
{
var path = FilePath(sessionId);
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// 忽略
}
}
// 只保留文件名安全字符,避免 session_id 含特殊字符破坏路径
private static string Sanitize(string s) =>
new(s.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_').ToArray());
}
+120
View File
@@ -0,0 +1,120 @@
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 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, request.TargetHwnd, request.WtRuntimeId, request.IconPath, request.DurationSecondsOverride);
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)(settings.Margin * scale);
var gap = (int)(Gap * scale);
// 垂直靠下时向上堆叠,否则从锚点向下堆叠
var stackUp = settings.Vertical == VEdge.Bottom;
var cursor = settings.Vertical switch
{
VEdge.Top => wa.Y + margin,
VEdge.Bottom => wa.Bottom - margin,
_ => wa.Y + wa.Height / 2,
};
foreach (var toast in _active)
{
var wPx = (int)(toast.Width * scale);
var hPx = (int)(toast.Bounds.Height * scale);
var x = settings.Horizontal switch
{
HEdge.Left => wa.X + margin,
HEdge.Right => wa.Right - margin - wPx,
_ => wa.X + (wa.Width - wPx) / 2,
};
int y;
if (stackUp)
{
cursor -= hPx;
y = cursor;
cursor -= gap;
}
else
{
y = cursor;
cursor += hPx + gap;
}
toast.Position = new PixelPoint(x, y);
}
}
}