From dd034c4646dc61b1c74ce1adf11504008da13cf3 Mon Sep 17 00:00:00 2001 From: twibster Date: Wed, 15 Apr 2026 20:25:47 +0200 Subject: [PATCH 1/7] Fix About page, tray icon size, and taskbar icon cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The About page previously rendered a generic Fluent speaker glyph and a hardcoded "Version 1.0.0" string — both drifted from reality once the app started shipping under the new icon and auto-release started cutting real version tags. Replace them with: - An Image bound to a new 256x256 Assets/app-icon.png rasterised from design/app-icon.svg (a PNG, not the multi-image app.ico, because BitmapImage picks the first frame of an ICO — which for our 16/20/24/... layout is the tiny 16x16 glyph, producing a blurry result). - A TextBlock bound to AppVersionDisplay, which reads the assembly's AssemblyInformationalVersion at runtime so CI's -p:Version flows through without any further hardcoding. Add a "Check for updates" button that hits the GitHub Releases API via a new UpdateService, compares tags, and shows a one-line status plus an "Open release page" hyperlink when a newer version is available. The service is pointer-only — no auto-download — so no elevation, hosting, or code-signing rotation is required. Fix the tray icon, which was loading via Icon.ExtractAssociatedIcon (returns one arbitrary frame, typically 32x32, then scaled down to tray size). Pack app.ico as a WPF resource and load it with the Icon ctor passing SystemInformation.SmallIconSize — a DPI-aware size that matches our embedded 16/20/24 frames exactly, so Windows gets an unscaled glyph. Set Window.Icon explicitly to Assets/app-icon.png so the taskbar gets a crisp high-res source rather than whatever WPF's default ICO-frame picker returns. Add a post-install ie4uinit.exe -show step to installer.iss so users who pinned the app to their taskbar before an update see the new icon without waiting for Explorer's shell-icon cache to roll over. Extend IconBuilder to dispatch on output extension: .ico keeps the current multi-size pack, .png emits a single rasterisation (used to generate app-icon.png here). Regenerate with: dotnet run --project design/tools/IconBuilder -- \ design/app-icon.svg AudioMonitorRouter/Assets/app-icon.png --- AudioMonitorRouter/App.xaml | 2 + AudioMonitorRouter/App.xaml.cs | 28 +++ AudioMonitorRouter/Assets/app-icon.png | Bin 0 -> 3630 bytes AudioMonitorRouter/AudioMonitorRouter.csproj | 20 ++ AudioMonitorRouter/Services/UpdateService.cs | 184 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 74 +++++++ AudioMonitorRouter/Views/MainWindow.xaml | 49 ++++- AudioMonitorRouter/Views/MainWindow.xaml.cs | 39 +++- design/tools/IconBuilder/Program.cs | 153 ++++++++++----- installer/installer.iss | 6 + 10 files changed, 495 insertions(+), 60 deletions(-) create mode 100644 AudioMonitorRouter/Assets/app-icon.png create mode 100644 AudioMonitorRouter/Services/UpdateService.cs 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 0000000000000000000000000000000000000000..b2ca76b4a4c2523131d58eac80f246e852f95699 GIT binary patch literal 3630 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 + + + + + + diff --git a/AudioMonitorRouter/Services/UpdateService.cs b/AudioMonitorRouter/Services/UpdateService.cs new file mode 100644 index 0000000..e879aa6 --- /dev/null +++ b/AudioMonitorRouter/Services/UpdateService.cs @@ -0,0 +1,184 @@ +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(); + + 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); + + if (!Version.TryParse(latest, out var latestVer) || + !Version.TryParse(current, out var currentVer)) + { + // If either side doesn't parse (e.g. "1.2.3-beta"), fall back to + // a simple string compare. Better to surface uncertainty than + // silently claim "up to date". + return string.Equals(latest, current, StringComparison.Ordinal) + ? new UpdateCheckResult.UpToDate(current) + : new UpdateCheckResult.UpdateAvailable(current, latest, + release.HtmlUrl ?? $"https://github.com/twibster/AudioMonitorRouter/releases/tag/{release.TagName}"); + } + + if (latestVer > currentVer) + { + return new UpdateCheckResult.UpdateAvailable( + CurrentVersion: current, + LatestVersion: latest, + ReleaseUrl: release.HtmlUrl ?? + $"https://github.com/twibster/AudioMonitorRouter/releases/tag/{release.TagName}"); + } + + return 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; + + // 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..4198bd6 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,35 @@ 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}"; + + [ObservableProperty] + private string _updateStatus = string.Empty; + + [ObservableProperty] + private string _latestReleaseUrl = string.Empty; + + [ObservableProperty] + private bool _isCheckingForUpdate; + public MainWindowViewModel() { _dispatcher = Application.Current.Dispatcher; @@ -186,6 +216,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 +667,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..798f69c 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"> @@ -497,12 +498,20 @@ - + + - + @@ -536,6 +545,36 @@ FontSize="13" Foreground="{DynamicResource TextFillColorSecondaryBrush}"/> + + + + + + + + + + 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/design/tools/IconBuilder/Program.cs b/design/tools/IconBuilder/Program.cs index 11e2d8d..e7e186e 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,91 @@ } 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; + 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..ccb593a 100644 --- a/installer/installer.iss +++ b/installer/installer.iss @@ -81,6 +81,12 @@ 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. +Filename: "{sys}\ie4uinit.exe"; Parameters: "-show"; Flags: runhidden skipifdoesntexist Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent [UninstallRun] From f58490e86f24c03a1353deda785303b2f51d07ff Mon Sep 17 00:00:00 2001 From: twibster Date: Wed, 15 Apr 2026 20:33:24 +0200 Subject: [PATCH 2/7] Re-proportion app icon to fill the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous design had 58px of padding top AND bottom (45% of the 256px canvas empty vertically), which is why the taskbar glyph looked visibly smaller than neighbour icons — at 24px cell size our artwork was only ~13px tall while Chrome/Explorer/etc fill ~22px. Redraw with the same semantic layout (widescreen monitor + speaker + soundwave + outbound arrow crossing the right bezel), but: • Monitor aspect 5:3 → 1.25:1 (170x100 → 170x140), shifted up/down to sit close to both canvas edges. • Strokes bumped 12→14 / 13→15 so linework doesn't look spindly at the new scale. • Speaker, soundwave, and arrow re-positioned so the arrow tail still starts just past the wave and the tip clears the bezel cleanly. Content bounds: before after width fill 84% 89% height fill 55% 79% glyph@24px ~20x13 ~21x19 (+45% visible area) Regenerate both assets from the new SVG: 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 --- AudioMonitorRouter/Assets/app-icon.png | Bin 3630 -> 4615 bytes AudioMonitorRouter/app.ico | Bin 8870 -> 10522 bytes design/app-icon.svg | 50 +++++++++++++------------ 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/AudioMonitorRouter/Assets/app-icon.png b/AudioMonitorRouter/Assets/app-icon.png index b2ca76b4a4c2523131d58eac80f246e852f95699..6e97acf86ca69aaa47ecab2caddafea0851a439f 100644 GIT binary patch literal 4615 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 Ccp#eq literal 8870 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 z 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 - + - - + + - - - + + + - - - + + - - + From 1e03c4121c70a2fd7601e02ad2f0088ddfd7c023 Mon Sep 17 00:00:00 2001 From: twibster Date: Wed, 15 Apr 2026 20:40:45 +0200 Subject: [PATCH 3/7] Tighten app icon further to match peer glyph weight v2 still sat ~9%/12%/5%/6% padded (top/bottom/left/right) while peer icons (Chrome, Explorer, etc.) fill ~3-4% per side. Pushing the glyph harder into the canvas makes it read at a comparable weight on the taskbar and tray. - Content bounds now ~94% x 85% (was 89% x 79% in v2). - Monitor aspect relaxed to 1.18:1 (was 1.25:1) to recover vertical height the old proportion was spending on empty padding. The stand still grounds it so it reads as a display, not a picture frame. - Stand base widened (62..140 vs v2's narrower base) so the wider monitor frame still feels supported rather than top-heavy. - Speaker, soundwave, and outbound arrow scaled proportionally; the arrow tip is now flush with the right canvas edge (round cap ends at x=247.5). --- AudioMonitorRouter/Assets/app-icon.png | Bin 4615 -> 4958 bytes AudioMonitorRouter/app.ico | Bin 10522 -> 11232 bytes design/app-icon.svg | 51 ++++++++++++++----------- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/AudioMonitorRouter/Assets/app-icon.png b/AudioMonitorRouter/Assets/app-icon.png index 6e97acf86ca69aaa47ecab2caddafea0851a439f..ca3ff0d65db0b131b0b4f9621ef86eca6527593b 100644 GIT binary patch literal 4958 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 literal 4615 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 literal 10522 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 Ccp#eq diff --git a/design/app-icon.svg b/design/app-icon.svg index 06474a8..ac7c47b 100644 --- a/design/app-icon.svg +++ b/design/app-icon.svg @@ -1,36 +1,43 @@ - + - - + - - + - + - - + - - + - - + From 7799979dcad54f01d99e5394759e70bf7471391f Mon Sep 17 00:00:00 2001 From: twibster Date: Wed, 15 Apr 2026 20:49:05 +0200 Subject: [PATCH 4/7] Replace About page Features list with a Links section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bullet list of features was restating the app description in a less skimmable form — anyone who reads the one-line description already knows roughly what the app does, and the bullet format was the visual heaviest block on the page. Swapped it for a compact footer with: - A copyright line pulled from [AssemblyCopyright] (MSBuild fills it from the csproj's ) with "(c)" rewritten to © for display. - Two external links via wpfui:Hyperlink with the Link24 glyph: Project website on GitHub (repo root) Latest release on GitHub (/releases/latest, GitHub redirects to the current tag) UpdateService gained a Copyright property alongside CurrentVersion so the VM has one place to read assembly metadata from. --- AudioMonitorRouter/Services/UpdateService.cs | 19 ++++++++ .../ViewModels/MainWindowViewModel.cs | 10 ++++ AudioMonitorRouter/Views/MainWindow.xaml | 46 +++++++++---------- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/AudioMonitorRouter/Services/UpdateService.cs b/AudioMonitorRouter/Services/UpdateService.cs index e879aa6..68c3ac9 100644 --- a/AudioMonitorRouter/Services/UpdateService.cs +++ b/AudioMonitorRouter/Services/UpdateService.cs @@ -65,6 +65,16 @@ private static ProductInfoHeaderValue BuildUserAgent() /// 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; @@ -171,6 +181,15 @@ private static string GetInformationalVersion() private static string StripVPrefix(string tag) => tag.StartsWith('v') || tag.StartsWith('V') ? tag[1..] : tag; + 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} twibster" + : 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 diff --git a/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs b/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs index 4198bd6..b4bf236 100644 --- a/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs +++ b/AudioMonitorRouter/ViewModels/MainWindowViewModel.cs @@ -198,6 +198,16 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable 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; diff --git a/AudioMonitorRouter/Views/MainWindow.xaml b/AudioMonitorRouter/Views/MainWindow.xaml index 798f69c..6a8ca38 100644 --- a/AudioMonitorRouter/Views/MainWindow.xaml +++ b/AudioMonitorRouter/Views/MainWindow.xaml @@ -522,30 +522,6 @@ is displayed on. Apps on Monitor A play through Speaker A, apps on Monitor B play through Speaker B. - - - - - - - - - - - - - - - - - - - - + + + + + From 2243ed870221df07e31d515fcc737bd6c2200412 Mon Sep 17 00:00:00 2001 From: twibster Date: Wed, 15 Apr 2026 20:54:48 +0200 Subject: [PATCH 5/7] Reshape About page: copyright in header, Links over Updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three adjustments to the About card: 1. Copyright moves into the identity block next to the icon, stacked directly under the version line. One visual unit now answers "what is this and who made it" instead of scattering that info across the card. 2. The one-sentence app description is dropped. It was re-explaining what the sidebar and home page already make obvious. 3. Links section lifts into the vacated slot; Updates drops to the bottom. Reading order is now identity → external links → action (check for updates), which matches what someone actually comes to the About page to do. Also updated the csproj property (and the UpdateService fallback that mirrors it) from "twibster" to "Omar Omran" — that's the real copyright holder; "twibster" is just the GitHub handle. --- AudioMonitorRouter/AudioMonitorRouter.csproj | 2 +- AudioMonitorRouter/Services/UpdateService.cs | 2 +- AudioMonitorRouter/Views/MainWindow.xaml | 63 ++++++++++---------- 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/AudioMonitorRouter/AudioMonitorRouter.csproj b/AudioMonitorRouter/AudioMonitorRouter.csproj index cc5f50f..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 diff --git a/AudioMonitorRouter/Services/UpdateService.cs b/AudioMonitorRouter/Services/UpdateService.cs index 68c3ac9..f674e43 100644 --- a/AudioMonitorRouter/Services/UpdateService.cs +++ b/AudioMonitorRouter/Services/UpdateService.cs @@ -186,7 +186,7 @@ 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} twibster" + ? $"Copyright (c) {DateTime.Now.Year} Omar Omran" : value; } diff --git a/AudioMonitorRouter/Views/MainWindow.xaml b/AudioMonitorRouter/Views/MainWindow.xaml index 6a8ca38..62155f1 100644 --- a/AudioMonitorRouter/Views/MainWindow.xaml +++ b/AudioMonitorRouter/Views/MainWindow.xaml @@ -497,6 +497,14 @@ + + - - 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. - + + + + + + @@ -551,28 +570,6 @@ NavigateUri="{Binding LatestReleaseUrl}" HorizontalAlignment="Left" Visibility="{Binding LatestReleaseUrl, Converter={StaticResource StringToVisibilityConverter}}"/> - - - - - - - From 0a940be180156c21cf5dd504dd5e46f2b7729b34 Mon Sep 17 00:00:00 2001 From: twibster Date: Wed, 15 Apr 2026 20:57:25 +0200 Subject: [PATCH 6/7] Tighten About page Links list The two hyperlinks were being rendered with wpfui:Button's default Padding (11,5,11,6), which made the stack read as two spaced-out buttons instead of a compact link list like the rest of the app's peers (LLT, EarTrumpet). Dropped Padding to 8,2 and removed the 4px inter-link margin. The horizontal hit target stays generous (16px total) so clicking remains forgiving; only the vertical whitespace shrinks. --- AudioMonitorRouter/Views/MainWindow.xaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/AudioMonitorRouter/Views/MainWindow.xaml b/AudioMonitorRouter/Views/MainWindow.xaml index 62155f1..e823594 100644 --- a/AudioMonitorRouter/Views/MainWindow.xaml +++ b/AudioMonitorRouter/Views/MainWindow.xaml @@ -529,16 +529,22 @@ Two static GitHub URLs; /releases/latest redirects to the current tag so we don't have to bake a version into the URL here. + + Padding is tightened from the default wpfui:Button + (11,5,11,6) down to 8,2 so the stacked hyperlinks + read as a compact list rather than two spaced-out + buttons. No margin between them — the reduced + padding alone gives enough breathing room. --> + HorizontalAlignment="Left" Padding="8,2"/> + HorizontalAlignment="Left" Padding="8,2"/> - - + + + + + + - - + diff --git a/design/tools/IconBuilder/Program.cs b/design/tools/IconBuilder/Program.cs index e7e186e..d33f2f9 100644 --- a/design/tools/IconBuilder/Program.cs +++ b/design/tools/IconBuilder/Program.cs @@ -89,6 +89,13 @@ static void WritePng(SvgDocument svg, int size, string path) static void WriteIco(SvgDocument svg, int[] sizes, string path) { + // 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) diff --git a/installer/installer.iss b/installer/installer.iss index ccb593a..018926d 100644 --- a/installer/installer.iss +++ b/installer/installer.iss @@ -86,7 +86,10 @@ Filename: "{tmp}\windowsdesktop-runtime-8-x64.exe"; Parameters: "/install /quiet ; 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. -Filename: "{sys}\ie4uinit.exe"; Parameters: "-show"; Flags: runhidden skipifdoesntexist +; 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]