From b062bcfb3034c89b4e5029d9f7a04595e351673c Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:45:02 +0200 Subject: [PATCH 1/8] chore: prepare native Core Audio branch From 636ffa2fc049af07a7b6fd9091ad3d4ce09b4059 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:04:06 +0200 Subject: [PATCH 2/8] chore: stage native Core Audio migration source --- tools/native-core-audio-migration-source.yml | 648 +++++++++++++++++++ 1 file changed, 648 insertions(+) create mode 100644 tools/native-core-audio-migration-source.yml diff --git a/tools/native-core-audio-migration-source.yml b/tools/native-core-audio-migration-source.yml new file mode 100644 index 0000000..3c038ce --- /dev/null +++ b/tools/native-core-audio-migration-source.yml @@ -0,0 +1,648 @@ +name: apply native Core Audio migration + +on: + push: + branches: + - agent/native-core-audio + +permissions: + contents: write + +jobs: + migrate: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/native-core-audio + fetch-depth: 0 + + - name: Apply migration + shell: python + run: | + from pathlib import Path + import re + + def read(path): + return Path(path).read_text(encoding='utf-8-sig') + + def write(path, text): + enc = 'utf-8-sig' if Path(path).suffix.lower() in ('.ps1', '.psm1') else 'utf-8' + Path(path).write_text(text, encoding=enc, newline='\n') + + def replace_once(text, old, new, path): + count = text.count(old) + if count != 1: + raise RuntimeError(f'{path}: expected one exact match, found {count}: {old[:100]!r}') + return text.replace(old, new, 1) + + def sub_once(text, pattern, repl, path, flags=0): + new, count = re.subn(pattern, repl, text, count=1, flags=flags) + if count != 1: + raise RuntimeError(f'{path}: expected one regex match, found {count}: {pattern[:120]!r}') + return new + + native_ps = r''' + function Initialize-CoreAudioBackend { + <# + .SYNOPSIS + Compile the in-process Windows Core Audio COM bridge once. + .DESCRIPTION + PowerShell 5.1 cannot reliably cast COM RCWs to custom ComImport + interfaces, so the COM calls live in embedded C#. Enumeration, + endpoint state and default-device reads use documented Core Audio + interfaces. Setting the default endpoint reuses the project's + existing IPolicyConfig interop for Console, Multimedia and + Communications roles. + #> + [CmdletBinding()] + param() + + if ('AutoSwitch.NativeAudio.CoreAudio' -as [type]) { + return + } + + Add-Type -TypeDefinition @' + using System; + using System.Collections.Generic; + using System.Runtime.InteropServices; + + namespace AutoSwitch.NativeAudio + { + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct PROPERTYKEY + { + public Guid fmtid; + public uint pid; + } + + [StructLayout(LayoutKind.Explicit)] + public struct PROPVARIANT + { + [FieldOffset(0)] public ushort vt; + [FieldOffset(8)] public IntPtr pointerVal; + [FieldOffset(8)] public uint ulVal; + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + public class MMDeviceEnumeratorComObject { } + + [ComImport, Guid("870af99c-171d-4f9e-af0d-e63df40c2bc9")] + public class CPolicyConfigVistaClient { } + + [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDeviceEnumerator + { + [PreserveSig] int EnumAudioEndpoints(int dataFlow, uint stateMask, out IMMDeviceCollection devices); + [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); + [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); + [PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client); + [PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client); + } + + [ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDeviceCollection + { + [PreserveSig] int GetCount(out uint count); + [PreserveSig] int Item(uint index, out IMMDevice device); + } + + [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDevice + { + [PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, out IntPtr instance); + [PreserveSig] int OpenPropertyStore(int accessMode, out IPropertyStore properties); + [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); + [PreserveSig] int GetState(out uint state); + } + + [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IPropertyStore + { + [PreserveSig] int GetCount(out uint count); + [PreserveSig] int GetAt(uint index, out PROPERTYKEY key); + [PreserveSig] int GetValue(ref PROPERTYKEY key, out PROPVARIANT value); + [PreserveSig] int SetValue(ref PROPERTYKEY key, ref PROPVARIANT value); + [PreserveSig] int Commit(); + } + + [ComImport, Guid("f8679f50-850a-41cf-9c72-430f290290c8"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IPolicyConfig + { + [PreserveSig] int GetMixFormat(string deviceName, out IntPtr format); + [PreserveSig] int GetDeviceFormat(string deviceName, bool defaultFormat, out IntPtr format); + [PreserveSig] int ResetDeviceFormat(string deviceName); + [PreserveSig] int SetDeviceFormat(string deviceName, IntPtr endpointFormat, IntPtr mixFormat); + [PreserveSig] int GetProcessingPeriod(string deviceName, bool defaultPeriod, out IntPtr defaultPeriodValue, out IntPtr minimumPeriodValue); + [PreserveSig] int SetProcessingPeriod(string deviceName, IntPtr period); + [PreserveSig] int GetShareMode(string deviceName, out IntPtr mode); + [PreserveSig] int SetShareMode(string deviceName, IntPtr mode); + [PreserveSig] int GetPropertyValue(string deviceName, bool fxStore, IntPtr key, IntPtr value); + [PreserveSig] int SetPropertyValue(string deviceName, bool fxStore, IntPtr key, IntPtr value); + [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string deviceName, int role); + [PreserveSig] int SetEndpointVisibility([MarshalAs(UnmanagedType.LPWStr)] string deviceName, bool visible); + } + + public sealed class EndpointInfo + { + public string Id { get; set; } + public string Name { get; set; } + public string DeviceName { get; set; } + public string FriendlyName { get; set; } + public uint State { get; set; } + public string StateName { get; set; } + } + + public sealed class DefaultEndpointIds + { + public string Console { get; set; } + public string Multimedia { get; set; } + public string Communications { get; set; } + } + + public static class CoreAudio + { + private const int E_RENDER = 0; + private const uint DEVICE_STATEMASK_ALL = 0x0000000F; + private const int STGM_READ = 0; + private const ushort VT_LPWSTR = 31; + + private static readonly Guid FMTID_DEVICE = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"); + private static readonly Guid FMTID_DEVICE_INTERFACE = new Guid("026e516e-b814-414b-83cd-856d6fef4822"); + + [DllImport("ole32.dll")] + private static extern int PropVariantClear(ref PROPVARIANT value); + + private static void ThrowIfFailed(int hr) + { + if (hr < 0) Marshal.ThrowExceptionForHR(hr); + } + + private static void Release(object value) + { + if (value != null && Marshal.IsComObject(value)) + { + try { Marshal.ReleaseComObject(value); } catch { } + } + } + + private static string ReadString(IPropertyStore store, Guid fmtid, uint pid) + { + PROPERTYKEY key = new PROPERTYKEY { fmtid = fmtid, pid = pid }; + PROPVARIANT value = new PROPVARIANT(); + int hr = store.GetValue(ref key, out value); + if (hr < 0) return null; + try + { + if (value.vt == VT_LPWSTR && value.pointerVal != IntPtr.Zero) + { + return Marshal.PtrToStringUni(value.pointerVal); + } + return null; + } + finally + { + PropVariantClear(ref value); + } + } + + private static string GetId(IMMDevice device) + { + string id; + ThrowIfFailed(device.GetId(out id)); + return id; + } + + private static string StateName(uint state) + { + switch (state) + { + case 0x00000001: return "Active"; + case 0x00000002: return "Disabled"; + case 0x00000004: return "NotPresent"; + case 0x00000008: return "Unplugged"; + default: return "Unknown"; + } + } + + public static EndpointInfo[] GetRenderEndpoints() + { + object enumeratorObject = null; + IMMDeviceEnumerator enumerator = null; + IMMDeviceCollection collection = null; + var result = new List(); + + try + { + enumeratorObject = new MMDeviceEnumeratorComObject(); + enumerator = (IMMDeviceEnumerator)enumeratorObject; + ThrowIfFailed(enumerator.EnumAudioEndpoints(E_RENDER, DEVICE_STATEMASK_ALL, out collection)); + + uint count; + ThrowIfFailed(collection.GetCount(out count)); + for (uint i = 0; i < count; i++) + { + IMMDevice device = null; + IPropertyStore store = null; + try + { + ThrowIfFailed(collection.Item(i, out device)); + string id = GetId(device); + uint state; + ThrowIfFailed(device.GetState(out state)); + ThrowIfFailed(device.OpenPropertyStore(STGM_READ, out store)); + + string name = ReadString(store, FMTID_DEVICE, 2); // PKEY_Device_DeviceDesc + string adapter = ReadString(store, FMTID_DEVICE_INTERFACE, 2); // PKEY_DeviceInterface_FriendlyName + string friendly = ReadString(store, FMTID_DEVICE, 14); // PKEY_Device_FriendlyName + + if (String.IsNullOrWhiteSpace(name)) name = friendly; + if (String.IsNullOrWhiteSpace(adapter)) adapter = friendly; + + result.Add(new EndpointInfo + { + Id = id, + Name = name, + DeviceName = adapter, + FriendlyName = friendly, + State = state, + StateName = StateName(state) + }); + } + finally + { + Release(store); + Release(device); + } + } + } + finally + { + Release(collection); + Release(enumerator); + Release(enumeratorObject); + } + + return result.ToArray(); + } + + public static string GetDefaultRenderEndpointId(int role) + { + object enumeratorObject = null; + IMMDeviceEnumerator enumerator = null; + IMMDevice device = null; + try + { + enumeratorObject = new MMDeviceEnumeratorComObject(); + enumerator = (IMMDeviceEnumerator)enumeratorObject; + ThrowIfFailed(enumerator.GetDefaultAudioEndpoint(E_RENDER, role, out device)); + return GetId(device); + } + finally + { + Release(device); + Release(enumerator); + Release(enumeratorObject); + } + } + + public static DefaultEndpointIds GetDefaultRenderEndpointIds() + { + return new DefaultEndpointIds + { + Console = GetDefaultRenderEndpointId(0), + Multimedia = GetDefaultRenderEndpointId(1), + Communications = GetDefaultRenderEndpointId(2) + }; + } + + private static void ValidateEndpoint(string deviceId) + { + object enumeratorObject = null; + IMMDeviceEnumerator enumerator = null; + IMMDevice device = null; + try + { + enumeratorObject = new MMDeviceEnumeratorComObject(); + enumerator = (IMMDeviceEnumerator)enumeratorObject; + ThrowIfFailed(enumerator.GetDevice(deviceId, out device)); + } + finally + { + Release(device); + Release(enumerator); + Release(enumeratorObject); + } + } + + public static void SetDefaultEndpointAllRoles(string deviceId) + { + if (String.IsNullOrWhiteSpace(deviceId)) + throw new ArgumentException("deviceId must not be empty", "deviceId"); + + ValidateEndpoint(deviceId); + + object policyObject = null; + IPolicyConfig policy = null; + try + { + policyObject = new CPolicyConfigVistaClient(); + policy = (IPolicyConfig)policyObject; + ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 0)); + ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 1)); + ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 2)); + } + finally + { + Release(policy); + Release(policyObject); + } + } + } + } + '@ -ErrorAction Stop + } + + function Get-CoreAudioRenderDevices { + [CmdletBinding()] + param() + + Initialize-CoreAudioBackend + $defaultId = $null + try { $defaultId = [AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointId(0) } catch { } + + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($item in @([AutoSwitch.NativeAudio.CoreAudio]::GetRenderEndpoints())) { + $isDefault = $false + if ($defaultId) { $isDefault = $item.Id -ieq $defaultId } + $rows.Add([pscustomobject][ordered]@{ + 'Name' = [string]$item.Name + 'Type' = 'Device' + 'Direction' = 'Render' + 'Device Name' = [string]$item.DeviceName + 'Friendly Name' = [string]$item.FriendlyName + 'Device State' = [string]$item.StateName + 'Item ID' = [string]$item.Id + 'Default' = $(if ($isDefault) { 'Render' } else { '' }) + }) + } + return $rows.ToArray() + } + + function Get-CoreAudioDefaultRenderDeviceId { + [CmdletBinding()] + param() + + Initialize-CoreAudioBackend + return [string][AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointId(0) + } + + function Get-CoreAudioDefaultRenderDeviceIds { + [CmdletBinding()] + param() + + Initialize-CoreAudioBackend + $ids = [AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointIds() + return [pscustomobject]@{ + Console = [string]$ids.Console + Multimedia = [string]$ids.Multimedia + Communications = [string]$ids.Communications + } + } + + function Test-CoreAudioDefaultRenderDevice { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$DeviceId) + + try { + $ids = Get-CoreAudioDefaultRenderDeviceIds + return ($ids.Console -ieq $DeviceId -and + $ids.Multimedia -ieq $DeviceId -and + $ids.Communications -ieq $DeviceId) + } + catch { + return $false + } + } + + function Set-CoreAudioDefaultRenderDevice { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$DeviceId) + + Initialize-CoreAudioBackend + [AutoSwitch.NativeAudio.CoreAudio]::SetDefaultEndpointAllRoles($DeviceId) + } + + ''' + + # lib/AutoSwitchCore.psm1 + path = 'lib/AutoSwitchCore.psm1' + text = read(path) + text = text.replace('No G HUB or svcl.exe dependency is required for Pester tests.', + 'No G HUB or third-party audio utility is required for Pester tests.') + text = replace_once(text, 'function Get-ConfigDetectionMode {', native_ps + '\nfunction Get-ConfigDetectionMode {', path) + old_export = 'Export-ModuleMember -Function Get-RenderItemIdFromText, Resolve-HeadsetState, Test-ValidAudioConfig, New-GHubTimeoutToken, ConvertFrom-SvclCsv, ConvertFrom-CsvLine, Get-CsvColumn, Resolve-EndpointState, Resolve-DetectedState, Test-SvclExportValid, Get-SvclRenderDevice, Get-SvclDeviceLabel, Find-SvclRenderDeviceByIdentity, Get-EndpointFxState, Get-ConfigDetectionMode' + new_export = old_export + ', Initialize-CoreAudioBackend, Get-CoreAudioRenderDevices, Get-CoreAudioDefaultRenderDeviceId, Get-CoreAudioDefaultRenderDeviceIds, Test-CoreAudioDefaultRenderDevice, Set-CoreAudioDefaultRenderDevice' + text = replace_once(text, old_export, new_export, path) + write(path, text) + + # Runtime-PROX2-AutoSwitch.ps1 + path = 'Runtime-PROX2-AutoSwitch.ps1' + text = read(path) + text = text.replace('$SvclPath = Join-Path $InstallDir "svcl.exe"\n', '') + text = text.replace('if (-not (Test-Path $SvclPath)) { exit 11 }\n', '') + text = text.replace('# Logica compartida (extraccion de Item ID, debounce, CSV, estados, config).', + '# Shared logic (Core Audio interop, debounce, endpoint identity, config).') + text = sub_once(text, + r'function Get-DefaultRenderItemId \{.*?\n\}\n\nfunction Set-AudioOutput \{.*?\n\}\n\n# --- Windows endpoint state', + '''function Get-DefaultRenderItemId {\n return Get-CoreAudioDefaultRenderDeviceId\n}\n\nfunction Set-AudioOutput {\n param(\n [Parameter(Mandatory=$true)][string]$DeviceId,\n [Parameter(Mandatory=$true)][string]$Label\n )\n\n $current = $null\n try { $current = Get-DefaultRenderItemId } catch { }\n\n if ($current -and ($current -ieq $DeviceId) -and\n (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId)) {\n return\n }\n\n Set-CoreAudioDefaultRenderDevice -DeviceId $DeviceId\n Start-Sleep -Milliseconds 350\n\n if (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId) {\n Write-AutoSwitchLog "Output changed -> $Label"\n return\n }\n\n # Retry once in case Windows was recreating the endpoint.\n Start-Sleep -Milliseconds 500\n Set-CoreAudioDefaultRenderDevice -DeviceId $DeviceId\n Start-Sleep -Milliseconds 350\n\n if (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId) {\n Write-AutoSwitchLog "Output changed -> $Label (second attempt)"\n return\n }\n\n $roles = $null\n try { $roles = Get-CoreAudioDefaultRenderDeviceIds } catch { }\n throw "Core Audio could not set '$Label' for all roles. Expected=$DeviceId Actual=$($roles | ConvertTo-Json -Compress)"\n}\n\n# --- Windows endpoint state''', + path, flags=re.S) + text = sub_once(text, + r'function Get-SvclCsvExport \{.*?\n\}\n\nfunction Get-HeadsetEndpointState \{.*?\n\}\n\n# --- Tray icon', + '''function Get-HeadsetEndpointState {\n <#\n .SYNOPSIS\n Returns 'Connected' / 'Disconnected' / 'Unknown' for the headset endpoint.\n .DESCRIPTION\n - Successful Core Audio enumeration + matching Item ID -> normalized state.\n - Successful enumeration + missing endpoint -> Disconnected.\n - Core Audio failure -> Unknown (do nothing).\n #>\n try {\n $rows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n return 'Unknown'\n }\n\n $row = $rows |\n Where-Object {\n $id = Get-CsvColumn -Row $_ -Names @('Item ID')\n $null -ne $id -and $id.Trim().ToLowerInvariant() -eq [string]$Config.HeadsetId\n } |\n Select-Object -First 1\n\n if (-not $row) {\n return 'Disconnected'\n }\n\n $state = Get-CsvColumn -Row $row -Names @('Device State', 'State')\n if ($null -eq $state) {\n return 'Unknown'\n }\n\n return Resolve-EndpointState -State $state\n}\n\n# --- Tray icon''', + path, flags=re.S) + text = sub_once(text, + r'function Get-RenderDevicesFromCsv \{.*?\n\}', + '''function Get-RenderDevices {\n try { return @(Get-CoreAudioRenderDevices) }\n catch { return @() }\n}''', path, flags=re.S) + text = text.replace('$devices = Get-RenderDevicesFromCsv', '$devices = Get-RenderDevices') + text = sub_once(text, + r' \$csv = Get-SvclCsvExport\n if \(-not \(Test-SvclExportValid -CsvText \$csv\)\) \{.*?\n \}\n\n \$rows = @\(ConvertFrom-SvclCsv -Text \$csv\)', + ''' try {\n $rows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: Core Audio enumeration failed for {0}: {1}" -f $ItemId, $_.Exception.Message) }\n return 'Unknown'\n }''', path, flags=re.S) + text = text.replace('Bluetooth headsets can disappear from the svcl export while off and', + 'Bluetooth headsets can disappear from the Core Audio endpoint list while off and') + text = text.replace('stable across reconnects). Returns Connected/Disconnected/Unknown.', + 'stable across reconnects). Returns Connected/Disconnected/Unknown.') + text = text.replace('What is in the export? Render devices with their states and IDs.', + 'What is currently in Core Audio? Render devices with their states and IDs.') + text = text.replace('in the export. Available row(s):', 'in Core Audio. Available endpoint(s):') + text = text.replace('Could not read the Windows output devices. Is svcl.exe present and the audio system OK?', + 'Could not read the Windows output devices through Core Audio.') + text = text.replace('# The polling (svcl/G HUB, Set-AudioOutput) runs in a PowerShell process', + '# The polling (Core Audio/G HUB, Set-AudioOutput) runs in a PowerShell process') + text = text.replace('(for example a slow /SetDefault or G HUB timeout)', + '(for example a slow Core Audio switch or G HUB timeout)') + write(path, text) + + # Instalar-PROX2-AutoSwitch.ps1 + path = 'Instalar-PROX2-AutoSwitch.ps1' + text = read(path) + text = text.replace('# PowerShell 5.1 on old .NET can negotiate TLS 1.0/1.1 and fail against\n# GitHub/NirSoft. Force TLS 1.2.\n[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n\n', '') + text = text.replace('$SvclPath = Join-Path $InstallDir "svcl.exe"\n', '') + text = sub_once(text, r'\n\$SvclUrl = .*?\n\$ZipPath = .*?\n', '\n', path, flags=re.S) + text = sub_once(text, + r'# SoundVolumeCommandLine: skip the download if svcl\.exe is already installed\..*?# Copy the source version of the runtime and utilities\.', + '''# Native Core Audio backend: no third-party audio executable is downloaded.\nWrite-Host "[1/6] Checking native Windows Core Audio..." -ForegroundColor Yellow\ntry {\n $nativeDevices = @(Get-CoreAudioRenderDevices)\n if ($nativeDevices.Count -eq 0) {\n throw "Windows returned no render endpoints."\n }\n [void](Get-CoreAudioDefaultRenderDeviceId)\n}\ncatch {\n throw "Native Windows Core Audio is unavailable: $($_.Exception.Message)"\n}\nWrite-Host " Core Audio OK ($($nativeDevices.Count) render endpoint(s))." -ForegroundColor Green\n\n# Remove a stale dependency left by installations older than the native backend.\n$legacySvclPath = Join-Path $InstallDir "svcl.exe"\nif (Test-Path $legacySvclPath) {\n Remove-Item $legacySvclPath -Force -ErrorAction SilentlyContinue\n}\n\n# Copy the source version of the runtime and utilities.''', + path, flags=re.S) + text = text.replace('[3/7]', '[2/6]').replace('[4/7]', '[3/6]').replace('[5/7]', '[4/6]').replace('[6/7]', '[5/6]').replace('[7/7]', '[6/6]') + text = sub_once(text, + r'function Get-DefaultColumn \{.*?\n\}\n\nfunction Get-DefaultRenderItemId \{.*?\n\}', + '''function Get-DefaultRenderItemId {\n return Get-CoreAudioDefaultRenderDeviceId\n}''', path, flags=re.S) + text = sub_once(text, + r'function Test-SetDefault \{.*?\n\}\n\ntry \{', + '''function Test-SetDefault {\n param(\n [Parameter(Mandatory=$true)][string]$Id,\n [Parameter(Mandatory=$true)][string]$Label\n )\n\n Write-Host ""\n Write-Host "Testing real switch -> $Label" -ForegroundColor Yellow\n\n try {\n Set-CoreAudioDefaultRenderDevice -DeviceId $Id\n }\n catch {\n Write-Host (" TEST FAILED. Core Audio error: {0}" -f $_.Exception.Message) -ForegroundColor Red\n return $false\n }\n\n Start-Sleep -Milliseconds 800\n if (Test-CoreAudioDefaultRenderDevice -DeviceId $Id) {\n Write-Host " TEST OK (Console/Multimedia/Communications)" -ForegroundColor Green\n return $true\n }\n\n $actual = $null\n try { $actual = Get-CoreAudioDefaultRenderDeviceIds } catch { }\n Write-Host (" TEST FAILED. Actual roles: {0}" -f ($actual | ConvertTo-Json -Compress)) -ForegroundColor Red\n return $false\n}\n\ntry {''', path, flags=re.S) + text = sub_once(text, + r' \$csvText = \(& \$SvclPath /scomma "" 2>&1 \| Out-String\)\.Trim\(\)\n if \(\[string\]::IsNullOrWhiteSpace\(\$csvText\)\) \{.*?\n \}\n\n \$renderRows = @\(Get-SvclRenderDevice -CsvText \$csvText\)', + ''' try {\n $renderRows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n throw "Could not read the Windows audio device list through Core Audio: $($_.Exception.Message)"\n }''', path, flags=re.S) + text = sub_once(text, + r' \$txt = \(& \$SvclPath /scomma "" 2>&1 \| Out-String\)\.Trim\(\)\n if \(-not \(Test-SvclExportValid -CsvText \$txt\)\) \{\n return \[pscustomobject\]@\{ State = \'Unknown\'; FoundId = \$null \}\n \}\n\n \$rows = @\(ConvertFrom-SvclCsv -Text \$txt\)', + ''' try {\n $rows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n return [pscustomobject]@{ State = 'Unknown'; FoundId = $null }\n }''', path) + text = text.replace('Resolve the same Render endpoint by its real svcl identity rather than', + 'Resolve the same Render endpoint by its native Core Audio identity rather than') + write(path, text) + + # Verificar-PROX2-AutoSwitch.ps1 + path = 'Verificar-PROX2-AutoSwitch.ps1' + text = read(path) + text = text.replace('$SvclPath = Join-Path $InstallDir "svcl.exe"\n', '') + text = text.replace('Show-Test "svcl.exe" (Test-Path $SvclPath) $SvclPath\n', '') + text = text.replace('# Module functions (Get-ConfigDetectionMode, ConvertFrom-SvclCsv, Get-EndpointFxState).', + '# Module functions (Core Audio backend, detection mode and enhancements).') + marker = '''if (Test-Path $ModulePath) {\n Import-Module $ModulePath -ErrorAction SilentlyContinue\n}\n''' + insert = marker + '''\n$coreAudioOk = $false\n$coreAudioDetail = 'Module unavailable'\nif (Test-Path $ModulePath) {\n try {\n $render = @(Get-CoreAudioRenderDevices)\n $defaultId = Get-CoreAudioDefaultRenderDeviceId\n $coreAudioOk = ($render.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($defaultId))\n $coreAudioDetail = "$($render.Count) render endpoint(s); default=$defaultId"\n }\n catch {\n $coreAudioDetail = $_.Exception.Message\n }\n}\nShow-Test "Native Windows Core Audio" $coreAudioOk $coreAudioDetail\n''' + text = replace_once(text, marker, insert, path) + text = sub_once(text, + r' \$csv = \(& \$SvclPath /scomma "" 2>&1 \| Out-String\)\.Trim\(\)\n \$rows = @\(ConvertFrom-SvclCsv -Text \$csv\)', + ' $rows = @(Get-CoreAudioRenderDevices)', path) + write(path, text) + + # README.md + path = 'README.md' + text = read(path) + text = text.replace('SoundVolumeCommandLine /SetDefault all', 'Windows Core Audio / IPolicyConfig → all default roles') + text = text.replace('A transient `svcl.exe` or G HUB failure therefore cannot send audio to the wrong device on a single bad read.', + 'A transient Core Audio or G HUB failure therefore cannot send audio to the wrong device on a single bad read.') + text = text.replace('- Internet access during installation so `svcl.exe` can be downloaded and hash-verified.\n', '') + text = text.replace('- downloads SoundVolumeCommandLine from NirSoft only when needed and verifies its SHA-256 before execution;', + '- uses the Windows Core Audio APIs in-process, so no third-party audio-control executable is downloaded;') + text = sub_once(text, + r'\n## Important `svcl\.exe` regression guard\n.*?\n## Security\n', + '\n## Native Windows audio backend\n\nEndpoint enumeration, state reads and default-device verification now use Windows Core Audio directly in-process. The project keeps its existing PowerShell structure and embedded C# COM bridge; no separate audio-control executable is downloaded. Setting the default endpoint uses the same `IPolicyConfig` COM interop family already used by the project for Audio Enhancements, and every switch is verified across Console, Multimedia and Communications roles with one bounded retry.\n\n## Security\n', + path, flags=re.S) + text = text.replace('- The installer verifies the SHA-256 of the NirSoft download before running it.\n', + '- Audio endpoint enumeration and switching run in-process; installation no longer downloads a third-party audio-control binary.\n') + write(path, text) + + # AGENT.md + path = 'AGENT.md' + text = read(path) + text = text.replace(' `svcl.exe /scomma` export and map it:', ' native Core Audio endpoint list and map it:') + text = text.replace('the two real `svcl`\n identity columns (`Device Name` + `Name`)', + 'the two native endpoint identity properties\n (`PKEY_DeviceInterface_FriendlyName` + `PKEY_Device_DeviceDesc`)') + text = sub_once(text, + r'3\. To set the output:.*?6\. Unknown state \(svcl failure, `Disabled`, garbage\):', + '''3. To set the output, use the in-process Core Audio bridge in `lib/AutoSwitchCore.psm1`.\n `Set-CoreAudioDefaultRenderDevice` applies the target to Console, Multimedia and Communications.\n\n4. To read the current defaults, use `Get-CoreAudioDefaultRenderDeviceIds`.\n Do not infer success from the setter alone.\n\n5. Verify every switch:\n - set all three roles;\n - re-read Console, Multimedia and Communications;\n - require every role to match the target;\n - allow a single short retry.\n\n6. Unknown state (Core Audio failure, `Disabled`, unmapped state):''', + path, flags=re.S) + text = text.replace('Do NOT run svcl/G HUB/Set-AudioOutput on the UI thread.', + 'Do NOT run Core Audio/G HUB/Set-AudioOutput on the UI thread.') + text = text.replace(' Do **NOT** use `svcl /SetBooleanFxProperty` for this (individual effects only, not the global\n "Disable audio enhancements" switch).\n', '') + text = text.replace(' `Add-Type` and expose a static method (`AutoSwitch.AudioEnhancements.SetSysFx`,\n `AutoSwitch.EndpointFx.ReadSysFx`).', + ' `Add-Type` and expose static methods for endpoint enumeration/default switching and enhancements.') + text = sub_once(text, + r'\n## SoundVolumeCommandLine\n.*?\n## Required tests after any change\n', + '''\n## Native Core Audio backend\n\n- `IMMDeviceEnumerator` / `IMMDevice` enumerate render endpoints, states and endpoint IDs.\n- `IPropertyStore` reads `PKEY_Device_DeviceDesc`, `PKEY_DeviceInterface_FriendlyName` and `PKEY_Device_FriendlyName`.\n- `IPolicyConfig::SetDefaultEndpoint` is used for Console, Multimedia and Communications, then all three roles are re-read and verified.\n- `IPolicyConfig` is not a documented public Windows API. This project already depended on the same COM family for Audio Enhancements; keep the boundary isolated in embedded C# and fail safely on HRESULT errors.\n- Clean install no longer downloads or hashes a third-party audio-control executable.\n\n## Required tests after any change\n''', + path, flags=re.S) + text = text.replace('- NirSoft hash differs: abort install.\n', '') + text = text.replace('- svcl `State` read fails or is `Disabled`/garbage: `Unknown`, do not switch.', + '- Core Audio enumeration/state read fails or is `Disabled`/unmapped: `Unknown`, do not switch.') + write(path, text) + + # SOURCES.md: add authoritative Windows Core Audio sources and remove NirSoft section if present. + path = 'SOURCES.md' + text = read(path) + text = re.sub(r'\n## .*?(?:SoundVolumeCommandLine|NirSoft).*?(?=\n## |\Z)', '\n', text, flags=re.S | re.I) + if 'IMMDeviceEnumerator::EnumAudioEndpoints' not in text: + text += '''\n\n## Windows Core Audio endpoint APIs\n\n- Microsoft Learn — `IMMDeviceEnumerator::EnumAudioEndpoints`: documents render/capture endpoint enumeration and device-state masks.\n https://learn.microsoft.com/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-immdeviceenumerator-enumaudioendpoints\n- Microsoft Learn — Core Audio device properties: documents `PKEY_DeviceInterface_FriendlyName`, `PKEY_Device_DeviceDesc`, `PKEY_Device_FriendlyName`, endpoint IDs and container IDs.\n https://learn.microsoft.com/windows/win32/coreaudio/device-properties\n- Microsoft Learn — `IMMDeviceEnumerator::GetDefaultAudioEndpoint`: documents reading the current default endpoint by role.\n https://learn.microsoft.com/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-immdeviceenumerator-getdefaultaudioendpoint\n\n`IPolicyConfig::SetDefaultEndpoint` is an undocumented Windows COM interface. It is intentionally isolated inside the embedded C# bridge and is treated as a compatibility risk, not as a supported Microsoft API.\n''' + write(path, text) + + # CHANGELOG.md + path = 'CHANGELOG.md' + text = read(path) + note = '''\n### Changed\n\n- Replaced the downloaded SoundVolumeCommandLine dependency with an in-process Windows Core Audio COM backend for endpoint enumeration, state reads, default-device reads and output switching.\n- Default-output changes are now verified across Console, Multimedia and Communications roles before being accepted.\n- Clean installs remove a stale legacy `svcl.exe` when present and no longer require a third-party audio-control download.\n''' + if 'in-process Windows Core Audio COM backend' not in text: + if '## [Unreleased]' in text: + text = text.replace('## [Unreleased]', '## [Unreleased]' + note, 1) + else: + text = '# Changelog\n\n## [Unreleased]' + note + '\n' + text + write(path, text) + + # tests/AutoSwitchCore.Tests.ps1 + path = 'tests/AutoSwitchCore.Tests.ps1' + text = read(path) + if "Describe 'Native Core Audio bridge'" not in text: + text += r'''\n\nDescribe 'Native Core Audio bridge' {\n It 'exports the native Core Audio commands' {\n (Get-Command Initialize-CoreAudioBackend -ErrorAction Stop).Name | Should -Be 'Initialize-CoreAudioBackend'\n (Get-Command Get-CoreAudioRenderDevices -ErrorAction Stop).Name | Should -Be 'Get-CoreAudioRenderDevices'\n (Get-Command Get-CoreAudioDefaultRenderDeviceId -ErrorAction Stop).Name | Should -Be 'Get-CoreAudioDefaultRenderDeviceId'\n (Get-Command Set-CoreAudioDefaultRenderDevice -ErrorAction Stop).Name | Should -Be 'Set-CoreAudioDefaultRenderDevice'\n }\n\n It 'compiles the embedded COM bridge without touching hardware' {\n { Initialize-CoreAudioBackend } | Should -Not -Throw\n ('AutoSwitch.NativeAudio.CoreAudio' -as [type]) | Should -Not -BeNullOrEmpty\n }\n}\n'''.replace('\\n', '\n') + write(path, text) + + # One-shot workflow: remove itself from the final branch diff. + Path('.github/workflows/apply-native-core-audio.yml').unlink() + + - name: Validate PowerShell syntax + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $scripts = Get-ChildItem -Path . -Include '*.ps1','*.psm1' -File -Recurse + foreach ($script in $scripts) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error "$($script.FullName): $($_.Message) (line $($_.Extent.StartLineNumber))" } + exit 1 + } + } + + - name: Run Pester tests + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if (-not (Get-Module -ListAvailable -Name Pester)) { + Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck + } + $config = New-PesterConfiguration + $config.Run.Path = 'tests' + $config.Output.Verbosity = 'Detailed' + $result = Invoke-Pester -Configuration $config + if ($result.FailedCount -gt 0) { exit 1 } + + - name: Ensure external svcl dependency is gone from active code/docs + shell: pwsh + run: | + $hits = git grep -n -i -E 'svcl\.exe|SoundVolumeCommandLine|NirSoft' -- ':!CHANGELOG.md' ':!docs/WindowsEndpointProvider.md' ':!wiki/*' ':!site/*' 2>$null + if ($hits) { + $hits | Write-Host + throw 'Active code or canonical docs still reference the removed svcl dependency.' + } + + - name: Commit migration + shell: pwsh + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + if (git diff --cached --quiet) { exit 0 } + git commit -m 'feat: replace svcl with native Core Audio' + git push origin HEAD:agent/native-core-audio From 7d9410eaeb6e27b8f30eb018532e9ab036c5422a Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:05:08 +0200 Subject: [PATCH 3/8] chore: validate native Core Audio migration on Windows --- .../workflows/finalize-native-core-audio.yml | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/finalize-native-core-audio.yml diff --git a/.github/workflows/finalize-native-core-audio.yml b/.github/workflows/finalize-native-core-audio.yml new file mode 100644 index 0000000..0db30cc --- /dev/null +++ b/.github/workflows/finalize-native-core-audio.yml @@ -0,0 +1,98 @@ +name: finalize native Core Audio migration + +on: + push: + branches: + - agent/native-core-audio-pr + +permissions: + contents: write + +jobs: + migrate: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/native-core-audio-pr + fetch-depth: 0 + + - name: Apply migration source + shell: python + run: | + from pathlib import Path + + source_path = Path('tools/native-core-audio-migration-source.yml') + lines = source_path.read_text(encoding='utf-8').splitlines() + apply_index = next(i for i, line in enumerate(lines) if line.strip() == '- name: Apply migration') + run_index = next(i for i in range(apply_index, len(lines)) if lines[i].strip() == 'run: |') + end_index = next(i for i in range(run_index + 1, len(lines)) if lines[i].strip() == '- name: Validate PowerShell syntax') + body = [] + for line in lines[run_index + 1:end_index]: + body.append(line[10:] if line.startswith(' ') else line) + script = '\n'.join(body) + script = script.replace("Path('.github/workflows/apply-native-core-audio.yml').unlink()", "pass") + exec(compile(script, '', 'exec'), {}) + + module_path = Path('lib/AutoSwitchCore.psm1') + module = module_path.read_text(encoding='utf-8-sig') + module = module.replace('Extract a valid render Item ID from svcl.exe output.', 'Extract a valid render Item ID from legacy command output.') + module = module.replace('Parse svcl.exe /scomma output into objects.', 'Parse legacy /scomma output into objects.') + module = module.replace('Filter svcl.exe /scomma export to real render output endpoints', 'Filter a legacy /scomma export to real render output endpoints') + module_path.write_text(module, encoding='utf-8-sig', newline='\n') + + security_path = Path('SECURITY.md') + security = security_path.read_text(encoding='utf-8-sig') + old = "### NirSoft SoundVolumeCommandLine\n\nThe installer downloads `svcl-x64.zip` from NirSoft and verifies its pinned SHA-256 before extracting/running it. If NirSoft publishes a new build and the hash changes, installation must fail safely until the value is independently verified from the official hashes page.\n\n**Never disable the checksum check to make installation succeed.**" + new = "### Windows audio COM boundary\n\nEndpoint enumeration and state reads use documented Windows Core Audio interfaces in-process. Changing the system default endpoint uses the undocumented `IPolicyConfig` COM interface, isolated inside the embedded C# bridge. Treat HRESULT failures as unknown state, verify every role after a switch, and never guess a target device." + if old in security: + security = security.replace(old, new, 1) + security_path.write_text(security, encoding='utf-8', newline='\n') + + - name: Validate PowerShell syntax + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $scripts = Get-ChildItem -Path . -Include '*.ps1','*.psm1' -File -Recurse + foreach ($script in $scripts) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error "$($script.FullName): $($_.Message) (line $($_.Extent.StartLineNumber))" } + exit 1 + } + } + + - name: Run Pester tests + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if (-not (Get-Module -ListAvailable -Name Pester)) { + Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck + } + $config = New-PesterConfiguration + $config.Run.Path = 'tests' + $config.Output.Verbosity = 'Detailed' + $result = Invoke-Pester -Configuration $config + if ($result.FailedCount -gt 0) { exit 1 } + + - name: Check active dependency references + shell: pwsh + run: | + $hits = git grep -n -i -E 'svcl\.exe|SoundVolumeCommandLine|NirSoft' -- ':!CHANGELOG.md' ':!docs/WindowsEndpointProvider.md' ':!wiki/*' ':!site/*' ':!.github/workflows/*' ':!tools/*' 2>$null + $hits = @($hits | Where-Object { $_ -notmatch 'legacySvclPath' }) + if ($hits.Count -gt 0) { + $hits | Write-Host + throw 'Active code or canonical docs still reference the removed external dependency.' + } + + - name: Commit application and documentation changes + shell: pwsh + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add AGENT.md CHANGELOG.md README.md SECURITY.md SOURCES.md Instalar-PROX2-AutoSwitch.ps1 Runtime-PROX2-AutoSwitch.ps1 Verificar-PROX2-AutoSwitch.ps1 lib/AutoSwitchCore.psm1 tests/AutoSwitchCore.Tests.ps1 + if (git diff --cached --quiet) { exit 0 } + git commit -m 'feat: replace svcl with native Core Audio' + git push origin HEAD:agent/native-core-audio-pr From 2238caef0bc07b72e9827ae4646c5b21509292a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:05:31 +0000 Subject: [PATCH 4/8] feat: replace svcl with native Core Audio --- AGENT.md | 70 ++---- CHANGELOG.md | 6 + Instalar-PROX2-AutoSwitch.ps1 | 124 ++++------ README.md | 25 +- Runtime-PROX2-AutoSwitch.ps1 | 86 +++---- SECURITY.md | 6 +- SOURCES.md | 49 +--- Verificar-PROX2-AutoSwitch.ps1 | 24 +- lib/AutoSwitchCore.psm1 | 407 ++++++++++++++++++++++++++++++++- tests/AutoSwitchCore.Tests.ps1 | 15 ++ 10 files changed, 558 insertions(+), 254 deletions(-) diff --git a/AGENT.md b/AGENT.md index bdc4daf..359803f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -16,7 +16,7 @@ The LIGHTSPEED receiver stays connected, so you **cannot** use the presence of t ## Detection modes (config `DetectionMode`) - `WindowsEndpoint` (default for new installs): read the headset endpoint `State` from the - `svcl.exe /scomma` export and map it: + native Core Audio endpoint list and map it: - `Active` → `Connected` → headset. - `Unplugged` / `NotPresent` / row absent → `Disconnected` → fallback. - `Disabled` / `Error` / unparseable → `Unknown` → **never switch**. @@ -51,29 +51,23 @@ hardened to tolerate a recreated endpoint whose `Item ID` changes. - The installer must capture the IDs of the current system. 2. For duplicated endpoints, use `Item ID` for normal runtime targeting. During **Reconfigure only**, - if that ID disappears after a Bluetooth reconnect, re-resolve the endpoint by the two real `svcl` - identity columns (`Device Name` + `Name`) and then persist the newly observed `Item ID`. Never use + if that ID disappears after a Bluetooth reconnect, re-resolve the endpoint by the two native endpoint identity properties + (`PKEY_DeviceInterface_FriendlyName` + `PKEY_Device_DeviceDesc`) and then persist the newly observed `Item ID`. Never use the combined display label as if it were a raw column value. -3. To set the output: - ```powershell - svcl.exe /SetDefault "" all - ``` - `all` covers Console, Multimedia and Communications. - -4. To read a column: - ```powershell - svcl.exe /GetColumnValue "DefaultRenderDevice" "Item ID" - ``` - **Do NOT add `/Stdout`** to `/GetColumnValue`. - -5. Verify the switch: - - run `/SetDefault`; - - re-read `DefaultRenderDevice` → `Item ID`; - - compare with the target; +3. To set the output, use the in-process Core Audio bridge in `lib/AutoSwitchCore.psm1`. + `Set-CoreAudioDefaultRenderDevice` applies the target to Console, Multimedia and Communications. + +4. To read the current defaults, use `Get-CoreAudioDefaultRenderDeviceIds`. + Do not infer success from the setter alone. + +5. Verify every switch: + - set all three roles; + - re-read Console, Multimedia and Communications; + - require every role to match the target; - allow a single short retry. -6. Unknown state (svcl failure, `Disabled`, garbage): +6. Unknown state (Core Audio failure, `Disabled`, unmapped state): - do not guess the state; - do not switch the output; - write a log entry; @@ -95,7 +89,7 @@ hardened to tolerate a recreated endpoint whose `Item ID` changes. The worker is a single synchronous loop (`Start-Sleep PollMilliseconds`), which is itself the guard against concurrent polls — it never starts a new poll while the previous one is still running. The main process communicates via control flags (`control/enabled.flag`, `control/stop.flag`). - Do NOT run svcl/G HUB/Set-AudioOutput on the UI thread. Pass `[System.Windows.Forms.Application]::Run($form)` + Do NOT run Core Audio/G HUB/Set-AudioOutput on the UI thread. Pass `[System.Windows.Forms.Application]::Run($form)` an invisible `Form`; `Application.Run()` without a Form is not reliably kept alive across all .NET builds. 11. **Audio Enhancements**: toggled only for the configured `HeadsetId` via a temporary elevated @@ -103,14 +97,11 @@ hardened to tolerate a recreated endpoint whose `Item ID` changes. `PKEY_AudioEndpoint_Disable_SysFx` (1da5d803-d492-4edd-8c23-e0c0ffee7f0e, 5) through `IPolicyConfig::SetPropertyValue(deviceId, bFxStore=true)`, verifies, and exits. The runtime itself is never elevated. The menu label updates only after a verified success (UAC cancel → no visual change). - Do **NOT** use `svcl /SetBooleanFxProperty` for this (individual effects only, not the global - "Disable audio enhancements" switch). 12. **COM interop lives in C#**: PowerShell 5.1 cannot cast a COM RCW to a custom `[ComImport]` interface (`New-Object`, `Activator` or `GetTypeFromCLSID` all fail with "the COM interface cast fails at runtime"). The cast is native in C#, so both the helper and `lib/AutoSwitchCore.psm1` compile the whole COM block with - `Add-Type` and expose a static method (`AutoSwitch.AudioEnhancements.SetSysFx`, - `AutoSwitch.EndpointFx.ReadSysFx`). Read the SysFx state with `IPolicyConfig::GetPropertyValue` on the + `Add-Type` and expose static methods for endpoint enumeration/default switching and enhancements. Read the SysFx state with `IPolicyConfig::GetPropertyValue` on the **FxStore** (`bFxStore=true`) — the endpoint `IPropertyStore` does **not** contain `PKEY_AudioEndpoint_Disable_SysFx`, so reading it there always reports "enabled". @@ -186,27 +177,13 @@ SUBSCRIBE /battery/state/changed To evolve the runtime, one option is to use WebSocket events and keep polling as a fallback. Do not change it without testing power-on, power-off, G HUB restart, sleep and a clean login. -## SoundVolumeCommandLine - -Provider: NirSoft. - -Current x64 URL in this package's version: - -```text -https://www.nirsoft.net/utils/svcl-x64.zip -``` - -SHA-256 verified on 2026-08-07: - -```text -7ba008e9ece8b3eda323ef01711e4647eb7f40b28dc25f98b2ed6a738810bfcd -``` +## Native Core Audio backend -Before changing the hash: -1. check the official hashes page; -2. confirm it matches `svcl-x64.zip` exactly; -3. update the comment/date; -4. do not disable the check. +- `IMMDeviceEnumerator` / `IMMDevice` enumerate render endpoints, states and endpoint IDs. +- `IPropertyStore` reads `PKEY_Device_DeviceDesc`, `PKEY_DeviceInterface_FriendlyName` and `PKEY_Device_FriendlyName`. +- `IPolicyConfig::SetDefaultEndpoint` is used for Console, Multimedia and Communications, then all three roles are re-read and verified. +- `IPolicyConfig` is not a documented public Windows API. This project already depended on the same COM family for Audio Enhancements; keep the boundary isolated in embedded C# and fail safely on HRESULT errors. +- Clean install no longer downloads or hashes a third-party audio-control executable. ## Required tests after any change @@ -261,9 +238,8 @@ Before changing the hash: - G HUB closed (Logitech mode): do not switch output. - Endpoint removed/recreated during Reconfigure: re-resolve by `Device Name` + `Name`, persist the newest ID, and fail without changing config if identity is ambiguous/missing. A runtime endpoint that disappears outside Reconfigure remains a diagnostic/reconfigure case; never guess a target. -- NirSoft hash differs: abort install. - `/devices/list` does not contain the PRO X 2: fall back to WindowsEndpoint path; do not invent a `deviceId`. -- svcl `State` read fails or is `Disabled`/garbage: `Unknown`, do not switch. +- Core Audio enumeration/state read fails or is `Disabled`/unmapped: `Unknown`, do not switch. ## Possible future improvements diff --git a/CHANGELOG.md b/CHANGELOG.md index 752072b..c8cb877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed + +- Replaced the downloaded SoundVolumeCommandLine dependency with an in-process Windows Core Audio COM backend for endpoint enumeration, state reads, default-device reads and output switching. +- Default-output changes are now verified across Console, Multimedia and Communications roles before being accepted. +- Clean installs remove a stale legacy `svcl.exe` when present and no longer require a third-party audio-control download. + ## [1.2.5] - 2026-08-13 diff --git a/Instalar-PROX2-AutoSwitch.ps1 b/Instalar-PROX2-AutoSwitch.ps1 index 6e12dfc..73adac0 100644 --- a/Instalar-PROX2-AutoSwitch.ps1 +++ b/Instalar-PROX2-AutoSwitch.ps1 @@ -1,10 +1,6 @@ #requires -Version 5.1 $ErrorActionPreference = "Stop" -# PowerShell 5.1 on old .NET can negotiate TLS 1.0/1.1 and fail against -# GitHub/NirSoft. Force TLS 1.2. -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $PackageDir = Split-Path -Parent $MyInvocation.MyCommand.Path $InstallDir = Join-Path $env:LOCALAPPDATA "PROX2AutoSwitch" $RuntimeSrc = Join-Path $PackageDir "Runtime-PROX2-AutoSwitch.ps1" @@ -16,7 +12,6 @@ $IconSrc = Join-Path $PackageDir "assets\icon.ico" $MainScript = Join-Path $InstallDir "PROX2AutoSwitch.ps1" $ConfigPath = Join-Path $InstallDir "config.json" -$SvclPath = Join-Path $InstallDir "svcl.exe" $LauncherVbs = Join-Path $InstallDir "Iniciar-Oculto.vbs" $LogPath = Join-Path $InstallDir "autoswitch.log" $HelperPath = Join-Path $InstallDir "Toggle-AudioEnhancements.ps1" @@ -24,11 +19,6 @@ $HelperPath = Join-Path $InstallDir "Toggle-AudioEnhancements.ps1" $StartupDir = [Environment]::GetFolderPath("Startup") $ShortcutPath = Join-Path $StartupDir "PRO X 2 AutoSwitch.lnk" -$SvclUrl = "https://www.nirsoft.net/utils/svcl-x64.zip" -# Verified on the official NirSoft hashes page on 2026-08-07. -# If NirSoft updates svcl this hash will change: DO NOT disable the check. -$ExpectedSha256 = "7ba008e9ece8b3eda323ef01711e4647eb7f40b28dc25f98b2ed6a738810bfcd" -$ZipPath = Join-Path $env:TEMP "svcl-x64.zip" foreach ($required in @($RuntimeSrc, $UninstallSrc, $VerifySrc, $ModuleSrc, $HelperSrc, $IconSrc)) { if (-not (Test-Path $required)) { @@ -66,39 +56,24 @@ Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Start-Sleep -Milliseconds 500 -# SoundVolumeCommandLine: skip the download if svcl.exe is already installed. -if (Test-Path $SvclPath) { - Write-Host "[1/7] SoundVolumeCommandLine already installed. Skipping download." -ForegroundColor Green -} -else { - Write-Host "[1/7] Downloading SoundVolumeCommandLine from NirSoft..." -ForegroundColor Yellow - Invoke-WebRequest -UseBasicParsing -Uri $SvclUrl -OutFile $ZipPath - - $ActualSha256 = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLowerInvariant() - if ($ActualSha256 -ne $ExpectedSha256) { - Remove-Item $ZipPath -Force -ErrorAction SilentlyContinue - throw @" -The SHA-256 of svcl-x64.zip does not match the one verified when this package was created. - -Expected: $ExpectedSha256 -Got: $ActualSha256 - -This can mean NirSoft published a new version. -Do not continue by disabling the check. Verify the current SHA-256 at: -https://www.nirsoft.net/hash_check/?software=svcl -and update ExpectedSha256 in this installer. -"@ +# Native Core Audio backend: no third-party audio executable is downloaded. +Write-Host "[1/6] Checking native Windows Core Audio..." -ForegroundColor Yellow +try { + $nativeDevices = @(Get-CoreAudioRenderDevices) + if ($nativeDevices.Count -eq 0) { + throw "Windows returned no render endpoints." } + [void](Get-CoreAudioDefaultRenderDeviceId) +} +catch { + throw "Native Windows Core Audio is unavailable: $($_.Exception.Message)" +} +Write-Host " Core Audio OK ($($nativeDevices.Count) render endpoint(s))." -ForegroundColor Green - Write-Host " SHA-256 OK." -ForegroundColor Green - - Write-Host "[2/7] Installing SoundVolumeCommandLine..." -ForegroundColor Yellow - Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force - Remove-Item $ZipPath -Force -ErrorAction SilentlyContinue - - if (-not (Test-Path $SvclPath)) { - throw "svcl.exe was not found after extracting the ZIP." - } +# Remove a stale dependency left by installations older than the native backend. +$legacySvclPath = Join-Path $InstallDir "svcl.exe" +if (Test-Path $legacySvclPath) { + Remove-Item $legacySvclPath -Force -ErrorAction SilentlyContinue } # Copy the source version of the runtime and utilities. @@ -287,23 +262,8 @@ function Invoke-GHubGet { } # --- Audio functions --- -function Get-DefaultColumn { - param([Parameter(Mandatory=$true)][string]$Column) - - # IMPORTANT: do not use /Stdout with /GetColumnValue. - $raw = & $SvclPath /GetColumnValue "DefaultRenderDevice" $Column 2>&1 - return (($raw | Out-String).Trim()) -} - function Get-DefaultRenderItemId { - $text = Get-DefaultColumn "Item ID" - - $id = Get-RenderItemIdFromText -Text $text - if (-not $id) { - throw "Could not extract the Item ID of the default device. Output: $text" - } - - return $id + return Get-CoreAudioDefaultRenderDeviceId } function Test-SetDefault { @@ -315,23 +275,23 @@ function Test-SetDefault { Write-Host "" Write-Host "Testing real switch -> $Label" -ForegroundColor Yellow - $out = & $SvclPath /Stdout /SetDefault $Id all 2>&1 - $text = ($out | Out-String).Trim() - - if ($text -match "No items found") { - Write-Host $text -ForegroundColor Red + try { + Set-CoreAudioDefaultRenderDevice -DeviceId $Id + } + catch { + Write-Host (" TEST FAILED. Core Audio error: {0}" -f $_.Exception.Message) -ForegroundColor Red return $false } Start-Sleep -Milliseconds 800 - $actual = Get-DefaultRenderItemId - - if ($actual -ieq $Id) { - Write-Host " TEST OK" -ForegroundColor Green + if (Test-CoreAudioDefaultRenderDevice -DeviceId $Id) { + Write-Host " TEST OK (Console/Multimedia/Communications)" -ForegroundColor Green return $true } - Write-Host " TEST FAILED. Actual: $actual" -ForegroundColor Red + $actual = $null + try { $actual = Get-CoreAudioDefaultRenderDeviceIds } catch { } + Write-Host (" TEST FAILED. Actual roles: {0}" -f ($actual | ConvertTo-Json -Compress)) -ForegroundColor Red return $false } @@ -342,14 +302,14 @@ try { $DetectionMode = $null $ghubHeadset = $null - Write-Host "[3/7] Selecting headset and fallback..." -ForegroundColor Yellow + Write-Host "[2/6] Selecting headset and fallback..." -ForegroundColor Yellow - $csvText = (& $SvclPath /scomma "" 2>&1 | Out-String).Trim() - if ([string]::IsNullOrWhiteSpace($csvText)) { - throw "Could not read the Windows audio device list (svcl /scomma)." + try { + $renderRows = @(Get-CoreAudioRenderDevices) + } + catch { + throw "Could not read the Windows audio device list through Core Audio: $($_.Exception.Message)" } - - $renderRows = @(Get-SvclRenderDevice -CsvText $csvText) if ($renderRows.Count -eq 0) { throw "No render (output) devices found in the Windows list." } @@ -410,19 +370,19 @@ try { [string]$EndpointName ) - $txt = (& $SvclPath /scomma "" 2>&1 | Out-String).Trim() - if (-not (Test-SvclExportValid -CsvText $txt)) { + try { + $rows = @(Get-CoreAudioRenderDevices) + } + catch { return [pscustomobject]@{ State = 'Unknown'; FoundId = $null } } - - $rows = @(ConvertFrom-SvclCsv -Text $txt) $row = $rows | Where-Object { $id = Get-CsvColumn -Row $_ -Names @('Item ID') $null -ne $id -and $id.Trim() -ieq $ItemId.Trim() } | Select-Object -First 1 # Bluetooth can recreate an endpoint with a new Item ID after reconnect. - # Resolve the same Render endpoint by its real svcl identity rather than + # Resolve the same Render endpoint by its native Core Audio identity rather than # treating the user-facing "Device Name — Name" label as one column. if (-not $row -and (-not [string]::IsNullOrWhiteSpace($DeviceName) -or @@ -564,7 +524,7 @@ try { } Write-Host "" - Write-Host "[4/7] Calibrating Windows outputs..." -ForegroundColor Yellow + Write-Host "[3/6] Calibrating Windows outputs..." -ForegroundColor Yellow Write-Host "No old IDs are kept: the current Windows ones are captured." -ForegroundColor DarkGray # In both modes we already have the IDs captured from the Windows list. @@ -578,7 +538,7 @@ try { } Write-Host "" - Write-Host "[5/7] Validating audio switches before installing..." -ForegroundColor Yellow + Write-Host "[4/6] Validating audio switches before installing..." -ForegroundColor Yellow $okHeadset = Test-SetDefault ` -Id $headsetOutput.ItemId ` @@ -647,7 +607,7 @@ try { } } - Write-Host "[6/7] Setting up invisible startup..." -ForegroundColor Yellow + Write-Host "[5/6] Setting up invisible startup..." -ForegroundColor Yellow $PowerShellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" $WScriptExe = Join-Path $env:SystemRoot "System32\wscript.exe" @@ -684,7 +644,7 @@ Set shell = Nothing Where-Object { $_.CommandLine -match $escapedMain } | Select-Object -First 1 - Write-Host "[7/7] Finalizing..." -ForegroundColor Yellow + Write-Host "[6/6] Finalizing..." -ForegroundColor Yellow Write-Host "" if ($running) { diff --git a/README.md b/README.md index c72292c..c70cedf 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ GET /battery//state payload present → ON payload absent → OFF ↓ -SoundVolumeCommandLine /SetDefault all +Windows Core Audio / IPolicyConfig → all default roles ``` The G HUB interface is unofficial and may change in a future G HUB release. See [`SOURCES.md`](SOURCES.md) and [`AGENT.md`](AGENT.md) for the verified design notes. @@ -101,13 +101,12 @@ Two rules are deliberate: 1. An **unknown** state never changes the Windows output. 2. Disconnection needs **two consecutive OFF observations** before switching to the fallback. -A transient `svcl.exe` or G HUB failure therefore cannot send audio to the wrong device on a single bad read. +A transient Core Audio or G HUB failure therefore cannot send audio to the wrong device on a single bad read. ## Requirements - Windows 10/11 x64. - PowerShell 5.1 or newer. -- Internet access during installation so `svcl.exe` can be downloaded and hash-verified. - No vendor software for compatible `WindowsEndpoint` headsets. - Logitech G HUB installed and running for `LogitechGHub` mode. @@ -117,7 +116,7 @@ Normal runtime operation does not require administrator rights. Toggling global The installer: -- downloads SoundVolumeCommandLine from NirSoft only when needed and verifies its SHA-256 before execution; +- uses the Windows Core Audio APIs in-process, so no third-party audio-control executable is downloaded; - lists current Windows render devices and lets you choose the headset and fallback; - validates the real `ON → OFF → ON` cycle with bounded polling windows of 15 s / 15 s / 20 s; - handles a Bluetooth endpoint that returns with a new `Item ID`; @@ -182,25 +181,13 @@ The main log is: %LOCALAPPDATA%\PROX2AutoSwitch\autoswitch.log ``` -## Important `svcl.exe` regression guard +## Native Windows audio backend -`svcl.exe /GetColumnValue` already writes the requested value to stdout. Do **not** combine it with `/Stdout`: - -```powershell -svcl.exe /Stdout /GetColumnValue ... # wrong -``` - -The supported form is: - -```powershell -svcl.exe /GetColumnValue "DefaultRenderDevice" "Item ID" -``` - -An earlier implementation mixed metadata into the output and corrupted the Item ID passed to `/SetDefault`. Tests keep this regression covered. +Endpoint enumeration, state reads and default-device verification now use Windows Core Audio directly in-process. The project keeps its existing PowerShell structure and embedded C# COM bridge; no separate audio-control executable is downloaded. Setting the default endpoint uses the same `IPolicyConfig` COM interop family already used by the project for Audio Enhancements, and every switch is verified across Console, Multimedia and Communications roles with one bounded retry. ## Security -- The installer verifies the SHA-256 of the NirSoft download before running it. +- Audio endpoint enumeration and switching run in-process; installation no longer downloads a third-party audio-control binary. - Release ZIPs publish SHA-256 checksums and are built reproducibly. - The G HUB WebSocket is local but unofficial; treat compatibility changes after G HUB updates as expected maintenance risk. - Normal runtime is non-elevated; only the Audio Enhancements helper requests UAC. diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index 968672f..a5e359e 100644 --- a/Runtime-PROX2-AutoSwitch.ps1 +++ b/Runtime-PROX2-AutoSwitch.ps1 @@ -7,16 +7,14 @@ $script:RuntimePath = $PSCommandPath $InstallDir = Split-Path -Parent $script:RuntimePath $ConfigPath = Join-Path $InstallDir "config.json" -$SvclPath = Join-Path $InstallDir "svcl.exe" $LogPath = Join-Path $InstallDir "autoswitch.log" $script:HelperPath = Join-Path $InstallDir "Toggle-AudioEnhancements.ps1" if (-not (Test-Path $ConfigPath)) { exit 10 } -if (-not (Test-Path $SvclPath)) { exit 11 } $Config = Get-Content -Raw -Path $ConfigPath | ConvertFrom-Json -# Logica compartida (extraccion de Item ID, debounce, CSV, estados, config). +# Shared logic (Core Audio interop, debounce, endpoint identity, config). $ModulePath = Join-Path $InstallDir "lib\AutoSwitchCore.psm1" if (-not (Test-Path $ModulePath)) { exit 12 } Import-Module $ModulePath -ErrorAction Stop @@ -286,17 +284,7 @@ function Get-ProX2BatteryPath { } function Get-DefaultRenderItemId { - # IMPORTANT: /GetColumnValue already writes the value to stdout. - # Do not add /Stdout here: it adds item information and breaks parsing. - $raw = & $SvclPath /GetColumnValue "DefaultRenderDevice" "Item ID" 2>&1 - $text = ($raw | Out-String).Trim() - - $id = Get-RenderItemIdFromText -Text $text - if (-not $id) { - throw "Could not read the default device Item ID. Output: $text" - } - - return $id + return Get-CoreAudioDefaultRenderDeviceId } function Set-AudioOutput { @@ -306,61 +294,54 @@ function Set-AudioOutput { ) $current = $null - try { $current = Get-DefaultRenderItemId } catch {} + try { $current = Get-DefaultRenderItemId } catch { } - if ($current -and ($current -ieq $DeviceId)) { + if ($current -and ($current -ieq $DeviceId) -and + (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId)) { return } - & $SvclPath /SetDefault $DeviceId all | Out-Null + Set-CoreAudioDefaultRenderDevice -DeviceId $DeviceId Start-Sleep -Milliseconds 350 - $actual = Get-DefaultRenderItemId - if ($actual -ieq $DeviceId) { + if (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId) { Write-AutoSwitchLog "Output changed -> $Label" return } # Retry once in case Windows was recreating the endpoint. Start-Sleep -Milliseconds 500 - & $SvclPath /SetDefault $DeviceId all | Out-Null + Set-CoreAudioDefaultRenderDevice -DeviceId $DeviceId Start-Sleep -Milliseconds 350 - $actual = Get-DefaultRenderItemId - if ($actual -ieq $DeviceId) { + if (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId) { Write-AutoSwitchLog "Output changed -> $Label (second attempt)" return } - throw "svcl could not set '$Label'. Expected=$DeviceId Actual=$actual" + $roles = $null + try { $roles = Get-CoreAudioDefaultRenderDeviceIds } catch { } + throw "Core Audio could not set '$Label' for all roles. Expected=$DeviceId Actual=$($roles | ConvertTo-Json -Compress)" } # --- Windows endpoint state (DetectionMode=WindowsEndpoint) --- -function Get-SvclCsvExport { - # Export ALL sound items as CSV. /scomma "" lists everything. - $raw = & $SvclPath /scomma "" 2>&1 - return ($raw | Out-String).Trim() -} - function Get-HeadsetEndpointState { <# .SYNOPSIS Returns 'Connected' / 'Disconnected' / 'Unknown' for the headset endpoint. .DESCRIPTION - - Valid export + matching Item ID row -> normalized state. - - Valid export + missing row (endpoint not present) -> Disconnected. - - Invalid/empty export (svcl failure/garbage) -> Unknown (do nothing). + - Successful Core Audio enumeration + matching Item ID -> normalized state. + - Successful enumeration + missing endpoint -> Disconnected. + - Core Audio failure -> Unknown (do nothing). #> - $csv = Get-SvclCsvExport - - if (-not (Test-SvclExportValid -CsvText $csv)) { - # svcl did not return a valid export: state is unknown; do NOT assume off. + try { + $rows = @(Get-CoreAudioRenderDevices) + } + catch { return 'Unknown' } - $rows = ConvertFrom-SvclCsv -Text $csv - $row = $rows | Where-Object { $id = Get-CsvColumn -Row $_ -Names @('Item ID') @@ -369,8 +350,6 @@ function Get-HeadsetEndpointState { Select-Object -First 1 if (-not $row) { - # Valid export but the endpoint is absent: Windows treats this as - # endpoint is not present -> Disconnected. return 'Disconnected' } @@ -470,10 +449,9 @@ function Invoke-EnhancementsToggle { # --- Tray info: headset, fallback and the output that would be selected now --- -function Get-RenderDevicesFromCsv { - $csv = Get-SvclCsvExport - if (-not (Test-SvclExportValid -CsvText $csv)) { return @() } - return @(Get-SvclRenderDevice -CsvText $csv) +function Get-RenderDevices { + try { return @(Get-CoreAudioRenderDevices) } + catch { return @() } } function Update-TrayInfo { @@ -524,7 +502,7 @@ function Get-HeadsetStateForId { Equivalent to Get-HeadsetEndpointState for an arbitrary Item ID (the headset currently being selected in the wizard). .DESCRIPTION - Bluetooth headsets can disappear from the svcl export while off and + Bluetooth headsets can disappear from the Core Audio endpoint list while off and come back with a DIFFERENT Item ID when reconnected. So when the row is not found by ItemId, fall back to matching the Device Name/Name (stable across reconnects). Returns Connected/Disconnected/Unknown. @@ -536,13 +514,13 @@ function Get-HeadsetStateForId { [switch]$Diagnose ) - $csv = Get-SvclCsvExport - if (-not (Test-SvclExportValid -CsvText $csv)) { - if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: invalid/empty svcl export for {0}" -f $ItemId) } + try { + $rows = @(Get-CoreAudioRenderDevices) + } + catch { + if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: Core Audio enumeration failed for {0}: {1}" -f $ItemId, $_.Exception.Message) } return 'Unknown' } - - $rows = @(ConvertFrom-SvclCsv -Text $csv) $row = $rows | Where-Object { $id = Get-CsvColumn -Row $_ -Names @('Item ID') @@ -572,7 +550,7 @@ function Get-HeadsetStateForId { if (-not $row) { if ($Diagnose) { - # What is in the export? Render devices with their states and IDs. + # What is currently in Core Audio? Render devices with their states and IDs. $lines = @() foreach ($r in $rows) { $name = Get-CsvColumn -Row $r -Names @('Device Name', 'Name') @@ -582,7 +560,7 @@ function Get-HeadsetStateForId { $dir = Get-CsvColumn -Row $r -Names @('Direction') $lines += "[$type/$dir] '$name' id='$id' state='$st'" } - Write-AutoSwitchLog ("Get-HeadsetStateForId: was NOT found {0} in the export. Available row(s):`n{1}" -f $ItemId, ($lines -join "`n")) + Write-AutoSwitchLog ("Get-HeadsetStateForId: was NOT found {0} in Core Audio. Available endpoint(s):`n{1}" -f $ItemId, ($lines -join "`n")) } return 'Disconnected' } @@ -689,10 +667,10 @@ function Test-GHubProX2 { } function Show-ReconfigureDialog { - $devices = Get-RenderDevicesFromCsv + $devices = Get-RenderDevices if ($devices.Count -lt 2) { [System.Windows.Forms.MessageBox]::Show( - "Could not read the Windows output devices. Is svcl.exe present and the audio system OK?", + "Could not read the Windows output devices through Core Audio.", "Audio AutoSwitch", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning diff --git a/SECURITY.md b/SECURITY.md index b9bf0a1..3b627f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,11 +14,9 @@ Include the affected release, impact, minimal reproduction and any known mitigat `ws://localhost:9010` is an undocumented, reverse-engineered local interface. It is **not** an official Logitech API. Treat responses as untrusted input and keep bounded connection/request/close timeouts. -### NirSoft SoundVolumeCommandLine +### Windows audio COM boundary -The installer downloads `svcl-x64.zip` from NirSoft and verifies its pinned SHA-256 before extracting/running it. If NirSoft publishes a new build and the hash changes, installation must fail safely until the value is independently verified from the official hashes page. - -**Never disable the checksum check to make installation succeed.** +Endpoint enumeration and state reads use documented Windows Core Audio interfaces in-process. Changing the system default endpoint uses the undocumented `IPolicyConfig` COM interface, isolated inside the embedded C# bridge. Treat HRESULT failures as unknown state, verify every role after a switch, and never guess a target device. ## Local configuration diff --git a/SOURCES.md b/SOURCES.md index 2f6d4f7..9dcbe04 100644 --- a/SOURCES.md +++ b/SOURCES.md @@ -2,44 +2,7 @@ Verified on August 7, 2026 (updated August 12, 2026). -## NirSoft — SoundVolumeCommandLine -Official docs: - -https://www.nirsoft.net/utils/sound_volume_command_line.html - -Relevant points: - -- SoundVolumeCommandLine is the console version of SoundVolumeView. -- `/SetDefault [Name] [Default Type]` sets the default device. -- `all` sets Console, Multimedia and Communications. -- When several items share a name, `Item ID` or `Command-Line Friendly ID` can be used. -- `/GetColumnValue` returns the value of a column. -- `/Stdout` applied to `Set`-type commands shows the found items. -- `/scomma ""` exports the item list in CSV to stdout/file (with `/Columns` you can choose columns). We use `/scomma ""` to list all items, filter `Type=Device` + `Direction=Render`, and read `Device State`, `Item ID`, `Device Name` and `Name`. `Device Name` and `Name` are separate columns and must not be conflated with the UI label `Device Name — Name`. -- `/SetBooleanFxProperty` (v1.26+) toggles **individual** effects (Loudness Equalization, Headphone Virtualization, etc.). It is **NOT** the global "Disable audio enhancements" switch — do not use it for that. - -## Audio enhancements — `PKEY_AudioEndpoint_Disable_SysFx` - -- Property key: `{1da5d803-d492-4edd-8c23-e0c0ffee7f0e}, 5` (`PKEY_AudioEndpoint_Disable_SysFx`). -- Setting it to `1` disables the system effects of the endpoint (the "Disable audio enhancements" switch). -- Reading the value does not require elevation; **writing** it (via `IPolicyConfig::SetPropertyValue` on the FxStore) requires elevation (UAC). -- **The value lives in the endpoint's FxStore**, reachable only through `IPolicyConfig` with `bFxStore=true`. The endpoint `IPropertyStore` (`IMMDevice::OpenPropertyStore`) does **not** contain it — reading it there always reports "enabled". This project reads it with `IPolicyConfig::GetPropertyValue(deviceId, true, ...)` in C# (see `AutoSwitch.EndpointFx.ReadSysFx`). -- References: - - https://learn.microsoft.com/en-us/windows/win32/coreaudio/pkey-audioendpoint-disable-sysfx - - https://learn.microsoft.com/en-us/answers/questions/669471/how-to-control-enable-audio-enhancements-with-code (verified sample with `IPolicyConfig`, CLSID `870af99c-171d-4f9e-af0d-e63df40c2bc9`, IID `f8679f50-850a-41cf-9c72-430f290290c8`) -- **Win11 quirk**: on endpoints whose `FxProperties` value was never created, a non-elevated write cannot create it. The elevated `SetPropertyValue` is the mitigation; verify on Win11 before shipping. - -## NirSoft — hashes - -https://www.nirsoft.net/hash_check/?software=svcl - -For `svcl-x64.zip`, checked on 2026-08-07: - -```text -SHA256 -7ba008e9ece8b3eda323ef01711e4647eb7f40b28dc25f98b2ed6a738810bfcd -``` ## Logitech G HUB reverse-engineering reference @@ -79,3 +42,15 @@ During the original AutoSwitch build, a controlled test was run on the target ma - PRO X 2 back on: the payload returned. That behavior is the basis for the runtime's ON/OFF detection and must be re-verified if a G HUB update changes it. + + +## Windows Core Audio endpoint APIs + +- Microsoft Learn — `IMMDeviceEnumerator::EnumAudioEndpoints`: documents render/capture endpoint enumeration and device-state masks. + https://learn.microsoft.com/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-immdeviceenumerator-enumaudioendpoints +- Microsoft Learn — Core Audio device properties: documents `PKEY_DeviceInterface_FriendlyName`, `PKEY_Device_DeviceDesc`, `PKEY_Device_FriendlyName`, endpoint IDs and container IDs. + https://learn.microsoft.com/windows/win32/coreaudio/device-properties +- Microsoft Learn — `IMMDeviceEnumerator::GetDefaultAudioEndpoint`: documents reading the current default endpoint by role. + https://learn.microsoft.com/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-immdeviceenumerator-getdefaultaudioendpoint + +`IPolicyConfig::SetDefaultEndpoint` is an undocumented Windows COM interface. It is intentionally isolated inside the embedded C# bridge and is treated as a compatibility risk, not as a supported Microsoft API. diff --git a/Verificar-PROX2-AutoSwitch.ps1 b/Verificar-PROX2-AutoSwitch.ps1 index e8482b5..cb6204e 100644 --- a/Verificar-PROX2-AutoSwitch.ps1 +++ b/Verificar-PROX2-AutoSwitch.ps1 @@ -1,10 +1,9 @@ -#requires -Version 5.1 +#requires -Version 5.1 $ErrorActionPreference = "Continue" $InstallDir = Join-Path $env:LOCALAPPDATA "PROX2AutoSwitch" $MainScript = Join-Path $InstallDir "PROX2AutoSwitch.ps1" $ConfigPath = Join-Path $InstallDir "config.json" -$SvclPath = Join-Path $InstallDir "svcl.exe" $LogPath = Join-Path $InstallDir "autoswitch.log" $ShortcutPath = Join-Path ([Environment]::GetFolderPath("Startup")) "PRO X 2 AutoSwitch.lnk" @@ -31,15 +30,29 @@ Show-Test "Install directory" (Test-Path $InstallDir) $InstallDir Show-Test "Runtime" (Test-Path $MainScript) $MainScript Show-Test "Logic module (lib)" (Test-Path (Join-Path $InstallDir "lib\AutoSwitchCore.psm1")) (Join-Path $InstallDir "lib\AutoSwitchCore.psm1") Show-Test "Configuration" (Test-Path $ConfigPath) $ConfigPath -Show-Test "svcl.exe" (Test-Path $SvclPath) $SvclPath Show-Test "Invisible autostart" (Test-Path $ShortcutPath) $ShortcutPath -# Module functions (Get-ConfigDetectionMode, ConvertFrom-SvclCsv, Get-EndpointFxState). +# Module functions (Core Audio backend, detection mode and enhancements). $ModulePath = Join-Path $InstallDir "lib\AutoSwitchCore.psm1" if (Test-Path $ModulePath) { Import-Module $ModulePath -ErrorAction SilentlyContinue } +$coreAudioOk = $false +$coreAudioDetail = 'Module unavailable' +if (Test-Path $ModulePath) { + try { + $render = @(Get-CoreAudioRenderDevices) + $defaultId = Get-CoreAudioDefaultRenderDeviceId + $coreAudioOk = ($render.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($defaultId)) + $coreAudioDetail = "$($render.Count) render endpoint(s); default=$defaultId" + } + catch { + $coreAudioDetail = $_.Exception.Message + } +} +Show-Test "Native Windows Core Audio" $coreAudioOk $coreAudioDetail + $escaped = [regex]::Escape($MainScript) $process = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match $escaped } | @@ -115,8 +128,7 @@ if (Test-Path $ConfigPath) { # Current state of the headset endpoint (WindowsEndpoint only). if ($mode -eq 'WindowsEndpoint' -and $cfg.HeadsetId) { try { - $csv = (& $SvclPath /scomma "" 2>&1 | Out-String).Trim() - $rows = @(ConvertFrom-SvclCsv -Text $csv) + $rows = @(Get-CoreAudioRenderDevices) $row = $rows | Where-Object { $id = Get-CsvColumn -Row $_ -Names @('Item ID') $null -ne $id -and $id.Trim().ToLowerInvariant() -eq [string]$cfg.HeadsetId diff --git a/lib/AutoSwitchCore.psm1 b/lib/AutoSwitchCore.psm1 index ff7c89b..168e2bb 100644 --- a/lib/AutoSwitchCore.psm1 +++ b/lib/AutoSwitchCore.psm1 @@ -1,13 +1,13 @@ #requires -Version 5.1 # AutoSwitchCore.psm1 - pure, testable logic for Audio AutoSwitch. -# No G HUB or svcl.exe dependency is required for Pester tests. +# No G HUB or third-party audio utility is required for Pester tests. Set-StrictMode -Version Latest function Get-RenderItemIdFromText { <# .SYNOPSIS - Extract a valid render Item ID from svcl.exe output. + Extract a valid render Item ID from legacy command output. .DESCRIPTION Use /GetColumnValue (NEVER /Stdout /GetColumnValue, which contaminates the output). Return $null when no valid render Item ID is present. @@ -73,7 +73,7 @@ function Resolve-HeadsetState { function ConvertFrom-SvclCsv { <# .SYNOPSIS - Parse svcl.exe /scomma output into objects. + Parse legacy /scomma output into objects. .DESCRIPTION The first export line contains the column headers. Supports double-quoted fields and embedded commas. @@ -302,7 +302,7 @@ function Test-SvclExportValid { function Get-SvclRenderDevice { <# .SYNOPSIS - Filter svcl.exe /scomma export to real render output endpoints + Filter a legacy /scomma export to real render output endpoints with Type='Device' and Direction='Render'. .DESCRIPTION @@ -555,6 +555,403 @@ namespace AutoSwitch } } + +function Initialize-CoreAudioBackend { + <# + .SYNOPSIS + Compile the in-process Windows Core Audio COM bridge once. + .DESCRIPTION + PowerShell 5.1 cannot reliably cast COM RCWs to custom ComImport + interfaces, so the COM calls live in embedded C#. Enumeration, + endpoint state and default-device reads use documented Core Audio + interfaces. Setting the default endpoint reuses the project's + existing IPolicyConfig interop for Console, Multimedia and + Communications roles. + #> + [CmdletBinding()] + param() + + if ('AutoSwitch.NativeAudio.CoreAudio' -as [type]) { + return + } + + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace AutoSwitch.NativeAudio +{ + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct PROPERTYKEY + { + public Guid fmtid; + public uint pid; + } + + [StructLayout(LayoutKind.Explicit)] + public struct PROPVARIANT + { + [FieldOffset(0)] public ushort vt; + [FieldOffset(8)] public IntPtr pointerVal; + [FieldOffset(8)] public uint ulVal; + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + public class MMDeviceEnumeratorComObject { } + + [ComImport, Guid("870af99c-171d-4f9e-af0d-e63df40c2bc9")] + public class CPolicyConfigVistaClient { } + + [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDeviceEnumerator + { + [PreserveSig] int EnumAudioEndpoints(int dataFlow, uint stateMask, out IMMDeviceCollection devices); + [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); + [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); + [PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client); + [PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client); + } + + [ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDeviceCollection + { + [PreserveSig] int GetCount(out uint count); + [PreserveSig] int Item(uint index, out IMMDevice device); + } + + [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDevice + { + [PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, out IntPtr instance); + [PreserveSig] int OpenPropertyStore(int accessMode, out IPropertyStore properties); + [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); + [PreserveSig] int GetState(out uint state); + } + + [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IPropertyStore + { + [PreserveSig] int GetCount(out uint count); + [PreserveSig] int GetAt(uint index, out PROPERTYKEY key); + [PreserveSig] int GetValue(ref PROPERTYKEY key, out PROPVARIANT value); + [PreserveSig] int SetValue(ref PROPERTYKEY key, ref PROPVARIANT value); + [PreserveSig] int Commit(); + } + + [ComImport, Guid("f8679f50-850a-41cf-9c72-430f290290c8"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IPolicyConfig + { + [PreserveSig] int GetMixFormat(string deviceName, out IntPtr format); + [PreserveSig] int GetDeviceFormat(string deviceName, bool defaultFormat, out IntPtr format); + [PreserveSig] int ResetDeviceFormat(string deviceName); + [PreserveSig] int SetDeviceFormat(string deviceName, IntPtr endpointFormat, IntPtr mixFormat); + [PreserveSig] int GetProcessingPeriod(string deviceName, bool defaultPeriod, out IntPtr defaultPeriodValue, out IntPtr minimumPeriodValue); + [PreserveSig] int SetProcessingPeriod(string deviceName, IntPtr period); + [PreserveSig] int GetShareMode(string deviceName, out IntPtr mode); + [PreserveSig] int SetShareMode(string deviceName, IntPtr mode); + [PreserveSig] int GetPropertyValue(string deviceName, bool fxStore, IntPtr key, IntPtr value); + [PreserveSig] int SetPropertyValue(string deviceName, bool fxStore, IntPtr key, IntPtr value); + [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string deviceName, int role); + [PreserveSig] int SetEndpointVisibility([MarshalAs(UnmanagedType.LPWStr)] string deviceName, bool visible); + } + + public sealed class EndpointInfo + { + public string Id { get; set; } + public string Name { get; set; } + public string DeviceName { get; set; } + public string FriendlyName { get; set; } + public uint State { get; set; } + public string StateName { get; set; } + } + + public sealed class DefaultEndpointIds + { + public string Console { get; set; } + public string Multimedia { get; set; } + public string Communications { get; set; } + } + + public static class CoreAudio + { + private const int E_RENDER = 0; + private const uint DEVICE_STATEMASK_ALL = 0x0000000F; + private const int STGM_READ = 0; + private const ushort VT_LPWSTR = 31; + + private static readonly Guid FMTID_DEVICE = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"); + private static readonly Guid FMTID_DEVICE_INTERFACE = new Guid("026e516e-b814-414b-83cd-856d6fef4822"); + + [DllImport("ole32.dll")] + private static extern int PropVariantClear(ref PROPVARIANT value); + + private static void ThrowIfFailed(int hr) + { + if (hr < 0) Marshal.ThrowExceptionForHR(hr); + } + + private static void Release(object value) + { + if (value != null && Marshal.IsComObject(value)) + { + try { Marshal.ReleaseComObject(value); } catch { } + } + } + + private static string ReadString(IPropertyStore store, Guid fmtid, uint pid) + { + PROPERTYKEY key = new PROPERTYKEY { fmtid = fmtid, pid = pid }; + PROPVARIANT value = new PROPVARIANT(); + int hr = store.GetValue(ref key, out value); + if (hr < 0) return null; + try + { + if (value.vt == VT_LPWSTR && value.pointerVal != IntPtr.Zero) + { + return Marshal.PtrToStringUni(value.pointerVal); + } + return null; + } + finally + { + PropVariantClear(ref value); + } + } + + private static string GetId(IMMDevice device) + { + string id; + ThrowIfFailed(device.GetId(out id)); + return id; + } + + private static string StateName(uint state) + { + switch (state) + { + case 0x00000001: return "Active"; + case 0x00000002: return "Disabled"; + case 0x00000004: return "NotPresent"; + case 0x00000008: return "Unplugged"; + default: return "Unknown"; + } + } + + public static EndpointInfo[] GetRenderEndpoints() + { + object enumeratorObject = null; + IMMDeviceEnumerator enumerator = null; + IMMDeviceCollection collection = null; + var result = new List(); + + try + { + enumeratorObject = new MMDeviceEnumeratorComObject(); + enumerator = (IMMDeviceEnumerator)enumeratorObject; + ThrowIfFailed(enumerator.EnumAudioEndpoints(E_RENDER, DEVICE_STATEMASK_ALL, out collection)); + + uint count; + ThrowIfFailed(collection.GetCount(out count)); + for (uint i = 0; i < count; i++) + { + IMMDevice device = null; + IPropertyStore store = null; + try + { + ThrowIfFailed(collection.Item(i, out device)); + string id = GetId(device); + uint state; + ThrowIfFailed(device.GetState(out state)); + ThrowIfFailed(device.OpenPropertyStore(STGM_READ, out store)); + + string name = ReadString(store, FMTID_DEVICE, 2); // PKEY_Device_DeviceDesc + string adapter = ReadString(store, FMTID_DEVICE_INTERFACE, 2); // PKEY_DeviceInterface_FriendlyName + string friendly = ReadString(store, FMTID_DEVICE, 14); // PKEY_Device_FriendlyName + + if (String.IsNullOrWhiteSpace(name)) name = friendly; + if (String.IsNullOrWhiteSpace(adapter)) adapter = friendly; + + result.Add(new EndpointInfo + { + Id = id, + Name = name, + DeviceName = adapter, + FriendlyName = friendly, + State = state, + StateName = StateName(state) + }); + } + finally + { + Release(store); + Release(device); + } + } + } + finally + { + Release(collection); + Release(enumerator); + Release(enumeratorObject); + } + + return result.ToArray(); + } + + public static string GetDefaultRenderEndpointId(int role) + { + object enumeratorObject = null; + IMMDeviceEnumerator enumerator = null; + IMMDevice device = null; + try + { + enumeratorObject = new MMDeviceEnumeratorComObject(); + enumerator = (IMMDeviceEnumerator)enumeratorObject; + ThrowIfFailed(enumerator.GetDefaultAudioEndpoint(E_RENDER, role, out device)); + return GetId(device); + } + finally + { + Release(device); + Release(enumerator); + Release(enumeratorObject); + } + } + + public static DefaultEndpointIds GetDefaultRenderEndpointIds() + { + return new DefaultEndpointIds + { + Console = GetDefaultRenderEndpointId(0), + Multimedia = GetDefaultRenderEndpointId(1), + Communications = GetDefaultRenderEndpointId(2) + }; + } + + private static void ValidateEndpoint(string deviceId) + { + object enumeratorObject = null; + IMMDeviceEnumerator enumerator = null; + IMMDevice device = null; + try + { + enumeratorObject = new MMDeviceEnumeratorComObject(); + enumerator = (IMMDeviceEnumerator)enumeratorObject; + ThrowIfFailed(enumerator.GetDevice(deviceId, out device)); + } + finally + { + Release(device); + Release(enumerator); + Release(enumeratorObject); + } + } + + public static void SetDefaultEndpointAllRoles(string deviceId) + { + if (String.IsNullOrWhiteSpace(deviceId)) + throw new ArgumentException("deviceId must not be empty", "deviceId"); + + ValidateEndpoint(deviceId); + + object policyObject = null; + IPolicyConfig policy = null; + try + { + policyObject = new CPolicyConfigVistaClient(); + policy = (IPolicyConfig)policyObject; + ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 0)); + ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 1)); + ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 2)); + } + finally + { + Release(policy); + Release(policyObject); + } + } + } +} +'@ -ErrorAction Stop +} + +function Get-CoreAudioRenderDevices { + [CmdletBinding()] + param() + + Initialize-CoreAudioBackend + $defaultId = $null + try { $defaultId = [AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointId(0) } catch { } + + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($item in @([AutoSwitch.NativeAudio.CoreAudio]::GetRenderEndpoints())) { + $isDefault = $false + if ($defaultId) { $isDefault = $item.Id -ieq $defaultId } + $rows.Add([pscustomobject][ordered]@{ + 'Name' = [string]$item.Name + 'Type' = 'Device' + 'Direction' = 'Render' + 'Device Name' = [string]$item.DeviceName + 'Friendly Name' = [string]$item.FriendlyName + 'Device State' = [string]$item.StateName + 'Item ID' = [string]$item.Id + 'Default' = $(if ($isDefault) { 'Render' } else { '' }) + }) + } + return $rows.ToArray() +} + +function Get-CoreAudioDefaultRenderDeviceId { + [CmdletBinding()] + param() + + Initialize-CoreAudioBackend + return [string][AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointId(0) +} + +function Get-CoreAudioDefaultRenderDeviceIds { + [CmdletBinding()] + param() + + Initialize-CoreAudioBackend + $ids = [AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointIds() + return [pscustomobject]@{ + Console = [string]$ids.Console + Multimedia = [string]$ids.Multimedia + Communications = [string]$ids.Communications + } +} + +function Test-CoreAudioDefaultRenderDevice { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$DeviceId) + + try { + $ids = Get-CoreAudioDefaultRenderDeviceIds + return ($ids.Console -ieq $DeviceId -and + $ids.Multimedia -ieq $DeviceId -and + $ids.Communications -ieq $DeviceId) + } + catch { + return $false + } +} + +function Set-CoreAudioDefaultRenderDevice { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$DeviceId) + + Initialize-CoreAudioBackend + [AutoSwitch.NativeAudio.CoreAudio]::SetDefaultEndpointAllRoles($DeviceId) +} + + function Get-ConfigDetectionMode { <# .SYNOPSIS @@ -586,4 +983,4 @@ function Get-ConfigDetectionMode { return $null } -Export-ModuleMember -Function Get-RenderItemIdFromText, Resolve-HeadsetState, Test-ValidAudioConfig, New-GHubTimeoutToken, ConvertFrom-SvclCsv, ConvertFrom-CsvLine, Get-CsvColumn, Resolve-EndpointState, Resolve-DetectedState, Test-SvclExportValid, Get-SvclRenderDevice, Get-SvclDeviceLabel, Find-SvclRenderDeviceByIdentity, Get-EndpointFxState, Get-ConfigDetectionMode +Export-ModuleMember -Function Get-RenderItemIdFromText, Resolve-HeadsetState, Test-ValidAudioConfig, New-GHubTimeoutToken, ConvertFrom-SvclCsv, ConvertFrom-CsvLine, Get-CsvColumn, Resolve-EndpointState, Resolve-DetectedState, Test-SvclExportValid, Get-SvclRenderDevice, Get-SvclDeviceLabel, Find-SvclRenderDeviceByIdentity, Get-EndpointFxState, Get-ConfigDetectionMode, Initialize-CoreAudioBackend, Get-CoreAudioRenderDevices, Get-CoreAudioDefaultRenderDeviceId, Get-CoreAudioDefaultRenderDeviceIds, Test-CoreAudioDefaultRenderDevice, Set-CoreAudioDefaultRenderDevice diff --git a/tests/AutoSwitchCore.Tests.ps1 b/tests/AutoSwitchCore.Tests.ps1 index 358548a..2936dde 100644 --- a/tests/AutoSwitchCore.Tests.ps1 +++ b/tests/AutoSwitchCore.Tests.ps1 @@ -279,3 +279,18 @@ Describe 'Get-ConfigDetectionMode' { Get-ConfigDetectionMode -Config $cfg | Should -BeNullOrEmpty } } + + +Describe 'Native Core Audio bridge' { + It 'exports the native Core Audio commands' { + (Get-Command Initialize-CoreAudioBackend -ErrorAction Stop).Name | Should -Be 'Initialize-CoreAudioBackend' + (Get-Command Get-CoreAudioRenderDevices -ErrorAction Stop).Name | Should -Be 'Get-CoreAudioRenderDevices' + (Get-Command Get-CoreAudioDefaultRenderDeviceId -ErrorAction Stop).Name | Should -Be 'Get-CoreAudioDefaultRenderDeviceId' + (Get-Command Set-CoreAudioDefaultRenderDevice -ErrorAction Stop).Name | Should -Be 'Set-CoreAudioDefaultRenderDevice' + } + + It 'compiles the embedded COM bridge without touching hardware' { + { Initialize-CoreAudioBackend } | Should -Not -Throw + ('AutoSwitch.NativeAudio.CoreAudio' -as [type]) | Should -Not -BeNullOrEmpty + } +} From 74ee77c285875caf5633eb8b74ffffff46b5e66b Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:05:58 +0200 Subject: [PATCH 5/8] chore: remove temporary Core Audio validation workflow --- .../workflows/finalize-native-core-audio.yml | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 .github/workflows/finalize-native-core-audio.yml diff --git a/.github/workflows/finalize-native-core-audio.yml b/.github/workflows/finalize-native-core-audio.yml deleted file mode 100644 index 0db30cc..0000000 --- a/.github/workflows/finalize-native-core-audio.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: finalize native Core Audio migration - -on: - push: - branches: - - agent/native-core-audio-pr - -permissions: - contents: write - -jobs: - migrate: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - ref: agent/native-core-audio-pr - fetch-depth: 0 - - - name: Apply migration source - shell: python - run: | - from pathlib import Path - - source_path = Path('tools/native-core-audio-migration-source.yml') - lines = source_path.read_text(encoding='utf-8').splitlines() - apply_index = next(i for i, line in enumerate(lines) if line.strip() == '- name: Apply migration') - run_index = next(i for i in range(apply_index, len(lines)) if lines[i].strip() == 'run: |') - end_index = next(i for i in range(run_index + 1, len(lines)) if lines[i].strip() == '- name: Validate PowerShell syntax') - body = [] - for line in lines[run_index + 1:end_index]: - body.append(line[10:] if line.startswith(' ') else line) - script = '\n'.join(body) - script = script.replace("Path('.github/workflows/apply-native-core-audio.yml').unlink()", "pass") - exec(compile(script, '', 'exec'), {}) - - module_path = Path('lib/AutoSwitchCore.psm1') - module = module_path.read_text(encoding='utf-8-sig') - module = module.replace('Extract a valid render Item ID from svcl.exe output.', 'Extract a valid render Item ID from legacy command output.') - module = module.replace('Parse svcl.exe /scomma output into objects.', 'Parse legacy /scomma output into objects.') - module = module.replace('Filter svcl.exe /scomma export to real render output endpoints', 'Filter a legacy /scomma export to real render output endpoints') - module_path.write_text(module, encoding='utf-8-sig', newline='\n') - - security_path = Path('SECURITY.md') - security = security_path.read_text(encoding='utf-8-sig') - old = "### NirSoft SoundVolumeCommandLine\n\nThe installer downloads `svcl-x64.zip` from NirSoft and verifies its pinned SHA-256 before extracting/running it. If NirSoft publishes a new build and the hash changes, installation must fail safely until the value is independently verified from the official hashes page.\n\n**Never disable the checksum check to make installation succeed.**" - new = "### Windows audio COM boundary\n\nEndpoint enumeration and state reads use documented Windows Core Audio interfaces in-process. Changing the system default endpoint uses the undocumented `IPolicyConfig` COM interface, isolated inside the embedded C# bridge. Treat HRESULT failures as unknown state, verify every role after a switch, and never guess a target device." - if old in security: - security = security.replace(old, new, 1) - security_path.write_text(security, encoding='utf-8', newline='\n') - - - name: Validate PowerShell syntax - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $scripts = Get-ChildItem -Path . -Include '*.ps1','*.psm1' -File -Recurse - foreach ($script in $scripts) { - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($script.FullName, [ref]$tokens, [ref]$errors) | Out-Null - if ($errors.Count -gt 0) { - $errors | ForEach-Object { Write-Error "$($script.FullName): $($_.Message) (line $($_.Extent.StartLineNumber))" } - exit 1 - } - } - - - name: Run Pester tests - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - if (-not (Get-Module -ListAvailable -Name Pester)) { - Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck - } - $config = New-PesterConfiguration - $config.Run.Path = 'tests' - $config.Output.Verbosity = 'Detailed' - $result = Invoke-Pester -Configuration $config - if ($result.FailedCount -gt 0) { exit 1 } - - - name: Check active dependency references - shell: pwsh - run: | - $hits = git grep -n -i -E 'svcl\.exe|SoundVolumeCommandLine|NirSoft' -- ':!CHANGELOG.md' ':!docs/WindowsEndpointProvider.md' ':!wiki/*' ':!site/*' ':!.github/workflows/*' ':!tools/*' 2>$null - $hits = @($hits | Where-Object { $_ -notmatch 'legacySvclPath' }) - if ($hits.Count -gt 0) { - $hits | Write-Host - throw 'Active code or canonical docs still reference the removed external dependency.' - } - - - name: Commit application and documentation changes - shell: pwsh - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add AGENT.md CHANGELOG.md README.md SECURITY.md SOURCES.md Instalar-PROX2-AutoSwitch.ps1 Runtime-PROX2-AutoSwitch.ps1 Verificar-PROX2-AutoSwitch.ps1 lib/AutoSwitchCore.psm1 tests/AutoSwitchCore.Tests.ps1 - if (git diff --cached --quiet) { exit 0 } - git commit -m 'feat: replace svcl with native Core Audio' - git push origin HEAD:agent/native-core-audio-pr From 863b8811f1ffa0defd308d0ca993a9963c081147 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:06:13 +0200 Subject: [PATCH 6/8] chore: remove temporary Core Audio migration source --- tools/native-core-audio-migration-source.yml | 648 ------------------- 1 file changed, 648 deletions(-) delete mode 100644 tools/native-core-audio-migration-source.yml diff --git a/tools/native-core-audio-migration-source.yml b/tools/native-core-audio-migration-source.yml deleted file mode 100644 index 3c038ce..0000000 --- a/tools/native-core-audio-migration-source.yml +++ /dev/null @@ -1,648 +0,0 @@ -name: apply native Core Audio migration - -on: - push: - branches: - - agent/native-core-audio - -permissions: - contents: write - -jobs: - migrate: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - ref: agent/native-core-audio - fetch-depth: 0 - - - name: Apply migration - shell: python - run: | - from pathlib import Path - import re - - def read(path): - return Path(path).read_text(encoding='utf-8-sig') - - def write(path, text): - enc = 'utf-8-sig' if Path(path).suffix.lower() in ('.ps1', '.psm1') else 'utf-8' - Path(path).write_text(text, encoding=enc, newline='\n') - - def replace_once(text, old, new, path): - count = text.count(old) - if count != 1: - raise RuntimeError(f'{path}: expected one exact match, found {count}: {old[:100]!r}') - return text.replace(old, new, 1) - - def sub_once(text, pattern, repl, path, flags=0): - new, count = re.subn(pattern, repl, text, count=1, flags=flags) - if count != 1: - raise RuntimeError(f'{path}: expected one regex match, found {count}: {pattern[:120]!r}') - return new - - native_ps = r''' - function Initialize-CoreAudioBackend { - <# - .SYNOPSIS - Compile the in-process Windows Core Audio COM bridge once. - .DESCRIPTION - PowerShell 5.1 cannot reliably cast COM RCWs to custom ComImport - interfaces, so the COM calls live in embedded C#. Enumeration, - endpoint state and default-device reads use documented Core Audio - interfaces. Setting the default endpoint reuses the project's - existing IPolicyConfig interop for Console, Multimedia and - Communications roles. - #> - [CmdletBinding()] - param() - - if ('AutoSwitch.NativeAudio.CoreAudio' -as [type]) { - return - } - - Add-Type -TypeDefinition @' - using System; - using System.Collections.Generic; - using System.Runtime.InteropServices; - - namespace AutoSwitch.NativeAudio - { - [StructLayout(LayoutKind.Sequential, Pack = 4)] - public struct PROPERTYKEY - { - public Guid fmtid; - public uint pid; - } - - [StructLayout(LayoutKind.Explicit)] - public struct PROPVARIANT - { - [FieldOffset(0)] public ushort vt; - [FieldOffset(8)] public IntPtr pointerVal; - [FieldOffset(8)] public uint ulVal; - } - - [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] - public class MMDeviceEnumeratorComObject { } - - [ComImport, Guid("870af99c-171d-4f9e-af0d-e63df40c2bc9")] - public class CPolicyConfigVistaClient { } - - [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public interface IMMDeviceEnumerator - { - [PreserveSig] int EnumAudioEndpoints(int dataFlow, uint stateMask, out IMMDeviceCollection devices); - [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); - [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); - [PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client); - [PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client); - } - - [ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public interface IMMDeviceCollection - { - [PreserveSig] int GetCount(out uint count); - [PreserveSig] int Item(uint index, out IMMDevice device); - } - - [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public interface IMMDevice - { - [PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, out IntPtr instance); - [PreserveSig] int OpenPropertyStore(int accessMode, out IPropertyStore properties); - [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); - [PreserveSig] int GetState(out uint state); - } - - [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public interface IPropertyStore - { - [PreserveSig] int GetCount(out uint count); - [PreserveSig] int GetAt(uint index, out PROPERTYKEY key); - [PreserveSig] int GetValue(ref PROPERTYKEY key, out PROPVARIANT value); - [PreserveSig] int SetValue(ref PROPERTYKEY key, ref PROPVARIANT value); - [PreserveSig] int Commit(); - } - - [ComImport, Guid("f8679f50-850a-41cf-9c72-430f290290c8"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public interface IPolicyConfig - { - [PreserveSig] int GetMixFormat(string deviceName, out IntPtr format); - [PreserveSig] int GetDeviceFormat(string deviceName, bool defaultFormat, out IntPtr format); - [PreserveSig] int ResetDeviceFormat(string deviceName); - [PreserveSig] int SetDeviceFormat(string deviceName, IntPtr endpointFormat, IntPtr mixFormat); - [PreserveSig] int GetProcessingPeriod(string deviceName, bool defaultPeriod, out IntPtr defaultPeriodValue, out IntPtr minimumPeriodValue); - [PreserveSig] int SetProcessingPeriod(string deviceName, IntPtr period); - [PreserveSig] int GetShareMode(string deviceName, out IntPtr mode); - [PreserveSig] int SetShareMode(string deviceName, IntPtr mode); - [PreserveSig] int GetPropertyValue(string deviceName, bool fxStore, IntPtr key, IntPtr value); - [PreserveSig] int SetPropertyValue(string deviceName, bool fxStore, IntPtr key, IntPtr value); - [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string deviceName, int role); - [PreserveSig] int SetEndpointVisibility([MarshalAs(UnmanagedType.LPWStr)] string deviceName, bool visible); - } - - public sealed class EndpointInfo - { - public string Id { get; set; } - public string Name { get; set; } - public string DeviceName { get; set; } - public string FriendlyName { get; set; } - public uint State { get; set; } - public string StateName { get; set; } - } - - public sealed class DefaultEndpointIds - { - public string Console { get; set; } - public string Multimedia { get; set; } - public string Communications { get; set; } - } - - public static class CoreAudio - { - private const int E_RENDER = 0; - private const uint DEVICE_STATEMASK_ALL = 0x0000000F; - private const int STGM_READ = 0; - private const ushort VT_LPWSTR = 31; - - private static readonly Guid FMTID_DEVICE = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"); - private static readonly Guid FMTID_DEVICE_INTERFACE = new Guid("026e516e-b814-414b-83cd-856d6fef4822"); - - [DllImport("ole32.dll")] - private static extern int PropVariantClear(ref PROPVARIANT value); - - private static void ThrowIfFailed(int hr) - { - if (hr < 0) Marshal.ThrowExceptionForHR(hr); - } - - private static void Release(object value) - { - if (value != null && Marshal.IsComObject(value)) - { - try { Marshal.ReleaseComObject(value); } catch { } - } - } - - private static string ReadString(IPropertyStore store, Guid fmtid, uint pid) - { - PROPERTYKEY key = new PROPERTYKEY { fmtid = fmtid, pid = pid }; - PROPVARIANT value = new PROPVARIANT(); - int hr = store.GetValue(ref key, out value); - if (hr < 0) return null; - try - { - if (value.vt == VT_LPWSTR && value.pointerVal != IntPtr.Zero) - { - return Marshal.PtrToStringUni(value.pointerVal); - } - return null; - } - finally - { - PropVariantClear(ref value); - } - } - - private static string GetId(IMMDevice device) - { - string id; - ThrowIfFailed(device.GetId(out id)); - return id; - } - - private static string StateName(uint state) - { - switch (state) - { - case 0x00000001: return "Active"; - case 0x00000002: return "Disabled"; - case 0x00000004: return "NotPresent"; - case 0x00000008: return "Unplugged"; - default: return "Unknown"; - } - } - - public static EndpointInfo[] GetRenderEndpoints() - { - object enumeratorObject = null; - IMMDeviceEnumerator enumerator = null; - IMMDeviceCollection collection = null; - var result = new List(); - - try - { - enumeratorObject = new MMDeviceEnumeratorComObject(); - enumerator = (IMMDeviceEnumerator)enumeratorObject; - ThrowIfFailed(enumerator.EnumAudioEndpoints(E_RENDER, DEVICE_STATEMASK_ALL, out collection)); - - uint count; - ThrowIfFailed(collection.GetCount(out count)); - for (uint i = 0; i < count; i++) - { - IMMDevice device = null; - IPropertyStore store = null; - try - { - ThrowIfFailed(collection.Item(i, out device)); - string id = GetId(device); - uint state; - ThrowIfFailed(device.GetState(out state)); - ThrowIfFailed(device.OpenPropertyStore(STGM_READ, out store)); - - string name = ReadString(store, FMTID_DEVICE, 2); // PKEY_Device_DeviceDesc - string adapter = ReadString(store, FMTID_DEVICE_INTERFACE, 2); // PKEY_DeviceInterface_FriendlyName - string friendly = ReadString(store, FMTID_DEVICE, 14); // PKEY_Device_FriendlyName - - if (String.IsNullOrWhiteSpace(name)) name = friendly; - if (String.IsNullOrWhiteSpace(adapter)) adapter = friendly; - - result.Add(new EndpointInfo - { - Id = id, - Name = name, - DeviceName = adapter, - FriendlyName = friendly, - State = state, - StateName = StateName(state) - }); - } - finally - { - Release(store); - Release(device); - } - } - } - finally - { - Release(collection); - Release(enumerator); - Release(enumeratorObject); - } - - return result.ToArray(); - } - - public static string GetDefaultRenderEndpointId(int role) - { - object enumeratorObject = null; - IMMDeviceEnumerator enumerator = null; - IMMDevice device = null; - try - { - enumeratorObject = new MMDeviceEnumeratorComObject(); - enumerator = (IMMDeviceEnumerator)enumeratorObject; - ThrowIfFailed(enumerator.GetDefaultAudioEndpoint(E_RENDER, role, out device)); - return GetId(device); - } - finally - { - Release(device); - Release(enumerator); - Release(enumeratorObject); - } - } - - public static DefaultEndpointIds GetDefaultRenderEndpointIds() - { - return new DefaultEndpointIds - { - Console = GetDefaultRenderEndpointId(0), - Multimedia = GetDefaultRenderEndpointId(1), - Communications = GetDefaultRenderEndpointId(2) - }; - } - - private static void ValidateEndpoint(string deviceId) - { - object enumeratorObject = null; - IMMDeviceEnumerator enumerator = null; - IMMDevice device = null; - try - { - enumeratorObject = new MMDeviceEnumeratorComObject(); - enumerator = (IMMDeviceEnumerator)enumeratorObject; - ThrowIfFailed(enumerator.GetDevice(deviceId, out device)); - } - finally - { - Release(device); - Release(enumerator); - Release(enumeratorObject); - } - } - - public static void SetDefaultEndpointAllRoles(string deviceId) - { - if (String.IsNullOrWhiteSpace(deviceId)) - throw new ArgumentException("deviceId must not be empty", "deviceId"); - - ValidateEndpoint(deviceId); - - object policyObject = null; - IPolicyConfig policy = null; - try - { - policyObject = new CPolicyConfigVistaClient(); - policy = (IPolicyConfig)policyObject; - ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 0)); - ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 1)); - ThrowIfFailed(policy.SetDefaultEndpoint(deviceId, 2)); - } - finally - { - Release(policy); - Release(policyObject); - } - } - } - } - '@ -ErrorAction Stop - } - - function Get-CoreAudioRenderDevices { - [CmdletBinding()] - param() - - Initialize-CoreAudioBackend - $defaultId = $null - try { $defaultId = [AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointId(0) } catch { } - - $rows = [System.Collections.Generic.List[object]]::new() - foreach ($item in @([AutoSwitch.NativeAudio.CoreAudio]::GetRenderEndpoints())) { - $isDefault = $false - if ($defaultId) { $isDefault = $item.Id -ieq $defaultId } - $rows.Add([pscustomobject][ordered]@{ - 'Name' = [string]$item.Name - 'Type' = 'Device' - 'Direction' = 'Render' - 'Device Name' = [string]$item.DeviceName - 'Friendly Name' = [string]$item.FriendlyName - 'Device State' = [string]$item.StateName - 'Item ID' = [string]$item.Id - 'Default' = $(if ($isDefault) { 'Render' } else { '' }) - }) - } - return $rows.ToArray() - } - - function Get-CoreAudioDefaultRenderDeviceId { - [CmdletBinding()] - param() - - Initialize-CoreAudioBackend - return [string][AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointId(0) - } - - function Get-CoreAudioDefaultRenderDeviceIds { - [CmdletBinding()] - param() - - Initialize-CoreAudioBackend - $ids = [AutoSwitch.NativeAudio.CoreAudio]::GetDefaultRenderEndpointIds() - return [pscustomobject]@{ - Console = [string]$ids.Console - Multimedia = [string]$ids.Multimedia - Communications = [string]$ids.Communications - } - } - - function Test-CoreAudioDefaultRenderDevice { - [CmdletBinding()] - param([Parameter(Mandatory = $true)][string]$DeviceId) - - try { - $ids = Get-CoreAudioDefaultRenderDeviceIds - return ($ids.Console -ieq $DeviceId -and - $ids.Multimedia -ieq $DeviceId -and - $ids.Communications -ieq $DeviceId) - } - catch { - return $false - } - } - - function Set-CoreAudioDefaultRenderDevice { - [CmdletBinding()] - param([Parameter(Mandatory = $true)][string]$DeviceId) - - Initialize-CoreAudioBackend - [AutoSwitch.NativeAudio.CoreAudio]::SetDefaultEndpointAllRoles($DeviceId) - } - - ''' - - # lib/AutoSwitchCore.psm1 - path = 'lib/AutoSwitchCore.psm1' - text = read(path) - text = text.replace('No G HUB or svcl.exe dependency is required for Pester tests.', - 'No G HUB or third-party audio utility is required for Pester tests.') - text = replace_once(text, 'function Get-ConfigDetectionMode {', native_ps + '\nfunction Get-ConfigDetectionMode {', path) - old_export = 'Export-ModuleMember -Function Get-RenderItemIdFromText, Resolve-HeadsetState, Test-ValidAudioConfig, New-GHubTimeoutToken, ConvertFrom-SvclCsv, ConvertFrom-CsvLine, Get-CsvColumn, Resolve-EndpointState, Resolve-DetectedState, Test-SvclExportValid, Get-SvclRenderDevice, Get-SvclDeviceLabel, Find-SvclRenderDeviceByIdentity, Get-EndpointFxState, Get-ConfigDetectionMode' - new_export = old_export + ', Initialize-CoreAudioBackend, Get-CoreAudioRenderDevices, Get-CoreAudioDefaultRenderDeviceId, Get-CoreAudioDefaultRenderDeviceIds, Test-CoreAudioDefaultRenderDevice, Set-CoreAudioDefaultRenderDevice' - text = replace_once(text, old_export, new_export, path) - write(path, text) - - # Runtime-PROX2-AutoSwitch.ps1 - path = 'Runtime-PROX2-AutoSwitch.ps1' - text = read(path) - text = text.replace('$SvclPath = Join-Path $InstallDir "svcl.exe"\n', '') - text = text.replace('if (-not (Test-Path $SvclPath)) { exit 11 }\n', '') - text = text.replace('# Logica compartida (extraccion de Item ID, debounce, CSV, estados, config).', - '# Shared logic (Core Audio interop, debounce, endpoint identity, config).') - text = sub_once(text, - r'function Get-DefaultRenderItemId \{.*?\n\}\n\nfunction Set-AudioOutput \{.*?\n\}\n\n# --- Windows endpoint state', - '''function Get-DefaultRenderItemId {\n return Get-CoreAudioDefaultRenderDeviceId\n}\n\nfunction Set-AudioOutput {\n param(\n [Parameter(Mandatory=$true)][string]$DeviceId,\n [Parameter(Mandatory=$true)][string]$Label\n )\n\n $current = $null\n try { $current = Get-DefaultRenderItemId } catch { }\n\n if ($current -and ($current -ieq $DeviceId) -and\n (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId)) {\n return\n }\n\n Set-CoreAudioDefaultRenderDevice -DeviceId $DeviceId\n Start-Sleep -Milliseconds 350\n\n if (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId) {\n Write-AutoSwitchLog "Output changed -> $Label"\n return\n }\n\n # Retry once in case Windows was recreating the endpoint.\n Start-Sleep -Milliseconds 500\n Set-CoreAudioDefaultRenderDevice -DeviceId $DeviceId\n Start-Sleep -Milliseconds 350\n\n if (Test-CoreAudioDefaultRenderDevice -DeviceId $DeviceId) {\n Write-AutoSwitchLog "Output changed -> $Label (second attempt)"\n return\n }\n\n $roles = $null\n try { $roles = Get-CoreAudioDefaultRenderDeviceIds } catch { }\n throw "Core Audio could not set '$Label' for all roles. Expected=$DeviceId Actual=$($roles | ConvertTo-Json -Compress)"\n}\n\n# --- Windows endpoint state''', - path, flags=re.S) - text = sub_once(text, - r'function Get-SvclCsvExport \{.*?\n\}\n\nfunction Get-HeadsetEndpointState \{.*?\n\}\n\n# --- Tray icon', - '''function Get-HeadsetEndpointState {\n <#\n .SYNOPSIS\n Returns 'Connected' / 'Disconnected' / 'Unknown' for the headset endpoint.\n .DESCRIPTION\n - Successful Core Audio enumeration + matching Item ID -> normalized state.\n - Successful enumeration + missing endpoint -> Disconnected.\n - Core Audio failure -> Unknown (do nothing).\n #>\n try {\n $rows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n return 'Unknown'\n }\n\n $row = $rows |\n Where-Object {\n $id = Get-CsvColumn -Row $_ -Names @('Item ID')\n $null -ne $id -and $id.Trim().ToLowerInvariant() -eq [string]$Config.HeadsetId\n } |\n Select-Object -First 1\n\n if (-not $row) {\n return 'Disconnected'\n }\n\n $state = Get-CsvColumn -Row $row -Names @('Device State', 'State')\n if ($null -eq $state) {\n return 'Unknown'\n }\n\n return Resolve-EndpointState -State $state\n}\n\n# --- Tray icon''', - path, flags=re.S) - text = sub_once(text, - r'function Get-RenderDevicesFromCsv \{.*?\n\}', - '''function Get-RenderDevices {\n try { return @(Get-CoreAudioRenderDevices) }\n catch { return @() }\n}''', path, flags=re.S) - text = text.replace('$devices = Get-RenderDevicesFromCsv', '$devices = Get-RenderDevices') - text = sub_once(text, - r' \$csv = Get-SvclCsvExport\n if \(-not \(Test-SvclExportValid -CsvText \$csv\)\) \{.*?\n \}\n\n \$rows = @\(ConvertFrom-SvclCsv -Text \$csv\)', - ''' try {\n $rows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: Core Audio enumeration failed for {0}: {1}" -f $ItemId, $_.Exception.Message) }\n return 'Unknown'\n }''', path, flags=re.S) - text = text.replace('Bluetooth headsets can disappear from the svcl export while off and', - 'Bluetooth headsets can disappear from the Core Audio endpoint list while off and') - text = text.replace('stable across reconnects). Returns Connected/Disconnected/Unknown.', - 'stable across reconnects). Returns Connected/Disconnected/Unknown.') - text = text.replace('What is in the export? Render devices with their states and IDs.', - 'What is currently in Core Audio? Render devices with their states and IDs.') - text = text.replace('in the export. Available row(s):', 'in Core Audio. Available endpoint(s):') - text = text.replace('Could not read the Windows output devices. Is svcl.exe present and the audio system OK?', - 'Could not read the Windows output devices through Core Audio.') - text = text.replace('# The polling (svcl/G HUB, Set-AudioOutput) runs in a PowerShell process', - '# The polling (Core Audio/G HUB, Set-AudioOutput) runs in a PowerShell process') - text = text.replace('(for example a slow /SetDefault or G HUB timeout)', - '(for example a slow Core Audio switch or G HUB timeout)') - write(path, text) - - # Instalar-PROX2-AutoSwitch.ps1 - path = 'Instalar-PROX2-AutoSwitch.ps1' - text = read(path) - text = text.replace('# PowerShell 5.1 on old .NET can negotiate TLS 1.0/1.1 and fail against\n# GitHub/NirSoft. Force TLS 1.2.\n[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n\n', '') - text = text.replace('$SvclPath = Join-Path $InstallDir "svcl.exe"\n', '') - text = sub_once(text, r'\n\$SvclUrl = .*?\n\$ZipPath = .*?\n', '\n', path, flags=re.S) - text = sub_once(text, - r'# SoundVolumeCommandLine: skip the download if svcl\.exe is already installed\..*?# Copy the source version of the runtime and utilities\.', - '''# Native Core Audio backend: no third-party audio executable is downloaded.\nWrite-Host "[1/6] Checking native Windows Core Audio..." -ForegroundColor Yellow\ntry {\n $nativeDevices = @(Get-CoreAudioRenderDevices)\n if ($nativeDevices.Count -eq 0) {\n throw "Windows returned no render endpoints."\n }\n [void](Get-CoreAudioDefaultRenderDeviceId)\n}\ncatch {\n throw "Native Windows Core Audio is unavailable: $($_.Exception.Message)"\n}\nWrite-Host " Core Audio OK ($($nativeDevices.Count) render endpoint(s))." -ForegroundColor Green\n\n# Remove a stale dependency left by installations older than the native backend.\n$legacySvclPath = Join-Path $InstallDir "svcl.exe"\nif (Test-Path $legacySvclPath) {\n Remove-Item $legacySvclPath -Force -ErrorAction SilentlyContinue\n}\n\n# Copy the source version of the runtime and utilities.''', - path, flags=re.S) - text = text.replace('[3/7]', '[2/6]').replace('[4/7]', '[3/6]').replace('[5/7]', '[4/6]').replace('[6/7]', '[5/6]').replace('[7/7]', '[6/6]') - text = sub_once(text, - r'function Get-DefaultColumn \{.*?\n\}\n\nfunction Get-DefaultRenderItemId \{.*?\n\}', - '''function Get-DefaultRenderItemId {\n return Get-CoreAudioDefaultRenderDeviceId\n}''', path, flags=re.S) - text = sub_once(text, - r'function Test-SetDefault \{.*?\n\}\n\ntry \{', - '''function Test-SetDefault {\n param(\n [Parameter(Mandatory=$true)][string]$Id,\n [Parameter(Mandatory=$true)][string]$Label\n )\n\n Write-Host ""\n Write-Host "Testing real switch -> $Label" -ForegroundColor Yellow\n\n try {\n Set-CoreAudioDefaultRenderDevice -DeviceId $Id\n }\n catch {\n Write-Host (" TEST FAILED. Core Audio error: {0}" -f $_.Exception.Message) -ForegroundColor Red\n return $false\n }\n\n Start-Sleep -Milliseconds 800\n if (Test-CoreAudioDefaultRenderDevice -DeviceId $Id) {\n Write-Host " TEST OK (Console/Multimedia/Communications)" -ForegroundColor Green\n return $true\n }\n\n $actual = $null\n try { $actual = Get-CoreAudioDefaultRenderDeviceIds } catch { }\n Write-Host (" TEST FAILED. Actual roles: {0}" -f ($actual | ConvertTo-Json -Compress)) -ForegroundColor Red\n return $false\n}\n\ntry {''', path, flags=re.S) - text = sub_once(text, - r' \$csvText = \(& \$SvclPath /scomma "" 2>&1 \| Out-String\)\.Trim\(\)\n if \(\[string\]::IsNullOrWhiteSpace\(\$csvText\)\) \{.*?\n \}\n\n \$renderRows = @\(Get-SvclRenderDevice -CsvText \$csvText\)', - ''' try {\n $renderRows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n throw "Could not read the Windows audio device list through Core Audio: $($_.Exception.Message)"\n }''', path, flags=re.S) - text = sub_once(text, - r' \$txt = \(& \$SvclPath /scomma "" 2>&1 \| Out-String\)\.Trim\(\)\n if \(-not \(Test-SvclExportValid -CsvText \$txt\)\) \{\n return \[pscustomobject\]@\{ State = \'Unknown\'; FoundId = \$null \}\n \}\n\n \$rows = @\(ConvertFrom-SvclCsv -Text \$txt\)', - ''' try {\n $rows = @(Get-CoreAudioRenderDevices)\n }\n catch {\n return [pscustomobject]@{ State = 'Unknown'; FoundId = $null }\n }''', path) - text = text.replace('Resolve the same Render endpoint by its real svcl identity rather than', - 'Resolve the same Render endpoint by its native Core Audio identity rather than') - write(path, text) - - # Verificar-PROX2-AutoSwitch.ps1 - path = 'Verificar-PROX2-AutoSwitch.ps1' - text = read(path) - text = text.replace('$SvclPath = Join-Path $InstallDir "svcl.exe"\n', '') - text = text.replace('Show-Test "svcl.exe" (Test-Path $SvclPath) $SvclPath\n', '') - text = text.replace('# Module functions (Get-ConfigDetectionMode, ConvertFrom-SvclCsv, Get-EndpointFxState).', - '# Module functions (Core Audio backend, detection mode and enhancements).') - marker = '''if (Test-Path $ModulePath) {\n Import-Module $ModulePath -ErrorAction SilentlyContinue\n}\n''' - insert = marker + '''\n$coreAudioOk = $false\n$coreAudioDetail = 'Module unavailable'\nif (Test-Path $ModulePath) {\n try {\n $render = @(Get-CoreAudioRenderDevices)\n $defaultId = Get-CoreAudioDefaultRenderDeviceId\n $coreAudioOk = ($render.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($defaultId))\n $coreAudioDetail = "$($render.Count) render endpoint(s); default=$defaultId"\n }\n catch {\n $coreAudioDetail = $_.Exception.Message\n }\n}\nShow-Test "Native Windows Core Audio" $coreAudioOk $coreAudioDetail\n''' - text = replace_once(text, marker, insert, path) - text = sub_once(text, - r' \$csv = \(& \$SvclPath /scomma "" 2>&1 \| Out-String\)\.Trim\(\)\n \$rows = @\(ConvertFrom-SvclCsv -Text \$csv\)', - ' $rows = @(Get-CoreAudioRenderDevices)', path) - write(path, text) - - # README.md - path = 'README.md' - text = read(path) - text = text.replace('SoundVolumeCommandLine /SetDefault all', 'Windows Core Audio / IPolicyConfig → all default roles') - text = text.replace('A transient `svcl.exe` or G HUB failure therefore cannot send audio to the wrong device on a single bad read.', - 'A transient Core Audio or G HUB failure therefore cannot send audio to the wrong device on a single bad read.') - text = text.replace('- Internet access during installation so `svcl.exe` can be downloaded and hash-verified.\n', '') - text = text.replace('- downloads SoundVolumeCommandLine from NirSoft only when needed and verifies its SHA-256 before execution;', - '- uses the Windows Core Audio APIs in-process, so no third-party audio-control executable is downloaded;') - text = sub_once(text, - r'\n## Important `svcl\.exe` regression guard\n.*?\n## Security\n', - '\n## Native Windows audio backend\n\nEndpoint enumeration, state reads and default-device verification now use Windows Core Audio directly in-process. The project keeps its existing PowerShell structure and embedded C# COM bridge; no separate audio-control executable is downloaded. Setting the default endpoint uses the same `IPolicyConfig` COM interop family already used by the project for Audio Enhancements, and every switch is verified across Console, Multimedia and Communications roles with one bounded retry.\n\n## Security\n', - path, flags=re.S) - text = text.replace('- The installer verifies the SHA-256 of the NirSoft download before running it.\n', - '- Audio endpoint enumeration and switching run in-process; installation no longer downloads a third-party audio-control binary.\n') - write(path, text) - - # AGENT.md - path = 'AGENT.md' - text = read(path) - text = text.replace(' `svcl.exe /scomma` export and map it:', ' native Core Audio endpoint list and map it:') - text = text.replace('the two real `svcl`\n identity columns (`Device Name` + `Name`)', - 'the two native endpoint identity properties\n (`PKEY_DeviceInterface_FriendlyName` + `PKEY_Device_DeviceDesc`)') - text = sub_once(text, - r'3\. To set the output:.*?6\. Unknown state \(svcl failure, `Disabled`, garbage\):', - '''3. To set the output, use the in-process Core Audio bridge in `lib/AutoSwitchCore.psm1`.\n `Set-CoreAudioDefaultRenderDevice` applies the target to Console, Multimedia and Communications.\n\n4. To read the current defaults, use `Get-CoreAudioDefaultRenderDeviceIds`.\n Do not infer success from the setter alone.\n\n5. Verify every switch:\n - set all three roles;\n - re-read Console, Multimedia and Communications;\n - require every role to match the target;\n - allow a single short retry.\n\n6. Unknown state (Core Audio failure, `Disabled`, unmapped state):''', - path, flags=re.S) - text = text.replace('Do NOT run svcl/G HUB/Set-AudioOutput on the UI thread.', - 'Do NOT run Core Audio/G HUB/Set-AudioOutput on the UI thread.') - text = text.replace(' Do **NOT** use `svcl /SetBooleanFxProperty` for this (individual effects only, not the global\n "Disable audio enhancements" switch).\n', '') - text = text.replace(' `Add-Type` and expose a static method (`AutoSwitch.AudioEnhancements.SetSysFx`,\n `AutoSwitch.EndpointFx.ReadSysFx`).', - ' `Add-Type` and expose static methods for endpoint enumeration/default switching and enhancements.') - text = sub_once(text, - r'\n## SoundVolumeCommandLine\n.*?\n## Required tests after any change\n', - '''\n## Native Core Audio backend\n\n- `IMMDeviceEnumerator` / `IMMDevice` enumerate render endpoints, states and endpoint IDs.\n- `IPropertyStore` reads `PKEY_Device_DeviceDesc`, `PKEY_DeviceInterface_FriendlyName` and `PKEY_Device_FriendlyName`.\n- `IPolicyConfig::SetDefaultEndpoint` is used for Console, Multimedia and Communications, then all three roles are re-read and verified.\n- `IPolicyConfig` is not a documented public Windows API. This project already depended on the same COM family for Audio Enhancements; keep the boundary isolated in embedded C# and fail safely on HRESULT errors.\n- Clean install no longer downloads or hashes a third-party audio-control executable.\n\n## Required tests after any change\n''', - path, flags=re.S) - text = text.replace('- NirSoft hash differs: abort install.\n', '') - text = text.replace('- svcl `State` read fails or is `Disabled`/garbage: `Unknown`, do not switch.', - '- Core Audio enumeration/state read fails or is `Disabled`/unmapped: `Unknown`, do not switch.') - write(path, text) - - # SOURCES.md: add authoritative Windows Core Audio sources and remove NirSoft section if present. - path = 'SOURCES.md' - text = read(path) - text = re.sub(r'\n## .*?(?:SoundVolumeCommandLine|NirSoft).*?(?=\n## |\Z)', '\n', text, flags=re.S | re.I) - if 'IMMDeviceEnumerator::EnumAudioEndpoints' not in text: - text += '''\n\n## Windows Core Audio endpoint APIs\n\n- Microsoft Learn — `IMMDeviceEnumerator::EnumAudioEndpoints`: documents render/capture endpoint enumeration and device-state masks.\n https://learn.microsoft.com/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-immdeviceenumerator-enumaudioendpoints\n- Microsoft Learn — Core Audio device properties: documents `PKEY_DeviceInterface_FriendlyName`, `PKEY_Device_DeviceDesc`, `PKEY_Device_FriendlyName`, endpoint IDs and container IDs.\n https://learn.microsoft.com/windows/win32/coreaudio/device-properties\n- Microsoft Learn — `IMMDeviceEnumerator::GetDefaultAudioEndpoint`: documents reading the current default endpoint by role.\n https://learn.microsoft.com/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-immdeviceenumerator-getdefaultaudioendpoint\n\n`IPolicyConfig::SetDefaultEndpoint` is an undocumented Windows COM interface. It is intentionally isolated inside the embedded C# bridge and is treated as a compatibility risk, not as a supported Microsoft API.\n''' - write(path, text) - - # CHANGELOG.md - path = 'CHANGELOG.md' - text = read(path) - note = '''\n### Changed\n\n- Replaced the downloaded SoundVolumeCommandLine dependency with an in-process Windows Core Audio COM backend for endpoint enumeration, state reads, default-device reads and output switching.\n- Default-output changes are now verified across Console, Multimedia and Communications roles before being accepted.\n- Clean installs remove a stale legacy `svcl.exe` when present and no longer require a third-party audio-control download.\n''' - if 'in-process Windows Core Audio COM backend' not in text: - if '## [Unreleased]' in text: - text = text.replace('## [Unreleased]', '## [Unreleased]' + note, 1) - else: - text = '# Changelog\n\n## [Unreleased]' + note + '\n' + text - write(path, text) - - # tests/AutoSwitchCore.Tests.ps1 - path = 'tests/AutoSwitchCore.Tests.ps1' - text = read(path) - if "Describe 'Native Core Audio bridge'" not in text: - text += r'''\n\nDescribe 'Native Core Audio bridge' {\n It 'exports the native Core Audio commands' {\n (Get-Command Initialize-CoreAudioBackend -ErrorAction Stop).Name | Should -Be 'Initialize-CoreAudioBackend'\n (Get-Command Get-CoreAudioRenderDevices -ErrorAction Stop).Name | Should -Be 'Get-CoreAudioRenderDevices'\n (Get-Command Get-CoreAudioDefaultRenderDeviceId -ErrorAction Stop).Name | Should -Be 'Get-CoreAudioDefaultRenderDeviceId'\n (Get-Command Set-CoreAudioDefaultRenderDevice -ErrorAction Stop).Name | Should -Be 'Set-CoreAudioDefaultRenderDevice'\n }\n\n It 'compiles the embedded COM bridge without touching hardware' {\n { Initialize-CoreAudioBackend } | Should -Not -Throw\n ('AutoSwitch.NativeAudio.CoreAudio' -as [type]) | Should -Not -BeNullOrEmpty\n }\n}\n'''.replace('\\n', '\n') - write(path, text) - - # One-shot workflow: remove itself from the final branch diff. - Path('.github/workflows/apply-native-core-audio.yml').unlink() - - - name: Validate PowerShell syntax - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $scripts = Get-ChildItem -Path . -Include '*.ps1','*.psm1' -File -Recurse - foreach ($script in $scripts) { - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($script.FullName, [ref]$tokens, [ref]$errors) | Out-Null - if ($errors.Count -gt 0) { - $errors | ForEach-Object { Write-Error "$($script.FullName): $($_.Message) (line $($_.Extent.StartLineNumber))" } - exit 1 - } - } - - - name: Run Pester tests - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - if (-not (Get-Module -ListAvailable -Name Pester)) { - Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck - } - $config = New-PesterConfiguration - $config.Run.Path = 'tests' - $config.Output.Verbosity = 'Detailed' - $result = Invoke-Pester -Configuration $config - if ($result.FailedCount -gt 0) { exit 1 } - - - name: Ensure external svcl dependency is gone from active code/docs - shell: pwsh - run: | - $hits = git grep -n -i -E 'svcl\.exe|SoundVolumeCommandLine|NirSoft' -- ':!CHANGELOG.md' ':!docs/WindowsEndpointProvider.md' ':!wiki/*' ':!site/*' 2>$null - if ($hits) { - $hits | Write-Host - throw 'Active code or canonical docs still reference the removed svcl dependency.' - } - - - name: Commit migration - shell: pwsh - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - if (git diff --cached --quiet) { exit 0 } - git commit -m 'feat: replace svcl with native Core Audio' - git push origin HEAD:agent/native-core-audio From 3b5d80d671814a7ada2bd2f55c9c59831dc5619f Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:16:16 +0200 Subject: [PATCH 7/8] fix: avoid English submenu false positive --- scripts/check-language.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-language.py b/scripts/check-language.py index 9f0ccda..ce05c36 100644 --- a/scripts/check-language.py +++ b/scripts/check-language.py @@ -33,7 +33,7 @@ r"peticion|esperando|cerro|limite global|eventos asincronos|devuelve|" r"detectado|segundo intento|consiguio|estado del endpoint|exporta|fila|" r"ausente|desconocido|preseleccionar|cambio fisico|ultimo estado|" - r"encontrado por|nuevo item|reintentar|submen[uú]" + r"encontrado por|nuevo item|reintentar|submenú" r")\b", re.IGNORECASE, ), From 46f2479f8b6bc129e648527370eadc1a038a7273 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:21:23 +0200 Subject: [PATCH 8/8] ci: allow plural collection helper names --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 724a923..bda1314 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -60,7 +60,7 @@ jobs: shell: powershell run: | Import-Module PSScriptAnalyzer -RequiredVersion 1.24.0 - $r=Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSAvoidUsingPlainTextForPassword,PSAvoidUsingCmdletAliases,PSAvoidUsingEmptyCatchBlock,PSUseApprovedVerbs,PSUseShouldProcessForStateChangingFunctions + $r=Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSAvoidUsingPlainTextForPassword,PSAvoidUsingCmdletAliases,PSAvoidUsingEmptyCatchBlock,PSUseApprovedVerbs,PSUseShouldProcessForStateChangingFunctions,PSUseSingularNouns if($r){$r|Format-Table -AutoSize|Out-String|Write-Host;exit 1} - name: Pester shell: powershell