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]