feat: 提示音、调用方图标与消息清洗(P2)

- 提示音:winmm 从内存异步播放打包 wav,可在设置开关,带防连环音
- 调用方图标:进程树上溯识别编辑器/终端,ExtractIconEx + GDI 转 Avalonia 位图显示
- 进程树跳过 shell/运行时,取不到图标回退默认 Claude 图标
- 消息清洗:折叠换行/多余空白为单行,避免撑乱 toast 布局
- 全程 LibraryImport/源生成,AOT 友好
This commit is contained in:
2026-06-22 16:00:59 +08:00
Unverified
parent 891128fec3
commit f390bcd133
11 changed files with 497 additions and 5 deletions
+6
View File
@@ -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,
}));
}
+22 -2
View File
@@ -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()
+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);
}
}
+3
View File
@@ -23,4 +23,7 @@ public sealed class NotifyMessage
// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
public string? WtRuntimeId { get; set; }
// 调用方 App 的 exe 路径,用于显示其图标
public string? IconPath { get; set; }
}
+3
View File
@@ -13,4 +13,7 @@ public sealed class StateData
// 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回
public string WtRuntimeId { get; set; } = "";
// 调用方 App 的 exe 路径,用于提取并显示其图标
public string CallerExePath { get; set; } = "";
}
+5
View File
@@ -28,4 +28,9 @@ public sealed class ToastRequest
/// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
/// </summary>
public string? WtRuntimeId { get; init; }
/// <summary>
/// 调用方 App 的 exe 路径,用于显示其图标
/// </summary>
public string? IconPath { get; init; }
}
+1 -1
View File
@@ -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);
+1
View File
@@ -26,6 +26,7 @@
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<Image Grid.Column="0"
x:Name="IconImage"
Width="44" Height="44"
VerticalAlignment="Center"
Source="/Assets/claude.ico" />
+20 -2
View File
@@ -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);