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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 206 additions & 24 deletions tools/sunshine-ds5-sidecar/DefaultAudioEndpointGuard.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Channels;
using Microsoft.Win32;

namespace Sunshine.Ds5Sidecar;

/// <summary>
/// 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.
/// </summary>
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,
Expand All @@ -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<bool> _endpointChangeSignal =
Channel.CreateBounded<bool>(new BoundedChannelOptions(1)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropWrite,
});
private readonly Action<AudioRole> _onViolation;
private readonly Task _worker;
private int _reported;
Expand All @@ -37,24 +53,31 @@ internal DefaultAudioEndpointGuard(Action<AudioRole> 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<AudioRole>())
{
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)
{
Expand All @@ -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<DataFlow>())
{
foreach (var role in Enum.GetValues<AudioRole>())
{
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
{
Expand All @@ -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<DeviceNodeIdentity>();
for (var depth = 0; depth < 12; ++depth)
{
Expand Down Expand Up @@ -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<DataFlow, AudioRole, string?> _onDefaultDeviceChanged;

internal EndpointNotificationClient(Action<DataFlow, AudioRole, string?> 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[PreserveSig]
int OnPropertyValueChanged([MarshalAs(UnmanagedType.LPWStr)] string deviceId,
PropertyKey propertyKey);
}

[ComImport]
Expand All @@ -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]
Expand Down
94 changes: 94 additions & 0 deletions tools/sunshine-ds5-sidecar/DefaultAudioEndpointPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Microsoft.Win32;

namespace Sunshine.Ds5Sidecar;

/// <summary>
/// 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.
/// </summary>
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;

/// <summary>
/// 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.
/// </summary>
/// <returns>True when a matching interface was found.</returns>
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;
}
}
Loading
Loading