feat: 提示音、调用方图标与消息清洗(P2)
- 提示音:winmm 从内存异步播放打包 wav,可在设置开关,带防连环音 - 调用方图标:进程树上溯识别编辑器/终端,ExtractIconEx + GDI 转 Avalonia 位图显示 - 进程树跳过 shell/运行时,取不到图标回退默认 Claude 图标 - 消息清洗:折叠换行/多余空白为单行,避免撑乱 toast 布局 - 全程 LibraryImport/源生成,AOT 友好
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,7 @@ public sealed class NotifyMessage
|
||||
|
||||
// 目标若为 Windows Terminal,激活后要切回的标签 RuntimeId
|
||||
public string? WtRuntimeId { get; set; }
|
||||
|
||||
// 调用方 App 的 exe 路径,用于显示其图标
|
||||
public string? IconPath { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,4 +13,7 @@ public sealed class StateData
|
||||
|
||||
// 若前台是 Windows Terminal,记录当时选中标签的 RuntimeId,用于点击后切回
|
||||
public string WtRuntimeId { get; set; } = "";
|
||||
|
||||
// 调用方 App 的 exe 路径,用于提取并显示其图标
|
||||
public string CallerExePath { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user