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);
}