diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe3e80f..e3a47cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,5 @@ permissions: contents: read jobs: - build-native: - uses: ./.github/workflows/reusable-build-native.yml - test: - needs: build-native uses: ./.github/workflows/reusable-test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d58d06..ca8e98f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,12 +35,8 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Resolved version: $VERSION" - build-native: - needs: version - uses: ./.github/workflows/reusable-build-native.yml - test: - needs: [version, build-native] + needs: version uses: ./.github/workflows/reusable-test.yml pack: diff --git a/.github/workflows/reusable-build-native.yml b/.github/workflows/reusable-build-native.yml deleted file mode 100644 index b7bf487..0000000 --- a/.github/workflows/reusable-build-native.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Reusable Build Native - -on: - workflow_call: - -env: - DOTNET_NOLOGO: 'true' - DOTNET_CLI_TELEMETRY_OPTOUT: 'true' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' - VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' - -permissions: - contents: read - -jobs: - build-native: - name: Build native (${{ matrix.rid }}) - strategy: - fail-fast: false - matrix: - include: - - rid: osx-arm64 - os: macos-14 - - rid: linux-x64 - os: ubuntu-24.04 - - rid: linux-arm64 - os: ubuntu-24.04-arm - - rid: win-x64 - os: windows-2022 - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - fetch-depth: 0 - - - name: Install hidapi (macOS) - if: runner.os == 'macOS' - run: brew install hidapi - - - name: Install hidapi + toolchain (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - cmake build-essential pkg-config libhidapi-dev - - - name: Expose GHA cache tokens to vcpkg (Windows) - if: runner.os == 'Windows' - uses: actions/github-script@v9 - with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - - name: Set vcpkg toolchain (Windows) - if: runner.os == 'Windows' - shell: bash - run: | - echo "CMAKE_EXTRA_ARGS=-DCMAKE_TOOLCHAIN_FILE=${VCPKG_INSTALLATION_ROOT//\\//}/scripts/buildsystems/vcpkg.cmake -DVCPKG_TARGET_TRIPLET=x64-windows" >> "$GITHUB_ENV" - - - name: Cache CMake build dir - uses: actions/cache@v5 - with: - path: build/cmake/${{ matrix.rid }} - key: cmake-${{ matrix.rid }}-${{ hashFiles('headsetcontrollib/CMakeLists.txt', 'headsetcontrollib/lib/**/*.cpp', 'headsetcontrollib/lib/**/*.hpp', 'headsetcontrollib/lib/**/*.h', 'headsetcontrollib/vcpkg.json') }} - restore-keys: | - cmake-${{ matrix.rid }}- - - - name: Build native library - shell: bash - run: build/build-native.sh --rid ${{ matrix.rid }} - - - uses: actions/upload-artifact@v7 - with: - name: native-${{ matrix.rid }} - path: build/native/${{ matrix.rid }}/ - if-no-files-found: error - retention-days: 14 diff --git a/.github/workflows/reusable-nuget-publish.yml b/.github/workflows/reusable-nuget-publish.yml index fa6a1e2..19dfd54 100644 --- a/.github/workflows/reusable-nuget-publish.yml +++ b/.github/workflows/reusable-nuget-publish.yml @@ -18,6 +18,7 @@ jobs: publish: name: Publish to NuGet runs-on: ubuntu-24.04 + environment: production steps: - uses: actions/setup-dotnet@v5 with: diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index feaacf4..1fba664 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -7,6 +7,7 @@ env: DOTNET_NOLOGO: 'true' DOTNET_CLI_TELEMETRY_OPTOUT: 'true' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' permissions: contents: read @@ -29,26 +30,46 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - - name: Install hidapi runtime (macOS) + - name: Install hidapi + toolchain (macOS) if: runner.os == 'macOS' run: brew install hidapi - - name: Install hidapi runtime (Linux) + - name: Install hidapi + toolchain (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libhidapi-hidraw0 libhidapi-libusb0 + cmake build-essential pkg-config libhidapi-dev - - uses: actions/download-artifact@v8 + - name: Expose GHA cache tokens to vcpkg (Windows) + if: runner.os == 'Windows' + uses: actions/github-script@v9 with: - name: native-${{ matrix.rid }} - path: build/native/${{ matrix.rid }}/ + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Set vcpkg toolchain (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + echo "CMAKE_EXTRA_ARGS=-DCMAKE_TOOLCHAIN_FILE=${VCPKG_INSTALLATION_ROOT//\\//}/scripts/buildsystems/vcpkg.cmake -DVCPKG_TARGET_TRIPLET=x64-windows" >> "$GITHUB_ENV" + + - name: Cache CMake build dir + uses: actions/cache@v5 + with: + path: build/cmake/${{ matrix.rid }} + key: cmake-${{ matrix.rid }}-${{ hashFiles('headsetcontrollib/CMakeLists.txt', 'headsetcontrollib/lib/**/*.cpp', 'headsetcontrollib/lib/**/*.hpp', 'headsetcontrollib/lib/**/*.h', 'headsetcontrollib/vcpkg.json') }} + restore-keys: | + cmake-${{ matrix.rid }}- - name: Cache NuGet packages uses: actions/cache@v5 @@ -60,6 +81,8 @@ jobs: - run: dotnet restore + # The HscPrepareNativeArtifacts target in HeadsetControl.NET.Native.csproj + # invokes build/build-native.sh for the host RID if no artefact is present. - run: dotnet build -c Release --no-restore - run: >- @@ -68,6 +91,14 @@ jobs: --logger "trx;LogFileName=test-results.trx" --results-directory TestResults + - name: Upload native artefact + uses: actions/upload-artifact@v7 + with: + name: native-${{ matrix.rid }} + path: build/native/${{ matrix.rid }}/ + if-no-files-found: error + retention-days: 14 + - if: always() uses: actions/upload-artifact@v7 with: diff --git a/Directory.Build.props b/Directory.Build.props index 4f9f46d..6682628 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - net10.0 + net8.0;net9.0;net10.0 latest enable @@ -35,7 +35,8 @@ git GPL-3.0-only false - 0.2.0 + README.md + 0.3.0 @@ -48,6 +49,11 @@ PackagePath="\" Visible="false" Condition="Exists('$(MSBuildThisFileDirectory)LICENSE')" /> + diff --git a/README.md b/README.md index d31ebd6..80c1242 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ headsetcontrollib/ git submodule with the native source ```bash git submodule update --init --recursive -build/build-native.sh # host RID — or: --rid linux-x64 dotnet test -c Release ``` diff --git a/build/build-native.sh b/build/build-native.sh index 8bac803..95b5704 100755 --- a/build/build-native.sh +++ b/build/build-native.sh @@ -44,6 +44,37 @@ BUILD_DIR="${BUILD_ROOT}/${RID}" OUT_DIR="${OUT_ROOT}/${RID}" mkdir -p "${BUILD_DIR}" "${OUT_DIR}" +# Serialise concurrent invocations for the same RID — when this script is +# wired into a multi-targeted .NET build, MSBuild may run it in parallel for +# each TFM, and the inner CMake build directory is not concurrency-safe. +# `mkdir` is atomic across processes, so we use a sentinel directory as the +# lock. The first process builds; later ones wait, then exit early once the +# artefact is in place. +LOCK_DIR="${BUILD_DIR}.lock" +trap 'rmdir "${LOCK_DIR}" 2>/dev/null || true' EXIT + +WAITED=0 +while ! mkdir "${LOCK_DIR}" 2>/dev/null; do + if [[ ${WAITED} -ge 600 ]]; then + echo "error: timed out waiting for ${LOCK_DIR}" >&2 + exit 1 + fi + sleep 1 + WAITED=$((WAITED + 1)) +done + +case "${RID}" in + osx-*) EXPECTED="${OUT_DIR}/libheadsetcontrol.dylib" ;; + linux-*) EXPECTED="${OUT_DIR}/libheadsetcontrol.so" ;; + win-*) EXPECTED="${OUT_DIR}/headsetcontrol.dll" ;; + *) EXPECTED="" ;; +esac + +if [[ -n "${EXPECTED}" && -f "${EXPECTED}" ]]; then + echo ">> ${EXPECTED} already present, skipping build" + exit 0 +fi + echo ">> Configuring native HeadsetControl for ${RID}" # shellcheck disable=SC2086 cmake -S "${NATIVE_SRC}" -B "${BUILD_DIR}" \ diff --git a/headsetcontrollib b/headsetcontrollib index a35119a..3eaaab8 160000 --- a/headsetcontrollib +++ b/headsetcontrollib @@ -1 +1 @@ -Subproject commit a35119a57dc4c9c3833778db369413c82069c9c2 +Subproject commit 3eaaab8ea2650e05c1dbc177670036b6e0ce09c5 diff --git a/samples/HeadsetControl.NET.Sample/Program.cs b/samples/HeadsetControl.NET.Sample/Program.cs index aa1c3a3..27fff39 100644 --- a/samples/HeadsetControl.NET.Sample/Program.cs +++ b/samples/HeadsetControl.NET.Sample/Program.cs @@ -1,6 +1,7 @@ using HeadsetControl.NET; +using HeadsetControl.NET.Exceptions; -bool useTestDevice = args.Contains("--test", StringComparer.OrdinalIgnoreCase); +var useTestDevice = args.Contains("--test", StringComparer.OrdinalIgnoreCase); Console.WriteLine($"HeadsetControl native version: {HeadsetControlLibrary.Version}"); Console.WriteLine($"Supported device models: {HeadsetControlLibrary.SupportedDeviceCount}"); @@ -13,7 +14,7 @@ Console.WriteLine(); } -using HeadsetCollection headsets = HeadsetControlLibrary.Discover(); +using var headsets = HeadsetControlLibrary.Discover(); if (headsets.Count == 0) { @@ -21,9 +22,9 @@ return 0; } -for (int i = 0; i < headsets.Count; i++) +for (var i = 0; i < headsets.Count; i++) { - Headset headset = headsets[i]; + var headset = headsets[i]; Console.WriteLine($"[{i}] {headset}"); Console.WriteLine($" Vendor : {headset.VendorName ?? "(unknown)"} (0x{headset.VendorId:X4})"); @@ -47,8 +48,8 @@ static void TryReadBattery(Headset headset) try { - BatteryInfo battery = headset.GetBattery(); - string level = battery.LevelPercent is int pct ? $"{pct}%" : "n/a"; + var battery = headset.GetBattery(); + var level = battery.LevelPercent is int pct ? $"{pct}%" : "n/a"; Console.WriteLine($" Battery: {battery.Status} ({level})"); } catch (HeadsetControlException ex) @@ -66,7 +67,7 @@ static void TryReadChatMix(Headset headset) try { - ChatMixInfo mix = headset.GetChatMix(); + var mix = headset.GetChatMix(); Console.WriteLine($" ChatMix: level={mix.Level} game={mix.GameVolumePercent}% chat={mix.ChatVolumePercent}%"); } catch (HeadsetControlException ex) diff --git a/src/HeadsetControl.NET.Native/HeadsetControl.NET.Native.csproj b/src/HeadsetControl.NET.Native/HeadsetControl.NET.Native.csproj index ce2fbf9..88d874a 100644 --- a/src/HeadsetControl.NET.Native/HeadsetControl.NET.Native.csproj +++ b/src/HeadsetControl.NET.Native/HeadsetControl.NET.Native.csproj @@ -11,19 +11,15 @@ Native P/Invoke bindings for the HeadsetControl C library. Internal infrastructure for HeadsetControl.NET — application code should consume HeadsetControl.NET instead. HeadsetControl.NET.Native - - - <_HscNativeArtifact Include="$(MSBuildThisFileDirectory)../../build/native/**/*.dylib" /> - <_HscNativeArtifact Include="$(MSBuildThisFileDirectory)../../build/native/**/*.so" /> - <_HscNativeArtifact Include="$(MSBuildThisFileDirectory)../../build/native/**/*.dll" /> - - - + + true + @@ -34,4 +30,47 @@ + + + + <_HscOs Condition="$([MSBuild]::IsOSPlatform('OSX'))">osx + <_HscOs Condition="$([MSBuild]::IsOSPlatform('Linux'))">linux + <_HscOs Condition="$([MSBuild]::IsOSPlatform('Windows'))">win + + <_HscArch Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'X64'">x64 + <_HscArch Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64 + + <_HscHostRid>$(_HscOs)-$(_HscArch) + + <_HscNativeName Condition="'$(_HscOs)' == 'win'">headsetcontrol.dll + <_HscNativeName Condition="'$(_HscOs)' == 'osx'">libheadsetcontrol.dylib + <_HscNativeName Condition="'$(_HscOs)' == 'linux'">libheadsetcontrol.so + + <_HscNativeHostPath>$(MSBuildThisFileDirectory)..\..\build\native\$(_HscHostRid)\$(_HscNativeName) + <_HscBuildScript>$(MSBuildThisFileDirectory)..\..\build\build-native.sh + + + + + + + + <_HscNativeArtifact Include="$(MSBuildThisFileDirectory)..\..\build\native\**\*.dylib" /> + <_HscNativeArtifact Include="$(MSBuildThisFileDirectory)..\..\build\native\**\*.so" /> + <_HscNativeArtifact Include="$(MSBuildThisFileDirectory)..\..\build\native\**\*.dll" /> + + + + + diff --git a/src/HeadsetControl.NET.Native/NativeConstants.cs b/src/HeadsetControl.NET.Native/NativeConstants.cs index 0357b92..3ddef9e 100644 --- a/src/HeadsetControl.NET.Native/NativeConstants.cs +++ b/src/HeadsetControl.NET.Native/NativeConstants.cs @@ -1,6 +1,6 @@ namespace HeadsetControl.NET.Native; -internal enum HscResult +enum HscResult { Ok = 0, Error = -1, @@ -11,7 +11,7 @@ internal enum HscResult InvalidParam = -6, } -internal enum HscCapability +enum HscCapability { Sidetone = 0, BatteryStatus = 1, @@ -34,7 +34,7 @@ internal enum HscCapability // The native C wrapper currently forwards the underlying C++ battery_status // enum (0..4) through a static cast, so callers may receive either these // constants or the C++ values. ResultMapping handles both. -internal enum HscBatteryStatus +enum HscBatteryStatus { Unavailable = -1, Charging = -2, diff --git a/src/HeadsetControl.NET.Native/NativeLibraryLoader.cs b/src/HeadsetControl.NET.Native/NativeLibraryLoader.cs index 529e36c..0d51e35 100644 --- a/src/HeadsetControl.NET.Native/NativeLibraryLoader.cs +++ b/src/HeadsetControl.NET.Native/NativeLibraryLoader.cs @@ -3,33 +3,22 @@ namespace HeadsetControl.NET.Native; -internal static class NativeLibraryLoader +static class NativeLibraryLoader { public const string LibraryName = "headsetcontrol"; - private static readonly Lock InitLock = new(); - private static bool _initialized; - - public static void EnsureInitialized() + private static readonly Lazy Initializer = new(() => { - if (_initialized) - { - return; - } - - lock (InitLock) - { - if (_initialized) - { - return; - } + NativeLibrary.SetDllImportResolver( + typeof(NativeLibraryLoader).Assembly, + Resolve); - NativeLibrary.SetDllImportResolver( - typeof(NativeLibraryLoader).Assembly, - Resolve); + return true; + }); - _initialized = true; - } + public static void EnsureInitialized() + { + _ = Initializer.Value; } private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) @@ -39,34 +28,34 @@ private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSe return IntPtr.Zero; } - bool trace = string.Equals( + var trace = string.Equals( Environment.GetEnvironmentVariable("HEADSETCONTROL_NATIVE_TRACE"), "1", StringComparison.Ordinal); - foreach (string candidate in EnumerateCandidatePaths()) + foreach (var candidate in EnumerateCandidatePaths()) { if (trace) { Console.Error.WriteLine($"[HeadsetControl.NET] probe: {candidate}"); } - if (NativeLibrary.TryLoad(candidate, out IntPtr handle)) + if (NativeLibrary.TryLoad(candidate, out var handle)) { return handle; } } - return NativeLibrary.TryLoad(libraryName, assembly, searchPath, out IntPtr fallback) + return NativeLibrary.TryLoad(libraryName, assembly, searchPath, out var fallback) ? fallback : IntPtr.Zero; } private static IEnumerable EnumerateCandidatePaths() { - string rid = GetRuntimeIdentifier(); - string fileName = GetNativeFileName(); + var rid = GetRuntimeIdentifier(); + var fileName = GetNativeFileName(); - string? baseDir = AppContext.BaseDirectory; + var baseDir = AppContext.BaseDirectory; if (!string.IsNullOrEmpty(baseDir)) { yield return Path.Combine(baseDir, "runtimes", rid, "native", fileName); @@ -75,7 +64,7 @@ private static IEnumerable EnumerateCandidatePaths() // Under `dotnet test` BaseDirectory points at the testhost, so also // probe the directory of this assembly itself. - string? assemblyDir = GetAssemblyDirectory(); + var assemblyDir = GetAssemblyDirectory(); if (!string.IsNullOrEmpty(assemblyDir) && !string.Equals(assemblyDir, baseDir, StringComparison.Ordinal)) { @@ -90,7 +79,7 @@ private static IEnumerable EnumerateCandidatePaths() Justification = "Empty Location is checked; the BaseDirectory probe covers single-file.")] private static string? GetAssemblyDirectory() { - string location = typeof(NativeLibraryLoader).Assembly.Location; + var location = typeof(NativeLibraryLoader).Assembly.Location; return string.IsNullOrEmpty(location) ? null : Path.GetDirectoryName(location); } @@ -114,7 +103,7 @@ private static string GetRuntimeIdentifier() osPart = "unknown"; } - string archPart = RuntimeInformation.ProcessArchitecture switch + var archPart = RuntimeInformation.ProcessArchitecture switch { Architecture.X64 => "x64", Architecture.Arm64 => "arm64", @@ -126,18 +115,8 @@ private static string GetRuntimeIdentifier() return $"{osPart}-{archPart}"; } - private static string GetNativeFileName() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "headsetcontrol.dll"; - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return "libheadsetcontrol.dylib"; - } - - return "libheadsetcontrol.so"; - } + private static string GetNativeFileName() => + OperatingSystem.IsWindows() ? "headsetcontrol.dll" : + OperatingSystem.IsMacOS() ? "libheadsetcontrol.dylib" : + "libheadsetcontrol.so"; } diff --git a/src/HeadsetControl.NET.Native/NativeMethods.cs b/src/HeadsetControl.NET.Native/NativeMethods.cs index 989ca18..610fed9 100644 --- a/src/HeadsetControl.NET.Native/NativeMethods.cs +++ b/src/HeadsetControl.NET.Native/NativeMethods.cs @@ -4,7 +4,7 @@ namespace HeadsetControl.NET.Native; // Const-char* returns are marshalled as IntPtr and decoded by NativeStringMarshaller // so the source generator doesn't free memory the native library still owns. -internal static partial class NativeMethods +static partial class NativeMethods { private const string LibName = NativeLibraryLoader.LibraryName; diff --git a/src/HeadsetControl.NET.Native/NativeStringMarshaller.cs b/src/HeadsetControl.NET.Native/NativeStringMarshaller.cs index e109503..4ba23a4 100644 --- a/src/HeadsetControl.NET.Native/NativeStringMarshaller.cs +++ b/src/HeadsetControl.NET.Native/NativeStringMarshaller.cs @@ -2,7 +2,7 @@ namespace HeadsetControl.NET.Native; -internal static class NativeStringMarshaller +static class NativeStringMarshaller { public static string? PtrToString(IntPtr ptr) => ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr); diff --git a/src/HeadsetControl.NET.Native/NativeStructs.cs b/src/HeadsetControl.NET.Native/NativeStructs.cs index bbfb2ea..295833e 100644 --- a/src/HeadsetControl.NET.Native/NativeStructs.cs +++ b/src/HeadsetControl.NET.Native/NativeStructs.cs @@ -3,7 +3,7 @@ namespace HeadsetControl.NET.Native; [StructLayout(LayoutKind.Sequential)] -internal struct HscBattery +struct HscBattery { public int LevelPercent; public HscBatteryStatus Status; @@ -13,7 +13,7 @@ internal struct HscBattery } [StructLayout(LayoutKind.Sequential)] -internal struct HscSidetone +struct HscSidetone { public byte CurrentLevel; public byte MinLevel; @@ -21,7 +21,7 @@ internal struct HscSidetone } [StructLayout(LayoutKind.Sequential)] -internal struct HscChatMix +struct HscChatMix { public int Level; public int GameVolumePercent; @@ -29,7 +29,7 @@ internal struct HscChatMix } [StructLayout(LayoutKind.Sequential)] -internal struct HscInactiveTime +struct HscInactiveTime { public byte Minutes; public byte MinMinutes; diff --git a/src/HeadsetControl.NET/Exceptions/HeadsetControlException.cs b/src/HeadsetControl.NET/Exceptions/HeadsetControlException.cs index bbb71ef..cebe726 100644 --- a/src/HeadsetControl.NET/Exceptions/HeadsetControlException.cs +++ b/src/HeadsetControl.NET/Exceptions/HeadsetControlException.cs @@ -1,4 +1,4 @@ -namespace HeadsetControl.NET; +namespace HeadsetControl.NET.Exceptions; public class HeadsetControlException : Exception { diff --git a/src/HeadsetControl.NET/Headset.cs b/src/HeadsetControl.NET/Headset.cs index 9a19ce9..b3ad4e6 100644 --- a/src/HeadsetControl.NET/Headset.cs +++ b/src/HeadsetControl.NET/Headset.cs @@ -31,19 +31,8 @@ internal Headset(HeadsetCollection owner, IntPtr handle) CapabilitiesBitmask = NativeMethods.GetCapabilities(handle); } - public IEnumerable SupportedCapabilities - { - get - { - foreach (HeadsetCapability cap in Enum.GetValues()) - { - if ((CapabilitiesBitmask & (1 << (int)cap)) != 0) - { - yield return cap; - } - } - } - } + public IEnumerable SupportedCapabilities => Enum.GetValues() + .Where(cap => (CapabilitiesBitmask & 1 << (int)cap) != 0); public bool Supports(HeadsetCapability capability) { @@ -54,10 +43,10 @@ public bool Supports(HeadsetCapability capability) public BatteryInfo GetBattery() { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.GetBattery(_handle, out HscBattery raw); + var result = NativeMethods.GetBattery(_handle, out var raw); ResultMapping.ThrowIfError(result, "GetBattery", HeadsetCapability.BatteryStatus); - BatteryStatus status = ResultMapping.MapBatteryStatus(raw.Status); + var status = ResultMapping.MapBatteryStatus(raw.Status); int? level = status == BatteryStatus.Available ? raw.LevelPercent : null; Voltage? voltage = raw.VoltageMv >= 0 ? new Voltage(raw.VoltageMv) : null; TimeSpan? timeToFull = raw.TimeToFullMin >= 0 ? TimeSpan.FromMinutes(raw.TimeToFullMin) : null; @@ -69,7 +58,7 @@ public BatteryInfo GetBattery() public ChatMixInfo GetChatMix() { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.GetChatMix(_handle, out HscChatMix raw); + var result = NativeMethods.GetChatMix(_handle, out var raw); ResultMapping.ThrowIfError(result, "GetChatMix", HeadsetCapability.ChatMixStatus); return new ChatMixInfo(raw.Level, raw.GameVolumePercent, raw.ChatVolumePercent); } @@ -83,7 +72,7 @@ public SidetoneResult SetSidetone(byte level) throw new ArgumentOutOfRangeException(nameof(level), level, "Sidetone level must be in 0..128."); } - HscResult result = NativeMethods.SetSidetone(_handle, level, out HscSidetone raw); + var result = NativeMethods.SetSidetone(_handle, level, out var raw); ResultMapping.ThrowIfError(result, "SetSidetone", HeadsetCapability.Sidetone); return new SidetoneResult(raw.CurrentLevel, raw.MinLevel, raw.MaxLevel); } @@ -91,14 +80,14 @@ public SidetoneResult SetSidetone(byte level) public void SetVolumeLimiter(bool enabled) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetVolumeLimiter(_handle, enabled); + var result = NativeMethods.SetVolumeLimiter(_handle, enabled); ResultMapping.ThrowIfError(result, "SetVolumeLimiter", HeadsetCapability.VolumeLimiter); } public void SetEqualizerPreset(byte preset) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetEqualizerPreset(_handle, preset); + var result = NativeMethods.SetEqualizerPreset(_handle, preset); ResultMapping.ThrowIfError(result, "SetEqualizerPreset", HeadsetCapability.EqualizerPreset); } @@ -114,7 +103,7 @@ public void SetEqualizer(ReadOnlySpan bands) { fixed (float* p = bands) { - HscResult result = NativeMethods.SetEqualizer(_handle, p, bands.Length); + var result = NativeMethods.SetEqualizer(_handle, p, bands.Length); ResultMapping.ThrowIfError(result, "SetEqualizer", HeadsetCapability.Equalizer); } } @@ -123,19 +112,19 @@ public void SetEqualizer(ReadOnlySpan bands) public IReadOnlyList GetEqualizerPresets() { ThrowIfHandleInvalid(); - int count = NativeMethods.GetEqualizerPresetsCount(_handle); + var count = NativeMethods.GetEqualizerPresetsCount(_handle); if (count <= 0) { return Array.Empty(); } var presets = new EqualizerPreset[count]; - for (int i = 0; i < count; i++) + for (var i = 0; i < count; i++) { - string name = NativeStringMarshaller.PtrToStringOrEmpty( + var name = NativeStringMarshaller.PtrToStringOrEmpty( NativeMethods.GetEqualizerPresetName(_handle, i)); - int bandCount = NativeMethods.GetEqualizerPresetBandCount(_handle, i); - float[] bands = bandCount > 0 ? new float[bandCount] : Array.Empty(); + var bandCount = NativeMethods.GetEqualizerPresetBandCount(_handle, i); + var bands = bandCount > 0 ? new float[bandCount] : Array.Empty(); if (bandCount > 0) { @@ -143,7 +132,7 @@ public IReadOnlyList GetEqualizerPresets() { fixed (float* p = bands) { - HscResult result = NativeMethods.GetEqualizerPresetBands(_handle, i, p, bandCount); + var result = NativeMethods.GetEqualizerPresetBands(_handle, i, p, bandCount); ResultMapping.ThrowIfError(result, "GetEqualizerPresets"); } } @@ -164,7 +153,7 @@ public void SetMicrophoneVolume(byte volume) throw new ArgumentOutOfRangeException(nameof(volume), volume, "Microphone volume must be in 0..128."); } - HscResult result = NativeMethods.SetMicVolume(_handle, volume); + var result = NativeMethods.SetMicVolume(_handle, volume); ResultMapping.ThrowIfError(result, "SetMicrophoneVolume", HeadsetCapability.MicrophoneVolume); } @@ -177,35 +166,35 @@ public void SetMicrophoneMuteLedBrightness(byte brightness) throw new ArgumentOutOfRangeException(nameof(brightness), brightness, "Brightness must be in 0..3."); } - HscResult result = NativeMethods.SetMicMuteLedBrightness(_handle, brightness); + var result = NativeMethods.SetMicMuteLedBrightness(_handle, brightness); ResultMapping.ThrowIfError(result, "SetMicrophoneMuteLedBrightness", HeadsetCapability.MicrophoneMuteLedBrightness); } public void SetRotateToMute(bool enabled) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetRotateToMute(_handle, enabled); + var result = NativeMethods.SetRotateToMute(_handle, enabled); ResultMapping.ThrowIfError(result, "SetRotateToMute", HeadsetCapability.RotateToMute); } public void SetLights(bool enabled) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetLights(_handle, enabled); + var result = NativeMethods.SetLights(_handle, enabled); ResultMapping.ThrowIfError(result, "SetLights", HeadsetCapability.Lights); } public void SetVoicePrompts(bool enabled) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetVoicePrompts(_handle, enabled); + var result = NativeMethods.SetVoicePrompts(_handle, enabled); ResultMapping.ThrowIfError(result, "SetVoicePrompts", HeadsetCapability.VoicePrompts); } public void PlayNotificationSound(byte soundId) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.PlayNotificationSound(_handle, soundId); + var result = NativeMethods.PlayNotificationSound(_handle, soundId); ResultMapping.ThrowIfError(result, "PlayNotificationSound", HeadsetCapability.NotificationSound); } @@ -213,7 +202,7 @@ public void PlayNotificationSound(byte soundId) public InactiveTimeResult SetInactiveTime(byte minutes) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetInactiveTime(_handle, minutes, out HscInactiveTime raw); + var result = NativeMethods.SetInactiveTime(_handle, minutes, out var raw); ResultMapping.ThrowIfError(result, "SetInactiveTime", HeadsetCapability.InactiveTime); return new InactiveTimeResult(raw.Minutes, raw.MinMinutes, raw.MaxMinutes); } @@ -221,14 +210,14 @@ public InactiveTimeResult SetInactiveTime(byte minutes) public void SetBluetoothWhenPoweredOn(bool enabled) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetBluetoothWhenPoweredOn(_handle, enabled); + var result = NativeMethods.SetBluetoothWhenPoweredOn(_handle, enabled); ResultMapping.ThrowIfError(result, "SetBluetoothWhenPoweredOn", HeadsetCapability.BluetoothWhenPoweredOn); } public void SetBluetoothCallVolume(byte volume) { ThrowIfHandleInvalid(); - HscResult result = NativeMethods.SetBluetoothCallVolume(_handle, volume); + var result = NativeMethods.SetBluetoothCallVolume(_handle, volume); ResultMapping.ThrowIfError(result, "SetBluetoothCallVolume", HeadsetCapability.BluetoothCallVolume); } diff --git a/src/HeadsetControl.NET/HeadsetCollection.cs b/src/HeadsetControl.NET/HeadsetCollection.cs index 430c215..1dbfec6 100644 --- a/src/HeadsetControl.NET/HeadsetCollection.cs +++ b/src/HeadsetControl.NET/HeadsetCollection.cs @@ -20,9 +20,9 @@ internal HeadsetCollection(IntPtr nativeArray, int count) _count = count; _headsets = new Headset[count]; - for (int i = 0; i < count; i++) + for (var i = 0; i < count; i++) { - IntPtr handle = Marshal.ReadIntPtr(nativeArray, i * IntPtr.Size); + var handle = Marshal.ReadIntPtr(nativeArray, i * IntPtr.Size); _headsets[i] = new Headset(this, handle); } } @@ -46,11 +46,13 @@ public void Dispose() IsDisposed = true; - if (_nativeArray != IntPtr.Zero) + if (_nativeArray == IntPtr.Zero) { - NativeMethods.FreeHeadsets(_nativeArray, _count); - _nativeArray = IntPtr.Zero; - _count = 0; + return; } + + NativeMethods.FreeHeadsets(_nativeArray, _count); + _nativeArray = IntPtr.Zero; + _count = 0; } } diff --git a/src/HeadsetControl.NET/HeadsetControlLibrary.cs b/src/HeadsetControl.NET/HeadsetControlLibrary.cs index 45d94e2..d390e4d 100644 --- a/src/HeadsetControl.NET/HeadsetControlLibrary.cs +++ b/src/HeadsetControl.NET/HeadsetControlLibrary.cs @@ -13,8 +13,7 @@ static HeadsetControlLibrary() NativeLibraryLoader.EnsureInitialized(); } - public static string Version - => NativeStringMarshaller.PtrToString(NativeMethods.Version()) ?? "0.0.0"; + public static string Version => NativeStringMarshaller.PtrToString(NativeMethods.Version()) ?? "0.0.0"; public static TimeSpan DeviceTimeout { @@ -26,8 +25,8 @@ public static TimeSpan DeviceTimeout throw new ArgumentOutOfRangeException(nameof(value), value, "Timeout must be non-negative."); } - double ms = value.TotalMilliseconds; - int clamped = ms >= int.MaxValue ? int.MaxValue : (int)ms; + var ms = value.TotalMilliseconds; + var clamped = ms >= int.MaxValue ? int.MaxValue : (int)ms; NativeMethods.SetDeviceTimeout(clamped); } } @@ -54,14 +53,14 @@ public static IReadOnlyList SupportedDeviceNames { get { - int count = NativeMethods.SupportedDeviceCount(); + var count = NativeMethods.SupportedDeviceCount(); if (count <= 0) { return Array.Empty(); } var names = new string[count]; - for (int i = 0; i < count; i++) + for (var i = 0; i < count; i++) { names[i] = NativeStringMarshaller.PtrToStringOrEmpty(NativeMethods.SupportedDeviceName(i)); } @@ -72,7 +71,7 @@ public static IReadOnlyList SupportedDeviceNames public static HeadsetCollection Discover() { - int count = NativeMethods.Discover(out IntPtr nativeArray); + var count = NativeMethods.Discover(out var nativeArray); if (count < 0) { diff --git a/src/HeadsetControl.NET/Internal/ResultMapping.cs b/src/HeadsetControl.NET/Internal/ResultMapping.cs index 97cdc93..3ee787b 100644 --- a/src/HeadsetControl.NET/Internal/ResultMapping.cs +++ b/src/HeadsetControl.NET/Internal/ResultMapping.cs @@ -1,8 +1,9 @@ +using HeadsetControl.NET.Exceptions; using HeadsetControl.NET.Native; namespace HeadsetControl.NET.Internal; -internal static class ResultMapping +static class ResultMapping { public static void ThrowIfError(HscResult result, string operation, HeadsetCapability? capability = null) { @@ -44,7 +45,7 @@ public static void ThrowIfError(HscResult result, string operation, HeadsetCapab // current pass-through behaviour. public static BatteryStatus MapBatteryStatus(HscBatteryStatus raw) { - int value = (int)raw; + var value = (int)raw; return value switch { -1 => BatteryStatus.Unavailable, diff --git a/tests/HeadsetControl.NET.Tests/DomainTypeTests.cs b/tests/HeadsetControl.NET.Tests/DomainTypeTests.cs index e4f36e8..2c6d5b4 100644 --- a/tests/HeadsetControl.NET.Tests/DomainTypeTests.cs +++ b/tests/HeadsetControl.NET.Tests/DomainTypeTests.cs @@ -1,3 +1,5 @@ +using HeadsetControl.NET.Exceptions; + namespace HeadsetControl.NET.Tests; public sealed class DomainTypeTests diff --git a/tests/HeadsetControl.NET.Tests/HeadsetControlLibraryTests.cs b/tests/HeadsetControl.NET.Tests/HeadsetControlLibraryTests.cs index f81139e..ff7bfe3 100644 --- a/tests/HeadsetControl.NET.Tests/HeadsetControlLibraryTests.cs +++ b/tests/HeadsetControl.NET.Tests/HeadsetControlLibraryTests.cs @@ -17,7 +17,7 @@ public void Version_IsNonEmpty() { Skip.IfNot(_fixture.IsNativeLibraryAvailable, _fixture.LoadError?.Message); - string version = HeadsetControlLibrary.Version; + var version = HeadsetControlLibrary.Version; Assert.False(string.IsNullOrWhiteSpace(version)); } @@ -26,7 +26,7 @@ public void DeviceTimeout_RoundTrips() { Skip.IfNot(_fixture.IsNativeLibraryAvailable, _fixture.LoadError?.Message); - TimeSpan original = HeadsetControlLibrary.DeviceTimeout; + var original = HeadsetControlLibrary.DeviceTimeout; try { HeadsetControlLibrary.DeviceTimeout = TimeSpan.FromMilliseconds(1234); @@ -52,7 +52,7 @@ public void SupportedDeviceNames_AreReadable() { Skip.IfNot(_fixture.IsNativeLibraryAvailable, _fixture.LoadError?.Message); - IReadOnlyList names = HeadsetControlLibrary.SupportedDeviceNames; + var names = HeadsetControlLibrary.SupportedDeviceNames; Assert.Equal(HeadsetControlLibrary.SupportedDeviceCount, names.Count); Assert.All(names, name => Assert.False(string.IsNullOrWhiteSpace(name))); } @@ -63,10 +63,10 @@ public void Discover_WithTestDevice_FindsTheTestHeadset() Skip.IfNot(_fixture.IsNativeLibraryAvailable, _fixture.LoadError?.Message); using var scope = new TestDeviceScope(); - using HeadsetCollection headsets = HeadsetControlLibrary.Discover(); + using var headsets = HeadsetControlLibrary.Discover(); Assert.NotEmpty(headsets); - Headset test = headsets.Single(h => h.VendorId == 0xF00B && h.ProductId == 0xA00C); + var test = headsets.Single(h => h.VendorId == 0xF00B && h.ProductId == 0xA00C); Assert.False(string.IsNullOrWhiteSpace(test.Name)); } @@ -76,8 +76,8 @@ public void Discover_DisposedCollection_DisablesHeadsets() Skip.IfNot(_fixture.IsNativeLibraryAvailable, _fixture.LoadError?.Message); using var scope = new TestDeviceScope(); - HeadsetCollection headsets = HeadsetControlLibrary.Discover(); - Headset test = headsets.First(h => h.VendorId == 0xF00B); + var headsets = HeadsetControlLibrary.Discover(); + var test = headsets.First(h => h.VendorId == 0xF00B); headsets.Dispose(); diff --git a/tests/HeadsetControl.NET.Tests/HeadsetTests.cs b/tests/HeadsetControl.NET.Tests/HeadsetTests.cs index 55d0f18..ccb9f9b 100644 --- a/tests/HeadsetControl.NET.Tests/HeadsetTests.cs +++ b/tests/HeadsetControl.NET.Tests/HeadsetTests.cs @@ -1,3 +1,4 @@ +using HeadsetControl.NET.Exceptions; using HeadsetControl.NET.Tests.Support; namespace HeadsetControl.NET.Tests; @@ -15,7 +16,7 @@ public HeadsetTests(NativeLibraryFixture fixture) private (HeadsetCollection collection, Headset test) OpenTestDevice() { var collection = HeadsetControlLibrary.Discover(); - Headset test = collection.First(h => h.VendorId == 0xF00B); + var test = collection.First(h => h.VendorId == 0xF00B); return (collection, test); } @@ -45,9 +46,9 @@ public void Headset_Supports_IsConsistentWithBitmask() var (collection, test) = OpenTestDevice(); using (collection) { - foreach (HeadsetCapability cap in Enum.GetValues()) + foreach (var cap in Enum.GetValues()) { - bool bit = (test.CapabilitiesBitmask & (1 << (int)cap)) != 0; + var bit = (test.CapabilitiesBitmask & (1 << (int)cap)) != 0; Assert.Equal(bit, test.Supports(cap)); } } @@ -104,7 +105,7 @@ public void GetBattery_ReturnsKnownStatus() Skip.IfNot(test.Supports(HeadsetCapability.BatteryStatus), "Test device build does not expose battery status."); - BatteryInfo battery = test.GetBattery(); + var battery = test.GetBattery(); Assert.True(Enum.IsDefined(battery.Status)); } } diff --git a/tests/HeadsetControl.NET.Tests/ResultMappingTests.cs b/tests/HeadsetControl.NET.Tests/ResultMappingTests.cs index f854734..10e1bb0 100644 --- a/tests/HeadsetControl.NET.Tests/ResultMappingTests.cs +++ b/tests/HeadsetControl.NET.Tests/ResultMappingTests.cs @@ -1,3 +1,4 @@ +using HeadsetControl.NET.Exceptions; using HeadsetControl.NET.Internal; using HeadsetControl.NET.Native; @@ -14,7 +15,7 @@ public void ThrowIfError_Ok_DoesNotThrow() [Fact] public void ThrowIfError_NotSupported_ThrowsFeatureNotSupported() { - FeatureNotSupportedException ex = Assert.Throws( + var ex = Assert.Throws( () => ResultMapping.ThrowIfError(HscResult.NotSupported, "op", HeadsetCapability.Sidetone)); Assert.Equal(HeadsetCapability.Sidetone, ex.Capability); @@ -24,7 +25,7 @@ public void ThrowIfError_NotSupported_ThrowsFeatureNotSupported() [Fact] public void ThrowIfError_NotSupported_WithoutCapability_StillThrows() { - FeatureNotSupportedException ex = Assert.Throws( + var ex = Assert.Throws( () => ResultMapping.ThrowIfError(HscResult.NotSupported, "op")); Assert.Null(ex.Capability); @@ -33,7 +34,7 @@ public void ThrowIfError_NotSupported_WithoutCapability_StillThrows() [Fact] public void ThrowIfError_DeviceOffline_ThrowsDeviceOffline() { - DeviceOfflineException ex = Assert.Throws( + var ex = Assert.Throws( () => ResultMapping.ThrowIfError(HscResult.DeviceOffline, "op")); Assert.Equal(HeadsetControlErrorCode.DeviceOffline, ex.ErrorCode); @@ -63,7 +64,7 @@ public void ThrowIfError_InvalidParam_ThrowsInvalidParameter() [Fact] public void ThrowIfError_GenericError_ThrowsBaseException() { - HeadsetControlException ex = Assert.Throws( + var ex = Assert.Throws( () => ResultMapping.ThrowIfError(HscResult.Error, "op")); Assert.Equal(HeadsetControlErrorCode.Error, ex.ErrorCode); diff --git a/tests/HeadsetControl.NET.Tests/Support/NativeLibraryFixture.cs b/tests/HeadsetControl.NET.Tests/Support/NativeLibraryFixture.cs index a136962..d6519a3 100644 --- a/tests/HeadsetControl.NET.Tests/Support/NativeLibraryFixture.cs +++ b/tests/HeadsetControl.NET.Tests/Support/NativeLibraryFixture.cs @@ -8,7 +8,7 @@ public NativeLibraryFixture() { try { - string version = HeadsetControlLibrary.Version; + var version = HeadsetControlLibrary.Version; IsNativeLibraryAvailable = !string.IsNullOrEmpty(version); Version = version; } diff --git a/tests/HeadsetControl.NET.Tests/Support/TestDeviceScope.cs b/tests/HeadsetControl.NET.Tests/Support/TestDeviceScope.cs index f9ac1de..b05d8fb 100644 --- a/tests/HeadsetControl.NET.Tests/Support/TestDeviceScope.cs +++ b/tests/HeadsetControl.NET.Tests/Support/TestDeviceScope.cs @@ -1,6 +1,11 @@ namespace HeadsetControl.NET.Tests.Support; -internal sealed class TestDeviceScope : IDisposable +/// +/// Enables the synthetic HeadsetControl Test device for the lifetime of the +/// scope and restores the previous state on disposal. Keeps tests from +/// leaking global library state into each other. +/// +sealed class TestDeviceScope : IDisposable { private readonly bool _previousEnabled; private readonly int _previousProfile;