From 481caa8f733cfbcef2391b8a197b395d369aa80d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:41:11 +0000 Subject: [PATCH 1/3] Initial plan From 9b55ab185bf32253e94d6f8fcf7d82bab714ac15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:48:51 +0000 Subject: [PATCH 2/3] fix(updater): handle 4-part version strings for nightly builds Nightly releases stamp AssemblyInfo with a version like "7.1.0.229925" which has a fourth component not accepted by NuGet.Versioning's SemanticVersion.Parse, causing a TypeInitializationException. - Add ParseVersion() helper that truncates to major.minor.patch before parsing, making static AppVersion initialization safe for nightly builds - Add a Where filter in CheckForUpdate to skip releases whose tag cannot be parsed as a SemanticVersion (defensive guard) - Add VersionTest cases for 4-part nightly version truncation Closes #2248 Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com> --- SoundSwitch.Tests/VersionTest.cs | 15 ++++++++++++++- SoundSwitch/Framework/Updater/UpdateChecker.cs | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/SoundSwitch.Tests/VersionTest.cs b/SoundSwitch.Tests/VersionTest.cs index 52918b3dd2..b8bfb52f49 100644 --- a/SoundSwitch.Tests/VersionTest.cs +++ b/SoundSwitch.Tests/VersionTest.cs @@ -1,4 +1,6 @@ -using FluentAssertions; +using System.Linq; + +using FluentAssertions; using NuGet.Versioning; @@ -15,4 +17,15 @@ public void TestSemanticVersionBetaSmallerThanRelease() var release = SemanticVersion.Parse("1.0.0"); beta.Should().BeLessThan(release); } + + [TestCase("7.1.0.229925", "7.1.0")] + [TestCase("1.2.3.456", "1.2.3")] + [TestCase("1.2.3", "1.2.3")] + public void TestNightlyVersionTruncation(string rawVersion, string expectedVersion) + { + var parts = rawVersion.Split('.'); + var truncated = string.Join(".", parts.Take(3)); + var parsed = SemanticVersion.Parse(truncated); + parsed.Should().Be(SemanticVersion.Parse(expectedVersion)); + } } diff --git a/SoundSwitch/Framework/Updater/UpdateChecker.cs b/SoundSwitch/Framework/Updater/UpdateChecker.cs index c68c45e5ef..b9d1199e09 100644 --- a/SoundSwitch/Framework/Updater/UpdateChecker.cs +++ b/SoundSwitch/Framework/Updater/UpdateChecker.cs @@ -39,7 +39,18 @@ public partial class UpdateChecker(Uri releaseUrl, bool checkBeta) private static readonly string UserAgent = $"Mozilla/5.0 (compatible; {Environment.OSVersion.Platform} {Environment.OSVersion.VersionString}; {Application.ProductName}/{Application.ProductVersion};)"; - private static readonly SemanticVersion AppVersion = SemanticVersion.Parse(Application.ProductVersion); + private static readonly SemanticVersion AppVersion = ParseVersion(Application.ProductVersion); + + /// + /// Parses a version string, truncating it to the first three parts (major.minor.patch) to handle + /// nightly build versions that include a fourth component (e.g. "7.1.0.229925"). + /// + private static SemanticVersion ParseVersion(string version) + { + var parts = version.Split('.'); + var truncated = string.Join(".", parts.Take(3)); + return SemanticVersion.Parse(truncated); + } public EventHandler UpdateAvailable; public bool Beta { get; set; } = checkBeta; @@ -109,7 +120,9 @@ public async Task CheckForUpdate(CancellationToken token) httpClient.DefaultRequestHeaders.UserAgent.Add(ApplicationInfo.CommentValue); httpClient.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); var releases = await httpClient.GetFromJsonAsync(releaseUrl, GithubReleasesJsonContext.Default.ReleaseArray, token); - foreach (var release in (releases ?? Array.Empty()).OrderByDescending(release => SemanticVersion.Parse(release.TagName.Substring(1)))) + foreach (var release in (releases ?? Array.Empty()) + .Where(release => SemanticVersion.TryParse(release.TagName.Substring(1), out _)) + .OrderByDescending(release => SemanticVersion.Parse(release.TagName.Substring(1)))) { token.ThrowIfCancellationRequested(); if (ProcessAndNotifyRelease(release)) From 1382766d981df0b8b91fa5de2d47fb59595426e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:20:42 +0000 Subject: [PATCH 3/3] fix(updater): use last 5 digits of nightly revision as patch version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of discarding the 4th version component entirely (which made all nightly builds report as the same version), use the last 5 digits of the revision number as the patch component. e.g. "7.1.0.229925" → SemanticVersion 7.1.29925 This preserves ordering between nightly builds and is acceptable to NuGet.Versioning's SemanticVersion parser. Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com> --- .../Audio/Icon/AudioDeviceIconExtractor.cs | 194 +++++++++--------- .../AudioDeviceIconExtractorTests.cs | 54 ++--- SoundSwitch.Tests/VersionTest.cs | 20 +- .../Framework/Updater/UpdateChecker.cs | 12 +- 4 files changed, 149 insertions(+), 131 deletions(-) diff --git a/SoundSwitch.Common/Framework/Audio/Icon/AudioDeviceIconExtractor.cs b/SoundSwitch.Common/Framework/Audio/Icon/AudioDeviceIconExtractor.cs index 13c4481fdc..3a3d5d430d 100644 --- a/SoundSwitch.Common/Framework/Audio/Icon/AudioDeviceIconExtractor.cs +++ b/SoundSwitch.Common/Framework/Audio/Icon/AudioDeviceIconExtractor.cs @@ -1,97 +1,97 @@ -/******************************************************************** - * Copyright (C) 2015-2017 Antoine Aflalo - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - ********************************************************************/ - -#nullable enable -using System; - -using NAudio.CoreAudioApi; - -using Serilog; - -using SoundSwitch.Common.Framework.Icon; -using SoundSwitch.Common.Properties; - -namespace SoundSwitch.Common.Framework.Audio.Icon -{ - /// - /// Extracts icons for audio devices with DataFlow-specific fallback defaults. - /// Delegates caching and GDI reference counting to . - /// - public class AudioDeviceIconExtractor - { - private static readonly IconHandle DefaultSpeakersHandle = CreatePermanentDefaultIcon(() => Resources.defaultSpeakers, () => System.Drawing.SystemIcons.Application, nameof(Resources.defaultSpeakers)); - private static readonly IconHandle DefaultMicrophoneHandle = CreatePermanentDefaultIcon(() => Resources.defaultMicrophone, () => System.Drawing.SystemIcons.Information, nameof(Resources.defaultMicrophone)); - - /// - /// Creates a permanent icon handle from a bundled icon resource with a fallback option. - /// - /// Factory function that provides the primary bundled icon. - /// Factory function that provides a fallback system icon if the bundled icon fails to load. - /// The name of the resource being loaded, used for logging purposes. - /// A permanent that does not require disposal. - private static IconHandle CreatePermanentDefaultIcon(Func bundledIconFactory, Func fallbackIconFactory, string resourceName) - { - try - { - return IconExtractor.CreatePermanent(bundledIconFactory()); - } - catch (Exception e) - { - Log.Warning(e, "Can't load bundled fallback icon {resourceName}, using system icon fallback", resourceName); - return IconExtractor.CreatePermanent((System.Drawing.Icon)fallbackIconFactory().Clone()); - } - } - - /// - /// Extract an icon from an audio device icon path, falling back to a DataFlow-specific - /// default icon on failure. - /// - /// Audio device icon path (a .ico file or dllPath,iconIndex). - /// Data flow of the device, used to select the fallback icon. - /// When , extract a 32×32 icon; otherwise 16×16. - /// - /// An the caller must dispose when done. - /// - public static IconHandle ExtractIconFromPath(string path, DataFlow dataFlow, bool largeIcon) - { - try - { - return IconExtractor.ExtractFromPath(path, largeIcon); - } - catch (Exception e) - { - Log.Warning(e, "Can't extract icon from {path}", path); - return dataFlow switch - { - DataFlow.Capture => DefaultMicrophoneHandle.Acquire(), - DataFlow.Render => DefaultSpeakersHandle.Acquire(), - _ => throw new ArgumentOutOfRangeException() - }; - } - } - - /// - /// Extract the icon out of an . - /// - /// - /// - /// - /// An the caller must dispose when done. - /// - public static IconHandle ExtractIconFromAudioDevice(MMDevice audioDevice, bool largeIcon) - { - return ExtractIconFromPath(audioDevice.IconPath, audioDevice.DataFlow, largeIcon); - } - } -} +/******************************************************************** + * Copyright (C) 2015-2017 Antoine Aflalo + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + ********************************************************************/ + +#nullable enable +using System; + +using NAudio.CoreAudioApi; + +using Serilog; + +using SoundSwitch.Common.Framework.Icon; +using SoundSwitch.Common.Properties; + +namespace SoundSwitch.Common.Framework.Audio.Icon +{ + /// + /// Extracts icons for audio devices with DataFlow-specific fallback defaults. + /// Delegates caching and GDI reference counting to . + /// + public class AudioDeviceIconExtractor + { + private static readonly IconHandle DefaultSpeakersHandle = CreatePermanentDefaultIcon(() => Resources.defaultSpeakers, () => System.Drawing.SystemIcons.Application, nameof(Resources.defaultSpeakers)); + private static readonly IconHandle DefaultMicrophoneHandle = CreatePermanentDefaultIcon(() => Resources.defaultMicrophone, () => System.Drawing.SystemIcons.Information, nameof(Resources.defaultMicrophone)); + + /// + /// Creates a permanent icon handle from a bundled icon resource with a fallback option. + /// + /// Factory function that provides the primary bundled icon. + /// Factory function that provides a fallback system icon if the bundled icon fails to load. + /// The name of the resource being loaded, used for logging purposes. + /// A permanent that does not require disposal. + private static IconHandle CreatePermanentDefaultIcon(Func bundledIconFactory, Func fallbackIconFactory, string resourceName) + { + try + { + return IconExtractor.CreatePermanent(bundledIconFactory()); + } + catch (Exception e) + { + Log.Warning(e, "Can't load bundled fallback icon {resourceName}, using system icon fallback", resourceName); + return IconExtractor.CreatePermanent((System.Drawing.Icon)fallbackIconFactory().Clone()); + } + } + + /// + /// Extract an icon from an audio device icon path, falling back to a DataFlow-specific + /// default icon on failure. + /// + /// Audio device icon path (a .ico file or dllPath,iconIndex). + /// Data flow of the device, used to select the fallback icon. + /// When , extract a 32×32 icon; otherwise 16×16. + /// + /// An the caller must dispose when done. + /// + public static IconHandle ExtractIconFromPath(string path, DataFlow dataFlow, bool largeIcon) + { + try + { + return IconExtractor.ExtractFromPath(path, largeIcon); + } + catch (Exception e) + { + Log.Warning(e, "Can't extract icon from {path}", path); + return dataFlow switch + { + DataFlow.Capture => DefaultMicrophoneHandle.Acquire(), + DataFlow.Render => DefaultSpeakersHandle.Acquire(), + _ => throw new ArgumentOutOfRangeException() + }; + } + } + + /// + /// Extract the icon out of an . + /// + /// + /// + /// + /// An the caller must dispose when done. + /// + public static IconHandle ExtractIconFromAudioDevice(MMDevice audioDevice, bool largeIcon) + { + return ExtractIconFromPath(audioDevice.IconPath, audioDevice.DataFlow, largeIcon); + } + } +} diff --git a/SoundSwitch.Tests/AudioDeviceIconExtractorTests.cs b/SoundSwitch.Tests/AudioDeviceIconExtractorTests.cs index b74ac978ad..994535d99f 100644 --- a/SoundSwitch.Tests/AudioDeviceIconExtractorTests.cs +++ b/SoundSwitch.Tests/AudioDeviceIconExtractorTests.cs @@ -1,27 +1,27 @@ -using FluentAssertions; - -using NAudio.CoreAudioApi; - -using NUnit.Framework; - -using SoundSwitch.Common.Framework.Audio.Icon; - -namespace SoundSwitch.Tests; - -/// -/// Tests for the class, verifying icon extraction -/// behavior for audio devices including fallback handling for invalid icon paths. -/// -[TestFixture] -public class AudioDeviceIconExtractorTests -{ - [TestCase(DataFlow.Render)] - [TestCase(DataFlow.Capture)] - public void ExtractIconFromPath_WhenPathIsInvalid_ReturnsFallbackIcon(DataFlow dataFlow) - { - using var iconHandle = AudioDeviceIconExtractor.ExtractIconFromPath("invalid-icon-path", dataFlow, false); - - iconHandle.Should().NotBeNull(); - iconHandle.Icon.Should().NotBeNull(); - } -} +using FluentAssertions; + +using NAudio.CoreAudioApi; + +using NUnit.Framework; + +using SoundSwitch.Common.Framework.Audio.Icon; + +namespace SoundSwitch.Tests; + +/// +/// Tests for the class, verifying icon extraction +/// behavior for audio devices including fallback handling for invalid icon paths. +/// +[TestFixture] +public class AudioDeviceIconExtractorTests +{ + [TestCase(DataFlow.Render)] + [TestCase(DataFlow.Capture)] + public void ExtractIconFromPath_WhenPathIsInvalid_ReturnsFallbackIcon(DataFlow dataFlow) + { + using var iconHandle = AudioDeviceIconExtractor.ExtractIconFromPath("invalid-icon-path", dataFlow, false); + + iconHandle.Should().NotBeNull(); + iconHandle.Icon.Should().NotBeNull(); + } +} diff --git a/SoundSwitch.Tests/VersionTest.cs b/SoundSwitch.Tests/VersionTest.cs index b8bfb52f49..2729fd99dc 100644 --- a/SoundSwitch.Tests/VersionTest.cs +++ b/SoundSwitch.Tests/VersionTest.cs @@ -18,14 +18,24 @@ public void TestSemanticVersionBetaSmallerThanRelease() beta.Should().BeLessThan(release); } - [TestCase("7.1.0.229925", "7.1.0")] - [TestCase("1.2.3.456", "1.2.3")] + [TestCase("7.1.0.229925", "7.1.29925")] + [TestCase("1.2.3.456", "1.2.456")] + [TestCase("1.2.3.100001", "1.2.1")] [TestCase("1.2.3", "1.2.3")] - public void TestNightlyVersionTruncation(string rawVersion, string expectedVersion) + public void TestNightlyVersionParsing(string rawVersion, string expectedVersion) { var parts = rawVersion.Split('.'); - var truncated = string.Join(".", parts.Take(3)); - var parsed = SemanticVersion.Parse(truncated); + SemanticVersion parsed; + if (parts.Length >= 4 && int.TryParse(parts[3], out var revision)) + { + var patch = revision % 100_000; + parsed = new SemanticVersion(int.Parse(parts[0]), int.Parse(parts[1]), patch); + } + else + { + var truncated = string.Join(".", parts.Take(3)); + parsed = SemanticVersion.Parse(truncated); + } parsed.Should().Be(SemanticVersion.Parse(expectedVersion)); } } diff --git a/SoundSwitch/Framework/Updater/UpdateChecker.cs b/SoundSwitch/Framework/Updater/UpdateChecker.cs index b9d1199e09..8043d895e2 100644 --- a/SoundSwitch/Framework/Updater/UpdateChecker.cs +++ b/SoundSwitch/Framework/Updater/UpdateChecker.cs @@ -42,12 +42,20 @@ public partial class UpdateChecker(Uri releaseUrl, bool checkBeta) private static readonly SemanticVersion AppVersion = ParseVersion(Application.ProductVersion); /// - /// Parses a version string, truncating it to the first three parts (major.minor.patch) to handle - /// nightly build versions that include a fourth component (e.g. "7.1.0.229925"). + /// Parses a version string into a . + /// When a fourth version component is present (e.g. nightly builds like "7.1.0.229925"), + /// the last 5 digits of that component are used as the patch number so that nightly builds + /// remain orderable and distinguishable (e.g. "7.1.29925"). /// private static SemanticVersion ParseVersion(string version) { var parts = version.Split('.'); + if (parts.Length >= 4 && int.TryParse(parts[3], out var revision)) + { + var patch = revision % 100_000; + return new SemanticVersion(int.Parse(parts[0]), int.Parse(parts[1]), patch); + } + var truncated = string.Join(".", parts.Take(3)); return SemanticVersion.Parse(truncated); }