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
+25
View File
@@ -0,0 +1,25 @@
<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"
Clicked="OnTrayClicked">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="退出" Click="OnExitClick" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
</Application>
+121
View File
@@ -0,0 +1,121 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Notify.Ipc;
using Notify.Models;
using Notify.Services;
using Notify.ViewModels;
using Notify.Views;
namespace Notify;
public partial class App : Application
{
private SettingsWindow? _settingsWindow;
private SpoolWatcher? _spoolWatcher;
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);
// 监视 spool 目录,把 CLI 投递的请求转成 toast
_spoolWatcher = new SpoolWatcher(OnNotify);
_spoolWatcher.Start();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// 无主窗口的常驻进程:仅托盘存在,靠托盘菜单或外部请求驱动
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
// --demo:启动即弹一条 toast 并打开设置,便于无托盘交互地验证
if (desktop.Args is { Length: > 0 } args && System.Array.IndexOf(args, "--demo") >= 0)
{
Dispatcher.UIThread.Post(RunDemo);
}
}
base.OnFrameworkInitializationCompleted();
}
// 监视线程收到请求,切回 UI 线程弹出 toast
private void OnNotify(NotifyMessage message)
{
if (Settings.Current.PlaySound)
{
Notify.Interop.Sound.Play();
}
// 目标窗口已是前台(你正盯着看):完成类通知弹一下即可,用更短的停留
int? durationOverride = null;
if (!message.InputMode && message.TargetHwnd != 0 &&
Notify.Interop.Win32.GetForegroundWindow().ToInt64() == message.TargetHwnd)
{
durationOverride = Settings.Current.FocusedDurationSeconds;
}
Dispatcher.UIThread.Post(() => Toasts.Show(new ToastRequest
{
Title = message.Title,
Message = message.Message,
InputMode = message.InputMode,
Sticky = message.Sticky,
TargetHwnd = message.TargetHwnd,
WtRuntimeId = message.WtRuntimeId,
IconPath = message.IconPath,
DurationSecondsOverride = durationOverride,
}));
}
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 void OnTrayClicked(object? sender, EventArgs e) => OpenSettings();
private void OpenSettings()
{
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.
+185
View File
@@ -0,0 +1,185 @@
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using Notify.Interop;
using Notify.Ipc;
using Notify.Models;
using Notify.Serialization;
using Notify.Services;
namespace Notify.Cli;
/// <summary>
/// 钩子子命令实现:纯互操作 / IPC,绝不加载 Avalonia,做完即退出
/// </summary>
public static class CliRunner
{
// UserPromptSubmit:记录前台窗口与 prompt
public static int Save()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
var hwnd = Win32.GetForegroundWindow();
// 前台是 Windows Terminal 时,记录当前标签的 RuntimeId
var wtRuntimeId = WinTerminalTabs.IsWindowsTerminal(hwnd)
? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd)
: "";
StateStore.Save(input.SessionId, new StateData
{
Hwnd = hwnd.ToInt64(),
Prompt = input.Prompt ?? "",
WtRuntimeId = wtRuntimeId,
CallerExePath = ProcessTree.FindCallerExePath(),
});
return 0;
}
// Stop:任务完成通知,正文取本次 prompt
public static int Notify()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
var state = StateStore.Load(input.SessionId);
var message = !string.IsNullOrWhiteSpace(state?.Prompt) ? state!.Prompt : "Task completed";
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = "Claude Code",
Message = Sanitize(message),
InputMode = false,
Sticky = false,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
IconPath = state?.CallerExePath,
});
return 0;
}
// Notification / PreToolUse:需要输入,常驻显示
public static int Input()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
// 过滤无需打扰的类型
if (input.NotificationType is "auth_success" or "elicitation_complete" or "elicitation_response")
{
return 0;
}
var (title, message) = Resolve(input);
var state = StateStore.Load(input.SessionId);
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = title,
Message = Sanitize(message),
InputMode = true,
Sticky = true,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
IconPath = state?.CallerExePath,
});
return 0;
}
// SessionEnd:清理会话状态
public static int Cleanup()
{
var input = ReadStdin();
if (!string.IsNullOrEmpty(input?.SessionId))
{
StateStore.Delete(input.SessionId);
}
return 0;
}
// 按 tool_name / notification_type 决定标题与正文,对齐原版语义
private static (string Title, string Message) Resolve(HookInput input)
{
if (input.ToolName == "AskUserQuestion")
{
var msg = string.IsNullOrEmpty(input.Message) ? "Claude 在向你提问" : input.Message!;
return ("Claude is Asking", msg);
}
if (input.ToolName == "ExitPlanMode")
{
return ("Plan Ready for Approval", "Claude 提交了一份计划,待批准");
}
var title = input.NotificationType switch
{
"permission_prompt" => "Permission Required",
"idle_prompt" => "Claude is Waiting",
"elicitation_dialog" => "MCP Asks",
_ => "Input Required",
};
var message = string.IsNullOrEmpty(input.Message) ? "Claude needs your input" : input.Message!;
return (title, message);
}
// 折叠换行/制表/多余空白为单行,避免撑乱 toast 布局(截断交给 toast 的省略号)
private static string Sanitize(string s)
{
if (string.IsNullOrEmpty(s))
{
return s;
}
s = s.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
while (s.Contains(" "))
{
s = s.Replace(" ", " ");
}
return s.Trim();
}
// 直接读原始字节并按 UTF-8 解码:WinExe 下 Console.In 不可靠,且其代码页
// 会把中文解成乱码(GBK),这里绕开
private static HookInput? ReadStdin()
{
try
{
using var stdin = Console.OpenStandardInput();
using var ms = new MemoryStream();
stdin.CopyTo(ms);
var bytes = ms.ToArray();
if (bytes.Length == 0)
{
return null;
}
var text = Encoding.UTF8.GetString(bytes).TrimStart('');
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.HookInput);
}
catch
{
return null;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Notify.Cli;
/// <summary>
/// Claude Code 钩子经 stdin 传入的 JSON
/// </summary>
public sealed class HookInput
{
[JsonPropertyName("session_id")]
public string? SessionId { get; set; }
[JsonPropertyName("prompt")]
public string? Prompt { get; set; }
[JsonPropertyName("notification_type")]
public string? NotificationType { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("tool_name")]
public string? ToolName { get; set; }
}
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace Notify.Interop;
/// <summary>
/// 从 exe 提取图标并转成 Avalonia 位图
///
/// ExtractIconEx 拿 HICON,再用 GDI 读出 BGRA 像素构造 Bitmap;不依赖
/// System.Drawing(其 AOT 不友好)
/// </summary>
internal static partial class AppIcon
{
public static Bitmap? Extract(string exePath)
{
if (string.IsNullOrEmpty(exePath))
{
return null;
}
var hIcon = IntPtr.Zero;
try
{
if (ExtractIconExW(exePath, 0, out hIcon, out _, 1) == 0 || hIcon == IntPtr.Zero)
{
return null;
}
return IconToBitmap(hIcon);
}
catch
{
return null;
}
finally
{
if (hIcon != IntPtr.Zero)
{
DestroyIcon(hIcon);
}
}
}
private static Bitmap? IconToBitmap(IntPtr hIcon)
{
if (!GetIconInfo(hIcon, out var ii))
{
return null;
}
try
{
var bm = default(BITMAP);
if (GetObjectW(ii.hbmColor, Marshal.SizeOf<BITMAP>(), ref bm) == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0)
{
return null;
}
var w = bm.bmWidth;
var h = bm.bmHeight;
var buffer = new byte[w * h * 4];
var bmi = new BITMAPINFOHEADER
{
biSize = (uint)Marshal.SizeOf<BITMAPINFOHEADER>(),
biWidth = w,
biHeight = -h, // 负数 = 自上而下,行序正常
biPlanes = 1,
biBitCount = 32,
biCompression = 0,
};
var hdc = GetDC(IntPtr.Zero);
try
{
if (GetDIBits(hdc, ii.hbmColor, 0, (uint)h, buffer, ref bmi, 0) == 0)
{
return null;
}
}
finally
{
ReleaseDC(IntPtr.Zero, hdc);
}
// 某些老图标无 alpha 通道(全 0),那样会整块透明,补成不透明
var anyAlpha = false;
for (var i = 3; i < buffer.Length; i += 4)
{
if (buffer[i] != 0)
{
anyAlpha = true;
break;
}
}
if (!anyAlpha)
{
for (var i = 3; i < buffer.Length; i += 4)
{
buffer[i] = 255;
}
}
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
return new Bitmap(
PixelFormat.Bgra8888,
AlphaFormat.Unpremul,
handle.AddrOfPinnedObject(),
new PixelSize(w, h),
new Vector(96, 96),
w * 4);
}
finally
{
handle.Free();
}
}
finally
{
if (ii.hbmColor != IntPtr.Zero)
{
DeleteObject(ii.hbmColor);
}
if (ii.hbmMask != IntPtr.Zero)
{
DeleteObject(ii.hbmMask);
}
}
}
[LibraryImport("shell32.dll", EntryPoint = "ExtractIconExW", StringMarshalling = StringMarshalling.Utf16)]
private static partial uint ExtractIconExW(string lpszFile, int nIconIndex, out IntPtr phiconLarge, out IntPtr phiconSmall, uint nIcons);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool DestroyIcon(IntPtr hIcon);
[LibraryImport("gdi32.dll", EntryPoint = "GetObjectW")]
private static partial int GetObjectW(IntPtr hgdiobj, int cbBuffer, ref BITMAP lpvObject);
[LibraryImport("gdi32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool DeleteObject(IntPtr hObject);
[LibraryImport("gdi32.dll")]
private static partial int GetDIBits(IntPtr hdc, IntPtr hbmp, uint uStartScan, uint cScanLines, [Out] byte[] lpvBits, ref BITMAPINFOHEADER lpbi, uint uUsage);
[LibraryImport("user32.dll")]
private static partial IntPtr GetDC(IntPtr hWnd);
[LibraryImport("user32.dll")]
private static partial int ReleaseDC(IntPtr hWnd, IntPtr hDC);
}
[StructLayout(LayoutKind.Sequential)]
internal struct ICONINFO
{
public int fIcon;
public uint xHotspot;
public uint yHotspot;
public IntPtr hbmMask;
public IntPtr hbmColor;
}
[StructLayout(LayoutKind.Sequential)]
internal struct BITMAP
{
public int bmType;
public int bmWidth;
public int bmHeight;
public int bmWidthBytes;
public ushort bmPlanes;
public ushort bmBitsPixel;
public IntPtr bmBits;
}
[StructLayout(LayoutKind.Sequential)]
internal struct BITMAPINFOHEADER
{
public uint biSize;
public int biWidth;
public int biHeight;
public ushort biPlanes;
public ushort biBitCount;
public uint biCompression;
public uint biSizeImage;
public int biXPelsPerMeter;
public int biYPelsPerMeter;
public uint biClrUsed;
public uint biClrImportant;
}
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace Notify.Interop;
/// <summary>
/// 沿父进程上溯,跳过 shell/运行时,找到真正的调用方 App(编辑器/终端)
/// </summary>
internal static partial class ProcessTree
{
// 这些进程是 shell / 运行时 / 包装器,不是用户面对的 App,跳过继续上溯
private static readonly HashSet<string> SkipNames = new(StringComparer.OrdinalIgnoreCase)
{
"cmd", "powershell", "pwsh", "bash", "sh", "zsh", "fish",
"wsl", "wslhost", "conhost", "openconsole",
"node", "deno", "bun", "python", "python3", "py",
"uv", "uvx", "npm", "npx", "yarn", "pnpm",
"claude", "dotnet", "git", "env", "busybox", "winpty", "sudo",
"notify",
};
public static string FindCallerExePath()
{
try
{
var parents = BuildParentMap();
var pid = GetCurrentProcessId();
for (var i = 0; i < 16; i++)
{
if (!parents.TryGetValue(pid, out var info))
{
break;
}
pid = info.Parent;
if (pid == 0 || !parents.TryGetValue(pid, out var anc))
{
break;
}
var name = anc.Name;
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
name = name[..^4];
}
if (SkipNames.Contains(name))
{
continue;
}
// 第一个非 shell/运行时的祖先即调用方 App
return GetFullPath(pid);
}
}
catch
{
// 取不到就回退默认图标
}
return "";
}
private static Dictionary<uint, (uint Parent, string Name)> BuildParentMap()
{
var map = new Dictionary<uint, (uint, string)>();
var snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1))
{
return map;
}
try
{
var entry = default(PROCESSENTRY32W);
entry.dwSize = (uint)Marshal.SizeOf<PROCESSENTRY32W>();
if (Process32FirstW(snapshot, ref entry))
{
do
{
map[entry.th32ProcessID] = (entry.th32ParentProcessID, ReadExeName(ref entry));
}
while (Process32NextW(snapshot, ref entry));
}
}
finally
{
CloseHandle(snapshot);
}
return map;
}
private static unsafe string ReadExeName(ref PROCESSENTRY32W entry)
{
fixed (char* p = entry.szExeFile)
{
return new string(p);
}
}
private static string GetFullPath(uint pid)
{
var h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (h == IntPtr.Zero)
{
return "";
}
try
{
var buf = new char[1024];
var size = (uint)buf.Length;
return QueryFullProcessImageName(h, 0, ref buf[0], ref size) ? new string(buf, 0, (int)size) : "";
}
finally
{
CloseHandle(h);
}
}
private const uint TH32CS_SNAPPROCESS = 0x00000002;
private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
[LibraryImport("kernel32.dll")]
private static partial uint GetCurrentProcessId();
[LibraryImport("kernel32.dll")]
private static partial IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool Process32FirstW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe);
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool Process32NextW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe);
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool CloseHandle(IntPtr hObject);
[LibraryImport("kernel32.dll")]
private static partial IntPtr OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId);
[LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool QueryFullProcessImageName(IntPtr hProcess, uint dwFlags, ref char lpExeName, ref uint lpdwSize);
}
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct PROCESSENTRY32W
{
public uint dwSize;
public uint cntUsage;
public uint th32ProcessID;
public nint th32DefaultHeapID;
public uint th32ModuleID;
public uint cntThreads;
public uint th32ParentProcessID;
public int pcPriClassBase;
public uint dwFlags;
public fixed char szExeFile[260];
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Avalonia.Platform;
namespace Notify.Interop;
/// <summary>
/// 播放打包的提示音 wav
///
/// 用 winmm 的 PlaySound 从内存异步播放;为配合 SND_ASYNC,wav 拷到不会被 GC
/// 移动的非托管内存里常驻
/// </summary>
internal static partial class Sound
{
private const uint SND_ASYNC = 0x0001;
private const uint SND_NODEFAULT = 0x0002;
private const uint SND_MEMORY = 0x0004;
private static IntPtr _wavPtr;
private static DateTime _lastPlay = DateTime.MinValue;
[LibraryImport("winmm.dll", EntryPoint = "PlaySoundW")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool PlaySound(IntPtr pszSound, IntPtr hmod, uint fdwSound);
public static void Play()
{
// 防连环音:300ms 内只响一次
var now = DateTime.UtcNow;
if ((now - _lastPlay).TotalMilliseconds < 300)
{
return;
}
_lastPlay = now;
try
{
EnsureLoaded();
if (_wavPtr != IntPtr.Zero)
{
PlaySound(_wavPtr, IntPtr.Zero, SND_MEMORY | SND_ASYNC | SND_NODEFAULT);
}
}
catch
{
// 播放失败无所谓
}
}
private static void EnsureLoaded()
{
if (_wavPtr != IntPtr.Zero)
{
return;
}
using var s = AssetLoader.Open(new Uri("avares://notify/Assets/notification.wav"));
using var ms = new MemoryStream();
s.CopyTo(ms);
var bytes = ms.ToArray();
_wavPtr = Marshal.AllocHGlobal(bytes.Length);
Marshal.Copy(bytes, 0, _wavPtr, bytes.Length);
}
}
+209
View File
@@ -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 说明:接口用源生成 COMGeneratedComInterface),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);
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using System.Runtime.InteropServices;
namespace Notify.Interop;
internal static partial class Win32
{
[LibraryImport("user32.dll")]
internal static partial IntPtr GetForegroundWindow();
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetForegroundWindow(IntPtr hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool AllowSetForegroundWindow(uint dwProcessId);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
[LibraryImport("user32.dll")]
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool BringWindowToTop(IntPtr hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ShowWindow(IntPtr hWnd, int nCmdShow);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool IsIconic(IntPtr hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool IsWindow(IntPtr hWnd);
[LibraryImport("user32.dll")]
internal static partial void SwitchToThisWindow(IntPtr hWnd, [MarshalAs(UnmanagedType.Bool)] bool fAltTab);
[LibraryImport("user32.dll")]
internal static partial void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo);
[LibraryImport("kernel32.dll")]
internal static partial uint GetCurrentThreadId();
[LibraryImport("user32.dll", EntryPoint = "GetClassNameW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial int GetClassName(IntPtr hWnd, ref char lpClassName, int nMaxCount);
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
internal static partial IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
internal static partial IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
// 把窗口设为工具窗口:从任务栏与 Alt+Tab 中隐藏
internal static void MakeToolWindow(IntPtr hWnd)
{
var ex = GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64();
ex = (ex | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW;
SetWindowLongPtr(hWnd, GWL_EXSTYLE, new IntPtr(ex));
}
// 取窗口类名
internal static string GetClassNameOf(IntPtr hWnd)
{
var buf = new char[256];
var n = GetClassName(hWnd, ref buf[0], buf.Length);
return n > 0 ? new string(buf, 0, n) : "";
}
// --- 常量 ---
internal const uint ASFW_ANY = 0xFFFFFFFF;
internal const int SW_RESTORE = 9;
internal const int SW_SHOW = 5;
internal const uint SWP_NOSIZE = 0x0001;
internal const uint SWP_NOMOVE = 0x0002;
internal const uint SWP_SHOWWINDOW = 0x0040;
internal const byte VK_MENU = 0x12;
internal const uint KEYEVENTF_KEYUP = 0x0002;
internal const int GWL_EXSTYLE = -20;
internal const long WS_EX_TOOLWINDOW = 0x00000080;
internal const long WS_EX_APPWINDOW = 0x00040000;
}
+299
View File
@@ -0,0 +1,299 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
using System.Text;
namespace Notify.Interop;
/// <summary>
/// Windows Terminal 标签页的捕获与切换
///
/// save 时记录当前选中标签的 RuntimeId,点击通知激活窗口后再据此切回该标签
/// 全程走源生成 COMGeneratedComInterface),保证 NativeAOT 兼容
/// vtable 顺序与 GUID 均取自 Windows SDK UIAutomationClient.h
/// </summary>
public static partial class WinTerminalTabs
{
private const string WtClass = "CASCADIA_HOSTING_WINDOW_CLASS";
private const int TreeScopeDescendants = 4;
private const int ControlTypePropertyId = 30003;
private const int IsSelectedPropertyId = 30079;
private const int TabItemControlTypeId = 50019;
private const int SelectionItemPatternId = 10010;
private const int CLSCTX_INPROC_SERVER = 1;
private static readonly StrategyBasedComWrappers ComWrappers = new();
private static IUIAutomation? _uia;
private static bool _initTried;
public static bool IsWindowsTerminal(IntPtr hwnd) =>
hwnd != IntPtr.Zero && Win32.GetClassNameOf(hwnd) == WtClass;
// 返回当前选中标签的 RuntimeId 串,失败返回空串
public static string GetSelectedTabRuntimeId(IntPtr hwnd)
{
try
{
var uia = EnsureUia();
if (uia is null)
{
return "";
}
var root = uia.ElementFromHandle(hwnd);
var cond = uia.CreateTrueCondition();
var all = root.FindAll(TreeScopeDescendants, cond);
var count = all.Length();
for (var i = 0; i < count; i++)
{
var el = all.GetElement(i);
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
{
continue;
}
if (GetBoolProperty(el, IsSelectedPropertyId))
{
return RuntimeIdOf(el);
}
}
}
catch
{
// 任何 COM 异常退回空串
}
return "";
}
// 在已激活的 WT 窗口里找到匹配 RuntimeId 的标签并选中
public static bool SelectTab(IntPtr hwnd, string runtimeId)
{
if (string.IsNullOrEmpty(runtimeId))
{
return false;
}
try
{
var uia = EnsureUia();
if (uia is null)
{
return false;
}
var root = uia.ElementFromHandle(hwnd);
var cond = uia.CreateTrueCondition();
var all = root.FindAll(TreeScopeDescendants, cond);
var count = all.Length();
for (var i = 0; i < count; i++)
{
var el = all.GetElement(i);
if (GetIntProperty(el, ControlTypePropertyId) != TabItemControlTypeId)
{
continue;
}
if (RuntimeIdOf(el) != runtimeId)
{
continue;
}
var pattern = el.GetCurrentPattern(SelectionItemPatternId);
if (pattern is null)
{
return false;
}
pattern.Select();
return true;
}
}
catch
{
// 切换失败不影响窗口已被激活
}
return false;
}
private static IUIAutomation? EnsureUia()
{
if (_initTried)
{
return _uia;
}
_initTried = true;
try
{
var clsid = new Guid("ff48dba4-60ef-4201-aa87-54103eef594e");
var iid = typeof(IUIAutomation).GUID;
var hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref iid, out var ptr);
if (hr >= 0 && ptr != IntPtr.Zero)
{
_uia = (IUIAutomation)ComWrappers.GetOrCreateObjectForComInstance(ptr, CreateObjectFlags.None);
Marshal.Release(ptr);
}
}
catch
{
_uia = null;
}
return _uia;
}
private static int GetIntProperty(IUIAutomationElement el, int propertyId)
{
var v = el.GetCurrentPropertyValue(propertyId);
return v.lVal;
}
private static bool GetBoolProperty(IUIAutomationElement el, int propertyId)
{
var v = el.GetCurrentPropertyValue(propertyId);
return v.boolVal != 0;
}
// RuntimeId 是一个 int 数组(SAFEARRAY),拼成点分串用于比较
private static string RuntimeIdOf(IUIAutomationElement el)
{
var psa = el.GetRuntimeId();
if (psa == IntPtr.Zero)
{
return "";
}
try
{
if (SafeArrayGetLBound(psa, 1, out var lb) < 0 || SafeArrayGetUBound(psa, 1, out var ub) < 0)
{
return "";
}
var sb = new StringBuilder();
for (var idx = lb; idx <= ub; idx++)
{
var i = idx;
if (SafeArrayGetElement(psa, ref i, out var val) < 0)
{
return "";
}
if (sb.Length > 0)
{
sb.Append('.');
}
sb.Append(val);
}
return sb.ToString();
}
finally
{
SafeArrayDestroy(psa);
}
}
[LibraryImport("ole32.dll")]
private static partial int CoCreateInstance(ref Guid rclsid, IntPtr pUnkOuter, int dwClsContext, ref Guid riid, out IntPtr ppv);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetLBound(IntPtr psa, uint nDim, out int plLbound);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetUBound(IntPtr psa, uint nDim, out int plUbound);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayGetElement(IntPtr psa, ref int rgIndices, out int pv);
[LibraryImport("oleaut32.dll")]
private static partial int SafeArrayDestroy(IntPtr psa);
}
// VARIANT 的最小化布局(x64 为 24 字节),只读 VT_I4 / VT_BOOL
[StructLayout(LayoutKind.Explicit, Size = 24)]
internal struct VARIANT
{
[FieldOffset(0)]
public ushort vt;
[FieldOffset(8)]
public int lVal;
[FieldOffset(8)]
public short boolVal;
}
[GeneratedComInterface]
[Guid("30cbe57d-d9d0-452a-ab13-7ac5ac4825ee")]
internal partial interface IUIAutomation
{
void _CompareElements();
void _CompareRuntimeIds();
void _GetRootElement();
IUIAutomationElement ElementFromHandle(IntPtr hwnd);
void _ElementFromPoint();
void _GetFocusedElement();
void _GetRootElementBuildCache();
void _ElementFromHandleBuildCache();
void _ElementFromPointBuildCache();
void _GetFocusedElementBuildCache();
void _CreateTreeWalker();
void _get_ControlViewWalker();
void _get_ContentViewWalker();
void _get_RawViewWalker();
void _get_RawViewCondition();
void _get_ControlViewCondition();
void _get_ContentViewCondition();
void _CreateCacheRequest();
IUIAutomationCondition CreateTrueCondition();
}
[GeneratedComInterface]
[Guid("d22108aa-8ac5-49a5-837b-37bbb3d7591e")]
internal partial interface IUIAutomationElement
{
void _SetFocus();
IntPtr GetRuntimeId();
void _FindFirst();
IUIAutomationElementArray FindAll(int scope, IUIAutomationCondition condition);
void _FindFirstBuildCache();
void _FindAllBuildCache();
void _BuildUpdatedCache();
VARIANT GetCurrentPropertyValue(int propertyId);
void _GetCurrentPropertyValueEx();
void _GetCachedPropertyValue();
void _GetCachedPropertyValueEx();
void _GetCurrentPatternAs();
void _GetCachedPatternAs();
[return: MarshalUsing(typeof(UniqueComInterfaceMarshaller<IUIAutomationSelectionItemPattern>))]
IUIAutomationSelectionItemPattern? GetCurrentPattern(int patternId);
}
[GeneratedComInterface]
[Guid("14314595-b4bc-4055-95f2-58f2e42c9855")]
internal partial interface IUIAutomationElementArray
{
int Length();
IUIAutomationElement GetElement(int index);
}
[GeneratedComInterface]
[Guid("352ffba8-0973-437c-a61f-f64cafd81df9")]
internal partial interface IUIAutomationCondition
{
}
[GeneratedComInterface]
[Guid("a8efa66a-0fda-421a-9194-38021f3578ea")]
internal partial interface IUIAutomationSelectionItemPattern
{
void Select();
}
+72
View File
@@ -0,0 +1,72 @@
using System;
namespace Notify.Interop;
/// <summary>
/// 把目标窗口拉回前台
///
/// Windows 限制后台进程抢焦点,这里用一套组合技绕过:ALT 键模拟 +
/// AttachThreadInput 把当前线程与前台/目标线程的输入队列挂接 +
/// SetWindowPos/BringWindowToTop/SwitchToThisWindow/SetForegroundWindow 多管齐下
/// </summary>
public static class WindowActivator
{
public static bool Activate(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero || !Win32.IsWindow(hwnd))
{
return false;
}
try
{
// 最小化的先还原
if (Win32.IsIconic(hwnd))
{
Win32.ShowWindow(hwnd, Win32.SW_RESTORE);
}
var foreground = Win32.GetForegroundWindow();
var curThread = Win32.GetCurrentThreadId();
var fgThread = Win32.GetWindowThreadProcessId(foreground, out _);
var targetThread = Win32.GetWindowThreadProcessId(hwnd, out _);
// 模拟一次 ALT 抬起,满足 Windows 的"防焦点抢占"前置条件
Win32.keybd_event(Win32.VK_MENU, 0, 0, IntPtr.Zero);
Win32.keybd_event(Win32.VK_MENU, 0, Win32.KEYEVENTF_KEYUP, IntPtr.Zero);
if (fgThread != curThread)
{
Win32.AttachThreadInput(curThread, fgThread, true);
}
if (targetThread != curThread && targetThread != fgThread)
{
Win32.AttachThreadInput(curThread, targetThread, true);
}
Win32.AllowSetForegroundWindow(Win32.ASFW_ANY);
Win32.SetWindowPos(hwnd, IntPtr.Zero, 0, 0, 0, 0, Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_SHOWWINDOW);
Win32.BringWindowToTop(hwnd);
Win32.SwitchToThisWindow(hwnd, true);
Win32.SetForegroundWindow(hwnd);
Win32.ShowWindow(hwnd, Win32.SW_SHOW);
if (targetThread != curThread && targetThread != fgThread)
{
Win32.AttachThreadInput(curThread, targetThread, false);
}
if (fgThread != curThread)
{
Win32.AttachThreadInput(curThread, fgThread, false);
}
return Win32.GetForegroundWindow() == hwnd;
}
catch
{
return false;
}
}
}
+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);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Notify.Models;
/// <summary>
/// 每会话持久化的状态,由 save 钩子写入、notify 钩子与点击激活读取
/// </summary>
public sealed class StateData
{
// 触发时的前台窗口句柄
public long Hwnd { get; set; }
// 用户当次输入的 prompt,用作"任务完成"通知的正文
public string Prompt { get; set; } = "";
// 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回
public string WtRuntimeId { get; set; } = "";
// 调用方 App 的 exe 路径,用于提取并显示其图标
public string CallerExePath { get; set; } = "";
}
+41
View File
@@ -0,0 +1,41 @@
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; }
/// <summary>
/// 点击 toast 主体时要激活的窗口句柄,0 表示不激活
/// </summary>
public long TargetHwnd { get; init; }
/// <summary>
/// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
/// </summary>
public string? WtRuntimeId { get; init; }
/// <summary>
/// 调用方 App 的 exe 路径,用于显示其图标
/// </summary>
public string? IconPath { get; init; }
/// <summary>
/// 覆盖停留秒数;为 null 时用设置里的默认值
/// </summary>
public int? DurationSecondsOverride { get; init; }
}
+84
View File
@@ -0,0 +1,84 @@
namespace Notify.Models;
/// <summary>
/// 水平方向:决定 toast 贴左/居中/贴右
/// </summary>
public enum HEdge
{
Left,
Center,
Right,
}
/// <summary>
/// 垂直方向:决定 toast 贴上/居中/贴下,并决定堆叠方向
/// </summary>
public enum VEdge
{
Top,
Center,
Bottom,
}
/// <summary>
/// 持久化的弹窗设置(纯数据模型,序列化到磁盘)
/// </summary>
public sealed class ToastSettings
{
/// <summary>
/// 自动消失前的停留秒数
/// </summary>
public int DurationSeconds { get; set; } = 4;
/// <summary>
/// 目标窗口已在前台时的停留秒数(你正盯着看,弹一下即可)
/// </summary>
public int FocusedDurationSeconds { get; set; } = 2;
/// <summary>
/// 水平方向
/// </summary>
public HEdge Horizontal { get; set; } = HEdge.Right;
/// <summary>
/// 垂直方向
/// </summary>
public VEdge Vertical { get; set; } = VEdge.Bottom;
/// <summary>
/// 与屏幕边缘的留白(DIP
/// </summary>
public int Margin { get; set; } = 12;
/// <summary>
/// 不透明度 01
/// </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();
}
+82
View File
@@ -0,0 +1,82 @@
<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>
<Version>1.0.0</Version>
<IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- NativeAOT 发布配置(仅 publish 生效):单文件原生 exe -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<StripSymbols>true</StripSymbols>
<DebuggerSupport>false</DebuggerSupport>
<OptimizationPreference>Size</OptimizationPreference>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<!-- 这些 UI 库未完全标注 trim/AOT 安全,整体保留避免裁剪导致运行时异常 -->
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<TrimmerRootAssembly Include="Ursa" />
<TrimmerRootAssembly Include="Ursa.Themes.Semi" />
<TrimmerRootAssembly Include="Semi.Avalonia" />
</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>
<!--
AOT 单文件:把 Skia / HarfBuzz / ANGLE 三个原生库静态链接进 exe
CoreUtils.SkiaSharp.Static 含 skia + libHarfBuzzSharp 的 .libCoreUtils.ANGLE.Static 含 ANGLE
两包各自的 .targets 会在 PublishAot 时自动追加 NativeLibrary,这里只补 DirectPInvoke 与系统 lib
版本对应:Avalonia 12 → SkiaSharp 3.119
-->
<ItemGroup>
<PackageReference Include="CoreUtils.SkiaSharp.Static" Version="3.119.0.1" />
<PackageReference Include="CoreUtils.ANGLE.Static" Version="7151.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<DirectPInvoke Include="libSkiaSharp" />
<DirectPInvoke Include="libHarfBuzzSharp" />
<DirectPInvoke Include="av_libglesv2" />
<!-- ANGLE 静态库需要的系统库 -->
<LinkerArg Include="gdi32.lib" />
<LinkerArg Include="user32.lib" />
<LinkerArg Include="d3d9.lib" />
<LinkerArg Include="dxgi.lib" />
<LinkerArg Include="dxguid.lib" />
<LinkerArg Include="synchronization.lib" />
</ItemGroup>
<!-- 静态链接后清理发布目录:动态原生 DLL、链接期 .lib、调试 .pdb 都不需要,只留 notify.exe -->
<Target Name="CleanAotSingleFileOutput" AfterTargets="Publish" Condition="'$(PublishAot)' == 'true'">
<ItemGroup>
<_AotJunk Include="$(PublishDir)*.dll" />
<_AotJunk Include="$(PublishDir)*.lib" />
<_AotJunk Include="$(PublishDir)*.pdb" />
</ItemGroup>
<Delete Files="@(_AotJunk)" />
</Target>
</Project>
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Notify.Cli;
using Notify.Ipc;
namespace Notify;
internal static class Program
{
// 保活期间持有,确保 Host 单例
private static Mutex? _hostMutex;
// Avalonia configuration, don't remove; also used by the visual designer.
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
[STAThread]
public static int Main(string[] args)
{
var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "host";
// 钩子子命令:纯互操作 / IPC,不加载 Avalonia
return mode switch
{
"save" => CliRunner.Save(),
"notify" => CliRunner.Notify(),
"input" => CliRunner.Input(),
"cleanup" => CliRunner.Cleanup(),
_ => RunHost(args),
};
}
// 常驻 Host:加载 Avalonia,无主窗口保活,监听命名管道
private static int RunHost(string[] args)
{
_hostMutex = new Mutex(true, IpcConstants.HostMutexName, out var created);
if (!created)
{
// 已有 Host 在跑,本进程退出
return 0;
}
BuildAvaloniaApp()
// OnExplicitShutdown = 持续保活:没有主窗口也不会退出,只有显式 Shutdown 才结束
.StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
return 0;
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
using Notify.Cli;
using Notify.Ipc;
using Notify.Models;
namespace Notify.Serialization;
// System.Text.Json 源生成:为后续 NativeAOT 准备,避免反射序列化被裁剪
[JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true)]
[JsonSerializable(typeof(ToastSettings))]
[JsonSerializable(typeof(StateData))]
[JsonSerializable(typeof(HookInput))]
[JsonSerializable(typeof(NotifyMessage))]
internal partial class AppJsonContext : JsonSerializerContext;
+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);
}
}
}
+105
View File
@@ -0,0 +1,105 @@
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;
FocusedDurationSeconds = s.FocusedDurationSeconds;
Horizontal = s.Horizontal;
Vertical = s.Vertical;
Margin = s.Margin;
Opacity = s.Opacity;
MaxVisible = s.MaxVisible;
PlaySound = s.PlaySound;
Width = s.Width;
FadeMilliseconds = s.FadeMilliseconds;
ShowOnAllDesktops = s.ShowOnAllDesktops;
}
public IReadOnlyList<HEdge> Horizontals { get; } = [HEdge.Left, HEdge.Center, HEdge.Right];
public IReadOnlyList<VEdge> Verticals { get; } = [VEdge.Top, VEdge.Center, VEdge.Bottom];
[ObservableProperty]
public partial int DurationSeconds { get; set; }
[ObservableProperty]
public partial int FocusedDurationSeconds { get; set; }
[ObservableProperty]
public partial HEdge Horizontal { get; set; }
[ObservableProperty]
public partial VEdge Vertical { get; set; }
[ObservableProperty]
public partial int Margin { 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,
FocusedDurationSeconds = FocusedDurationSeconds,
Horizontal = Horizontal,
Vertical = Vertical,
Margin = Margin,
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,
});
}
}
+23
View File
@@ -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; }
}
+84
View File
@@ -0,0 +1,84 @@
<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="聚焦时停留(秒)">
<u:NumericIntUpDown Value="{Binding FocusedDurationSeconds}" Minimum="1" Maximum="60" />
</u:FormItem>
<u:FormItem Label="水平方向">
<ComboBox ItemsSource="{Binding Horizontals}"
SelectedItem="{Binding Horizontal}"
HorizontalAlignment="Stretch" />
</u:FormItem>
<u:FormItem Label="垂直方向">
<ComboBox ItemsSource="{Binding Verticals}"
SelectedItem="{Binding Vertical}"
HorizontalAlignment="Stretch" />
</u:FormItem>
<u:FormItem Label="边缘留白(DIP">
<u:NumericIntUpDown Value="{Binding Margin}" Minimum="0" Maximum="200" Step="2" />
</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>
+8
View File
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace Notify.Views;
public partial class SettingsWindow : Window
{
public SettingsWindow() => InitializeComponent();
}
+59
View File
@@ -0,0 +1,59 @@
<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"
x:Name="IconImage"
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>
+197
View File
@@ -0,0 +1,197 @@
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 readonly long _targetHwnd;
private readonly string? _wtRuntimeId;
private Avalonia.Media.Imaging.Bitmap? _appIcon;
private bool _closing;
// 设计器需要的无参构造
public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null, null, null)
{
}
public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId, string? iconPath, int? durationOverride)
{
_settings = settings;
_targetHwnd = targetHwnd;
_wtRuntimeId = wtRuntimeId;
var duration = durationOverride ?? settings.DurationSeconds;
// 常驻:请求显式 Sticky,或停留时长 <= 0
_sticky = sticky || duration <= 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);
// 调用方 App 图标,取不到则保留默认 Claude 图标
if (!string.IsNullOrEmpty(iconPath))
{
_appIcon = Notify.Interop.AppIcon.Extract(iconPath);
if (_appIcon is not null)
{
IconImage.Source = _appIcon;
}
}
_dismissTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(Math.Max(1, duration)),
};
_dismissTimer.Tick += (_, _) => BeginClose();
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
_appIcon?.Dispose();
_appIcon = null;
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
Opacity = _settings.Opacity; // 触发淡入
if (TryGetPlatformHandle()?.Handle is { } hwnd)
{
// 工具窗口:从任务栏与 Alt+Tab 中隐藏
Notify.Interop.Win32.MakeToolWindow(hwnd);
// 跨虚拟桌面:把窗口钉到所有桌面(失败自动忽略)
if (_settings.ShowOnAllDesktops)
{
TryPinWithRetry(hwnd);
}
}
if (!_sticky)
{
_dismissTimer.Start();
}
}
/// <summary>
/// 窗口刚打开时 shell 可能还没给它登记 ApplicationViewGetViewForHwnd 报
/// 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)
{
if (_targetHwnd != 0)
{
var target = new IntPtr(_targetHwnd);
Notify.Interop.WindowActivator.Activate(target);
// 目标是 Windows Terminal 则切回原标签
if (!string.IsNullOrEmpty(_wtRuntimeId))
{
Notify.Interop.WinTerminalTabs.SelectTab(target, _wtRuntimeId);
}
}
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();
}
}
+18
View File
@@ -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>