diff --git a/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs index 0a60f2c8..6ac3ae9b 100644 --- a/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs +++ b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs @@ -1,18 +1,22 @@ using System.Runtime.InteropServices; using System.Text; +using System.Threading.Channels; using Microsoft.Win32; namespace Sunshine.Ds5Sidecar; /// -/// Fails closed when Windows selects the virtual HIDMaestro DualSense speaker -/// as a default render endpoint. This is intentionally read-only: changing -/// Windows audio policy relies on undocumented APIs and would make the helper -/// responsible for restoring user preferences after crashes or upgrades. +/// Fails closed when Windows selects a virtual HIDMaestro DualSense audio +/// endpoint as a default render or capture endpoint. The guard is a read-only +/// fallback for systems where the documented never-default policy cannot be +/// applied. /// internal sealed class DefaultAudioEndpointGuard : IDisposable { - internal enum AudioRole : byte + private const int DataFlowCount = 2; + private const int AudioRoleCount = 3; + + internal enum AudioRole { Console = 0, Multimedia = 1, @@ -24,6 +28,18 @@ internal enum AudioRole : byte private static readonly Guid MmDeviceEnumeratorClass = new("BCDE0395-E52F-467C-8E3D-C4579291692E"); private readonly CancellationTokenSource _stopping = new(); + // Each flow/role pair owns one atomic latest-value slot. The capacity-one + // channel is only a non-blocking wakeup, so notification bursts coalesce + // without allowing a backlog to grow. + private readonly DefaultEndpointChange?[] _pendingEndpointChanges = + new DefaultEndpointChange?[DataFlowCount * AudioRoleCount]; + private readonly Channel _endpointChangeSignal = + Channel.CreateBounded(new BoundedChannelOptions(1) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropWrite, + }); private readonly Action _onViolation; private readonly Task _worker; private int _reported; @@ -37,24 +53,31 @@ internal DefaultAudioEndpointGuard(Action onViolation) private async Task MonitorAsync() { IMMDeviceEnumerator? enumerator = null; + EndpointNotificationClient? notification = null; + var registered = false; try { var type = Type.GetTypeFromCLSID(MmDeviceEnumeratorClass, throwOnError: true)!; enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(type)!; - using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(400)); - do + notification = new EndpointNotificationClient(OnDefaultDeviceChanged); + var registrationResult = enumerator.RegisterEndpointNotificationCallback(notification); + registered = registrationResult >= 0; + + // Register before taking the initial snapshot so a default-device + // change racing startup is either observed by the callback or by + // the snapshot (and harmlessly deduplicated by _reported). + CheckCurrentDefaults(enumerator); + if (registered) { - foreach (var role in Enum.GetValues()) - { - if (IsDefaultVirtualDualSense(enumerator, role) && - Interlocked.Exchange(ref _reported, 1) == 0) - { - _onViolation(role); - return; - } - } + await ProcessEndpointChangesAsync(); + } + else + { + Console.Error.WriteLine( + $"Unable to register the default audio endpoint monitor (0x{registrationResult:X8}); " + + "falling back to low-frequency polling"); + await PollDefaultsAsync(enumerator); } - while (await timer.WaitForNextTickAsync(_stopping.Token)); } catch (OperationCanceledException) when (_stopping.IsCancellationRequested) { @@ -68,23 +91,113 @@ private async Task MonitorAsync() } finally { + if (registered && enumerator is not null && notification is not null) + { + var result = enumerator.UnregisterEndpointNotificationCallback(notification); + if (result < 0 && !_stopping.IsCancellationRequested) + Console.Error.WriteLine($"Unable to unregister the default audio endpoint monitor: 0x{result:X8}"); + } if (enumerator is not null && Marshal.IsComObject(enumerator)) Marshal.FinalReleaseComObject(enumerator); + GC.KeepAlive(notification); + } + } + + private void CheckCurrentDefaults(IMMDeviceEnumerator enumerator) + { + foreach (var flow in Enum.GetValues()) + { + foreach (var role in Enum.GetValues()) + { + if (TryGetDefaultEndpointId(enumerator, flow, role, out var endpointId)) + ReportIfVirtualDualSense(role, endpointId); + } + } + } + + private async Task PollDefaultsAsync(IMMDeviceEnumerator enumerator) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2)); + while (await timer.WaitForNextTickAsync(_stopping.Token)) + CheckCurrentDefaults(enumerator); + } + + private async Task ProcessEndpointChangesAsync() + { + while (await _endpointChangeSignal.Reader.WaitToReadAsync(_stopping.Token)) + { + _endpointChangeSignal.Reader.TryRead(out _); + for (var slot = 0; slot < _pendingEndpointChanges.Length; ++slot) + { + var change = Interlocked.Exchange(ref _pendingEndpointChanges[slot], null); + if (change is null) + continue; + try + { + ReportIfVirtualDualSense(change.Role, change.EndpointId); + } + catch (Exception error) + { + Console.Error.WriteLine( + $"Unable to inspect the changed {change.Flow} default audio endpoint: {error.Message}"); + } + if (Volatile.Read(ref _reported) != 0) + return; + } + } + } + + private void OnDefaultDeviceChanged(DataFlow flow, AudioRole role, string? endpointId) + { + if (endpointId is null || _stopping.IsCancellationRequested || + Volatile.Read(ref _reported) != 0 || !TryGetEndpointSlot(flow, role, out var slot)) + { + return; + } + Interlocked.Exchange( + ref _pendingEndpointChanges[slot], new DefaultEndpointChange(flow, role, endpointId)); + _endpointChangeSignal.Writer.TryWrite(true); + } + + private static bool TryGetEndpointSlot(DataFlow flow, AudioRole role, out int slot) + { + var flowIndex = (int)flow; + var roleIndex = (int)role; + if ((uint)flowIndex >= DataFlowCount || (uint)roleIndex >= AudioRoleCount) + { + slot = -1; + return false; + } + slot = flowIndex * AudioRoleCount + roleIndex; + return true; + } + + private void ReportIfVirtualDualSense(AudioRole role, string endpointId) + { + if (Volatile.Read(ref _reported) != 0 || + !IsVirtualDualSenseEndpoint(endpointId) || + Interlocked.Exchange(ref _reported, 1) != 0) + { + return; } + _onViolation(role); } - private static bool IsDefaultVirtualDualSense(IMMDeviceEnumerator enumerator, AudioRole role) + private static bool TryGetDefaultEndpointId( + IMMDeviceEnumerator enumerator, DataFlow flow, AudioRole role, out string endpointId) { + endpointId = string.Empty; IMMDevice? endpoint = null; try { // AUDCLNT_E_DEVICE_INVALIDATED and E_NOTFOUND are normal while an // endpoint is being created or removed, so treat any failed lookup - // as "not currently default" and retry on the next poll. - if (enumerator.GetDefaultAudioEndpoint(DataFlow.Render, role, out endpoint) < 0 || endpoint is null || - endpoint.GetId(out var endpointId) < 0) + // as "not currently default". A later notification (or the rare + // registration-failure polling fallback) will retry it. + if (enumerator.GetDefaultAudioEndpoint(flow, role, out endpoint) < 0 || endpoint is null || + endpoint.GetId(out endpointId) < 0) return false; - return IsVirtualDualSenseEndpoint(endpointId); + return true; } finally { @@ -101,6 +214,21 @@ internal static bool IsVirtualDualSenseEndpoint(string endpointId) if (CM_Locate_DevNodeW(out var node, instanceId, 0) != 0) return false; + return IsVirtualDualSenseDeviceNode(node); + } + + internal static bool IsVirtualDualSenseDeviceNode(string instanceId, bool includePhantom) + { + if (CM_Locate_DevNodeW(out var node, instanceId, 0) != 0 && + (!includePhantom || CM_Locate_DevNodeW(out node, instanceId, 1) != 0)) + { + return false; + } + return IsVirtualDualSenseDeviceNode(node); + } + + private static bool IsVirtualDualSenseDeviceNode(uint node) + { var chain = new List(); for (var depth = 0; depth < 12; ++depth) { @@ -172,6 +300,60 @@ public void Dispose() private enum DataFlow { Render = 0, + Capture = 1, + } + + private sealed record DefaultEndpointChange( + DataFlow Flow, AudioRole Role, string EndpointId); + + [ComVisible(true)] + [ClassInterface(ClassInterfaceType.None)] + private sealed class EndpointNotificationClient : IMMNotificationClient + { + private readonly Action _onDefaultDeviceChanged; + + internal EndpointNotificationClient(Action onDefaultDeviceChanged) + { + _onDefaultDeviceChanged = onDefaultDeviceChanged; + } + + public int OnDeviceStateChanged(string deviceId, uint newState) => 0; + public int OnDeviceAdded(string deviceId) => 0; + public int OnDeviceRemoved(string deviceId) => 0; + + public int OnDefaultDeviceChanged(DataFlow flow, AudioRole role, string? defaultDeviceId) + { + _onDefaultDeviceChanged(flow, role, defaultDeviceId); + return 0; + } + + public int OnPropertyValueChanged(string deviceId, PropertyKey propertyKey) => 0; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct PropertyKey + { + private readonly Guid _formatId; + private readonly uint _propertyId; + } + + [ComImport] + [Guid("7991EEC9-7E89-4D85-8390-6C703CEC60C0")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMNotificationClient + { + [PreserveSig] + int OnDeviceStateChanged([MarshalAs(UnmanagedType.LPWStr)] string deviceId, uint newState); + [PreserveSig] + int OnDeviceAdded([MarshalAs(UnmanagedType.LPWStr)] string deviceId); + [PreserveSig] + int OnDeviceRemoved([MarshalAs(UnmanagedType.LPWStr)] string deviceId); + [PreserveSig] + int OnDefaultDeviceChanged(DataFlow flow, AudioRole role, + [MarshalAs(UnmanagedType.LPWStr)] string? defaultDeviceId); + [PreserveSig] + int OnPropertyValueChanged([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + PropertyKey propertyKey); } [ComImport] @@ -186,9 +368,9 @@ private interface IMMDeviceEnumerator [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice endpoint); [PreserveSig] - int RegisterEndpointNotificationCallback(IntPtr callback); + int RegisterEndpointNotificationCallback(IMMNotificationClient callback); [PreserveSig] - int UnregisterEndpointNotificationCallback(IntPtr callback); + int UnregisterEndpointNotificationCallback(IMMNotificationClient callback); } [ComImport] diff --git a/tools/sunshine-ds5-sidecar/DefaultAudioEndpointPolicy.cs b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointPolicy.cs new file mode 100644 index 00000000..036f9ed4 --- /dev/null +++ b/tools/sunshine-ds5-sidecar/DefaultAudioEndpointPolicy.cs @@ -0,0 +1,94 @@ +using Microsoft.Win32; + +namespace Sunshine.Ds5Sidecar; + +/// +/// Applies Windows' documented never-default policy to HIDMaestro-backed +/// DualSense audio interfaces. The policy is scoped to the concrete virtual +/// device instance, so a physical DualSense with the same VID/PID is untouched. +/// +internal static class DefaultAudioEndpointPolicy +{ + private const string AudioInterfaceClass = + @"SYSTEM\CurrentControlSet\Control\DeviceClasses\{6994ad04-93ef-11d0-a3cc-00a0c9223196}"; + private const string EndpointAssociation = + "{1DA5D803-D492-4EDD-8C23-E0C0FFEE7F0E},2"; + private const string NeverSetAsDefaultEndpoint = + "{F3E80BEF-1723-4FF2-BCC4-7F83DC5E46D4},3"; + private const string AnyKsNodeType = "{00000000-0000-0000-0000-000000000000}"; + // FLOW_MASK_RENDER | FLOW_MASK_CAPTURE | every default-device role. + private const int AllRolesAndFlows = 0x00000307; + + /// + /// Finds the virtual DualSense KS audio interface and applies the endpoint + /// policy. A short poll is needed because the USB audio child appears + /// asynchronously after HIDMaestro creates the composite device. + /// + /// True when a matching interface was found. + internal static bool EnsureNeverDefault(TimeSpan timeout, bool includePhantom, out bool changed) + { + var deadline = DateTime.UtcNow + timeout; + do + { + if (EnsureOnce(includePhantom, out changed)) + return true; + if (DateTime.UtcNow >= deadline) + break; + Thread.Sleep(50); + } + while (true); + + changed = false; + return false; + } + + private static bool EnsureOnce(bool includePhantom, out bool changed) + { + changed = false; + var matched = false; + using var audioInterfaces = Registry.LocalMachine.OpenSubKey(AudioInterfaceClass, writable: false); + if (audioInterfaces is null) + return false; + + foreach (var interfaceName in audioInterfaces.GetSubKeyNames()) + { + var interfacePath = AudioInterfaceClass + "\\" + interfaceName; + using var interfaceKey = Registry.LocalMachine.OpenSubKey(interfacePath, writable: false); + if (interfaceKey?.GetValue("DeviceInstance") is not string deviceInstance || + !DefaultAudioEndpointGuard.IsVirtualDualSenseDeviceNode(deviceInstance, includePhantom)) + { + continue; + } + + foreach (var referenceName in interfaceKey.GetSubKeyNames()) + { + var parametersPath = interfacePath + "\\" + referenceName + "\\Device Parameters"; + using var parameters = Registry.LocalMachine.OpenSubKey(parametersPath, writable: true); + if (parameters is null) + continue; + + using var endpoint = parameters.CreateSubKey(@"EP\0", writable: true); + if (endpoint is null) + continue; + + matched = true; + if (NeedsUpdate(endpoint.GetValue(EndpointAssociation), endpoint.GetValue(NeverSetAsDefaultEndpoint))) + { + endpoint.SetValue(EndpointAssociation, AnyKsNodeType, RegistryValueKind.String); + endpoint.SetValue(NeverSetAsDefaultEndpoint, AllRolesAndFlows, RegistryValueKind.DWord); + changed = true; + } + } + } + + return matched; + } + + internal static bool NeedsUpdate(object? association, object? policy) + { + return association is not string associationText || + !associationText.Equals(AnyKsNodeType, StringComparison.OrdinalIgnoreCase) || + policy is not int policyMask || + (policyMask & AllRolesAndFlows) != AllRolesAndFlows; + } +} diff --git a/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs b/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs index fda7d853..3e008f30 100644 --- a/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs +++ b/tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs @@ -150,10 +150,13 @@ internal static void RunDeterministicChecks() VerifyBundledCompositeProfile(); VerifyHapticsChannelIsolation(); VerifyDefaultAudioEndpointClassification(); + VerifyDefaultAudioEndpointPolicy(); } private static void VerifyDefaultAudioEndpointClassification() { + Require(Enum.GetUnderlyingType(typeof(DefaultAudioEndpointGuard.AudioRole)) == typeof(int), + "default audio role COM width"); var virtualDualSense = new[] { new DefaultAudioEndpointGuard.DeviceNodeIdentity( @@ -176,6 +179,18 @@ private static void VerifyDefaultAudioEndpointClassification() }), "unrelated HIDMaestro endpoint exclusion"); } + private static void VerifyDefaultAudioEndpointPolicy() + { + Require(DefaultAudioEndpointPolicy.NeedsUpdate(null, null), + "missing default audio endpoint policy"); + Require(DefaultAudioEndpointPolicy.NeedsUpdate( + "{00000000-0000-0000-0000-000000000000}", 0x00000101), + "partial default audio endpoint policy"); + Require(!DefaultAudioEndpointPolicy.NeedsUpdate( + "{00000000-0000-0000-0000-000000000000}", 0x00000307), + "complete default audio endpoint policy"); + } + private static void VerifyBundledCompositeProfile() { using var stream = typeof(ProtocolSelfTest).Assembly.GetManifestResourceStream( diff --git a/tools/sunshine-ds5-sidecar/SidecarServer.cs b/tools/sunshine-ds5-sidecar/SidecarServer.cs index de00eddd..c0fe1818 100644 --- a/tools/sunshine-ds5-sidecar/SidecarServer.cs +++ b/tools/sunshine-ds5-sidecar/SidecarServer.cs @@ -205,7 +205,26 @@ private void Attach(uint requestId, ReadOnlySpan payload) ?? throw new InvalidOperationException($"HIDMaestro profile '{profileId}' is missing"); if (!profile.RequiresUsbipBackend) _context.InstallDriver(); + + if (profile.RequiresUsbipBackend) + { + try + { + // Seed a previously seen virtual interface before it becomes + // present. This avoids even a transient default-device switch + // on every attach after the first one. + DefaultAudioEndpointPolicy.EnsureNeverDefault( + TimeSpan.Zero, includePhantom: true, out _); + } + catch (Exception error) + { + Console.Error.WriteLine($"Unable to preseed the DualSense audio endpoint policy: {error.Message}"); + } + } + var controller = _context.CreateController(profile); + if (profile.RequiresUsbipBackend) + controller = ApplyDefaultAudioEndpointPolicy(controller, profile); ControllerSession session; try { @@ -238,6 +257,46 @@ private void Attach(uint requestId, ReadOnlySpan payload) session.StartDefaultAudioEndpointGuard(); } + private HMController ApplyDefaultAudioEndpointPolicy(HMController controller, HMProfile profile) + { + const int recreationLimit = 2; + for (var recreation = 0; recreation <= recreationLimit; ++recreation) + { + bool changed; + try + { + if (!DefaultAudioEndpointPolicy.EnsureNeverDefault( + TimeSpan.FromSeconds(3), includePhantom: false, out changed)) + { + Console.Error.WriteLine( + "Unable to find the virtual DualSense audio interface; runtime default-device guard remains active"); + return controller; + } + } + catch (Exception error) + { + Console.Error.WriteLine( + $"Unable to apply the DualSense audio endpoint policy: {error.Message}; " + + "runtime default-device guard remains active"); + return controller; + } + + if (!changed) + return controller; + + // AudioEndpointBuilder consumes the EP properties when it creates + // the MMDevice endpoint. Once disposal begins, creation failures + // must propagate rather than returning a disposed controller. + controller.Dispose(); + controller = _context.CreateController(profile); + } + + Console.Error.WriteLine( + "DualSense audio endpoint identity did not stabilize after policy provisioning; " + + "runtime default-device guard remains active"); + return controller; + } + private void Detach(uint requestId, ReadOnlySpan payload) { if (payload.Length != 1)