From f390bcd133487ac94557d2fe37b16b27df22917f Mon Sep 17 00:00:00 2001 From: chuan Date: Mon, 22 Jun 2026 16:00:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E7=A4=BA=E9=9F=B3=E3=80=81?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B9=E5=9B=BE=E6=A0=87=E4=B8=8E=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=B8=85=E6=B4=97=EF=BC=88P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提示音:winmm 从内存异步播放打包 wav,可在设置开关,带防连环音 - 调用方图标:进程树上溯识别编辑器/终端,ExtractIconEx + GDI 转 Avalonia 位图显示 - 进程树跳过 shell/运行时,取不到图标回退默认 Claude 图标 - 消息清洗:折叠换行/多余空白为单行,避免撑乱 toast 布局 - 全程 LibraryImport/源生成,AOT 友好 --- Notify/App.axaml.cs | 6 + Notify/Cli/CliRunner.cs | 24 +++- Notify/Interop/AppIcon.cs | 202 ++++++++++++++++++++++++++++++ Notify/Interop/ProcessTree.cs | 167 ++++++++++++++++++++++++ Notify/Interop/Sound.cs | 67 ++++++++++ Notify/Ipc/NotifyMessage.cs | 3 + Notify/Models/StateData.cs | 3 + Notify/Models/ToastRequest.cs | 5 + Notify/Services/ToastManager.cs | 2 +- Notify/Views/ToastWindow.axaml | 1 + Notify/Views/ToastWindow.axaml.cs | 22 +++- 11 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 Notify/Interop/AppIcon.cs create mode 100644 Notify/Interop/ProcessTree.cs create mode 100644 Notify/Interop/Sound.cs diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs index fd27222..0f008b1 100644 --- a/Notify/App.axaml.cs +++ b/Notify/App.axaml.cs @@ -52,6 +52,11 @@ public partial class App : Application // 监视线程收到请求,切回 UI 线程弹出 toast private void OnNotify(NotifyMessage message) { + if (Settings.Current.PlaySound) + { + Notify.Interop.Sound.Play(); + } + Dispatcher.UIThread.Post(() => Toasts.Show(new ToastRequest { Title = message.Title, @@ -60,6 +65,7 @@ public partial class App : Application Sticky = message.Sticky, TargetHwnd = message.TargetHwnd, WtRuntimeId = message.WtRuntimeId, + IconPath = message.IconPath, })); } diff --git a/Notify/Cli/CliRunner.cs b/Notify/Cli/CliRunner.cs index e6b15e4..982683b 100644 --- a/Notify/Cli/CliRunner.cs +++ b/Notify/Cli/CliRunner.cs @@ -36,6 +36,7 @@ public static class CliRunner Hwnd = hwnd.ToInt64(), Prompt = input.Prompt ?? "", WtRuntimeId = wtRuntimeId, + CallerExePath = ProcessTree.FindCallerExePath(), }); return 0; } @@ -56,11 +57,12 @@ public static class CliRunner { SessionId = input.SessionId, Title = "Claude Code", - Message = message, + Message = Sanitize(message), InputMode = false, Sticky = false, TargetHwnd = state?.Hwnd ?? 0, WtRuntimeId = state?.WtRuntimeId, + IconPath = state?.CallerExePath, }); return 0; } @@ -87,11 +89,12 @@ public static class CliRunner { SessionId = input.SessionId, Title = title, - Message = message, + Message = Sanitize(message), InputMode = true, Sticky = true, TargetHwnd = state?.Hwnd ?? 0, WtRuntimeId = state?.WtRuntimeId, + IconPath = state?.CallerExePath, }); return 0; } @@ -186,6 +189,23 @@ public static class CliRunner 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() diff --git a/Notify/Interop/AppIcon.cs b/Notify/Interop/AppIcon.cs new file mode 100644 index 0000000..a38977f --- /dev/null +++ b/Notify/Interop/AppIcon.cs @@ -0,0 +1,202 @@ +using System; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace Notify.Interop; + +/// +/// 从 exe 提取图标并转成 Avalonia 位图 +/// +/// ExtractIconEx 拿 HICON,再用 GDI 读出 BGRA 像素构造 Bitmap;不依赖 +/// System.Drawing(其 AOT 不友好) +/// +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(), 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(), + 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; +} diff --git a/Notify/Interop/ProcessTree.cs b/Notify/Interop/ProcessTree.cs new file mode 100644 index 0000000..e62834d --- /dev/null +++ b/Notify/Interop/ProcessTree.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Notify.Interop; + +/// +/// 沿父进程上溯,跳过 shell/运行时,找到真正的调用方 App(编辑器/终端) +/// +internal static partial class ProcessTree +{ + // 这些进程是 shell / 运行时 / 包装器,不是用户面对的 App,跳过继续上溯 + private static readonly HashSet 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 BuildParentMap() + { + var map = new Dictionary(); + 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(); + + 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]; +} diff --git a/Notify/Interop/Sound.cs b/Notify/Interop/Sound.cs new file mode 100644 index 0000000..c546742 --- /dev/null +++ b/Notify/Interop/Sound.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Avalonia.Platform; + +namespace Notify.Interop; + +/// +/// 播放打包的提示音 wav +/// +/// 用 winmm 的 PlaySound 从内存异步播放;为配合 SND_ASYNC,wav 拷到不会被 GC +/// 移动的非托管内存里常驻 +/// +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); + } +} diff --git a/Notify/Ipc/NotifyMessage.cs b/Notify/Ipc/NotifyMessage.cs index e433bae..9146160 100644 --- a/Notify/Ipc/NotifyMessage.cs +++ b/Notify/Ipc/NotifyMessage.cs @@ -23,4 +23,7 @@ public sealed class NotifyMessage // 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId public string? WtRuntimeId { get; set; } + + // 调用方 App 的 exe 路径,用于显示其图标 + public string? IconPath { get; set; } } diff --git a/Notify/Models/StateData.cs b/Notify/Models/StateData.cs index 416fda0..460a672 100644 --- a/Notify/Models/StateData.cs +++ b/Notify/Models/StateData.cs @@ -13,4 +13,7 @@ public sealed class StateData // 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回 public string WtRuntimeId { get; set; } = ""; + + // 调用方 App 的 exe 路径,用于提取并显示其图标 + public string CallerExePath { get; set; } = ""; } diff --git a/Notify/Models/ToastRequest.cs b/Notify/Models/ToastRequest.cs index 02c6685..8e1b4d2 100644 --- a/Notify/Models/ToastRequest.cs +++ b/Notify/Models/ToastRequest.cs @@ -28,4 +28,9 @@ public sealed class ToastRequest /// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId /// public string? WtRuntimeId { get; init; } + + /// + /// 调用方 App 的 exe 路径,用于显示其图标 + /// + public string? IconPath { get; init; } } diff --git a/Notify/Services/ToastManager.cs b/Notify/Services/ToastManager.cs index ebac14e..fac9221 100644 --- a/Notify/Services/ToastManager.cs +++ b/Notify/Services/ToastManager.cs @@ -33,7 +33,7 @@ public sealed class ToastManager } var vm = new ToastViewModel(request); - var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId); + var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId, request.IconPath); window.Closed += OnToastClosed; _active.Add(window); diff --git a/Notify/Views/ToastWindow.axaml b/Notify/Views/ToastWindow.axaml index 0974828..3c54e0c 100644 --- a/Notify/Views/ToastWindow.axaml +++ b/Notify/Views/ToastWindow.axaml @@ -26,6 +26,7 @@ diff --git a/Notify/Views/ToastWindow.axaml.cs b/Notify/Views/ToastWindow.axaml.cs index 18d1967..c148164 100644 --- a/Notify/Views/ToastWindow.axaml.cs +++ b/Notify/Views/ToastWindow.axaml.cs @@ -22,14 +22,15 @@ public partial class ToastWindow : Window 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) + public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null, null) { } - public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId) + public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId, string? iconPath) { _settings = settings; _targetHwnd = targetHwnd; @@ -55,6 +56,16 @@ public partial class ToastWindow : Window 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, settings.DurationSeconds)), @@ -62,6 +73,13 @@ public partial class ToastWindow : Window _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);