From c7f5194ef44fbe71d57f3d9da91e01c5022009d1 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 30 May 2026 01:26:15 +0000 Subject: [PATCH] Wrap tray identicon in an ICO container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new log surfaced the next exception in the chain: once H.NotifyIcon could read our PNG file (PR #15), it handed the bytes to new System.Drawing.Icon(stream, size) — which only accepts ICO-format input and throws ArgumentException on PNG. Modern Windows accepts a PNG payload embedded inside an ICO container: a 6-byte ICONDIR header plus a 16-byte ICONDIRENTRY pointing at the PNG bytes. WrapPngAsIco builds that header in-place; no native code, no extra dependencies, ~20 lines. The tray icon file changes extension from .png to .ico; pixels are unchanged. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 9 +++- .../Services/HNotifyIconTrayService.cs | 47 ++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c885b4c..6ebb75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `LogsDirectory`. The log rotates at 5 MB. ### Fixed +- Tray icon initialisation crash, take two + (`ArgumentException: Argument 'picture' must be a picture that can be + used as a Icon.`): once H.NotifyIcon could read the file (PR #15) it + passed the bytes to `new System.Drawing.Icon(stream, size)`, which + only accepts ICO-format input. The tray service now wraps the + identicon PNG in a minimal ICO container (modern Windows accepts + PNG-in-ICO) and writes `%LOCALAPPDATA%\Snipdeck\tray-icon.ico`. - Tray icon initialisation crash (`NullReferenceException` inside `H.NotifyIcon.ImageExtensions.ToStream(Uri)`): H.NotifyIcon resolves `TaskbarIcon.IconSource` by reading `BitmapImage.UriSource`, but the tray service was loading the identicon via `BitmapImage.SetSourceAsync` from an in-memory stream — pixels populated, URI null. The service now - persists the identicon to `%LOCALAPPDATA%\Snipdeck\tray-icon.png` and + persists the identicon to `%LOCALAPPDATA%\Snipdeck\tray-icon.ico` and loads the `BitmapImage` from that URI, which is the shape H.NotifyIcon expects. - First-run crash on startup (`RPC_E_WRONG_THREAD` / `0x8001010E`): diff --git a/src/Snipdeck.App/Services/HNotifyIconTrayService.cs b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs index 8465513..c2845d1 100644 --- a/src/Snipdeck.App/Services/HNotifyIconTrayService.cs +++ b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs @@ -1,3 +1,5 @@ +using System.Buffers.Binary; + using H.NotifyIcon; using Microsoft.UI.Xaml.Controls; @@ -12,7 +14,8 @@ internal sealed partial class HNotifyIconTrayService : ITrayService { // Stable seed so the tray icon never changes between runs of Snipdeck. private static readonly Guid _iconSeed = Guid.Parse("5147DEC0-0000-0000-0000-000000000001"); - private const string _trayIconFileName = "tray-icon.png"; + private const string _trayIconFileName = "tray-icon.ico"; + private const int _trayIconPixels = 32; private readonly IPathProvider _paths; private TaskbarIcon? _icon; @@ -37,10 +40,10 @@ public async Task InitialiseAsync() return; } - // H.NotifyIcon resolves IconSource by reading BitmapImage.UriSource, - // not the pixel data — a stream-loaded BitmapImage NREs deep inside - // the library. Persist the identicon to a stable file path and load - // the BitmapImage from that URI instead. + // H.NotifyIcon resolves IconSource by reading BitmapImage.UriSource + // and then hands the file bytes to System.Drawing.Icon — which only + // accepts ICO format. Wrap the identicon PNG in a minimal ICO + // container before writing it out. var iconPath = await WriteTrayIconFileAsync(); var image = new BitmapImage(new Uri(iconPath, UriKind.Absolute)); @@ -102,13 +105,43 @@ private void RaiseShowRequested() private async Task WriteTrayIconFileAsync() { - var bytes = IdenticonService.GeneratePng(_iconSeed, size: 32); + var pngBytes = IdenticonService.GeneratePng(_iconSeed, size: _trayIconPixels); + var icoBytes = WrapPngAsIco(pngBytes, _trayIconPixels, _trayIconPixels); _ = Directory.CreateDirectory(_paths.AppDataDirectory); var path = Path.Combine(_paths.AppDataDirectory, _trayIconFileName); - await File.WriteAllBytesAsync(path, bytes); + await File.WriteAllBytesAsync(path, icoBytes); return path; } + // Modern Windows accepts a PNG embedded inside an ICO container — + // just a 6-byte ICONDIR header plus a 16-byte ICONDIRENTRY pointing + // at the PNG bytes. Format ref: ICONDIR / ICONDIRENTRY on MSDN. + private static byte[] WrapPngAsIco(byte[] png, int width, int height) + { + const int headerSize = 6; + const int entrySize = 16; + const int dataOffset = headerSize + entrySize; + + var ico = new byte[dataOffset + png.Length]; + var span = ico.AsSpan(); + + // ICONDIR: reserved(0), type(1 = icon), count(1) + BinaryPrimitives.WriteUInt16LittleEndian(span[2..4], 1); + BinaryPrimitives.WriteUInt16LittleEndian(span[4..6], 1); + + // ICONDIRENTRY: width/height bytes are 0 for >=256, otherwise the literal size. + ico[6] = width >= 256 ? (byte)0 : (byte)width; + ico[7] = height >= 256 ? (byte)0 : (byte)height; + // ico[8] colorCount = 0 (>= 8bpp), ico[9] reserved = 0 — already zero. + BinaryPrimitives.WriteUInt16LittleEndian(span[10..12], 1); // planes + BinaryPrimitives.WriteUInt16LittleEndian(span[12..14], 32); // bits per pixel + BinaryPrimitives.WriteUInt32LittleEndian(span[14..18], (uint)png.Length); + BinaryPrimitives.WriteUInt32LittleEndian(span[18..22], dataOffset); + + Buffer.BlockCopy(png, 0, ico, dataOffset, png.Length); + return ico; + } + private sealed partial class RelayCommand(Action execute) : System.Windows.Input.ICommand { #pragma warning disable CS0067 // 'CanExecuteChanged' is never used — relay never changes.