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);