diff --git a/AudioMonitorRouter/App.xaml b/AudioMonitorRouter/App.xaml
index 728b9f7..2cface0 100644
--- a/AudioMonitorRouter/App.xaml
+++ b/AudioMonitorRouter/App.xaml
@@ -15,6 +15,8 @@
+
+
diff --git a/AudioMonitorRouter/App.xaml.cs b/AudioMonitorRouter/App.xaml.cs
index 65833ad..4526295 100644
--- a/AudioMonitorRouter/App.xaml.cs
+++ b/AudioMonitorRouter/App.xaml.cs
@@ -72,3 +72,31 @@ public object Convert(object value, Type targetType, object parameter, CultureIn
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
+
+///
+/// Non-empty/whitespace string → Visible, empty/null → Collapsed. Used by the
+/// About page to show the "Open release page" hyperlink only when the update
+/// check has produced a URL to link to.
+///
+public class StringToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ => !string.IsNullOrWhiteSpace(value as string) ? Visibility.Visible : Visibility.Collapsed;
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotImplementedException();
+}
+
+///
+/// Flips a bool. Lets us bind an IsEnabled to a "busy" flag without
+/// duplicating the inverse state in the ViewModel — e.g. "button is enabled
+/// when IsCheckingForUpdate is false".
+///
+public class InverseBooleanConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is bool b ? !b : true;
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is bool b ? !b : false;
+}
diff --git a/AudioMonitorRouter/Assets/app-icon.png b/AudioMonitorRouter/Assets/app-icon.png
new file mode 100644
index 0000000..ca3ff0d
Binary files /dev/null and b/AudioMonitorRouter/Assets/app-icon.png differ
diff --git a/AudioMonitorRouter/AudioMonitorRouter.csproj b/AudioMonitorRouter/AudioMonitorRouter.csproj
index 8605d39..d8520fa 100644
--- a/AudioMonitorRouter/AudioMonitorRouter.csproj
+++ b/AudioMonitorRouter/AudioMonitorRouter.csproj
@@ -22,7 +22,7 @@
twibster
twibster
Automatically routes per-app audio to different speakers based on monitor placement on Windows 11.
- Copyright (c) 2026 twibster
+ Copyright (c) 2026 Omar Omran
https://github.com/twibster/AudioMonitorRouter
git
@@ -34,4 +34,24 @@
+
+
+
+
+
+
diff --git a/AudioMonitorRouter/Services/UpdateService.cs b/AudioMonitorRouter/Services/UpdateService.cs
new file mode 100644
index 0000000..8cce53c
--- /dev/null
+++ b/AudioMonitorRouter/Services/UpdateService.cs
@@ -0,0 +1,232 @@
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AudioMonitorRouter.Services;
+
+///
+/// Outcome of an update probe. The About page binds its status text to one of
+/// these four shapes — success with no update, success with an update, a clean
+/// "couldn't reach the server" message, or an unexpected error we want to log.
+///
+public abstract record UpdateCheckResult
+{
+ /// Already on the latest published release.
+ public sealed record UpToDate(string CurrentVersion) : UpdateCheckResult;
+
+ /// A newer release is published on GitHub.
+ public sealed record UpdateAvailable(
+ string CurrentVersion,
+ string LatestVersion,
+ string ReleaseUrl) : UpdateCheckResult;
+
+ /// Network/HTTP failure — expected when the user is offline.
+ public sealed record NetworkError(string Message) : UpdateCheckResult;
+
+ /// Anything else (bad JSON, unparseable tag, rate limit, etc).
+ public sealed record Failed(string Message) : UpdateCheckResult;
+}
+
+///
+/// Queries the GitHub Releases API to find out whether a newer version of the
+/// app is available. We deliberately do NOT download or apply updates — this is
+/// a pointer-only check; the user clicks through to the GitHub release page and
+/// runs the installer themselves. Keeping it read-only avoids needing admin
+/// elevation, auto-update hosting, and code-signing rotation.
+///
+public class UpdateService
+{
+ // Public, unauthenticated endpoint — rate-limited to 60 requests/hour per IP,
+ // which is plenty for a user-initiated "check for updates" button.
+ private const string LatestReleaseApi =
+ "https://api.github.com/repos/twibster/AudioMonitorRouter/releases/latest";
+
+ // GitHub requires a User-Agent on every request; the product name is also
+ // useful in their server logs if we ever need to correlate a rate-limit bug
+ // with a specific release. Fall back to a plain version if the current
+ // informational version contains characters ProductInfoHeaderValue rejects
+ // (e.g. a future "+shahash" sourcelink suffix we haven't stripped) — we'd
+ // rather send a slightly-stale User-Agent than crash the About page.
+ private static readonly ProductInfoHeaderValue UserAgent = BuildUserAgent();
+
+ private static ProductInfoHeaderValue BuildUserAgent()
+ {
+ try { return new ProductInfoHeaderValue("AudioMonitorRouter", GetInformationalVersion()); }
+ catch { return new ProductInfoHeaderValue("AudioMonitorRouter", "1.0"); }
+ }
+
+ ///
+ /// The version string shown in the About page. Derived from the assembly's
+ /// [AssemblyInformationalVersion] (falls back to the file version)
+ /// so the value automatically tracks what CI built rather than a hardcoded
+ /// literal that drifts from the tag.
+ ///
+ public string CurrentVersion => GetInformationalVersion();
+
+ ///
+ /// Copyright line surfaced in the About page footer. Read from the assembly's
+ /// [AssemblyCopyright] attribute, which MSBuild fills from the csproj's
+ /// <Copyright> property — so bumping the year in one place updates
+ /// both the Win32 file-properties dialog and the UI. The verbatim form uses
+ /// "(c)" for ASCII compatibility with those Win32 consumers; the VM rewrites
+ /// that to the © glyph for display.
+ ///
+ public string Copyright => GetAssemblyCopyright();
+
+ public async Task CheckForUpdateAsync(CancellationToken ct = default)
+ {
+ string current = CurrentVersion;
+
+ try
+ {
+ // A fresh HttpClient per call is fine for a once-in-a-blue-moon
+ // user action — no need for IHttpClientFactory ceremony here, and
+ // the socket exhaustion concerns that usually drive it don't apply
+ // at this call rate.
+ using var http = new HttpClient();
+ http.DefaultRequestHeaders.UserAgent.Add(UserAgent);
+ http.DefaultRequestHeaders.Accept.Add(
+ new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ http.Timeout = TimeSpan.FromSeconds(10);
+
+ using var response = await http.GetAsync(LatestReleaseApi, ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ // Treat 403 (rate-limit) specially so the message is actionable
+ // rather than a generic HTTP code.
+ if ((int)response.StatusCode == 403)
+ return new UpdateCheckResult.Failed(
+ "GitHub rate limit reached — try again in an hour.");
+ return new UpdateCheckResult.NetworkError(
+ $"GitHub returned {(int)response.StatusCode} {response.ReasonPhrase}.");
+ }
+
+ using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ var release = await JsonSerializer.DeserializeAsync(
+ stream, cancellationToken: ct).ConfigureAwait(false);
+
+ if (release == null || string.IsNullOrWhiteSpace(release.TagName))
+ return new UpdateCheckResult.Failed("Release metadata was empty.");
+
+ string latest = StripVPrefix(release.TagName);
+
+ // html_url should always be present on a GitHub release, but the
+ // API technically permits nulls. Fall back to composing the tag
+ // URL ourselves — Uri.EscapeDataString because tag names can
+ // legally contain characters (/, #, spaces) that would otherwise
+ // break the path segment.
+ string releaseUrl = release.HtmlUrl ??
+ $"https://github.com/twibster/AudioMonitorRouter/releases/tag/{Uri.EscapeDataString(release.TagName)}";
+
+ return CompareSemVer(latest, current) > 0
+ ? new UpdateCheckResult.UpdateAvailable(current, latest, releaseUrl)
+ : new UpdateCheckResult.UpToDate(current);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (HttpRequestException ex)
+ {
+ return new UpdateCheckResult.NetworkError(ex.Message);
+ }
+ catch (TaskCanceledException)
+ {
+ // HttpClient timeout surfaces as TaskCanceledException with no
+ // cancellation token involvement. Treat it as a network error so
+ // the user message makes sense.
+ return new UpdateCheckResult.NetworkError("Request timed out.");
+ }
+ catch (Exception ex)
+ {
+ return new UpdateCheckResult.Failed(ex.Message);
+ }
+ }
+
+ ///
+ /// Reads the three-part version from the entry assembly. We prefer
+ /// AssemblyInformationalVersion because MSBuild's -p:Version
+ /// feeds into it verbatim, whereas AssemblyVersion gets padded to
+ /// four parts (1.2.3 → 1.2.3.0) which looks wrong in UI.
+ ///
+ private static string GetInformationalVersion()
+ {
+ var asm = Assembly.GetEntryAssembly() ?? typeof(UpdateService).Assembly;
+
+ var info = asm.GetCustomAttribute()?.InformationalVersion;
+ if (!string.IsNullOrWhiteSpace(info))
+ {
+ // InformationalVersion can include a '+commithash' SourceLink
+ // suffix on CI builds; strip it for display.
+ int plus = info.IndexOf('+');
+ return plus >= 0 ? info[..plus] : info;
+ }
+
+ return asm.GetName().Version?.ToString(3) ?? "0.0.0";
+ }
+
+ private static string StripVPrefix(string tag) =>
+ tag.StartsWith('v') || tag.StartsWith('V') ? tag[1..] : tag;
+
+ ///
+ /// Compares two version strings "SemVer-enough" for an update probe.
+ /// Splits each into a numeric core (compared via )
+ /// and an optional prerelease tail after the first '-'. On a numeric
+ /// tie, a final release (no tail) outranks any prerelease — so
+ /// "1.2.3" > "1.2.3-beta". If the numeric core on either side fails
+ /// to parse we fall back to an ordinal string compare of the original
+ /// input, which may false-positive an update but will never silently
+ /// hide a real one.
+ ///
+ private static int CompareSemVer(string a, string b)
+ {
+ var (coreA, preA) = SplitPrerelease(a);
+ var (coreB, preB) = SplitPrerelease(b);
+
+ if (Version.TryParse(coreA, out var va) && Version.TryParse(coreB, out var vb))
+ {
+ int byCore = va.CompareTo(vb);
+ if (byCore != 0) return byCore;
+
+ // Numeric cores are equal — prerelease < release on the same core.
+ return (preA.Length, preB.Length) switch
+ {
+ (0, 0) => 0,
+ (0, _) => 1, // a is release, b is prerelease → a > b
+ (_, 0) => -1, // a is prerelease, b is release → a < b
+ _ => string.Compare(preA, preB, StringComparison.Ordinal),
+ };
+ }
+
+ // Numeric parse failed somewhere; don't pretend we know the ordering.
+ return string.Compare(a, b, StringComparison.Ordinal);
+ }
+
+ private static (string core, string prerelease) SplitPrerelease(string v)
+ {
+ int dash = v.IndexOf('-');
+ return dash < 0 ? (v, "") : (v[..dash], v[(dash + 1)..]);
+ }
+
+ private static string GetAssemblyCopyright()
+ {
+ var asm = Assembly.GetEntryAssembly() ?? typeof(UpdateService).Assembly;
+ var value = asm.GetCustomAttribute()?.Copyright;
+ return string.IsNullOrWhiteSpace(value)
+ ? $"Copyright (c) {DateTime.Now.Year} Omar Omran"
+ : value;
+ }
+
+ // Minimal shape of the GitHub "latest release" response — only the two
+ // fields we actually read. System.Text.Json ignores anything else.
+ private sealed class GitHubRelease
+ {
+ [JsonPropertyName("tag_name")]
+ public string? TagName { get; set; }
+
+ [JsonPropertyName("html_url")]
+ public string? HtmlUrl { get; set; }
+ }
+}
diff --git a/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs b/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs
index 111776a..b4bf236 100644
--- a/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs
+++ b/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs
@@ -119,6 +119,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
private readonly AudioRouterService _routerService;
private readonly RoutingEngine _routingEngine;
private readonly SettingsService _settingsService;
+ private readonly UpdateService _updateService;
private readonly AudioDeviceNotifier _deviceNotifier;
private readonly DispatcherTimer _deviceRefreshTimer;
private readonly Dispatcher _dispatcher;
@@ -177,6 +178,45 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
[ObservableProperty]
private int _currentPage; // 0 = Home, 1 = Pinned, 2 = Settings, 3 = About
+ // --- About page bindings ---------------------------------------------
+ //
+ // AppVersionDisplay is read once at construction from the assembly's
+ // informational version so the About page always shows the build that's
+ // actually running — no more hardcoded "Version 1.0.0" drifting behind
+ // the actual release tag.
+ //
+ // UpdateStatus is a single line of text rendered under the "Check for
+ // updates" button. States cycle through:
+ // "" — idle, button hidden-label state
+ // "Checking…" — request in flight
+ // "You're up to date" — latest == current
+ // "vX.Y.Z available — click to open" — newer release found
+ // "Couldn't reach GitHub: …" — network/rate-limit failure
+ //
+ // LatestReleaseUrl is non-empty only when an update is available; the XAML
+ // binds its visibility so the "Download" hyperlink appears only then.
+
+ public string AppVersionDisplay => $"Version {_updateService.CurrentVersion}";
+
+ ///
+ /// Copyright footer rendered at the bottom of the About page. Takes the
+ /// standard "Copyright (c) YYYY …" string emitted by MSBuild and swaps the
+ /// "(c)" for a proper © glyph — same info, cleaner typography. We do the
+ /// swap here rather than in the csproj because the Win32 file-properties
+ /// dialog (and some installer pipelines) prefer the ASCII form.
+ ///
+ public string CopyrightDisplay =>
+ _updateService.Copyright.Replace("Copyright (c)", "©", StringComparison.OrdinalIgnoreCase);
+
+ [ObservableProperty]
+ private string _updateStatus = string.Empty;
+
+ [ObservableProperty]
+ private string _latestReleaseUrl = string.Empty;
+
+ [ObservableProperty]
+ private bool _isCheckingForUpdate;
+
public MainWindowViewModel()
{
_dispatcher = Application.Current.Dispatcher;
@@ -186,6 +226,7 @@ public MainWindowViewModel()
_sessionService = new AudioSessionService();
_routerService = new AudioRouterService();
_settingsService = new SettingsService();
+ _updateService = new UpdateService();
_routingEngine = new RoutingEngine(_monitorService, _sessionService, _routerService);
_routingEngine.SessionsUpdated += OnSessionsUpdated;
@@ -636,6 +677,49 @@ private void ApplyAutoStart(bool enable)
catch { }
}
+ // ── About page: check for updates ────────────────────────────────────
+ //
+ // User clicks the button → we hit the GitHub Releases API, compare tags,
+ // and write a one-line result into UpdateStatus. LatestReleaseUrl is set
+ // only when an update is available; the XAML uses it to decide whether to
+ // render the "Download" hyperlink. IsCheckingForUpdate gates the button so
+ // a double-click can't fire two concurrent probes.
+
+ [RelayCommand]
+ private async Task CheckForUpdatesAsync()
+ {
+ if (IsCheckingForUpdate) return;
+
+ IsCheckingForUpdate = true;
+ UpdateStatus = "Checking…";
+ LatestReleaseUrl = string.Empty;
+
+ try
+ {
+ var result = await _updateService.CheckForUpdateAsync();
+ switch (result)
+ {
+ case UpdateCheckResult.UpToDate upToDate:
+ UpdateStatus = $"You're on the latest version (v{upToDate.CurrentVersion}).";
+ break;
+ case UpdateCheckResult.UpdateAvailable update:
+ UpdateStatus = $"Version {update.LatestVersion} is available.";
+ LatestReleaseUrl = update.ReleaseUrl;
+ break;
+ case UpdateCheckResult.NetworkError net:
+ UpdateStatus = $"Couldn't reach GitHub: {net.Message}";
+ break;
+ case UpdateCheckResult.Failed fail:
+ UpdateStatus = $"Update check failed: {fail.Message}";
+ break;
+ }
+ }
+ finally
+ {
+ IsCheckingForUpdate = false;
+ }
+ }
+
[RelayCommand]
private void Refresh()
{
diff --git a/AudioMonitorRouter/Views/MainWindow.xaml b/AudioMonitorRouter/Views/MainWindow.xaml
index 60c610e..bedd91a 100644
--- a/AudioMonitorRouter/Views/MainWindow.xaml
+++ b/AudioMonitorRouter/Views/MainWindow.xaml
@@ -8,7 +8,8 @@
MinHeight="560" MinWidth="720"
WindowStartupLocation="CenterScreen"
ExtendsContentIntoTitleBar="True"
- WindowBackdropType="Mica">
+ WindowBackdropType="Mica"
+ Icon="/Assets/app-icon.png">
@@ -496,46 +497,101 @@
+
-
+
+
-
+
-
- Automatically routes audio output based on which monitor an application
- is displayed on. Apps on Monitor A play through Speaker A, apps on
- Monitor B play through Speaker B.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AudioMonitorRouter/Views/MainWindow.xaml.cs b/AudioMonitorRouter/Views/MainWindow.xaml.cs
index 491ba74..dfa817c 100644
--- a/AudioMonitorRouter/Views/MainWindow.xaml.cs
+++ b/AudioMonitorRouter/Views/MainWindow.xaml.cs
@@ -96,14 +96,27 @@ private void SetupTrayIcon()
{
_trayIcon = new Forms.NotifyIcon();
+ // Previously this used Icon.ExtractAssociatedIcon(exePath), which pulls
+ // a single arbitrary frame (typically the 32x32) from the exe's Win32
+ // icon resource. The NotifyIcon then scaled that down to the system
+ // tray size, producing a visibly smaller, fuzzier glyph than neighbour
+ // icons that were authored at the correct pixel size.
+ //
+ // Load from the WPF-resource-packed app.ico instead and hand the Icon
+ // ctor SystemInformation.SmallIconSize — that's the DPI-adjusted tray
+ // size (16 at 100%, 20 at 125%, 24 at 150%, ...). The Icon(Stream,Size)
+ // ctor picks the closest-matching frame from the multi-size file, so
+ // the 16/20/24 frames we pack in app.ico all get used at their native
+ // size with no resampling.
try
{
- var exePath = Environment.ProcessPath;
- if (exePath != null)
- _trayIcon.Icon = Drawing.Icon.ExtractAssociatedIcon(exePath);
+ _trayIcon.Icon = LoadTrayIconFromResources();
}
catch
{
+ // Any failure here (missing resource, exe rewritten in an odd way,
+ // etc.) falls back to a neutral shell icon rather than crashing
+ // the tray setup — the app still works without the tray glyph.
_trayIcon.Icon = Drawing.SystemIcons.Application;
}
@@ -118,6 +131,26 @@ private void SetupTrayIcon()
_trayIcon.DoubleClick += (_, _) => ShowFromTray();
}
+ ///
+ /// Loads the multi-size app.ico (packed as a WPF resource via the csproj
+ /// <Resource> item) and asks for the frame matching the current tray
+ /// icon size. Done once at startup; the tray doesn't update the icon when
+ /// DPI changes mid-session, which matches how every other tray app behaves.
+ ///
+ private static Drawing.Icon LoadTrayIconFromResources()
+ {
+ var uri = new Uri("pack://application:,,,/app.ico", UriKind.Absolute);
+ var streamInfo = System.Windows.Application.GetResourceStream(uri)
+ ?? throw new InvalidOperationException("app.ico resource not found");
+ using var stream = streamInfo.Stream;
+
+ // SystemInformation.SmallIconSize is DPI-aware: returns 16x16 at 100%,
+ // 20x20 at 125%, 24x24 at 150%. Our ICO contains exact matches for all
+ // three so the Icon ctor returns a perfect, unscaled frame.
+ var size = Forms.SystemInformation.SmallIconSize;
+ return new Drawing.Icon(stream, size);
+ }
+
private void ShowFromTray()
{
Show();
diff --git a/AudioMonitorRouter/app.ico b/AudioMonitorRouter/app.ico
index 8d30d77..85c5953 100644
Binary files a/AudioMonitorRouter/app.ico and b/AudioMonitorRouter/app.ico differ
diff --git a/design/app-icon.svg b/design/app-icon.svg
index 1c10cfc..ac7c47b 100644
--- a/design/app-icon.svg
+++ b/design/app-icon.svg
@@ -1,34 +1,43 @@
diff --git a/design/tools/IconBuilder/Program.cs b/design/tools/IconBuilder/Program.cs
index 11e2d8d..d33f2f9 100644
--- a/design/tools/IconBuilder/Program.cs
+++ b/design/tools/IconBuilder/Program.cs
@@ -1,10 +1,23 @@
-// Rasterises an SVG at a set of Windows-standard icon sizes and packs the
-// results into a single multi-size .ico file. Run from the repo root:
+// Rasterises an SVG into either a multi-size .ico or a single .png, dispatching
+// on the output file's extension. Run from the repo root:
//
// dotnet run --project design/tools/IconBuilder -- \
// design/app-icon.svg \
// AudioMonitorRouter/app.ico
//
+// dotnet run --project design/tools/IconBuilder -- \
+// design/app-icon.svg \
+// AudioMonitorRouter/Assets/app-icon.png
+//
+// Why both formats:
+// - .ico is the Windows shell icon (tray, taskbar, file explorer, installer).
+// We pack eight sizes into one file so the OS can pick the frame that
+// matches the current DPI without blurry upscaling.
+// - .png is for in-app XAML Image sources. WPF's default BitmapImage decoder
+// picks the FIRST frame of a multi-image ICO rather than the largest, so
+// using app.ico directly for an Image produces a tiny 16x16 blur. A single
+// 256x256 PNG sidesteps that entirely.
+//
// The .ico format spec is well documented; we hand-write the header +
// directory + PNG payloads rather than pulling in another dependency.
// Ref: https://learn.microsoft.com/en-us/previous-versions/ms997538(v=msdn.10)
@@ -22,16 +35,20 @@
// 64 = medium tile / 200% tray
// 128 = jumbo icons
// 256 = file-explorer "extra large" + Start menu scaling target
-int[] sizes = { 16, 20, 24, 32, 48, 64, 128, 256 };
+int[] icoSizes = { 16, 20, 24, 32, 48, 64, 128, 256 };
+
+// Single-PNG output size. 256 matches the largest ICO frame so the in-app
+// About image stays crisp if the XAML asks for it at any reasonable size.
+const int pngSize = 256;
if (args.Length != 2)
{
- Console.Error.WriteLine("Usage: IconBuilder ");
+ Console.Error.WriteLine("Usage: IconBuilder ");
return 1;
}
string svgPath = args[0];
-string icoPath = args[1];
+string outPath = args[1];
if (!File.Exists(svgPath))
{
@@ -40,59 +57,98 @@
}
var svg = SvgDocument.Open(svgPath);
-var pngBlobs = new List();
-foreach (int size in sizes)
+string ext = Path.GetExtension(outPath).ToLowerInvariant();
+switch (ext)
{
- // Rasterise at the exact target size. We re-render per size (rather
- // than scaling a single bitmap) so hinting + anti-aliasing adapt to
- // the actual pixel grid — critical for legibility at 16 and 20.
- using var bmp = svg.Draw(size, size);
- using var ms = new MemoryStream();
- bmp.Save(ms, ImageFormat.Png);
- pngBlobs.Add(ms.ToArray());
- Console.WriteLine($" rendered {size,3}x{size,-3} {ms.Length,6} B");
+ case ".ico":
+ WriteIco(svg, icoSizes, outPath);
+ break;
+ case ".png":
+ WritePng(svg, pngSize, outPath);
+ break;
+ default:
+ Console.Error.WriteLine($"Unsupported output extension '{ext}'. Use .ico or .png.");
+ return 1;
}
-// --- Pack into .ico ------------------------------------------------------
-//
-// Layout:
-// ICONDIR (6 bytes)
-// ICONDIRENTRY[N] (16 bytes each)
-// PNG payloads (contiguous, referenced by offsets in the entries)
-
-using var outStream = new FileStream(icoPath, FileMode.Create, FileAccess.Write);
-using var w = new BinaryWriter(outStream);
+return 0;
-// ICONDIR
-w.Write((ushort)0); // Reserved, must be 0
-w.Write((ushort)1); // Type: 1 = icon
-w.Write((ushort)sizes.Length); // Image count
+static void WritePng(SvgDocument svg, int size, string path)
+{
+ // Ensure the target directory exists — helps when the caller points at a
+ // fresh Assets/ folder that hasn't been created yet.
+ string? dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
-// Data starts after the fixed-size header + all directory entries.
-int dataOffset = 6 + 16 * sizes.Length;
+ using var bmp = svg.Draw(size, size);
+ bmp.Save(path, ImageFormat.Png);
+ Console.WriteLine($"Wrote {path} ({size}x{size})");
+}
-for (int i = 0; i < sizes.Length; i++)
+static void WriteIco(SvgDocument svg, int[] sizes, string path)
{
- int sz = sizes[i];
- byte[] png = pngBlobs[i];
-
- // Per-image directory entry (ICONDIRENTRY, 16 bytes).
- w.Write((byte)(sz == 256 ? 0 : sz)); // Width (0 means 256)
- w.Write((byte)(sz == 256 ? 0 : sz)); // Height (0 means 256)
- w.Write((byte)0); // Colour palette count (0 = no palette)
- w.Write((byte)0); // Reserved
- w.Write((ushort)1); // Colour planes
- w.Write((ushort)32); // Bits per pixel
- w.Write((uint)png.Length); // Byte size of the PNG payload
- w.Write((uint)dataOffset); // Offset of the payload from file start
-
- dataOffset += png.Length;
+ // Same directory-safety dance as WritePng — the caller may point us at
+ // an Assets/ subfolder (or a deeper CI output dir) that doesn't exist
+ // yet, and FileStream.Create doesn't recurse parents on its own.
+ string? dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ var pngBlobs = new List();
+
+ foreach (int size in sizes)
+ {
+ // Rasterise at the exact target size. We re-render per size (rather
+ // than scaling a single bitmap) so hinting + anti-aliasing adapt to
+ // the actual pixel grid — critical for legibility at 16 and 20.
+ using var bmp = svg.Draw(size, size);
+ using var ms = new MemoryStream();
+ bmp.Save(ms, ImageFormat.Png);
+ pngBlobs.Add(ms.ToArray());
+ Console.WriteLine($" rendered {size,3}x{size,-3} {ms.Length,6} B");
+ }
+
+ // --- Pack into .ico --------------------------------------------------
+ //
+ // Layout:
+ // ICONDIR (6 bytes)
+ // ICONDIRENTRY[N] (16 bytes each)
+ // PNG payloads (contiguous, referenced by offsets in the entries)
+
+ using var outStream = new FileStream(path, FileMode.Create, FileAccess.Write);
+ using var w = new BinaryWriter(outStream);
+
+ // ICONDIR
+ w.Write((ushort)0); // Reserved, must be 0
+ w.Write((ushort)1); // Type: 1 = icon
+ w.Write((ushort)sizes.Length); // Image count
+
+ // Data starts after the fixed-size header + all directory entries.
+ int dataOffset = 6 + 16 * sizes.Length;
+
+ for (int i = 0; i < sizes.Length; i++)
+ {
+ int sz = sizes[i];
+ byte[] png = pngBlobs[i];
+
+ // Per-image directory entry (ICONDIRENTRY, 16 bytes).
+ w.Write((byte)(sz == 256 ? 0 : sz)); // Width (0 means 256)
+ w.Write((byte)(sz == 256 ? 0 : sz)); // Height (0 means 256)
+ w.Write((byte)0); // Colour palette count (0 = no palette)
+ w.Write((byte)0); // Reserved
+ w.Write((ushort)1); // Colour planes
+ w.Write((ushort)32); // Bits per pixel
+ w.Write((uint)png.Length); // Byte size of the PNG payload
+ w.Write((uint)dataOffset); // Offset of the payload from file start
+
+ dataOffset += png.Length;
+ }
+
+ // PNG payloads, in the same order as the directory entries.
+ foreach (byte[] png in pngBlobs)
+ w.Write(png);
+
+ Console.WriteLine($"Wrote {path} ({outStream.Length} bytes)");
}
-
-// PNG payloads, in the same order as the directory entries.
-foreach (byte[] png in pngBlobs)
- w.Write(png);
-
-Console.WriteLine($"Wrote {icoPath} ({outStream.Length} bytes)");
-return 0;
diff --git a/installer/installer.iss b/installer/installer.iss
index e17c5e1..018926d 100644
--- a/installer/installer.iss
+++ b/installer/installer.iss
@@ -81,6 +81,15 @@ Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType:
Filename: "{tmp}\windowsdesktop-runtime-8-x64.exe"; Parameters: "/install /quiet /norestart"; \
StatusMsg: "Installing .NET 8 Desktop Runtime..."; \
Check: NeedsDotNet8Install; Flags: waituntilterminated
+; Nudge Explorer to re-read icon metadata for this exe. Without this,
+; users who pinned the app to the taskbar before updating keep seeing the
+; previous icon until Explorer's cache rolls over naturally (often a
+; reboot away). ie4uinit -show is the documented, silent way to trigger a
+; refresh; runhidden avoids flashing a console window at the user.
+; runasoriginaluser matters for over-the-shoulder UAC installs: without it
+; this inherits the installer's elevated admin token and refreshes the
+; admin's icon cache instead of the signed-in user's Explorer.
+Filename: "{sys}\ie4uinit.exe"; Parameters: "-show"; Flags: runhidden skipifdoesntexist runasoriginaluser
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[UninstallRun]