Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 97 additions & 97 deletions SoundSwitch.Common/Framework/Audio/Icon/AudioDeviceIconExtractor.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Extracts icons for audio devices with DataFlow-specific fallback defaults.
/// Delegates caching and GDI reference counting to <see cref="IconExtractor"/>.
/// </summary>
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));
/// <summary>
/// Creates a permanent icon handle from a bundled icon resource with a fallback option.
/// </summary>
/// <param name="bundledIconFactory">Factory function that provides the primary bundled icon.</param>
/// <param name="fallbackIconFactory">Factory function that provides a fallback system icon if the bundled icon fails to load.</param>
/// <param name="resourceName">The name of the resource being loaded, used for logging purposes.</param>
/// <returns>A permanent <see cref="IconHandle"/> that does not require disposal.</returns>
private static IconHandle CreatePermanentDefaultIcon(Func<System.Drawing.Icon> bundledIconFactory, Func<System.Drawing.Icon> 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());
}
}
/// <summary>
/// Extract an icon from an audio device icon path, falling back to a DataFlow-specific
/// default icon on failure.
/// </summary>
/// <param name="path">Audio device icon path (a <c>.ico</c> file or <c>dllPath,iconIndex</c>).</param>
/// <param name="dataFlow">Data flow of the device, used to select the fallback icon.</param>
/// <param name="largeIcon">When <see langword="true"/>, extract a 32×32 icon; otherwise 16×16.</param>
/// <returns>
/// An <see cref="IconHandle"/> the caller <strong>must dispose</strong> when done.
/// </returns>
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()
};
}
}
/// <summary>
/// Extract the icon out of an <see cref="MMDevice"/>.
/// </summary>
/// <param name="audioDevice"></param>
/// <param name="largeIcon"></param>
/// <returns>
/// An <see cref="IconHandle"/> the caller <strong>must dispose</strong> when done.
/// </returns>
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
{
/// <summary>
/// Extracts icons for audio devices with DataFlow-specific fallback defaults.
/// Delegates caching and GDI reference counting to <see cref="IconExtractor"/>.
/// </summary>
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));

/// <summary>
/// Creates a permanent icon handle from a bundled icon resource with a fallback option.
/// </summary>
/// <param name="bundledIconFactory">Factory function that provides the primary bundled icon.</param>
/// <param name="fallbackIconFactory">Factory function that provides a fallback system icon if the bundled icon fails to load.</param>
/// <param name="resourceName">The name of the resource being loaded, used for logging purposes.</param>
/// <returns>A permanent <see cref="IconHandle"/> that does not require disposal.</returns>
private static IconHandle CreatePermanentDefaultIcon(Func<System.Drawing.Icon> bundledIconFactory, Func<System.Drawing.Icon> 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());
}
}

/// <summary>
/// Extract an icon from an audio device icon path, falling back to a DataFlow-specific
/// default icon on failure.
/// </summary>
/// <param name="path">Audio device icon path (a <c>.ico</c> file or <c>dllPath,iconIndex</c>).</param>
/// <param name="dataFlow">Data flow of the device, used to select the fallback icon.</param>
/// <param name="largeIcon">When <see langword="true"/>, extract a 32×32 icon; otherwise 16×16.</param>
/// <returns>
/// An <see cref="IconHandle"/> the caller <strong>must dispose</strong> when done.
/// </returns>
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()
};
}
}

/// <summary>
/// Extract the icon out of an <see cref="MMDevice"/>.
/// </summary>
/// <param name="audioDevice"></param>
/// <param name="largeIcon"></param>
/// <returns>
/// An <see cref="IconHandle"/> the caller <strong>must dispose</strong> when done.
/// </returns>
public static IconHandle ExtractIconFromAudioDevice(MMDevice audioDevice, bool largeIcon)
{
return ExtractIconFromPath(audioDevice.IconPath, audioDevice.DataFlow, largeIcon);
}
}
}
54 changes: 27 additions & 27 deletions SoundSwitch.Tests/AudioDeviceIconExtractorTests.cs
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
using FluentAssertions;
using NAudio.CoreAudioApi;
using NUnit.Framework;
using SoundSwitch.Common.Framework.Audio.Icon;
namespace SoundSwitch.Tests;
/// <summary>
/// Tests for the <see cref="AudioDeviceIconExtractor"/> class, verifying icon extraction
/// behavior for audio devices including fallback handling for invalid icon paths.
/// </summary>
[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;

/// <summary>
/// Tests for the <see cref="AudioDeviceIconExtractor"/> class, verifying icon extraction
/// behavior for audio devices including fallback handling for invalid icon paths.
/// </summary>
[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();
}
}
25 changes: 24 additions & 1 deletion SoundSwitch.Tests/VersionTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using FluentAssertions;
using System.Linq;

using FluentAssertions;

using NuGet.Versioning;

Expand All @@ -15,4 +17,25 @@ public void TestSemanticVersionBetaSmallerThanRelease()
var release = SemanticVersion.Parse("1.0.0");
beta.Should().BeLessThan(release);
}

[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")]
Comment on lines +21 to +24
public void TestNightlyVersionParsing(string rawVersion, string expectedVersion)
{
var parts = rawVersion.Split('.');
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);
}
Comment on lines +27 to +38
parsed.Should().Be(SemanticVersion.Parse(expectedVersion));
}
}
25 changes: 23 additions & 2 deletions SoundSwitch/Framework/Updater/UpdateChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,26 @@ 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);

/// <summary>
/// Parses a version string into a <see cref="SemanticVersion"/>.
/// 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").
/// </summary>
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);
}
Comment on lines +52 to +61

public EventHandler<NewReleaseEvent> UpdateAvailable;
public bool Beta { get; set; } = checkBeta;
Expand Down Expand Up @@ -109,7 +128,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<Release>()).OrderByDescending(release => SemanticVersion.Parse(release.TagName.Substring(1))))
foreach (var release in (releases ?? Array.Empty<Release>())
.Where(release => SemanticVersion.TryParse(release.TagName.Substring(1), out _))
.OrderByDescending(release => SemanticVersion.Parse(release.TagName.Substring(1))))
Comment on lines +131 to +133
{
token.ThrowIfCancellationRequested();
if (ProcessAndNotifyRelease(release))
Expand Down
Loading