diff --git a/DS4Windows/App.xaml.cs b/DS4Windows/App.xaml.cs index 93bdbdd..6782901 100644 --- a/DS4Windows/App.xaml.cs +++ b/DS4Windows/App.xaml.cs @@ -392,6 +392,19 @@ private void CheckOptions(ArgumentParser parser) exitApp = true; Current.Shutdown(); } + else if (parser.Validatedriver) + { + // Read-only smoke test of the Native Mode driver-validation + // gate. Runs before the ControlService or any window exists, so + // no controller is opened or released, no elevation is + // requested, and no attach can occur. Exit code: 0 pass, + // non-zero fail. + int validationExitCode = + DS4Windows.NativeModeDriverValidationCommand.Run(); + runShutdown = false; + exitApp = true; + Current.Shutdown(validationExitCode); + } else if (parser.ReenableDevice) { DS4Windows.DS4Devices.reEnableDevice(parser.DeviceInstanceId); @@ -748,6 +761,9 @@ private void CleanShutdown() { if (rootHub != null) { + // Application exit must synchronously tear down the child; + // otherwise usbip-win2 keeps the virtual pad attached. + rootHub.StopNativeModeForShutdown(); Task.Run(() => { if (rootHub.running) diff --git a/DS4Windows/ArgumentParser.cs b/DS4Windows/ArgumentParser.cs index ab1f487..20e5bc1 100644 --- a/DS4Windows/ArgumentParser.cs +++ b/DS4Windows/ArgumentParser.cs @@ -26,6 +26,7 @@ public class ArgumentParser private bool mini; private bool stop; private bool driverinstall; + private bool validatedriver; private bool reenableDevice; private string deviceInstanceId; private bool runtask; @@ -39,6 +40,7 @@ public class ArgumentParser public bool Mini { get => mini; } public bool Stop { get => stop; } public bool Driverinstall { get => driverinstall; } + public bool Validatedriver { get => validatedriver; } public bool ReenableDevice { get => reenableDevice; } public bool Runtask { get => runtask; } public bool Command { get => command; } @@ -63,6 +65,13 @@ public void Parse(string[] args) driverinstall = true; break; + // Read-only Native Mode driver-validation smoke test. Prints + // a diagnostic report and exits without starting Native Mode. + case "validatedriver": + case "-validatedriver": + validatedriver = true; + break; + case "re-enabledevice": case "-re-enabledevice": reenableDevice = true; diff --git a/DS4Windows/DS4Control/ControlService.cs b/DS4Windows/DS4Control/ControlService.cs index 8787262..359f6ae 100644 --- a/DS4Windows/DS4Control/ControlService.cs +++ b/DS4Windows/DS4Control/ControlService.cs @@ -25,8 +25,10 @@ You should have received a copy of the GNU General Public License using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.ExceptionServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -38,6 +40,10 @@ namespace DS4Windows { public class ControlService { + private const int NativeModeDeferredCleanupMaximumAttempts = 150; + private static readonly TimeSpan NativeModeDeferredCleanupPollInterval = + TimeSpan.FromMilliseconds(200); + public ViGEmClient vigemTestClient = null; // Might be useful for ScpVBus build public const int EXPANDED_CONTROLLER_COUNT = 8; @@ -93,12 +99,45 @@ public class ControlService private ControlServiceDeviceOptions deviceOptions; public ControlServiceDeviceOptions DeviceOptions { get => deviceOptions; } + private readonly NativeModeManager nativeModeManager = new NativeModeManager(); + private readonly NativeModeDriverGate nativeModeDriverGate = + NativeModeDriverGate.Default; + private readonly NativeModeElevationBroker nativeModeElevationBroker = + new NativeModeElevationBroker(NativeModeDriverGate.Default); + private readonly NativeModeAudioDefaultGuard nativeModeAudioDefaultGuard = + new NativeModeAudioDefaultGuard(); + private readonly NativeModeRenderKeepalive nativeModeRenderKeepalive = + new NativeModeRenderKeepalive(); + private readonly SemaphoreSlim nativeModeLifecycleGate = new SemaphoreSlim(1, 1); + private readonly NativeModeSessionCancellation nativeModeSessionCancellation = + new NativeModeSessionCancellation(); + private readonly object nativeModeDeferredCleanupGate = new object(); + private Task nativeModeDeferredCleanupTask = Task.CompletedTask; + private Task nativeModePendingCommandCompletion = Task.CompletedTask; + private long nativeModeProtectionGeneration; + private int nativeModeAutomaticCleanupInProgress; + private volatile bool nativeModeProtectionReleasePending; + private volatile bool nativeModePendingCommandBarrierActive; + private bool nativeModeDeferredRescanRequested; + private volatile bool nativeModeShutdownRequested; + public NativeModeManager NativeModeManager => nativeModeManager; + public NativeModeElevationBroker NativeModeElevationBroker => + nativeModeElevationBroker; + public bool IsNativeModeSessionActive => + NativeModeLifecyclePolicy.IsSessionActive(nativeModeManager.State, + DS4Devices.NativeModeGuard.IsActive, + nativeModeManager.HasOwnedProcess) || + nativeModeRenderKeepalive.HasSession || + nativeModeProtectionReleasePending || + nativeModePendingCommandBarrierActive; + private DS4WinWPF.ArgumentParser cmdParser; public event EventHandler ServiceStarted; public event EventHandler PreServiceStop; public event EventHandler ServiceStopped; public event EventHandler RunningChanged; + public event EventHandler NativeModeSessionActivityChanged; //public event EventHandler HotplugFinished; public delegate void HotplugControllerHandler(ControlService sender, DS4Device device, int index); public event HotplugControllerHandler HotplugController; @@ -229,6 +268,7 @@ public ControlService(DS4WinWPF.ArgumentParser cmdParser) DS4Devices.PostDS4Init = PostDS4DeviceInit; DS4Devices.PreparePendingDevice = CheckForSupportedDevice; outputslotMan.ViGEmFailure += OutputslotMan_ViGEmFailure; + nativeModeManager.StateChanged += NativeModeManager_StateChanged; Global.UDPServerSmoothingMincutoffChanged += ChangeUdpSmoothingAttrs; Global.UDPServerSmoothingBetaChanged += ChangeUdpSmoothingAttrs; @@ -606,6 +646,7 @@ public void PrepareDS4DeviceInit(DS4Device device) public void ShutDown() { + StopNativeModeForShutdown(); outputslotMan.ShutDown(); OutputSlotPersist.WriteConfig(outputslotMan); @@ -1560,6 +1601,8 @@ public void UnplugOutDev(int index, DS4Device device, bool immediate = false, bo public bool Start(bool showlog = true) { + nativeModeSessionCancellation.ResetForServiceStart(); + nativeModeShutdownRequested = false; inServiceTask = true; StartViGEm(); if (vigemTestClient != null) @@ -1795,10 +1838,20 @@ public void PrepareAbort() public bool Stop(bool showlog = true, bool immediateUnplug = false) { - if (running) + bool wasRunning = running; + if (wasRunning) { running = false; runHotPlug = false; + } + + // Cancel in-progress startup first, then synchronously complete the + // bounded ordered stop. A fail-closed stop may deliberately leave + // the helper and protections active rather than force-killing it. + StopNativeModeForShutdown(); + + if (wasRunning) + { inServiceTask = true; PreServiceStop?.Invoke(this, EventArgs.Empty); @@ -1915,7 +1968,7 @@ public bool Stop(bool showlog = true, bool immediateUnplug = false) public bool HotPlug() { - if (running) + if (running && !nativeModeShutdownRequested) { inServiceTask = true; loopControllers = true; @@ -1945,7 +1998,9 @@ public bool HotPlug() { DS4Device device = devEnum.Current; - if (device.isDisconnectingStatus()) + if (device.isDisconnectingStatus() || + DS4Devices.NativeModeGuard.ShouldSuppress( + device.getMacAddress(), device.HidDevice.DevicePath)) continue; // Use local method rather than Func @@ -2043,6 +2098,787 @@ bool checkAlreadyExists() return true; } + public async Task StartNativeModeAsync(int deviceIndex, + CancellationToken cancellationToken = default) + { + CancellationTokenSource sessionCancellation = + nativeModeSessionCancellation.Begin(cancellationToken); + CancellationToken startupToken = sessionCancellation.Token; + bool lifecycleGateHeld = false; + try + { + await nativeModeLifecycleGate.WaitAsync(startupToken) + .ConfigureAwait(false); + lifecycleGateHeld = true; + startupToken.ThrowIfCancellationRequested(); + + if (IsNativeModeSessionActive) + { + throw new InvalidOperationException( + "Native mode is already active. Stop it before starting another session."); + } + + if (nativeModeShutdownRequested || !running) + throw new InvalidOperationException("DS4Windows service is not running."); + + try + { + NativeModeStartupSafety.EnsureNoExistingVirtualDevice( + NativeModeDevicePresence.IsVirtualDualSensePresent); + } + catch (InvalidOperationException ex) + { + nativeModeManager.MarkSetupRequired(ex.Message); + throw; + } + + if (deviceIndex < 0 || deviceIndex >= DS4Controllers.Length) + throw new ArgumentOutOfRangeException(nameof(deviceIndex)); + + DS4Device device = DS4Controllers[deviceIndex]; + if (device == null || device.IsRemoved || device.IsRemoving || + device.isDisconnectingStatus()) + { + throw new InvalidOperationException( + "The selected controller is no longer available."); + } + + if (device.DeviceType != InputDevices.InputDeviceType.DualSense || + device.getConnectionType() != ConnectionType.BT) + { + throw new InvalidOperationException( + "Native mode requires a Bluetooth DualSense controller."); + } + + // Driver validation gate (doc/dev/native_mode_driver_policy.md + // §4): the usbip-win2 packages and usbip.exe client must match a + // supported release BEFORE the physical controller is released + // or elevation is requested. Fails closed with a specific, + // non-sensitive diagnostic and leaves the controller untouched. + NativeModeDriverValidationResult driverValidation = + nativeModeDriverGate.Validate(Global.UsbipExePath); + if (!driverValidation.Passed) + { + nativeModeManager.MarkSetupRequired(driverValidation.Diagnostic); + AppLogger.LogToGui( + "Native mode driver validation failed (" + + driverValidation.FailedComponent + "/" + + driverValidation.Reason + "): " + + driverValidation.Diagnostic, true); + throw new InvalidOperationException(driverValidation.Diagnostic); + } + + string macAddress = device.getMacAddress(); + string devicePath = device.HidDevice.DevicePath; + var dualSense = (InputDevices.DualSenseDevice)device; + string[] serverArguments = BuildNativeModeServerArguments( + dualSense.NativeOptionsStore, devicePath, + device.HidDevice.Attributes.VendorId, + device.HidDevice.Attributes.ProductId); + NativeModeAttachResult attachFailure = null; + + try + { + startupToken.ThrowIfCancellationRequested(); + await NativeModeStartupOrchestration + .RunWithAudioDefaultProtectionAsync( + nativeModeAudioDefaultGuard.Capture, + snapshot => + { + nativeModeProtectionGeneration++; + nativeModeProtectionReleasePending = true; + nativeModeDeferredRescanRequested = false; + nativeModeAudioDefaultGuard.BeginSession(snapshot); + }, + async () => + { + nativeModeRenderKeepalive.BeginSession(); + long keepaliveGeneration = + nativeModeProtectionGeneration; + Task unexpectedKeepaliveTermination = + nativeModeRenderKeepalive + .WaitForUnexpectedTerminationAsync(); + _ = ObserveNativeModeKeepaliveAsync( + keepaliveGeneration, + unexpectedKeepaliveTermination); + DS4Devices.BeginNativeModeSuppression(macAddress, + devicePath); + await Task.Run(() => + ReleaseControllerForNativeMode(device, + deviceIndex)).ConfigureAwait(false); + + startupToken.ThrowIfCancellationRequested(); + if (nativeModeShutdownRequested) + { + throw new InvalidOperationException( + "DS4Windows is stopping; native mode was canceled."); + } + + await nativeModeManager.StartAsync(serverArguments, + startupToken).ConfigureAwait(false); + + await nativeModeManager.WaitForServingAsync( + TimeSpan.FromSeconds(10), startupToken) + .ConfigureAwait(false); + + NativeModeAttachResult attachResult = + await nativeModeElevationBroker.RunAttachAsync( + Global.UsbipExePath, startupToken) + .ConfigureAwait(false); + if (!attachResult.Success) + { + attachFailure = attachResult; + throw new InvalidOperationException( + attachResult.Reason); + } + + await nativeModeRenderKeepalive.WaitForReadyAsync( + TimeSpan.FromSeconds(10), startupToken) + .ConfigureAwait(false); + await nativeModeManager.WaitForRenderKeepaliveAsync( + TimeSpan.FromSeconds(10), startupToken) + .ConfigureAwait(false); + nativeModeAudioDefaultGuard.ReconcileNow(); + nativeModeManager.MarkAttached(); + }).ConfigureAwait(false); + } + catch (Exception ex) + { + nativeModeAudioDefaultGuard.ReconcileNow(); + NativeModeState failedState = nativeModeManager.State; + if (attachFailure?.PendingCommandCompletion != null) + { + SchedulePendingNativeModeCommandRecovery( + attachFailure.PendingCommandCompletion, + nativeModeProtectionGeneration); + } + else + { + await RecoverControllerAfterFailedNativeStartAsync() + .ConfigureAwait(false); + } + if (!nativeModeShutdownRequested) + { + if (attachFailure?.FailureKind == + NativeModeAttachFailureKind.SetupRequired) + { + nativeModeManager.MarkSetupRequired(attachFailure.Reason); + } + else if (failedState == NativeModeState.PadLost) + { + nativeModeManager.MarkPadLost(ex.Message); + } + else + { + nativeModeManager.MarkFaulted( + attachFailure?.PendingCommandCompletion != null + ? (attachFailure.Reason + + " Native Mode is retaining its helper " + + "and protections until Windows confirms " + + "that the elevated command has stopped.") + : (attachFailure?.Reason ?? ex.Message)); + } + } + throw; + } + } + finally + { + if (lifecycleGateHeld) + nativeModeLifecycleGate.Release(); + + if (!nativeModeProtectionReleasePending) + { + nativeModeSessionCancellation.CompleteSession( + sessionCancellation); + } + } + } + + public static string[] BuildNativeModeServerArguments( + DualSenseControllerOptions options, string devicePath, + int vendorId, int productId) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + if (string.IsNullOrWhiteSpace(devicePath)) + throw new ArgumentException( + "The selected controller has no stable HID device path.", + nameof(devicePath)); + if (vendorId < 0 || vendorId > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(vendorId)); + if (productId < 0 || productId > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(productId)); + if (vendorId != 0x054C || + productId is not (0x0CE6 or 0x0DF2)) + { + throw new ArgumentException( + "Native mode requires a first-party Sony DualSense identity."); + } + + return new[] + { + "serve", + "--protocol-version", "2", + "--control-stdin", "required", + "--configuration", "composite", + "--input", "bluetooth", + "--device-path", devicePath.Trim(), + "--device-vid", vendorId.ToString(CultureInfo.InvariantCulture), + "--device-pid", productId.ToString(CultureInfo.InvariantCulture), + "--speaker-audio", options.NativeModeSpeakerAudio ? "on" : "off", + "--speaker-volume", options.NativeModeSpeakerVolume.ToString(CultureInfo.InvariantCulture), + "--route", options.NativeModeRoute.ToString().ToLowerInvariant(), + }; + } + + public Task EnsureNativeModeAttachTaskAsync( + CancellationToken cancellationToken = default) + { + return nativeModeElevationBroker.EnsureAttachTaskAsync( + Global.UsbipExePath, cancellationToken); + } + + private async Task ObserveNativeModeKeepaliveAsync(long generation, + Task unexpectedTermination) + { + try + { + await NativeModeStartupOrchestration + .ObserveUnexpectedTerminationAsync( + unexpectedTermination, + () => generation == Interlocked.Read( + ref nativeModeProtectionGeneration), + async failure => + { + await nativeModeLifecycleGate.WaitAsync() + .ConfigureAwait(false); + try + { + if (generation != nativeModeProtectionGeneration || + !nativeModeProtectionReleasePending || + nativeModeShutdownRequested) + { + return; + } + + NativeModeState state = nativeModeManager.State; + if (state != NativeModeState.Serving && + state != NativeModeState.Attached) + { + return; + } + + nativeModeManager.MarkFaulted( + "Mandatory virtual DualSense render keepalive " + + $"failed: {failure.Message}"); + } + finally + { + nativeModeLifecycleGate.Release(); + } + }).ConfigureAwait(false); + } + catch (Exception ex) + { + AppLogger.LogToGui( + $"[native] Keepalive failure observer failed: {ex.Message}", + true); + } + } + + public async Task StopNativeModeAsync( + CancellationToken cancellationToken = default) + { + await StopNativeModeAsync(rescanAfterStop: true, cancellationToken) + .ConfigureAwait(false); + } + + public void StopNativeModeForShutdown() + { + nativeModeShutdownRequested = true; + try + { + // Make cancellation explicit before the synchronous shutdown + // path can wait behind a startup which owns the lifecycle gate. + nativeModeSessionCancellation.RequestStop(); + StopNativeModeAsync(rescanAfterStop: false, + CancellationToken.None).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + AppLogger.LogToGui( + $"[native] Failed to stop native mode during shutdown: {ex.Message}", + true); + } + } + + private async Task StopNativeModeAsync(bool rescanAfterStop, + CancellationToken cancellationToken) + { + nativeModeSessionCancellation.RequestStop(); + bool lifecycleGateHeld = false; + try + { + await nativeModeLifecycleGate.WaitAsync(cancellationToken) + .ConfigureAwait(false); + lifecycleGateHeld = true; + + if (nativeModePendingCommandBarrierActive) + { + nativeModeDeferredRescanRequested |= rescanAfterStop; + AppLogger.LogToGui( + "[native] Native-mode stop is deferred until Windows " + + "confirms that the already-requested elevated action " + + "has stopped. The helper, render pins, audio-default " + + "guard, and controller suppression remain active.", + true); + return; + } + + if (!IsNativeModeSessionActive && + !nativeModeProtectionReleasePending) + { + nativeModeAudioDefaultGuard.EndSession(restoreDefaultsNow: true); + nativeModeSessionCancellation.CompleteCurrentSession(); + return; + } + + await StopNativeModeUnderGateAsync(rescanAfterStop) + .ConfigureAwait(false); + } + finally + { + if (lifecycleGateHeld) + nativeModeLifecycleGate.Release(); + nativeModeSessionCancellation.CompleteStop( + allowFutureStarts: !nativeModeShutdownRequested); + } + } + + private async Task StopNativeModeUnderGateAsync(bool rescanAfterStop) + { + long generation = nativeModeProtectionGeneration; + (NativeModeTeardownAttempt attempt, Task renderReleaseTask) = + await TryStopNativeModeUnderGateAsync(rescanAfterStop) + .ConfigureAwait(false); + HandleDeferredNativeModeCleanup(attempt, generation, + renderReleaseTask, startupRecovery: false); + if (attempt.Failure != null) + { + ExceptionDispatchInfo.Capture(attempt.Failure).Throw(); + } + } + + private async Task<(NativeModeTeardownAttempt Attempt, + Task RenderReleaseTask)> + TryStopNativeModeUnderGateAsync(bool rescanAfterStop) + { + if (!NativeModeLifecyclePolicy.CanBeginTeardown( + nativeModePendingCommandBarrierActive)) + { + throw new InvalidOperationException( + "Native Mode cannot begin teardown until Windows confirms " + + "that the pending elevated action has stopped."); + } + + nativeModeAudioDefaultGuard.ReconcileNow(); + nativeModeDeferredRescanRequested |= rescanAfterStop; + long generation = nativeModeProtectionGeneration; + Task renderReleaseTask = + nativeModeRenderKeepalive.WaitForReleaseAsync(); + NativeModeTeardownAttempt attempt = + await NativeModeTeardownOrchestration.StopAndTryReleaseAsync( + nativeModeRenderKeepalive.BeginTeardown, + () => nativeModeManager.StopAsync(CancellationToken.None), + () => nativeModeManager.HasOwnedProcess, + nativeModeRenderKeepalive.CompleteTeardownAsync, + () => !renderReleaseTask.IsCompletedSuccessfully || + NativeModeDevicePresence + .GetPresentVirtualDualSenseInstanceIds().Count != 0, + () => !renderReleaseTask.IsCompletedSuccessfully, + () => ReleaseNativeModeProtections(generation, + deferredCleanup: false)).ConfigureAwait(false); + return (attempt, renderReleaseTask); + } + + private void HandleDeferredNativeModeCleanup( + NativeModeTeardownAttempt attempt, long generation, + Task renderReleaseTask, bool startupRecovery) + { + if (attempt.Released || !nativeModeProtectionReleasePending) + return; + + string context = startupRecovery ? "startup recovery" : "stop"; + AppLogger.LogToGui( + $"[native] Native-mode {context} cleanup is deferred: " + + $"{attempt.DeferredReason}. Audio-default protection and " + + "controller suppression remain active; controller rescan is blocked.", + true); + + if (attempt.Failure == null) + { + ScheduleDeferredNativeModeCleanup(generation, + renderReleaseTask); + } + else + { + AppLogger.LogToGui( + $"[native] Native-mode {context} must be retried after the " + + $"stop failure: {attempt.Failure.Message}", true); + } + } + + private void SchedulePendingNativeModeCommandRecovery( + Task commandCompletion, long generation) + { + if (commandCompletion == null) + throw new ArgumentNullException(nameof(commandCompletion)); + if (nativeModePendingCommandBarrierActive) + { + throw new InvalidOperationException( + "A Native Mode elevated-command barrier is already active."); + } + + Volatile.Write(ref nativeModePendingCommandCompletion, + commandCompletion); + nativeModePendingCommandBarrierActive = true; + nativeModeDeferredRescanRequested = true; + AppLogger.LogToGui( + "[native] Windows still owns an outstanding elevated action. " + + "Cleanup is deferred fail-closed; do not end the helper or " + + "render keepalives until that action reaches a terminal state.", + true); + _ = Task.Run(() => RecoverAfterPendingNativeModeCommandAsync( + commandCompletion, generation)); + } + + private async Task RecoverAfterPendingNativeModeCommandAsync( + Task commandCompletion, long generation) + { + try + { + await NativeModeStartupOrchestration + .RunAfterCommandTerminationAsync(commandCompletion, + async () => + { + // NativeModeProcessRunner does not make the command + // terminal until any started client is confirmed gone. + await nativeModeLifecycleGate.WaitAsync() + .ConfigureAwait(false); + try + { + if (!ReferenceEquals( + Volatile.Read( + ref nativeModePendingCommandCompletion), + commandCompletion)) + { + return; + } + + nativeModePendingCommandBarrierActive = false; + Volatile.Write( + ref nativeModePendingCommandCompletion, + Task.CompletedTask); + + if (generation != + nativeModeProtectionGeneration || + !nativeModeProtectionReleasePending) + { + return; + } + + await RecoverControllerAfterFailedNativeStartAsync( + rescanAfterStop: + !nativeModeShutdownRequested) + .ConfigureAwait(false); + } + catch (Exception ex) + { + AppLogger.LogToGui( + "[native] Cleanup after the elevated action " + + "reached a terminal state failed: " + + $"{ex.Message}. Protections remain active; " + + "retry Stop Native Mode.", + true); + } + finally + { + nativeModeLifecycleGate.Release(); + } + }).ConfigureAwait(false); + } + catch (Exception ex) + { + // This operation is intentionally detached from the failed + // startup call; observe every failure and retain protections. + AppLogger.LogToGui( + "[native] Elevated-action cleanup observer failed: " + + $"{ex.Message}. Protections remain active.", + true); + } + } + + private void ScheduleDeferredNativeModeCleanup(long generation, + Task renderReleaseTask) + { + lock (nativeModeDeferredCleanupGate) + { + if (!nativeModeDeferredCleanupTask.IsCompleted) + return; + + nativeModeDeferredCleanupTask = Task.Run( + () => RunDeferredNativeModeCleanupAsync(generation, + renderReleaseTask)); + } + } + + private async Task RunDeferredNativeModeCleanupAsync(long generation, + Task renderReleaseTask) + { + string lastDeferredReason = "removal is not yet confirmed"; + try + { + bool released = await NativeModeTeardownOrchestration + .WaitForConfirmedReleaseAsync( + async () => + { + await nativeModeLifecycleGate.WaitAsync() + .ConfigureAwait(false); + try + { + if (generation != nativeModeProtectionGeneration || + !nativeModeProtectionReleasePending) + { + return true; + } + + NativeModeTeardownAttempt attempt = + NativeModeTeardownOrchestration + .TryReleaseIfConfirmed( + () => nativeModeManager.HasOwnedProcess, + () => !renderReleaseTask + .IsCompletedSuccessfully || + NativeModeDevicePresence + .GetPresentVirtualDualSenseInstanceIds() + .Count != 0, + () => !renderReleaseTask + .IsCompletedSuccessfully, + () => ReleaseNativeModeProtections( + generation, + deferredCleanup: true)); + lastDeferredReason = attempt.DeferredReason ?? + lastDeferredReason; + return attempt.Released; + } + catch (Exception ex) + { + lastDeferredReason = ex.Message; + return false; + } + finally + { + nativeModeLifecycleGate.Release(); + } + }, + (delay, token) => WaitForRenderReleaseOrDelayAsync( + renderReleaseTask, delay, token), + NativeModeDeferredCleanupMaximumAttempts, + NativeModeDeferredCleanupPollInterval, + CancellationToken.None).ConfigureAwait(false); + + if (!released) + { + AppLogger.LogToGui( + "[native] Deferred native-mode cleanup reached its bounded " + + $"wait limit ({lastDeferredReason}). Protections remain active; " + + "retry Stop Native Mode after the virtual device disappears.", + true); + } + } + catch (Exception ex) + { + AppLogger.LogToGui( + $"[native] Deferred native-mode cleanup failed: {ex.Message}. " + + "Protections remain active; retry Stop Native Mode.", true); + } + } + + private void ReleaseNativeModeProtections(long generation, + bool deferredCleanup) + { + if (generation != nativeModeProtectionGeneration || + !nativeModeProtectionReleasePending) + { + return; + } + + bool rescanAfterStop = nativeModeDeferredRescanRequested; + nativeModeProtectionReleasePending = false; + nativeModeDeferredRescanRequested = false; + nativeModeSessionCancellation.CompleteCurrentSession(); + try + { + nativeModeAudioDefaultGuard.EndSession(restoreDefaultsNow: true); + } + finally + { + DS4Devices.EndNativeModeSuppression(); + try + { + if (rescanAfterStop && running && + !nativeModeShutdownRequested) + HotPlug(); + } + finally + { + NotifyNativeModeSessionActivityChanged(); + } + } + + if (deferredCleanup) + { + AppLogger.LogToGui( + "[native] Deferred native-mode cleanup completed after " + + "confirmed server, virtual child, and audio endpoint removal.", + false); + } + } + + private void NotifyNativeModeSessionActivityChanged() + { + if (IsNativeModeSessionActive) + return; + + try + { + NativeModeSessionActivityChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + AppLogger.LogToGui( + $"[native] Could not refresh Native Mode controls: {ex.Message}", + true); + } + } + + private static async Task WaitForRenderReleaseOrDelayAsync( + Task renderReleaseTask, TimeSpan delay, + CancellationToken cancellationToken) + { + if (renderReleaseTask.IsCompleted) + { + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + return; + } + + Task delayTask = Task.Delay(delay, cancellationToken); + Task completed = await Task.WhenAny(renderReleaseTask, delayTask) + .ConfigureAwait(false); + if (ReferenceEquals(completed, delayTask)) + await delayTask.ConfigureAwait(false); + } + + private void NativeModeManager_StateChanged(object sender, + NativeModeStateChangedEventArgs e) + { + if (!nativeModeShutdownRequested && + !nativeModePendingCommandBarrierActive && + Volatile.Read(ref nativeModeAutomaticCleanupInProgress) == 0 && + NativeModeLifecyclePolicy.RequiresAutomaticCleanup(e.State, + DS4Devices.NativeModeGuard.IsActive)) + { + _ = CleanupTerminatedNativeModeAsync(e.State, e.Detail); + } + } + + private async Task CleanupTerminatedNativeModeAsync( + NativeModeState terminalState, string detail) + { + if (Interlocked.CompareExchange( + ref nativeModeAutomaticCleanupInProgress, 1, 0) != 0) + { + return; + } + + await nativeModeLifecycleGate.WaitAsync().ConfigureAwait(false); + try + { + if (nativeModeShutdownRequested || + !NativeModeLifecyclePolicy.CanBeginTeardown( + nativeModePendingCommandBarrierActive) || + !NativeModeLifecyclePolicy.RequiresAutomaticCleanup( + terminalState, DS4Devices.NativeModeGuard.IsActive)) + { + return; + } + + try + { + await StopNativeModeUnderGateAsync(rescanAfterStop: true) + .ConfigureAwait(false); + } + catch (Exception ex) + { + AppLogger.LogToGui( + $"[native] Automatic native-mode cleanup failed: {ex.Message}", + true); + } + + if (terminalState == NativeModeState.PadLost) + nativeModeManager.MarkPadLost(detail); + else + nativeModeManager.MarkFaulted(detail); + } + catch (Exception ex) + { + // This method is launched from a state event; observe every + // exception so a teardown failure cannot become unobserved. + AppLogger.LogToGui( + $"[native] Native-mode recovery failed: {ex.Message}", true); + } + finally + { + nativeModeLifecycleGate.Release(); + Volatile.Write(ref nativeModeAutomaticCleanupInProgress, 0); + } + } + + private void ReleaseControllerForNativeMode(DS4Device device, int deviceIndex) + { + // StopUpdate cancels and joins the input thread and stops every + // output producer, including the DualSense haptics streamer. + device.StopUpdate(); + + // Cancellation usually raises Removal from the input thread. If it + // did not, explicitly run the same service and registry paths. Both + // are idempotent for an already-gone controller. + On_DS4Removal(device, EventArgs.Empty); + DS4Devices.RemoveDevice(device); + + // DS4Windows' legacy CloseDevice only changes bookkeeping. Native + // mode additionally requires the kernel handle itself to be gone. + device.HidDevice.CloseDeviceHandle(); + if (!device.HidDevice.IsDeviceHandleReleased || + device.HidDevice.SafeReadHandle != null || device.HidDevice.IsOpen) + throw new InvalidOperationException("Failed to release the controller HID handle."); + + if (ReferenceEquals(DS4Controllers[deviceIndex], device)) + DS4Controllers[deviceIndex] = null; + activeControllers = DS4Controllers.Count(controller => controller != null); + } + + private async Task RecoverControllerAfterFailedNativeStartAsync( + bool rescanAfterStop = true) + { + long generation = nativeModeProtectionGeneration; + (NativeModeTeardownAttempt attempt, Task renderReleaseTask) = + await TryStopNativeModeUnderGateAsync(rescanAfterStop) + .ConfigureAwait(false); + HandleDeferredNativeModeCleanup(attempt, generation, + renderReleaseTask, startupRecovery: true); + } + private void PrepareConnectedInputControllerSettingEvents(int numControllers, DS4Device device, int index) { Global.RefreshExtrasButtons(index, GetKnownExtraButtons(device)); diff --git a/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs b/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs index 081c86b..9335342 100644 --- a/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs +++ b/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs @@ -212,6 +212,8 @@ public bool Enabled public class DualSenseControllerOptions : ControllerOptionsStore { + public const int DEFAULT_NATIVE_MODE_SPEAKER_VOLUME = 50; + public const string XML_ELEMENT_NAME = "DualSenseSupportSettings"; public enum LEDBarMode : ushort @@ -229,6 +231,28 @@ public enum MuteLEDMode : ushort Pulse } + public enum HapticsMode : ushort + { + Off, + SystemAudio, + RumbleToHaptics, + Mix, + } + + public enum AudioOutputRoute : ushort + { + Auto, // headphone jack when plugged in, speaker otherwise + Headphone, + Speaker, + } + + public enum AudioLatencyMode : ushort + { + Smooth, // maximum buffering; survives congested links + Balanced, + LowLatency, // minimum buffering; needs a clean link + } + private LEDBarMode ledMode = LEDBarMode.MultipleControllers; public LEDBarMode LedMode { @@ -255,6 +279,147 @@ public MuteLEDMode MuteLedMode } public event EventHandler MuteLedModeChanged; + // Bluetooth audio-haptics streaming settings. Fires a single combined + // change event; the device applies live-safe fields or restarts the + // capture pipeline as needed. + private HapticsMode btHapticsMode = HapticsMode.Off; + public HapticsMode BTHapticsMode + { + get => btHapticsMode; + set + { + if (btHapticsMode == value) return; + btHapticsMode = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + private double btHapticsGain = 3.0; + public double BTHapticsGain + { + get => btHapticsGain; + set + { + value = Math.Clamp(value, 0.1, 10.0); + if (btHapticsGain == value) return; + btHapticsGain = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + private int btHapticsLowPassHz = 350; + public int BTHapticsLowPassHz + { + get => btHapticsLowPassHz; + set + { + value = Math.Clamp(value, 40, 1000); + if (btHapticsLowPassHz == value) return; + btHapticsLowPassHz = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + private string btHapticsAudioDeviceId = string.Empty; + public string BTHapticsAudioDeviceId + { + get => btHapticsAudioDeviceId; + set + { + value ??= string.Empty; + if (btHapticsAudioDeviceId == value) return; + btHapticsAudioDeviceId = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + // Bluetooth listening-audio (headphone jack / speaker) settings. + private bool btAudioEnabled = false; + public bool BTAudioEnabled + { + get => btAudioEnabled; + set + { + if (btAudioEnabled == value) return; + btAudioEnabled = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + private AudioOutputRoute btAudioRoute = AudioOutputRoute.Auto; + public AudioOutputRoute BTAudioRoute + { + get => btAudioRoute; + set + { + if (btAudioRoute == value) return; + btAudioRoute = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + private int btAudioVolume = 50; + public int BTAudioVolume + { + get => btAudioVolume; + set + { + value = Math.Clamp(value, 0, 100); + if (btAudioVolume == value) return; + btAudioVolume = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + private AudioLatencyMode btAudioLatency = AudioLatencyMode.Smooth; + public AudioLatencyMode BTAudioLatency + { + get => btAudioLatency; + set + { + if (btAudioLatency == value) return; + btAudioLatency = value; + BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty); + } + } + + public event EventHandler BTHapticsOptionChanged; + + private bool nativeModeSpeakerAudio = true; + public bool NativeModeSpeakerAudio + { + get => nativeModeSpeakerAudio; + set + { + if (nativeModeSpeakerAudio == value) return; + nativeModeSpeakerAudio = value; + } + } + + private int nativeModeSpeakerVolume = + DEFAULT_NATIVE_MODE_SPEAKER_VOLUME; + public int NativeModeSpeakerVolume + { + get => nativeModeSpeakerVolume; + set + { + value = Math.Clamp(value, 0, 100); + if (nativeModeSpeakerVolume == value) return; + nativeModeSpeakerVolume = value; + } + } + + private AudioOutputRoute nativeModeRoute = AudioOutputRoute.Auto; + public AudioOutputRoute NativeModeRoute + { + get => nativeModeRoute; + set + { + if (nativeModeRoute == value) return; + nativeModeRoute = value; + } + } + public DualSenseControllerOptions(InputDeviceType deviceType) : base(deviceType) { diff --git a/DS4Windows/DS4Control/DTOXml/AppSettingsDTO.cs b/DS4Windows/DS4Control/DTOXml/AppSettingsDTO.cs index cfc8832..bf5e2e1 100644 --- a/DS4Windows/DS4Control/DTOXml/AppSettingsDTO.cs +++ b/DS4Windows/DS4Control/DTOXml/AppSettingsDTO.cs @@ -616,6 +616,12 @@ public string CustomSteamFolder get; set; } = string.Empty; + [XmlElement("UsbipExePath")] + public string UsbipExePath + { + get; set; + } = BackingStore.DEFAULT_USBIP_EXE_PATH; + [XmlIgnore] public bool AutoProfileRevertDefaultProfile { @@ -876,6 +882,7 @@ public void MapFrom(BackingStore source) }; UseCustomSteamFolder = source.useCustomSteamFolder; CustomSteamFolder = source.customSteamFolder; + UsbipExePath = source.usbipExePath; AutoProfileRevertDefaultProfile = source.autoProfileRevertDefaultProfile; AutoProfileSwitchNotifyChoice = source.autoProfileSwitchNotifyChoice; AbsRegionDisplay = source.absDisplayEDID; @@ -980,6 +987,9 @@ public void MapTo(BackingStore destination) destination.useCustomSteamFolder = UseCustomSteamFolder; destination.customSteamFolder = CustomSteamFolder; + destination.usbipExePath = string.IsNullOrWhiteSpace(UsbipExePath) + ? BackingStore.DEFAULT_USBIP_EXE_PATH + : UsbipExePath.Trim(); destination.autoProfileRevertDefaultProfile = AutoProfileRevertDefaultProfile; destination.autoProfileSwitchNotifyChoice = AutoProfileSwitchNotifyChoice; if (!string.IsNullOrEmpty(AbsRegionDisplay)) diff --git a/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs b/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs index ab85355..9642cef 100644 --- a/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs +++ b/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs @@ -40,16 +40,104 @@ public MuteLEDMode MuteLedMode get; set; } + [XmlElement("BTHapticsMode")] + public HapticsMode BTHapticsMode + { + get; set; + } = HapticsMode.Off; + + [XmlElement("BTHapticsGain")] + public double BTHapticsGain + { + get; set; + } = 3.0; + + [XmlElement("BTHapticsLowPassHz")] + public int BTHapticsLowPassHz + { + get; set; + } = 350; + + [XmlElement("BTHapticsAudioDeviceId")] + public string BTHapticsAudioDeviceId + { + get; set; + } = string.Empty; + + [XmlElement("BTAudioEnabled")] + public bool BTAudioEnabled + { + get; set; + } = false; + + [XmlElement("BTAudioRoute")] + public AudioOutputRoute BTAudioRoute + { + get; set; + } = AudioOutputRoute.Auto; + + [XmlElement("BTAudioVolume")] + public int BTAudioVolume + { + get; set; + } = 50; + + [XmlElement("BTAudioLatency")] + public AudioLatencyMode BTAudioLatency + { + get; set; + } = AudioLatencyMode.Smooth; + + [XmlElement("NativeModeSpeakerAudio")] + public bool NativeModeSpeakerAudio + { + get; set; + } = true; + + [XmlElement("NativeModeSpeakerVolume")] + public int NativeModeSpeakerVolume + { + get; set; + } = DualSenseControllerOptions.DEFAULT_NATIVE_MODE_SPEAKER_VOLUME; + + [XmlElement("NativeModeRoute")] + public AudioOutputRoute NativeModeRoute + { + get; set; + } = AudioOutputRoute.Auto; + public void MapFrom(DualSenseControllerOptions source) { LEDMode = source.LedMode; MuteLedMode = source.MuteLedMode; + BTHapticsMode = source.BTHapticsMode; + BTHapticsGain = source.BTHapticsGain; + BTHapticsLowPassHz = source.BTHapticsLowPassHz; + BTHapticsAudioDeviceId = source.BTHapticsAudioDeviceId; + BTAudioEnabled = source.BTAudioEnabled; + BTAudioRoute = source.BTAudioRoute; + BTAudioVolume = source.BTAudioVolume; + BTAudioLatency = source.BTAudioLatency; + NativeModeSpeakerAudio = source.NativeModeSpeakerAudio; + NativeModeSpeakerVolume = source.NativeModeSpeakerVolume; + NativeModeRoute = source.NativeModeRoute; } public void MapTo(DualSenseControllerOptions destination) { destination.LedMode = LEDMode; destination.MuteLedMode = MuteLedMode; + destination.BTHapticsMode = BTHapticsMode; + destination.BTHapticsGain = BTHapticsGain; + destination.BTHapticsLowPassHz = BTHapticsLowPassHz; + destination.BTHapticsAudioDeviceId = BTHapticsAudioDeviceId; + destination.BTAudioEnabled = BTAudioEnabled; + destination.BTAudioRoute = BTAudioRoute; + destination.BTAudioVolume = BTAudioVolume; + destination.BTAudioLatency = BTAudioLatency; + destination.NativeModeSpeakerAudio = NativeModeSpeakerAudio; + destination.NativeModeSpeakerVolume = NativeModeSpeakerVolume; + destination.NativeModeRoute = NativeModeRoute; } } } diff --git a/DS4Windows/DS4Control/NativeModeAudioDefaultGuard.cs b/DS4Windows/DS4Control/NativeModeAudioDefaultGuard.cs new file mode 100644 index 0000000..54a40d1 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeAudioDefaultGuard.cs @@ -0,0 +1,796 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using NAudio.CoreAudioApi; +using NAudio.CoreAudioApi.Interfaces; + +namespace DS4Windows +{ + internal enum NativeModeAudioFlow + { + Render = 0, + Capture = 1, + } + + internal enum NativeModeAudioRole + { + Console = 0, + Multimedia = 1, + Communications = 2, + } + + internal sealed class NativeModeAudioEndpoint + { + public NativeModeAudioEndpoint(string id, string friendlyName, + string deviceInstanceId = null, Guid? containerId = null) + { + Id = id; + FriendlyName = friendlyName; + DeviceInstanceId = deviceInstanceId; + ContainerId = containerId; + } + + public string Id { get; } + public string FriendlyName { get; } + public string DeviceInstanceId { get; } + public Guid? ContainerId { get; } + } + + internal sealed class NativeModeAudioDefaultsSnapshot + { + public NativeModeAudioDefaultsSnapshot( + IReadOnlyDictionary<(NativeModeAudioFlow Flow, NativeModeAudioRole Role), string> + defaultEndpointIds, + IReadOnlyDictionary> activeEndpointIds) + { + DefaultEndpointIds = defaultEndpointIds; + ActiveEndpointIds = activeEndpointIds; + } + + public IReadOnlyDictionary< + (NativeModeAudioFlow Flow, NativeModeAudioRole Role), string> + DefaultEndpointIds { get; } + + public IReadOnlyDictionary> + ActiveEndpointIds { get; } + } + + internal interface INativeModeAudioEndpointAccessor + { + IReadOnlyList GetActiveEndpoints( + NativeModeAudioFlow flow); + string GetDefaultEndpointId(NativeModeAudioFlow flow, + NativeModeAudioRole role); + void SetDefaultEndpoint(string endpointId, NativeModeAudioRole role); + } + + internal interface INativeModeAudioNotificationSource + { + IDisposable Subscribe(Action endpointOrDefaultChanged); + } + + internal interface INativeModeAudioWorkQueue + { + void Enqueue(Action work); + } + + /// + /// A composite DualSense adds render and capture endpoints after the USB + /// parent has already arrived. Windows or another audio policy client can + /// make those endpoints the defaults well after initial enumeration. Keep + /// observing for the lifetime of native mode instead of relying on a fixed + /// startup delay. + /// + internal sealed class NativeModeAudioDefaultGuard : IDisposable + { + private static readonly NativeModeAudioFlow[] Flows = + { + NativeModeAudioFlow.Render, + NativeModeAudioFlow.Capture, + }; + + private static readonly NativeModeAudioRole[] Roles = + { + NativeModeAudioRole.Console, + NativeModeAudioRole.Multimedia, + NativeModeAudioRole.Communications, + }; + + private const string DualSenseEndpointMarker = + "DualSense Wireless Controller"; + + private readonly object sessionGate = new object(); + private readonly INativeModeAudioEndpointAccessor accessor; + private readonly INativeModeAudioNotificationSource notificationSource; + private readonly INativeModeAudioWorkQueue workQueue; + private readonly Action log; + private NativeModeAudioDefaultSession currentSession; + + public NativeModeAudioDefaultGuard() : this( + new WindowsNativeModeAudioEndpointAccessor(), + new WindowsNativeModeAudioNotificationSource(), + new ThreadPoolNativeModeAudioWorkQueue(), + (message, warning) => AppLogger.LogToGui(message, warning)) + { + } + + internal NativeModeAudioDefaultGuard( + INativeModeAudioEndpointAccessor accessor, + INativeModeAudioNotificationSource notificationSource, + INativeModeAudioWorkQueue workQueue, + Action log) + { + this.accessor = accessor ?? throw new ArgumentNullException(nameof(accessor)); + this.notificationSource = notificationSource ?? + throw new ArgumentNullException(nameof(notificationSource)); + this.workQueue = workQueue ?? throw new ArgumentNullException(nameof(workQueue)); + this.log = log ?? throw new ArgumentNullException(nameof(log)); + } + + public NativeModeAudioDefaultsSnapshot Capture() + { + try + { + var defaults = new Dictionary< + (NativeModeAudioFlow Flow, NativeModeAudioRole Role), string>(); + var active = new Dictionary>(); + + foreach (NativeModeAudioFlow flow in Flows) + { + active[flow] = accessor.GetActiveEndpoints(flow) + .Select(endpoint => endpoint.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (NativeModeAudioRole role in Roles) + { + try + { + string endpointId = accessor.GetDefaultEndpointId(flow, role); + if (!string.IsNullOrWhiteSpace(endpointId)) + defaults[(flow, role)] = endpointId; + } + catch (Exception) + { + // A role can legitimately have no endpoint. Preserve the + // roles that are available instead of disabling the guard. + } + } + } + + return new NativeModeAudioDefaultsSnapshot(defaults, active); + } + catch (Exception ex) + { + log($"[native] Could not snapshot the current audio defaults: {ex.Message}", + true); + throw new InvalidOperationException( + "Native Mode cannot start without a complete audio-default " + + "endpoint snapshot.", ex); + } + } + + /// + /// Starts observing after the render keepalive owns the new endpoint, + /// using the snapshot captured before USB/IP attach. Notification + /// callbacks only enqueue work; all MMDevice and PolicyConfig calls run + /// on the queue. + /// + public void BeginSession(NativeModeAudioDefaultsSnapshot snapshot) + { + if (snapshot == null) + throw new ArgumentNullException(nameof(snapshot)); + + EndSession(restoreDefaultsNow: false); + + var session = new NativeModeAudioDefaultSession(snapshot, accessor, + notificationSource, workQueue, log); + lock (sessionGate) + currentSession = session; + + try + { + session.Start(); + } + catch + { + lock (sessionGate) + { + if (ReferenceEquals(currentSession, session)) + currentSession = null; + } + session.Stop(restoreDefaultsNow: false); + throw; + } + } + + /// + /// Reconciles once from a normal caller (never from an MMDevice callback). + /// This covers an attach transition which raced notification registration. + /// + public void ReconcileNow() + { + NativeModeAudioDefaultSession session; + lock (sessionGate) + session = currentSession; + session?.ReconcileNow(); + } + + /// + /// Unregisters notifications and invalidates queued callbacks. A final + /// reconciliation, when requested, closes the teardown race before the + /// virtual endpoint disappears. + /// + public void EndSession(bool restoreDefaultsNow) + { + NativeModeAudioDefaultSession session; + lock (sessionGate) + { + session = currentSession; + currentSession = null; + } + + session?.Stop(restoreDefaultsNow); + } + + public void Dispose() => EndSession(restoreDefaultsNow: false); + + private sealed class NativeModeAudioDefaultSession + { + private readonly object workGate = new object(); + private readonly NativeModeAudioDefaultsSnapshot snapshot; + private readonly INativeModeAudioEndpointAccessor accessor; + private readonly INativeModeAudioNotificationSource notificationSource; + private readonly INativeModeAudioWorkQueue workQueue; + private readonly Action log; + private readonly Dictionary< + (NativeModeAudioFlow Flow, NativeModeAudioRole Role), string> + desiredDefaults; + private readonly Dictionary< + (NativeModeAudioFlow Flow, NativeModeAudioRole Role), string> + pendingNewDefaults = new(); + private readonly Dictionary> + sessionEndpointIds; + private IDisposable registration; + private int acceptingNotifications = 1; + private bool disposed; + + public NativeModeAudioDefaultSession( + NativeModeAudioDefaultsSnapshot snapshot, + INativeModeAudioEndpointAccessor accessor, + INativeModeAudioNotificationSource notificationSource, + INativeModeAudioWorkQueue workQueue, + Action log) + { + this.snapshot = snapshot; + this.accessor = accessor; + this.notificationSource = notificationSource; + this.workQueue = workQueue; + this.log = log; + desiredDefaults = snapshot.DefaultEndpointIds.ToDictionary( + pair => pair.Key, pair => pair.Value); + sessionEndpointIds = Flows.ToDictionary(flow => flow, + _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + public void Start() + { + IDisposable newRegistration; + try + { + newRegistration = notificationSource.Subscribe(QueueReconcile) ?? + throw new InvalidOperationException( + "The audio notification source returned no registration."); + } + catch (Exception ex) + { + log($"[native] Could not monitor Windows audio defaults: {ex.Message}", + true); + throw new InvalidOperationException( + "Native Mode cannot start without Windows audio-default " + + "change monitoring.", ex); + } + + bool discardRegistration; + lock (workGate) + { + discardRegistration = disposed || + Volatile.Read(ref acceptingNotifications) == 0; + if (!discardRegistration) + registration = newRegistration; + } + + if (discardRegistration) + { + try + { + newRegistration.Dispose(); + } + catch (Exception ex) + { + log($"[native] Could not stop monitoring Windows audio defaults: " + + ex.Message, true); + } + } + } + + public void ReconcileNow() + { + lock (workGate) + { + if (disposed || Volatile.Read(ref acceptingNotifications) == 0) + return; + TryReconcileCore(); + } + } + + public void Stop(bool restoreDefaultsNow) + { + Interlocked.Exchange(ref acceptingNotifications, 0); + + IDisposable oldRegistration; + lock (workGate) + { + oldRegistration = registration; + registration = null; + } + + try + { + oldRegistration?.Dispose(); + } + catch (Exception ex) + { + log($"[native] Could not stop monitoring Windows audio defaults: " + + ex.Message, true); + } + + lock (workGate) + { + if (disposed) + return; + if (restoreDefaultsNow) + TryReconcileCore(); + disposed = true; + } + } + + private void QueueReconcile() + { + if (Volatile.Read(ref acceptingNotifications) == 0) + return; + + try + { + workQueue.Enqueue(ProcessQueuedReconcile); + } + catch (Exception ex) + { + // Never let a managed exception escape the COM callback. + log($"[native] Could not queue Windows audio default monitoring: " + + ex.Message, true); + } + } + + private void ProcessQueuedReconcile() + { + lock (workGate) + { + if (disposed || Volatile.Read(ref acceptingNotifications) == 0) + return; + TryReconcileCore(); + } + } + + private void TryReconcileCore() + { + try + { + ReconcileCore(); + } + catch (Exception ex) + { + // Establishing the snapshot and notification subscription is + // mandatory. After that boundary, an individual policy call + // remains retryable and must not escape a COM callback. + log($"[native] Could not preserve Windows audio defaults: " + + ex.Message, true); + } + } + + private void ReconcileCore() + { + bool restoredAny = false; + + foreach (NativeModeAudioFlow flow in Flows) + { + IReadOnlyList activeEndpoints; + try + { + activeEndpoints = accessor.GetActiveEndpoints(flow); + } + catch (Exception ex) + { + log($"[native] Could not enumerate {flow.ToString().ToLowerInvariant()} " + + $"audio endpoints: {ex.Message}", true); + continue; + } + + HashSet activeEndpointIds = activeEndpoints + .Select(endpoint => endpoint.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + HashSet previouslyActive = + snapshot.ActiveEndpointIds.TryGetValue(flow, out HashSet ids) + ? ids + : new HashSet(StringComparer.OrdinalIgnoreCase); + HashSet newlyRecognizedSessionEndpoints = activeEndpoints + .Where(endpoint => !previouslyActive.Contains(endpoint.Id) && + IsDualSenseEndpoint(endpoint)) + .Select(endpoint => endpoint.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + HashSet knownSessionEndpoints = sessionEndpointIds[flow]; + knownSessionEndpoints.UnionWith(newlyRecognizedSessionEndpoints); + + foreach (NativeModeAudioRole role in Roles) + { + var key = (flow, role); + if (pendingNewDefaults.TryGetValue(key, + out string pendingDefault)) + { + if (knownSessionEndpoints.Contains(pendingDefault) || + !activeEndpointIds.Contains(pendingDefault)) + { + pendingNewDefaults.Remove(key); + } + else if (knownSessionEndpoints.Count > 0) + { + // A newly attached endpoint selected before the + // virtual pad was identifiable is now known to be + // a separate user choice. + desiredDefaults[key] = pendingDefault; + pendingNewDefaults.Remove(key); + } + } + + string currentDefault; + try + { + currentDefault = accessor.GetDefaultEndpointId(flow, role); + } + catch (Exception) + { + continue; + } + + if (!knownSessionEndpoints.Contains(currentDefault)) + { + // A current, active non-session endpoint is an + // intentional user or application choice. Protect it + // from any later virtual-pad takeover in this session. + NativeModeAudioEndpoint currentEndpoint = activeEndpoints + .FirstOrDefault(endpoint => string.Equals(endpoint.Id, + currentDefault, StringComparison.OrdinalIgnoreCase)); + if (currentEndpoint != null && + (previouslyActive.Contains(currentDefault) || + knownSessionEndpoints.Count > 0)) + { + desiredDefaults[key] = currentDefault; + pendingNewDefaults.Remove(key); + } + else if (currentEndpoint != null) + { + // Defer promotion until the session endpoint is + // identified. If this candidate is that endpoint, + // it will be discarded instead of poisoning the + // trusted baseline. + pendingNewDefaults[key] = currentDefault; + } + continue; + } + + if (!desiredDefaults.TryGetValue(key, out string desiredDefault) || + !activeEndpointIds.Contains(desiredDefault) || + knownSessionEndpoints.Contains(desiredDefault) || + string.Equals(currentDefault, desiredDefault, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + // Close the user-choice race between the first read + // and PolicyConfig. A change to any other active + // endpoint becomes the new protected baseline. + string latestDefault = accessor.GetDefaultEndpointId( + flow, role); + if (!knownSessionEndpoints.Contains(latestDefault)) + { + if (activeEndpointIds.Contains(latestDefault)) + desiredDefaults[key] = latestDefault; + continue; + } + + // The setter is never called from an + // IMMNotificationClient callback and only replaces + // this session's virtual endpoint. + accessor.SetDefaultEndpoint(desiredDefault, role); + restoredAny = true; + } + catch (Exception ex) + { + log($"[native] Could not restore the " + + $"{flow.ToString().ToLowerInvariant()} audio default for " + + $"{role.ToString().ToLowerInvariant()}: {ex.Message}", true); + } + } + } + + if (restoredAny) + { + log("[native] Kept Windows audio on the selected non-virtual " + + "endpoints while the virtual controller is attached.", false); + } + } + + private static bool IsDualSenseEndpoint(NativeModeAudioEndpoint endpoint) => + endpoint.FriendlyName?.Contains(DualSenseEndpointMarker, + StringComparison.OrdinalIgnoreCase) == true; + } + } + + internal sealed class ThreadPoolNativeModeAudioWorkQueue : + INativeModeAudioWorkQueue + { + public void Enqueue(Action work) + { + if (work == null) + throw new ArgumentNullException(nameof(work)); + ThreadPool.QueueUserWorkItem(state => ((Action)state)(), work); + } + } + + internal sealed class WindowsNativeModeAudioNotificationSource : + INativeModeAudioNotificationSource + { + public IDisposable Subscribe(Action endpointOrDefaultChanged) => + new Registration(endpointOrDefaultChanged); + + private sealed class Registration : IDisposable + { + private readonly MMDeviceEnumerator enumerator; + private readonly NotificationClient client; + private int disposed; + + public Registration(Action endpointOrDefaultChanged) + { + if (endpointOrDefaultChanged == null) + throw new ArgumentNullException(nameof(endpointOrDefaultChanged)); + + enumerator = new MMDeviceEnumerator(); + client = new NotificationClient(endpointOrDefaultChanged); + try + { + int result = enumerator.RegisterEndpointNotificationCallback(client); + Marshal.ThrowExceptionForHR(result); + } + catch + { + enumerator.Dispose(); + throw; + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + return; + + try + { + int result = enumerator.UnregisterEndpointNotificationCallback(client); + Marshal.ThrowExceptionForHR(result); + } + finally + { + enumerator.Dispose(); + } + } + } + + private sealed class NotificationClient : IMMNotificationClient + { + private readonly Action changed; + + public NotificationClient(Action changed) + { + this.changed = changed; + } + + public void OnDeviceStateChanged(string deviceId, DeviceState newState) => + Notify(); + + public void OnDeviceAdded(string pwstrDeviceId) => Notify(); + + public void OnDeviceRemoved(string deviceId) => Notify(); + + public void OnDefaultDeviceChanged(DataFlow flow, Role role, + string defaultDeviceId) => Notify(); + + public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key) => + Notify(); + + private void Notify() + { + try + { + changed(); + } + catch + { + // COM notification methods must never propagate managed errors. + } + } + } + } + + internal sealed class WindowsNativeModeAudioEndpointAccessor : + INativeModeAudioEndpointAccessor + { + private static readonly PropertyKey DeviceContainerIdProperty = + new PropertyKey(new Guid("8C7ED206-3F8A-4827-B3AB-AE9E1FAEFC6C"), 2); + + public IReadOnlyList GetActiveEndpoints( + NativeModeAudioFlow flow) + { + using var enumerator = new MMDeviceEnumerator(); + MMDeviceCollection devices = enumerator.EnumerateAudioEndPoints( + (DataFlow)(int)flow, DeviceState.Active); + var result = new List(devices.Count); + foreach (MMDevice device in devices) + { + using (device) + { + result.Add(new NativeModeAudioEndpoint(device.ID, + device.FriendlyName ?? string.Empty, + TryGetStringProperty(device, + PropertyKeys.PKEY_Device_InstanceId), + TryGetGuidProperty(device, DeviceContainerIdProperty))); + } + } + return result; + } + + private static string TryGetStringProperty(MMDevice device, + PropertyKey propertyKey) + { + try + { + return device.Properties.Contains(propertyKey) + ? device.Properties[propertyKey]?.Value as string + : null; + } + catch + { + // Identity correlation has a parent/container fallback. A + // missing optional endpoint property must not abort enumeration. + return null; + } + } + + private static Guid? TryGetGuidProperty(MMDevice device, + PropertyKey propertyKey) + { + try + { + if (!device.Properties.Contains(propertyKey)) + return null; + + object value = device.Properties[propertyKey]?.Value; + if (value is Guid guid) + return guid; + return value is string text && Guid.TryParse(text, out guid) + ? guid + : null; + } + catch + { + return null; + } + } + + public string GetDefaultEndpointId(NativeModeAudioFlow flow, + NativeModeAudioRole role) + { + using var enumerator = new MMDeviceEnumerator(); + using MMDevice device = enumerator.GetDefaultAudioEndpoint( + (DataFlow)(int)flow, (Role)(int)role); + return device.ID; + } + + public void SetDefaultEndpoint(string endpointId, NativeModeAudioRole role) + { + IPolicyConfig policy = null; + try + { + policy = (IPolicyConfig)(object)new PolicyConfigClient(); + int result = policy.SetDefaultEndpoint(endpointId, (int)role); + Marshal.ThrowExceptionForHR(result); + } + finally + { + if (policy != null && Marshal.IsComObject(policy)) + Marshal.FinalReleaseComObject(policy); + } + } + + [ComImport] + [Guid("870AF99C-171D-4F9E-AF0D-E63DF40C2BC9")] + private sealed class PolicyConfigClient + { + } + + // Windows exposes no public setter beside its settings UI. This stable + // shell interface is isolated here so a COM failure remains a harmless + // best-effort warning rather than a native-mode startup failure. + [ComImport] + [Guid("F8679F50-850A-41CF-9C72-430F290290C8")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IPolicyConfig + { + [PreserveSig] + int GetMixFormat([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + out IntPtr format); + [PreserveSig] + int GetDeviceFormat([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + int defaultFormat, out IntPtr format); + [PreserveSig] + int ResetDeviceFormat([MarshalAs(UnmanagedType.LPWStr)] string deviceId); + [PreserveSig] + int SetDeviceFormat([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + IntPtr endpointFormat, IntPtr mixFormat); + [PreserveSig] + int GetProcessingPeriod([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + int defaultPeriod, out long period, out long minimumPeriod); + [PreserveSig] + int SetProcessingPeriod([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + ref long period); + [PreserveSig] + int GetShareMode([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + IntPtr mode); + [PreserveSig] + int SetShareMode([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + IntPtr mode); + [PreserveSig] + int GetPropertyValue([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + IntPtr key, IntPtr value); + [PreserveSig] + int SetPropertyValue([MarshalAs(UnmanagedType.LPWStr)] string deviceId, + IntPtr key, IntPtr value); + [PreserveSig] + int SetDefaultEndpoint( + [MarshalAs(UnmanagedType.LPWStr)] string deviceId, int role); + [PreserveSig] + int SetEndpointVisibility( + [MarshalAs(UnmanagedType.LPWStr)] string deviceId, int visible); + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeDeviceGuard.cs b/DS4Windows/DS4Control/NativeModeDeviceGuard.cs new file mode 100644 index 0000000..ccf5c22 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeDeviceGuard.cs @@ -0,0 +1,108 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; + +namespace DS4Windows +{ + /// + /// Identifies the one physical controller temporarily owned by native mode. + /// Device-path matching prevents DS4Windows from opening the HID handle just + /// to rediscover its serial; MAC matching covers already-created devices. + /// + public sealed class NativeModeDeviceGuard + { + private readonly object sync = new object(); + private string macAddress; + private string devicePath; + + public bool IsActive + { + get + { + lock (sync) + return macAddress != null || devicePath != null; + } + } + + public string MacAddress + { + get + { + lock (sync) + return macAddress; + } + } + + public void Activate(string macAddress, string devicePath) + { + string normalizedMac = Normalize(macAddress); + string normalizedPath = Normalize(devicePath); + if (normalizedMac == null) + throw new ArgumentException("A controller MAC address is required.", nameof(macAddress)); + if (normalizedPath == null) + throw new ArgumentException("A controller HID path is required.", nameof(devicePath)); + + lock (sync) + { + if (this.macAddress != null || this.devicePath != null) + throw new InvalidOperationException("A controller is already reserved for native mode."); + + this.macAddress = normalizedMac; + this.devicePath = normalizedPath; + } + } + + public void Clear() + { + lock (sync) + { + macAddress = null; + devicePath = null; + } + } + + public bool ShouldSuppress(string candidateMacAddress, string candidateDevicePath) + { + string normalizedMac = Normalize(candidateMacAddress); + string normalizedPath = Normalize(candidateDevicePath); + lock (sync) + { + return (macAddress != null && normalizedMac != null && + string.Equals(macAddress, normalizedMac, StringComparison.OrdinalIgnoreCase)) || + (devicePath != null && normalizedPath != null && + string.Equals(devicePath, normalizedPath, StringComparison.OrdinalIgnoreCase)); + } + } + + public bool ShouldSuppressPath(string candidateDevicePath) + { + string normalizedPath = Normalize(candidateDevicePath); + lock (sync) + { + return devicePath != null && normalizedPath != null && + string.Equals(devicePath, normalizedPath, StringComparison.OrdinalIgnoreCase); + } + } + + private static string Normalize(string value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeDriverInspectors.cs b/DS4Windows/DS4Control/NativeModeDriverInspectors.cs new file mode 100644 index 0000000..eaa4026 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeDriverInspectors.cs @@ -0,0 +1,731 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace DS4Windows +{ + /// + /// Enumerates usbip-win2 driver packages via SetupAPI / Configuration + /// Manager, never by parsing localized pnputil text. This is the + /// OS-touching side of validation; the decision logic lives in + /// so it can be tested without the + /// driver installed. + /// + public sealed class SetupApiDriverPackageInspector : IDriverPackageInspector + { + private const int ErrorInsufficientBuffer = 122; + private const int ErrorNoMoreItems = 259; + private const int InfStyleWin4 = 0x00000002; + private const uint CrSuccess = 0; + private const uint DnStarted = 0x00000008; + private static readonly IntPtr InvalidHandleValue = new IntPtr(-1); + + private static readonly int PresentAllClassesFlags = + NativeMethods.DIGCF_PRESENT | NativeMethods.DIGCF_ALLCLASSES; + + // DEVPKEY_Device_DriverInfPath: {a8b865dd-2e3d-4094-ad97-e593a70c75d6}, 5. + private static NativeMethods.DEVPROPKEY DevpkeyDeviceDriverInfPath = + new NativeMethods.DEVPROPKEY + { + fmtid = new Guid(0xa8b865dd, 0x2e3d, 0x4094, 0xad, 0x97, 0xe5, + 0x93, 0xa7, 0x0c, 0x75, 0xd6), + pid = 5, + }; + + // DEVPKEY_Device_Service: {a45c254e-df1c-4efd-8020-67d146a850e0}, 6. + private static NativeMethods.DEVPROPKEY DevpkeyDeviceService = + new NativeMethods.DEVPROPKEY + { + fmtid = new Guid(0xa45c254e, 0xdf1c, 0x4efd, 0x80, 0x20, 0x67, + 0xd1, 0x46, 0xa8, 0x50, 0xe0), + pid = 6, + }; + + public NativeModeDriverPackageInfo InspectHostController(string hardwareId) + { + if (string.IsNullOrWhiteSpace(hardwareId)) + throw new ArgumentException("Hardware ID is required.", nameof(hardwareId)); + + IntPtr deviceInfoSet = NativeMethods.SetupDiGetClassDevs( + IntPtr.Zero, null, 0, PresentAllClassesFlags); + if (deviceInfoSet == InvalidHandleValue) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "SetupDiGetClassDevs could not enumerate present devices."); + } + + try + { + for (int index = 0; ; index++) + { + var deviceInfo = new NativeMethods.SP_DEVINFO_DATA + { + cbSize = Marshal.SizeOf(), + }; + if (!NativeMethods.SetupDiEnumDeviceInfo(deviceInfoSet, index, + ref deviceInfo)) + { + int error = Marshal.GetLastWin32Error(); + if (error == ErrorNoMoreItems) + break; + throw new Win32Exception(error, + "SetupDiEnumDeviceInfo failed while locating the UDE host controller."); + } + + IReadOnlyList hardwareIds = GetStringListProperty( + deviceInfoSet, ref deviceInfo, + ref NativeMethods.DEVPKEY_Device_HardwareIds); + if (!ContainsHardwareId(hardwareIds, hardwareId)) + continue; + + return ReadHostController(deviceInfoSet, ref deviceInfo, + hardwareId); + } + } + finally + { + NativeMethods.SetupDiDestroyDeviceInfoList(deviceInfoSet); + } + + return new NativeModeDriverPackageInfo { Found = false }; + } + + private NativeModeDriverPackageInfo ReadHostController(IntPtr deviceInfoSet, + ref NativeMethods.SP_DEVINFO_DATA deviceInfo, string hardwareId) + { + string provider = GetStringProperty(deviceInfoSet, ref deviceInfo, + ref NativeMethods.DEVPKEY_Device_Provider); + string driverVersionText = GetStringProperty(deviceInfoSet, + ref deviceInfo, ref NativeMethods.DEVPKEY_Device_DriverVersion); + string service = GetStringProperty(deviceInfoSet, ref deviceInfo, + ref DevpkeyDeviceService); + string publishedInf = GetStringProperty(deviceInfoSet, ref deviceInfo, + ref DevpkeyDeviceDriverInfPath); + + string storeInfPath = ResolveDriverStorePath(publishedInf); + string infName = storeInfPath != null + ? Path.GetFileName(storeInfPath) + : publishedInf; + NativeModeDriverArchitecture architecture = + ParseArchitecture(storeInfPath); + string catalogFile = null; + string trustPath = null; + if (storeInfPath != null) + { + InfVersionValues values = ReadInfVersionValues(storeInfPath); + catalogFile = values.CatalogFile; + if (!string.IsNullOrWhiteSpace(values.CatalogFile)) + { + trustPath = Path.Combine( + Path.GetDirectoryName(storeInfPath) ?? string.Empty, + values.CatalogFile); + } + } + + (bool present, bool started) = GetDeviceHealth(ref deviceInfo); + + return new NativeModeDriverPackageInfo + { + Found = true, + HardwareId = hardwareId, + InfName = infName, + Provider = provider, + DriverVersion = ParseVersion(driverVersionText), + Service = service, + CatalogFile = catalogFile, + Architecture = architecture, + DeviceNodePresent = present, + Started = started, + TrustEvaluationPath = trustPath ?? storeInfPath, + }; + } + + public NativeModeDriverPackageInfo InspectFilterExtension(string infName) + { + if (string.IsNullOrWhiteSpace(infName)) + throw new ArgumentException("INF name is required.", nameof(infName)); + + string repository = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "DriverStore", "FileRepository"); + if (!Directory.Exists(repository)) + return new NativeModeDriverPackageInfo { Found = false }; + + NativeModeDriverPackageInfo best = null; + string prefix = infName + "_"; + foreach (string directory in Directory.EnumerateDirectories( + repository, prefix + "*")) + { + string candidateInf = Path.Combine(directory, infName); + if (!File.Exists(candidateInf)) + continue; + + InfVersionValues values = ReadInfVersionValues(candidateInf); + string catalogPath = string.IsNullOrWhiteSpace(values.CatalogFile) + ? candidateInf + : Path.Combine(directory, values.CatalogFile); + + var info = new NativeModeDriverPackageInfo + { + Found = true, + InfName = infName, + Provider = values.Provider, + DriverVersion = values.DriverVersion, + CatalogFile = values.CatalogFile, + Architecture = ParseArchitecture(directory), + // Extension packages have no device node of their own; the + // host controller carries presence/health. + DeviceNodePresent = true, + Started = true, + TrustEvaluationPath = catalogPath, + }; + + if (best == null || IsHigherVersion(info.DriverVersion, + best.DriverVersion)) + { + best = info; + } + } + + return best ?? new NativeModeDriverPackageInfo { Found = false }; + } + + public NativeModeUsbipClientInfo InspectUsbipClient(string executablePath) + { + if (string.IsNullOrWhiteSpace(executablePath) || + !File.Exists(executablePath)) + { + return new NativeModeUsbipClientInfo { Found = false }; + } + + Version productVersion = null; + try + { + FileVersionInfo info = FileVersionInfo.GetVersionInfo(executablePath); + productVersion = ParseVersion(info.ProductVersion) ?? + new Version(info.ProductMajorPart, info.ProductMinorPart, + info.ProductBuildPart, info.ProductPrivatePart); + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException || ex is FileNotFoundException) + { + return new NativeModeUsbipClientInfo { Found = false }; + } + + return new NativeModeUsbipClientInfo + { + Found = true, + FileName = Path.GetFileName(executablePath), + ProductVersion = productVersion, + }; + } + + private (bool present, bool started) GetDeviceHealth( + ref NativeMethods.SP_DEVINFO_DATA deviceInfo) + { + int result = CM_Get_DevNode_Status(out uint status, + out uint problem, (uint)deviceInfo.DevInst, 0); + if (result != CrSuccess) + return (false, false); + bool started = (status & DnStarted) != 0 && problem == 0; + return (true, started); + } + + private static bool ContainsHardwareId(IReadOnlyList hardwareIds, + string expected) + { + if (hardwareIds == null) + return false; + foreach (string id in hardwareIds) + { + if (string.Equals(id?.Trim(), expected, + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool IsHigherVersion(Version candidate, Version current) + { + if (candidate == null) + return false; + if (current == null) + return true; + return candidate > current; + } + + private static Version ParseVersion(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + string trimmed = text.Trim(); + // DriverVer values are "MM/DD/YYYY,x.y.z.w"; keep the numeric part. + int comma = trimmed.LastIndexOf(','); + if (comma >= 0 && comma + 1 < trimmed.Length) + trimmed = trimmed.Substring(comma + 1).Trim(); + // ProductVersion strings can carry a suffix such as "0.9.7.8 (rc)". + int space = trimmed.IndexOf(' '); + if (space > 0) + trimmed = trimmed.Substring(0, space); + return Version.TryParse(trimmed, out Version parsed) ? parsed : null; + } + + private static NativeModeDriverArchitecture ParseArchitecture(string storePath) + { + if (!string.IsNullOrWhiteSpace(storePath)) + { + if (storePath.IndexOf("_amd64_", StringComparison.OrdinalIgnoreCase) >= 0) + return NativeModeDriverArchitecture.X64; + if (storePath.IndexOf("_x86_", StringComparison.OrdinalIgnoreCase) >= 0) + return NativeModeDriverArchitecture.X86; + } + + // DS4Windows itself only builds x64/x86, so the running process + // architecture is a safe fallback when the store decoration is + // unavailable. + return RuntimeInformation.ProcessArchitecture == Architecture.X86 + ? NativeModeDriverArchitecture.X86 + : NativeModeDriverArchitecture.X64; + } + + private static string ResolveDriverStorePath(string publishedInf) + { + if (string.IsNullOrWhiteSpace(publishedInf)) + return null; + + int required = 0; + SetupGetInfDriverStoreLocation(publishedInf, IntPtr.Zero, null, null, + 0, ref required); + if (required <= 0) + return null; + + var buffer = new StringBuilder(required); + if (!SetupGetInfDriverStoreLocation(publishedInf, IntPtr.Zero, null, + buffer, buffer.Capacity, ref required)) + { + return null; + } + + return buffer.ToString(); + } + + private struct InfVersionValues + { + public string Provider; + public Version DriverVersion; + public string CatalogFile; + } + + private static InfVersionValues ReadInfVersionValues(string infPath) + { + var values = new InfVersionValues(); + if (string.IsNullOrWhiteSpace(infPath) || !File.Exists(infPath)) + return values; + + IntPtr handle = SetupOpenInfFile(infPath, null, InfStyleWin4, + out _); + if (handle == InvalidHandleValue || handle == IntPtr.Zero) + return values; + + try + { + values.Provider = ReadInfLineText(handle, "Version", "Provider"); + values.CatalogFile = ReadInfLineText(handle, "Version", "CatalogFile"); + values.DriverVersion = ParseVersion( + ReadInfLineText(handle, "Version", "DriverVer")); + } + finally + { + SetupCloseInfFile(handle); + } + + return values; + } + + private static string ReadInfLineText(IntPtr infHandle, string section, + string key) + { + int required = 0; + SetupGetLineText(IntPtr.Zero, infHandle, section, key, null, 0, + out required); + if (required <= 0) + return null; + + var buffer = new StringBuilder(required); + if (!SetupGetLineText(IntPtr.Zero, infHandle, section, key, buffer, + buffer.Capacity, out required)) + { + return null; + } + + string value = buffer.ToString().Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } + + private static string GetStringProperty(IntPtr deviceInfoSet, + ref NativeMethods.SP_DEVINFO_DATA deviceInfo, + ref NativeMethods.DEVPROPKEY key) + { + byte[] buffer = GetProperty(deviceInfoSet, ref deviceInfo, ref key); + if (buffer == null || buffer.Length < 2) + return null; + return Encoding.Unicode.GetString(buffer).TrimEnd('\0'); + } + + private static IReadOnlyList GetStringListProperty( + IntPtr deviceInfoSet, ref NativeMethods.SP_DEVINFO_DATA deviceInfo, + ref NativeMethods.DEVPROPKEY key) + { + byte[] buffer = GetProperty(deviceInfoSet, ref deviceInfo, ref key); + if (buffer == null || buffer.Length < 2) + return Array.Empty(); + string raw = Encoding.Unicode.GetString(buffer); + return raw.Split('\0', StringSplitOptions.RemoveEmptyEntries); + } + + private static byte[] GetProperty(IntPtr deviceInfoSet, + ref NativeMethods.SP_DEVINFO_DATA deviceInfo, + ref NativeMethods.DEVPROPKEY key) + { + ulong propertyType = 0; + int requiredSize = 0; + if (NativeMethods.SetupDiGetDeviceProperty(deviceInfoSet, + ref deviceInfo, ref key, ref propertyType, null, 0, + ref requiredSize, 0)) + { + return Array.Empty(); + } + + int error = Marshal.GetLastWin32Error(); + if (error != ErrorInsufficientBuffer || requiredSize <= 0) + return null; + + var buffer = new byte[requiredSize]; + if (!NativeMethods.SetupDiGetDeviceProperty(deviceInfoSet, + ref deviceInfo, ref key, ref propertyType, buffer, buffer.Length, + ref requiredSize, 0)) + { + return null; + } + + return buffer; + } + + [DllImport("cfgmgr32.dll")] + private static extern int CM_Get_DevNode_Status(out uint pulStatus, + out uint pulProblemNumber, uint dnDevInst, uint ulFlags); + + [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true, + EntryPoint = "SetupGetInfDriverStoreLocationW")] + private static extern bool SetupGetInfDriverStoreLocation(string fileName, + IntPtr alternatePlatformInfo, string localeName, + StringBuilder returnBuffer, int returnBufferSize, ref int requiredSize); + + [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true, + EntryPoint = "SetupOpenInfFileW")] + private static extern IntPtr SetupOpenInfFile(string fileName, + string infClass, int infStyle, out uint errorLine); + + [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true, + EntryPoint = "SetupGetLineTextW")] + private static extern bool SetupGetLineText(IntPtr context, + IntPtr infHandle, string section, string key, + StringBuilder returnBuffer, int returnBufferSize, out int requiredSize); + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern void SetupCloseInfFile(IntPtr infHandle); + } + + /// + /// Verifies Authenticode / catalog trust with the Windows trust APIs + /// (WinVerifyTrust, WINTRUST_ACTION_GENERIC_VERIFY_V2) under normal chain + /// policy, then reads the signing certificate from the verified chain via + /// WTHelper to identify the Microsoft Hardware Compatibility Publisher. The + /// publisher decision uses the chain-derived certificate, never a substring + /// match on a signer string. + /// + public sealed class WinTrustAuthenticodeVerifier : IAuthenticodeVerifier + { + private static readonly Guid WinTrustActionGenericVerifyV2 = + new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + + private const uint WtdUiNone = 2; + private const uint WtdRevokeWholeChain = 1; + private const uint WtdChoiceFile = 1; + private const uint WtdStateActionVerify = 1; + private const uint WtdStateActionClose = 2; + private const uint WtdRevocationCheckChain = 0x00000040; + private const uint WtdCacheOnlyUrlRetrieval = 0x00001000; + + private const uint TrustENoSignature = 0x800B0100; + private const uint CertEExpired = 0x800B0101; + private const uint CertERevoked = 0x800B010C; + private const uint CertEUntrustedRoot = 0x800B0109; + private const uint CertEUntrustedTestRoot = 0x800B010D; + private const uint TrustEExplicitDistrust = 0x800B0111; + + public NativeModeSignatureTrust VerifyDriverPackage( + NativeModeDriverPackageInfo package) + { + if (package == null || string.IsNullOrWhiteSpace(package.TrustEvaluationPath)) + { + return NativeModeSignatureTrust.Untrusted( + "no catalog or driver file was available to verify"); + } + + return VerifyFile(package.TrustEvaluationPath); + } + + public NativeModeSignatureTrust VerifyFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + return NativeModeSignatureTrust.Untrusted("the file was not found"); + + var fileInfo = new WINTRUST_FILE_INFO + { + cbStruct = (uint)Marshal.SizeOf(), + pcwszFilePath = filePath, + hFile = IntPtr.Zero, + pgKnownSubject = IntPtr.Zero, + }; + + IntPtr fileInfoPtr = Marshal.AllocHGlobal( + Marshal.SizeOf()); + IntPtr dataPtr = IntPtr.Zero; + try + { + Marshal.StructureToPtr(fileInfo, fileInfoPtr, false); + + var data = new WINTRUST_DATA + { + cbStruct = (uint)Marshal.SizeOf(), + dwUIChoice = WtdUiNone, + fdwRevocationChecks = WtdRevokeWholeChain, + dwUnionChoice = WtdChoiceFile, + pFile = fileInfoPtr, + dwStateAction = WtdStateActionVerify, + dwProvFlags = WtdRevocationCheckChain | WtdCacheOnlyUrlRetrieval, + }; + + dataPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(data, dataPtr, false); + + Guid action = WinTrustActionGenericVerifyV2; + int result = WinVerifyTrust(IntPtr.Zero, ref action, dataPtr); + + // Re-read the state handle that WinVerifyTrust populated. The + // signing certificate's common name is read whenever a chain is + // available, including on failure, so diagnostics can report the + // certificate that was actually found. Only a verified chain + // (hr == 0) may satisfy the publisher policy. + data = Marshal.PtrToStructure(dataPtr); + string signerCommonName = ReadSignerCommonName(data.hWVTStateData); + bool publisherOk = result == 0 && + IsHardwareCompatibilityPublisher(signerCommonName); + + return MapResult((uint)result, publisherOk, signerCommonName); + } + catch (Exception ex) + { + return NativeModeSignatureTrust.Untrusted( + "trust verification threw: " + ex.Message); + } + finally + { + if (dataPtr != IntPtr.Zero) + { + var close = Marshal.PtrToStructure(dataPtr); + close.dwStateAction = WtdStateActionClose; + Marshal.StructureToPtr(close, dataPtr, true); + Guid action = WinTrustActionGenericVerifyV2; + WinVerifyTrust(IntPtr.Zero, ref action, dataPtr); + Marshal.FreeHGlobal(dataPtr); + } + + if (fileInfoPtr != IntPtr.Zero) + Marshal.FreeHGlobal(fileInfoPtr); + } + } + + private static NativeModeSignatureTrust MapResult(uint hr, bool publisherOk, + string signerCommonName) + { + switch (hr) + { + case 0: + return new NativeModeSignatureTrust + { + Trusted = true, + IsMicrosoftHardwareCompatibilityPublisher = publisherOk, + Diagnostic = "trusted", + ObservedSignerCommonName = signerCommonName, + }; + case CertERevoked: + return new NativeModeSignatureTrust + { + Trusted = false, + Revoked = true, + Diagnostic = "certificate revoked", + ObservedSignerCommonName = signerCommonName, + }; + case CertEExpired: + return new NativeModeSignatureTrust + { + Trusted = false, + Expired = true, + Diagnostic = "certificate expired", + ObservedSignerCommonName = signerCommonName, + }; + case CertEUntrustedTestRoot: + return new NativeModeSignatureTrust + { + Trusted = false, + TestSigned = true, + Diagnostic = "test-signed (untrusted test root)", + ObservedSignerCommonName = signerCommonName, + }; + case CertEUntrustedRoot: + return new NativeModeSignatureTrust + { + Trusted = false, + DeveloperSigned = true, + Diagnostic = "untrusted root (developer/test signature)", + ObservedSignerCommonName = signerCommonName, + }; + case TrustENoSignature: + return NativeModeSignatureTrust.Untrusted("no valid signature", + signerCommonName); + case TrustEExplicitDistrust: + return NativeModeSignatureTrust.Untrusted("explicitly distrusted", + signerCommonName); + default: + return NativeModeSignatureTrust.Untrusted( + $"WinVerifyTrust hr=0x{hr:X8}", signerCommonName); + } + } + + private static bool IsHardwareCompatibilityPublisher(string commonName) => + string.Equals(commonName, + NativeModeDriverManifest + .MicrosoftHardwareCompatibilityPublisherCommonName, + StringComparison.OrdinalIgnoreCase); + + /// + /// Reads the common name of the signing certificate on the chain + /// WinVerifyTrust built, or null when no certificate is available. + /// + private static string ReadSignerCommonName(IntPtr stateData) + { + if (stateData == IntPtr.Zero) + return null; + + IntPtr provData = WTHelperProvDataFromStateData(stateData); + if (provData == IntPtr.Zero) + return null; + + IntPtr signer = WTHelperGetProvSignerFromChain(provData, 0, false, 0); + if (signer == IntPtr.Zero) + return null; + + IntPtr providerCert = WTHelperGetProvCertFromChain(signer, 0); + if (providerCert == IntPtr.Zero) + return null; + + CRYPT_PROVIDER_CERT cert = + Marshal.PtrToStructure(providerCert); + if (cert.pCert == IntPtr.Zero) + return null; + + try + { + using var certificate = new X509Certificate2(cert.pCert); + string commonName = certificate.GetNameInfo( + X509NameType.SimpleName, false); + return string.IsNullOrWhiteSpace(commonName) ? null : commonName; + } + catch (Exception ex) when (ex is CryptographicException || + ex is ArgumentException) + { + return null; + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_FILE_INFO + { + public uint cbStruct; + [MarshalAs(UnmanagedType.LPWStr)] + public string pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_DATA + { + public uint cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public uint dwUIChoice; + public uint fdwRevocationChecks; + public uint dwUnionChoice; + public IntPtr pFile; + public uint dwStateAction; + public IntPtr hWVTStateData; + public IntPtr pwszURLReference; + public uint dwProvFlags; + public uint dwUIContext; + public IntPtr pSignatureSettings; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CRYPT_PROVIDER_CERT + { + public uint cbStruct; + public IntPtr pCert; + } + + [DllImport("wintrust.dll", SetLastError = true)] + private static extern int WinVerifyTrust(IntPtr hwnd, ref Guid actionId, + IntPtr pWVTData); + + [DllImport("wintrust.dll", SetLastError = true)] + private static extern IntPtr WTHelperProvDataFromStateData(IntPtr hStateData); + + [DllImport("wintrust.dll", SetLastError = true)] + private static extern IntPtr WTHelperGetProvSignerFromChain(IntPtr pProvData, + uint idxSigner, [MarshalAs(UnmanagedType.Bool)] bool fCounterSigner, + uint idxCounterSigner); + + [DllImport("wintrust.dll", SetLastError = true)] + private static extern IntPtr WTHelperGetProvCertFromChain(IntPtr pSgnr, + uint idxCert); + } +} diff --git a/DS4Windows/DS4Control/NativeModeDriverManifest.cs b/DS4Windows/DS4Control/NativeModeDriverManifest.cs new file mode 100644 index 0000000..ce9fa71 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeDriverManifest.cs @@ -0,0 +1,290 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DS4Windows +{ + /// + /// Risk tier for a usbip-win2 release entry, per + /// doc/dev/native_mode_driver_policy.md §4.3. + /// + public enum NativeModeDriverTier + { + /// + /// Allowed to run, but only behind the experimental warning and the + /// per-Start confirmation. 0.9.7.8 is the current tested baseline; it + /// still carries the unrepaired request-lifetime risk. + /// + ExperimentalBaseline, + + /// + /// Reserved for a future maintainer-accepted signed release that fixes + /// the request-lifetime defect. No entry uses this tier yet; adding one + /// later must not require touching the validator, broker, or UI. + /// + Production, + } + + /// + /// Signer policy required for a driver package. Evaluated through the + /// Windows trust API chain, never by a substring match on a signer string. + /// + public enum NativeModeDriverSignerPolicy + { + /// + /// Require a valid Microsoft Hardware Compatibility Publisher chain + /// (attestation-signed). WHCP version reports unknown on 0.9.7.8, so + /// WHQL is deliberately not required here. + /// + MicrosoftHardwareCompatibilityPublisher, + + /// + /// Reserved for a future WHQL-certified production release. + /// + MicrosoftWhqlCertified, + } + + public enum NativeModeDriverArchitecture + { + X64, + X86, + } + + /// + /// Expected identity of one installed driver package (the UDE host + /// controller or the companion filter extension). Version comparisons live + /// here so they are not scattered across UI, broker, or installer code. + /// + public sealed class NativeModeDriverPackageSpec + { + public NativeModeDriverPackageSpec(string infName, string provider, + Version driverVersion) + { + if (string.IsNullOrWhiteSpace(infName)) + throw new ArgumentException("INF name is required.", nameof(infName)); + if (string.IsNullOrWhiteSpace(provider)) + throw new ArgumentException("Provider is required.", nameof(provider)); + InfName = infName; + Provider = provider; + DriverVersion = driverVersion ?? + throw new ArgumentNullException(nameof(driverVersion)); + } + + /// Original INF file name, e.g. usbip2_ude.inf. + public string InfName { get; } + + /// Windows-reported INF provider, e.g. USBIP-WIN2. + public string Provider { get; } + + /// + /// Windows DriverVer numeric component. This is the value Windows + /// reports per package, not the upstream release label. + /// + public Version DriverVersion { get; } + + public bool MatchesInf(string candidate) => + !string.IsNullOrWhiteSpace(candidate) && + string.Equals(candidate.Trim(), InfName, + StringComparison.OrdinalIgnoreCase); + + public bool MatchesProvider(string candidate) => + !string.IsNullOrWhiteSpace(candidate) && + string.Equals(candidate.Trim(), Provider, + StringComparison.OrdinalIgnoreCase); + + public bool MatchesVersion(Version candidate) => + candidate != null && candidate == DriverVersion; + } + + /// + /// Expected identity of the userspace usbip.exe client. The upstream + /// release label is carried on the release entry; this records only the + /// per-file product version and whether Authenticode is required. + /// + public sealed class NativeModeUsbipClientSpec + { + public NativeModeUsbipClientSpec(string fileName, Version productVersion, + bool requireAuthenticode) + { + if (string.IsNullOrWhiteSpace(fileName)) + throw new ArgumentException("File name is required.", nameof(fileName)); + FileName = fileName; + ProductVersion = productVersion ?? + throw new ArgumentNullException(nameof(productVersion)); + RequireAuthenticode = requireAuthenticode; + } + + public string FileName { get; } + + public Version ProductVersion { get; } + + public bool RequireAuthenticode { get; } + + public bool MatchesFileName(string candidate) => + !string.IsNullOrWhiteSpace(candidate) && + string.Equals(candidate.Trim(), FileName, + StringComparison.OrdinalIgnoreCase); + + public bool MatchesProductVersion(Version candidate) => + candidate != null && candidate == ProductVersion; + } + + /// + /// One supported release: maps a single upstream release label to both + /// driver packages, the userspace client, accepted architectures, tier, + /// and signer policy. Adding a future fixed release means adding another + /// entry to , not editing comparison + /// logic elsewhere. + /// + public sealed class NativeModeDriverRelease + { + public NativeModeDriverRelease(string releaseLabel, + NativeModeDriverTier tier, + NativeModeDriverSignerPolicy driverSignerPolicy, + NativeModeDriverPackageSpec udeHostController, + NativeModeDriverPackageSpec filterExtension, + NativeModeUsbipClientSpec userspaceClient, + IEnumerable architectures) + { + if (string.IsNullOrWhiteSpace(releaseLabel)) + throw new ArgumentException("Release label is required.", + nameof(releaseLabel)); + ReleaseLabel = releaseLabel; + Tier = tier; + DriverSignerPolicy = driverSignerPolicy; + UdeHostController = udeHostController ?? + throw new ArgumentNullException(nameof(udeHostController)); + FilterExtension = filterExtension ?? + throw new ArgumentNullException(nameof(filterExtension)); + UserspaceClient = userspaceClient ?? + throw new ArgumentNullException(nameof(userspaceClient)); + Architectures = (architectures ?? + throw new ArgumentNullException(nameof(architectures))) + .Distinct().ToArray(); + if (Architectures.Count == 0) + throw new ArgumentException( + "At least one architecture is required.", + nameof(architectures)); + } + + /// Upstream release label, e.g. 0.9.7.8. + public string ReleaseLabel { get; } + + public NativeModeDriverTier Tier { get; } + + public NativeModeDriverSignerPolicy DriverSignerPolicy { get; } + + public NativeModeDriverPackageSpec UdeHostController { get; } + + public NativeModeDriverPackageSpec FilterExtension { get; } + + public NativeModeUsbipClientSpec UserspaceClient { get; } + + public IReadOnlyList Architectures { get; } + + public bool SupportsArchitecture(NativeModeDriverArchitecture architecture) => + Architectures.Contains(architecture); + + /// + /// True when this release is allowed to run at all. Every current entry + /// is allowed; the experimental tier still requires the existing + /// warning and per-Start confirmation enforced by the caller. + /// + public bool IsRunAllowed => true; + } + + /// + /// The single versioned data structure that owns all tier and version + /// policy for Native Mode driver validation. §4.3 requires that this not be + /// duplicated across the UI, broker, or installer. + /// + public sealed class NativeModeDriverManifest + { + /// + /// Stable hardware ID of the emulated UDE host controller. Identity is + /// this hardware ID plus the expected provider, never a machine-specific + /// instance path such as ROOT\USB\0002. + /// + public const string UdeHostControllerHardwareId = @"ROOT\USBIP_WIN2\UDE"; + + /// + /// Common name the attestation-signing certificate carries when + /// + /// is satisfied. Single source of truth for the trust verifier and for + /// diagnostics; the decision itself is still made from the verified + /// chain, never from a substring match. + /// + public const string MicrosoftHardwareCompatibilityPublisherCommonName = + "Microsoft Windows Hardware Compatibility Publisher"; + + private NativeModeDriverManifest(IEnumerable releases) + { + Releases = (releases ?? throw new ArgumentNullException(nameof(releases))) + .ToArray(); + if (Releases.Count == 0) + throw new ArgumentException("At least one release is required.", + nameof(releases)); + } + + public IReadOnlyList Releases { get; } + + /// + /// The release used to produce diagnostics when nothing matches. The + /// most permissive currently-supported baseline is a sensible reference. + /// + public NativeModeDriverRelease ReferenceRelease => Releases[0]; + + /// + /// The tested, supported manifest. A future fixed release is added here + /// as a separate Production-tier entry; no other file changes. + /// + public static NativeModeDriverManifest Supported { get; } = BuildSupported(); + + private static NativeModeDriverManifest BuildSupported() + { + // Observed 0.9.7.8 package identity, doc/dev/native_mode_driver_policy.md §3. + var experimentalBaseline = new NativeModeDriverRelease( + releaseLabel: "0.9.7.8", + tier: NativeModeDriverTier.ExperimentalBaseline, + driverSignerPolicy: + NativeModeDriverSignerPolicy.MicrosoftHardwareCompatibilityPublisher, + udeHostController: new NativeModeDriverPackageSpec( + infName: "usbip2_ude.inf", + provider: "USBIP-WIN2", + driverVersion: new Version(1, 45, 29, 368)), + filterExtension: new NativeModeDriverPackageSpec( + infName: "usbip2_filter.inf", + provider: "USBIP-WIN2", + driverVersion: new Version(1, 45, 28, 868)), + userspaceClient: new NativeModeUsbipClientSpec( + fileName: "usbip.exe", + productVersion: new Version(0, 9, 7, 8), + requireAuthenticode: true), + architectures: new[] + { + NativeModeDriverArchitecture.X64, + NativeModeDriverArchitecture.X86, + }); + + return new NativeModeDriverManifest(new[] { experimentalBaseline }); + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeDriverReportFormatter.cs b/DS4Windows/DS4Control/NativeModeDriverReportFormatter.cs new file mode 100644 index 0000000..2f6f5ae --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeDriverReportFormatter.cs @@ -0,0 +1,458 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Linq; +using System.Text; + +namespace DS4Windows +{ + /// + /// Non-sensitive environment facts shown in the report header. Passed in so + /// stays pure and testable. + /// + public sealed class NativeModeDriverReportContext + { + public DateTimeOffset TimestampUtc { get; init; } + + public string AppVersion { get; init; } + + public string OsVersion { get; init; } + + public string ProcessArchitecture { get; init; } + + /// + /// Whether the diagnostic ran elevated. Reported because a few driver + /// reads can be restricted; the command never requests elevation. + /// + public bool Elevated { get; init; } + + /// + /// usbip.exe path being checked, already redacted of user paths by + /// . + /// + public string UsbipExecutablePath { get; init; } + + /// Display form of the saved report location, or null. + public string ReportFilePath { get; init; } + } + + /// + /// Composes the human-readable -validatedriver report from a + /// . Pure: no OS access, no + /// I/O, no static state, so the formatting is unit-tested with fabricated + /// observations. + /// + /// The report deliberately prints observed versus expected values for every + /// component even on success. The gate's OS probes (driver-store INF + /// resolution, DriverVer parsing, architecture detection, the expected + /// publisher common name) can only be confirmed against a real install, so a + /// bare pass/fail would hide a gate that fails closed against a good driver. + /// + /// Nothing sensitive is emitted: no device instance paths, serials, radio + /// addresses, driver-store paths, or user paths. + /// + public static class NativeModeDriverReportFormatter + { + private const string Ok = "[OK]"; + private const string Mismatch = "[MISMATCH]"; + private const string Info = "[INFO]"; + private const int LabelWidth = 22; + private const int ObservedWidth = 34; + private const string NotReported = "(not reported)"; + private const string NotEvaluated = "(not evaluated)"; + + public static string Format(NativeModeDriverValidationReport report, + NativeModeDriverReportContext context) + { + if (report == null) + throw new ArgumentNullException(nameof(report)); + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var body = new StringBuilder(); + int mismatches = 0; + + AppendHostSection(body, report, ref mismatches); + AppendFilterSection(body, report, ref mismatches); + AppendClientSection(body, report, context, ref mismatches); + AppendFooter(body); + + var text = new StringBuilder(); + AppendHeader(text, report, context, mismatches); + text.Append(body); + return text.ToString(); + } + + /// + /// Replaces a user account name in a Windows path with a placeholder so + /// report output carries no user profile paths. + /// + public static string RedactUserPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return "(not set)"; + + const string usersSegment = @"\Users\"; + int index = path.IndexOf(usersSegment, + StringComparison.OrdinalIgnoreCase); + if (index < 0) + return path; + + int nameStart = index + usersSegment.Length; + int nameEnd = path.IndexOf('\\', nameStart); + string tail = nameEnd < 0 ? string.Empty : path.Substring(nameEnd); + return path.Substring(0, nameStart) + "" + tail; + } + + private static void AppendHeader(StringBuilder text, + NativeModeDriverValidationReport report, + NativeModeDriverReportContext context, int mismatches) + { + NativeModeDriverRelease expected = report.ExpectedRelease; + NativeModeDriverValidationResult result = report.Result; + + text.AppendLine("DS4Windows Native Mode driver validation " + + "(read-only diagnostic)"); + text.AppendLine(new string('=', 62)); + AppendHeaderLine(text, "generated (UTC)", + context.TimestampUtc.ToUniversalTime() + .ToString("yyyy-MM-dd HH:mm:ss'Z'")); + AppendHeaderLine(text, "DS4Windows version", context.AppVersion); + AppendHeaderLine(text, "process architecture", + context.ProcessArchitecture); + AppendHeaderLine(text, "elevated", YesNo(context.Elevated)); + AppendHeaderLine(text, "operating system", context.OsVersion); + if (expected != null) + { + AppendHeaderLine(text, "expected release", + $"usbip-win2 {expected.ReleaseLabel} ({expected.Tier})"); + AppendHeaderLine(text, "signer policy", + expected.DriverSignerPolicy.ToString()); + } + + AppendHeaderLine(text, "usbip.exe checked", + context.UsbipExecutablePath); + if (!string.IsNullOrWhiteSpace(context.ReportFilePath)) + AppendHeaderLine(text, "report file", context.ReportFilePath); + + text.AppendLine(); + if (result == null) + { + text.AppendLine("RESULT: UNKNOWN (no validation result was " + + "produced)"); + } + else if (result.Passed) + { + text.AppendLine("RESULT: PASS - Native Mode would be allowed to " + + "start on this machine."); + AppendHeaderLine(text, "matched release", + $"{result.ReleaseLabel} ({result.Tier})"); + if (result.RequiresExperimentalConfirmation) + { + AppendHeaderLine(text, "note", "this release is experimental; " + + "Native Mode still requires the per-Start confirmation"); + } + } + else + { + text.AppendLine("RESULT: FAIL - Native Mode would refuse to start " + + "on this machine."); + AppendHeaderLine(text, "failed component", + result.FailedComponent.ToString()); + AppendHeaderLine(text, "reason", result.Reason.ToString()); + AppendHeaderLine(text, "diagnostic", result.Diagnostic); + } + + AppendHeaderLine(text, "observed mismatches", + mismatches.ToString()); + if (!string.IsNullOrWhiteSpace(report.PackageInspectionError)) + { + AppendHeaderLine(text, "package read error", + report.PackageInspectionError); + } + + if (!string.IsNullOrWhiteSpace(report.UsbipClientInspectionError)) + { + AppendHeaderLine(text, "client read error", + report.UsbipClientInspectionError); + } + } + + private static void AppendHostSection(StringBuilder text, + NativeModeDriverValidationReport report, ref int mismatches) + { + NativeModeDriverPackageSpec spec = + report.ExpectedRelease?.UdeHostController; + NativeModeDriverPackageInfo observed = report.HostController; + + AppendSectionHeader(text, "UDE host controller (queried by hardware " + + "ID " + NativeModeDriverManifest.UdeHostControllerHardwareId + ")"); + if (!AppendPackageIdentity(text, observed, spec, report.ExpectedRelease, + ref mismatches)) + { + return; + } + + AppendComparison(text, "device node present", YesNo(observed.DeviceNodePresent), + "yes", ref mismatches); + AppendComparison(text, "started, no problem", YesNo(observed.Started), + "yes", ref mismatches); + AppendComparison(text, "driver store target", + report.HostControllerStoreTargetResolved + ? "resolved" + : "NOT RESOLVED", "resolved", ref mismatches); + AppendInfo(text, "service", Text(observed.Service)); + AppendInfo(text, "catalog file", Text(observed.CatalogFile)); + AppendTrust(text, report.HostControllerTrust, requirePublisher: true, + ref mismatches); + } + + private static void AppendFilterSection(StringBuilder text, + NativeModeDriverValidationReport report, ref int mismatches) + { + NativeModeDriverPackageSpec spec = + report.ExpectedRelease?.FilterExtension; + NativeModeDriverPackageInfo observed = report.FilterExtension; + + AppendSectionHeader(text, "filter extension (located in the driver " + + "store FileRepository)"); + if (spec != null) + { + AppendInfo(text, "store search", @"%SystemRoot%\System32\" + + @"DriverStore\FileRepository\" + spec.InfName + "_*"); + } + + if (!AppendPackageIdentity(text, observed, spec, report.ExpectedRelease, + ref mismatches)) + { + return; + } + + AppendComparison(text, "driver store target", + report.FilterExtensionStoreTargetResolved + ? "resolved" + : "NOT RESOLVED", "resolved", ref mismatches); + AppendInfo(text, "catalog file", Text(observed.CatalogFile)); + AppendInfo(text, "device node", "not applicable (extension package; " + + "the host controller carries presence and health)"); + AppendTrust(text, report.FilterExtensionTrust, requirePublisher: true, + ref mismatches); + } + + private static void AppendClientSection(StringBuilder text, + NativeModeDriverValidationReport report, + NativeModeDriverReportContext context, ref int mismatches) + { + NativeModeUsbipClientSpec spec = + report.ExpectedRelease?.UserspaceClient; + NativeModeUsbipClientInfo observed = report.UsbipClient; + + AppendSectionHeader(text, "usbip.exe userspace client"); + AppendInfo(text, "resolved path", Text(context.UsbipExecutablePath)); + bool found = observed != null && observed.Found; + AppendComparison(text, "file found", YesNo(found), "yes", + ref mismatches); + if (!found) + { + AppendInfo(text, "note", "nothing further could be read for this " + + "component"); + return; + } + + AppendComparison(text, "file name", Text(observed.FileName), + Text(spec?.FileName), ref mismatches); + AppendComparison(text, "ProductVersion", + Text(observed.ProductVersion?.ToString()), + Text(spec?.ProductVersion?.ToString()), ref mismatches); + if (spec == null || spec.RequireAuthenticode) + { + AppendTrust(text, report.UsbipClientTrust, requirePublisher: false, + ref mismatches); + } + else + { + AppendInfo(text, "Authenticode", "not required for this release"); + } + } + + /// + /// Emits the shared package identity fields. Returns false when the + /// package was not found, in which case no further field can be read. + /// + private static bool AppendPackageIdentity(StringBuilder text, + NativeModeDriverPackageInfo observed, NativeModeDriverPackageSpec spec, + NativeModeDriverRelease release, ref int mismatches) + { + bool found = observed != null && observed.Found; + AppendComparison(text, "package found", YesNo(found), "yes", + ref mismatches); + if (!found) + { + AppendInfo(text, "note", "nothing further could be read for this " + + "component"); + return false; + } + + AppendComparison(text, "INF name", Text(observed.InfName), + Text(spec?.InfName), ref mismatches); + AppendComparison(text, "provider", Text(observed.Provider), + Text(spec?.Provider), ref mismatches); + AppendComparison(text, "DriverVer", + Text(observed.DriverVersion?.ToString()), + Text(spec?.DriverVersion?.ToString()), ref mismatches); + AppendComparison(text, "architecture", observed.Architecture.ToString(), + DescribeArchitectures(release), + release != null && release.SupportsArchitecture(observed.Architecture), + ref mismatches); + return true; + } + + private static void AppendTrust(StringBuilder text, + NativeModeSignatureTrust trust, bool requirePublisher, + ref int mismatches) + { + if (trust == null) + { + AppendInfo(text, "signature", NotEvaluated); + return; + } + + AppendComparison(text, "signature trusted", YesNo(trust.Trusted), "yes", + ref mismatches); + AppendInfo(text, "signature flags", DescribeTrustFlags(trust)); + AppendInfo(text, "trust diagnostic", Text(trust.Diagnostic)); + + bool commonNameReported = !string.IsNullOrWhiteSpace( + trust.ObservedSignerCommonName); + if (!requirePublisher) + { + AppendInfo(text, "signer common name", + trust.ObservedSignerCommonName); + return; + } + + AppendComparison(text, "publisher accepted", + YesNo(trust.IsMicrosoftHardwareCompatibilityPublisher), "yes", + ref mismatches); + + // Only compare a common name that was actually read. An unread name + // is unknowable rather than wrong, and "publisher accepted" above + // already carries the decision. + if (commonNameReported) + { + AppendComparison(text, "signer common name", + trust.ObservedSignerCommonName, + NativeModeDriverManifest + .MicrosoftHardwareCompatibilityPublisherCommonName, + ref mismatches); + } + else + { + AppendInfo(text, "signer common name", NotReported); + } + } + + private static void AppendSectionHeader(StringBuilder text, string title) + { + text.AppendLine(); + text.AppendLine("-- " + title + " " + + new string('-', Math.Max(3, 62 - title.Length))); + } + + private static void AppendHeaderLine(StringBuilder text, string label, + string value) + { + text.AppendLine(" " + label.PadRight(LabelWidth) + ": " + Text(value)); + } + + private static void AppendComparison(StringBuilder text, string label, + string observed, string expected, ref int mismatches) + { + AppendComparison(text, label, observed, expected, + string.Equals(observed, expected, StringComparison.OrdinalIgnoreCase), + ref mismatches); + } + + /// + /// Comparison line whose verdict is supplied by the caller, for fields + /// where the expectation is a set rather than one literal. + /// + private static void AppendComparison(StringBuilder text, string label, + string observed, string expected, bool matches, ref int mismatches) + { + if (!matches) + mismatches++; + text.AppendLine(" " + label.PadRight(LabelWidth) + + " observed: " + Text(observed).PadRight(ObservedWidth) + + " expected: " + Text(expected).PadRight(ObservedWidth) + " " + + (matches ? Ok : Mismatch)); + } + + private static void AppendInfo(StringBuilder text, string label, + string observed) + { + text.AppendLine(" " + label.PadRight(LabelWidth) + + " observed: " + Text(observed).PadRight(ObservedWidth) + " " + Info); + } + + private static void AppendFooter(StringBuilder text) + { + text.AppendLine(); + text.AppendLine("-- notes " + new string('-', 54)); + text.AppendLine(" This command is read-only. It does not release " + + "the controller, request"); + text.AppendLine(" elevation, attach a device, start a USB/IP server, " + + "or change any setting."); + text.AppendLine(" Every [MISMATCH] above is a value the driver " + + "validation gate refuses. If the"); + text.AppendLine(" usbip-win2 install is known good and a line still " + + "reads [MISMATCH], the gate's"); + text.AppendLine(" expectation or the way it reads that value is " + + "wrong; attach this report."); + text.AppendLine(" No device instance paths, serials, addresses, " + + "driver store paths, or user"); + text.AppendLine(" paths are included."); + } + + private static string DescribeTrustFlags(NativeModeSignatureTrust trust) + { + string[] flags = new[] + { + trust.Revoked ? "revoked" : null, + trust.Expired ? "expired" : null, + trust.TestSigned ? "test-signed" : null, + trust.DeveloperSigned ? "developer-signed" : null, + }.Where(flag => flag != null).ToArray(); + return flags.Length == 0 ? "none" : string.Join(", ", flags); + } + + private static string DescribeArchitectures(NativeModeDriverRelease release) + { + if (release == null) + return NotReported; + return string.Join(" or ", + release.Architectures.Select(architecture => architecture.ToString())); + } + + private static string YesNo(bool value) => value ? "yes" : "no"; + + private static string Text(string value) => + string.IsNullOrWhiteSpace(value) ? NotReported : value; + } +} diff --git a/DS4Windows/DS4Control/NativeModeDriverValidation.cs b/DS4Windows/DS4Control/NativeModeDriverValidation.cs new file mode 100644 index 0000000..924ce85 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeDriverValidation.cs @@ -0,0 +1,835 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; + +namespace DS4Windows +{ + /// + /// The component that failed validation. Non-sensitive; safe for logs. + /// + public enum NativeModeDriverComponent + { + None, + UdeHostController, + FilterExtension, + UsbipClient, + } + + /// + /// Why a component failed. Non-sensitive; safe for logs. + /// + public enum NativeModeDriverFailureReason + { + None, + NotFound, + WrongProvider, + WrongInf, + WrongVersion, + WrongArchitecture, + MixedPair, + Unhealthy, + UntrustedSignature, + InspectionFailed, + } + + /// + /// Read-only view of one installed driver package, produced by an + /// . Kept free of device instance + /// paths, serials, or user paths so results can be logged. + /// + public sealed class NativeModeDriverPackageInfo + { + public bool Found { get; init; } + + /// The hardware ID the package was located by (host controller). + public string HardwareId { get; init; } + + /// Original INF name resolved from the driver store, e.g. usbip2_ude.inf. + public string InfName { get; init; } + + public string Provider { get; init; } + + public Version DriverVersion { get; init; } + + public string Service { get; init; } + + /// Catalog identity (file name) for diagnostics; not identity. + public string CatalogFile { get; init; } + + public NativeModeDriverArchitecture Architecture { get; init; } + + /// True when a matching device node is present. + public bool DeviceNodePresent { get; init; } + + /// True when the device node is started with no problem code. + public bool Started { get; init; } + + /// + /// Path the trust verifier should evaluate (the driver-store INF or its + /// catalog). Never surfaced in diagnostics. + /// + public string TrustEvaluationPath { get; init; } + } + + /// + /// Read-only view of the userspace usbip.exe client. + /// + public sealed class NativeModeUsbipClientInfo + { + public bool Found { get; init; } + public string FileName { get; init; } + public Version ProductVersion { get; init; } + } + + /// + /// Outcome of a trust evaluation performed with the Windows trust APIs and + /// normal chain policy. All flags are derived from the trust chain, not + /// from a substring match on any signer string. + /// + public sealed class NativeModeSignatureTrust + { + /// WinVerifyTrust succeeded under normal chain policy. + public bool Trusted { get; init; } + public bool Revoked { get; init; } + public bool Expired { get; init; } + public bool TestSigned { get; init; } + + /// Self-signed or not chained to a trusted root. + public bool DeveloperSigned { get; init; } + + /// + /// The signing certificate obtained from the verified chain is the + /// Microsoft Windows Hardware Compatibility Publisher. + /// + public bool IsMicrosoftHardwareCompatibilityPublisher { get; init; } + + /// Short, non-sensitive diagnostic (e.g. an error mnemonic). + public string Diagnostic { get; init; } + + /// + /// Common name of the signing certificate found on the chain, when one + /// could be read. Diagnostic only: it is never part of the pass/fail + /// decision, which uses + /// . Reported so + /// a wrong expected common name is visible instead of silently failing + /// closed against a good install. + /// + public string ObservedSignerCommonName { get; init; } + + public static NativeModeSignatureTrust Untrusted(string diagnostic, + string observedSignerCommonName = null) => + new NativeModeSignatureTrust + { + Trusted = false, + Diagnostic = diagnostic, + ObservedSignerCommonName = observedSignerCommonName, + }; + } + + /// + /// Enumerates and reads driver-package and userspace-client identity via + /// SetupAPI / Configuration Manager. The interface exists so the + /// manifest-matching and fail-closed decision logic is unit-testable with + /// no driver installed (policy §4.3, §7). + /// + public interface IDriverPackageInspector + { + /// + /// Locate the present emulated UDE host controller by hardware ID and + /// read its bound INF, provider, DriverVer, service, catalog, arch, and + /// health. Never located by a machine-specific instance path. + /// + NativeModeDriverPackageInfo InspectHostController(string hardwareId); + + /// + /// Locate the companion filter extension package by original INF name + /// as a separate component. + /// + NativeModeDriverPackageInfo InspectFilterExtension(string infName); + + /// + /// Read the userspace usbip.exe file identity (file name and product + /// version) at an already path-validated location. + /// + NativeModeUsbipClientInfo InspectUsbipClient(string executablePath); + } + + /// + /// Verifies Authenticode / catalog trust via the Windows trust APIs. Behind + /// an interface so the decision logic can be exercised with fabricated + /// trust results (valid, expired, revoked, developer, test). + /// + public interface IAuthenticodeVerifier + { + /// Verify a driver package (catalog-backed) under normal chain policy. + NativeModeSignatureTrust VerifyDriverPackage(NativeModeDriverPackageInfo package); + + /// Verify a stand-alone signed file such as usbip.exe. + NativeModeSignatureTrust VerifyFile(string filePath); + } + + /// + /// Fail-closed result of a Native Mode driver validation pass. + /// Diagnostics are non-sensitive by construction. + /// + public sealed class NativeModeDriverValidationResult + { + private NativeModeDriverValidationResult(bool passed, + NativeModeDriverComponent failedComponent, + NativeModeDriverFailureReason reason, string diagnostic, + string releaseLabel, NativeModeDriverTier? tier) + { + Passed = passed; + FailedComponent = failedComponent; + Reason = reason; + Diagnostic = diagnostic; + ReleaseLabel = releaseLabel; + Tier = tier; + } + + public bool Passed { get; } + public NativeModeDriverComponent FailedComponent { get; } + public NativeModeDriverFailureReason Reason { get; } + public string Diagnostic { get; } + + /// Matched release label on success; otherwise null. + public string ReleaseLabel { get; } + + /// Matched tier on success; otherwise null. + public NativeModeDriverTier? Tier { get; } + + /// + /// True when the matched release is only allowed as an experimental + /// baseline, so the caller must keep the existing warning and per-Start + /// confirmation. + /// + public bool RequiresExperimentalConfirmation => + Passed && Tier == NativeModeDriverTier.ExperimentalBaseline; + + public static NativeModeDriverValidationResult Pass(string releaseLabel, + NativeModeDriverTier tier) => + new NativeModeDriverValidationResult(true, + NativeModeDriverComponent.None, + NativeModeDriverFailureReason.None, + $"Validated usbip-win2 release {releaseLabel} ({tier}).", + releaseLabel, tier); + + public static NativeModeDriverValidationResult Fail( + NativeModeDriverComponent component, + NativeModeDriverFailureReason reason, string diagnostic) => + new NativeModeDriverValidationResult(false, component, reason, + diagnostic, null, null); + } + + /// + /// Every observation a validation pass consumed, paired with the + /// authoritative . Purely + /// diagnostic: nothing here participates in the fail-closed decision, which + /// is still produced by . + /// Exists so a tester can see observed-vs-expected values on real hardware + /// instead of only a pass/fail verdict. Non-sensitive by construction: no + /// instance paths, serials, addresses, or user paths. + /// + public sealed class NativeModeDriverValidationReport + { + /// The authoritative fail-closed outcome. + public NativeModeDriverValidationResult Result { get; init; } + + /// Release the observations are described against. + public NativeModeDriverRelease ExpectedRelease { get; init; } + + public NativeModeDriverPackageInfo HostController { get; init; } + + public NativeModeDriverPackageInfo FilterExtension { get; init; } + + public NativeModeUsbipClientInfo UsbipClient { get; init; } + + public NativeModeSignatureTrust HostControllerTrust { get; init; } + + public NativeModeSignatureTrust FilterExtensionTrust { get; init; } + + public NativeModeSignatureTrust UsbipClientTrust { get; init; } + + /// + /// Message from an inspection that threw while enumerating driver + /// packages; null when enumeration completed. + /// + public string PackageInspectionError { get; init; } + + /// + /// Message from an inspection that threw while reading the userspace + /// client; null when the read completed. + /// + public string UsbipClientInspectionError { get; init; } + + /// + /// True when a driver-store target could be resolved for the host + /// controller. False means SetupGetInfDriverStoreLocation (or the INF + /// read) produced nothing, which makes the reported INF name and + /// architecture unreliable. The path itself is never surfaced. + /// + public bool HostControllerStoreTargetResolved { get; init; } + + /// + /// True when a driver-store target could be resolved for the filter + /// extension package. + /// + public bool FilterExtensionStoreTargetResolved { get; init; } + } + + /// + /// Pure manifest-matching and fail-closed decision logic. All OS access is + /// delegated to and + /// so this class is fully unit-testable. + /// + public sealed class NativeModeDriverValidator + { + private readonly NativeModeDriverManifest manifest; + private readonly IDriverPackageInspector inspector; + private readonly IAuthenticodeVerifier verifier; + + public NativeModeDriverValidator(NativeModeDriverManifest manifest, + IDriverPackageInspector inspector, IAuthenticodeVerifier verifier) + { + this.manifest = manifest ?? throw new ArgumentNullException(nameof(manifest)); + this.inspector = inspector ?? throw new ArgumentNullException(nameof(inspector)); + this.verifier = verifier ?? throw new ArgumentNullException(nameof(verifier)); + } + + public NativeModeDriverValidationResult Validate(string usbipExecutablePath) + { + NativeModeDriverPackageInfo host; + NativeModeDriverPackageInfo filter; + try + { + host = inspector.InspectHostController( + NativeModeDriverManifest.UdeHostControllerHardwareId); + filter = inspector.InspectFilterExtension( + manifest.ReferenceRelease.FilterExtension.InfName); + } + catch (Exception ex) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UdeHostController, + NativeModeDriverFailureReason.InspectionFailed, + "Native Mode could not enumerate usbip-win2 driver " + + $"packages: {ex.Message}"); + } + + // §4.2: both packages must match one supported release entry. + NativeModeDriverRelease matched = FindMatchingRelease(host, filter); + if (matched == null) + { + return DescribeIdentityFailure(host, filter, + manifest.ReferenceRelease); + } + + // §4.1: the host controller must be present, started, and healthy. + if (!host.DeviceNodePresent || !host.Started) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UdeHostController, + NativeModeDriverFailureReason.Unhealthy, + "The usbip-win2 UDE host controller is present but not " + + "started/healthy. Native Mode is blocked until the driver " + + "reports a started, problem-free state."); + } + + NativeModeDriverValidationResult hostTrust = ValidateDriverTrust(host, + NativeModeDriverComponent.UdeHostController, + matched.DriverSignerPolicy); + if (hostTrust != null) + return hostTrust; + + NativeModeDriverValidationResult filterTrust = ValidateDriverTrust(filter, + NativeModeDriverComponent.FilterExtension, + matched.DriverSignerPolicy); + if (filterTrust != null) + return filterTrust; + + NativeModeDriverValidationResult clientResult = + ValidateUsbipClient(usbipExecutablePath, matched.UserspaceClient); + if (clientResult != null) + return clientResult; + + return NativeModeDriverValidationResult.Pass(matched.ReleaseLabel, + matched.Tier); + } + + /// + /// Read-only diagnostic pass. Gathers the same observations + /// consumes and pairs them with the authoritative + /// outcome, so a report can show observed versus + /// expected values for every component even when validation passes. + /// Additive only: the fail-closed decision is unchanged, and every + /// observation is gathered defensively so a throwing inspector still + /// yields a report. + /// + public NativeModeDriverValidationReport Inspect(string usbipExecutablePath) + { + NativeModeDriverPackageInfo host = null; + NativeModeDriverPackageInfo filter = null; + string packageError = null; + try + { + host = inspector.InspectHostController( + NativeModeDriverManifest.UdeHostControllerHardwareId); + filter = inspector.InspectFilterExtension( + manifest.ReferenceRelease.FilterExtension.InfName); + } + catch (Exception ex) + { + packageError = ex.Message; + } + + NativeModeUsbipClientInfo client = null; + string clientError = null; + try + { + client = inspector.InspectUsbipClient(usbipExecutablePath); + } + catch (Exception ex) + { + clientError = ex.Message; + } + + return new NativeModeDriverValidationReport + { + Result = Validate(usbipExecutablePath), + ExpectedRelease = manifest.ReferenceRelease, + HostController = host, + FilterExtension = filter, + UsbipClient = client, + HostControllerTrust = InspectPackageTrust(host), + FilterExtensionTrust = InspectPackageTrust(filter), + UsbipClientTrust = InspectFileTrust(usbipExecutablePath, client), + PackageInspectionError = packageError, + UsbipClientInspectionError = clientError, + HostControllerStoreTargetResolved = + !string.IsNullOrWhiteSpace(host?.TrustEvaluationPath), + FilterExtensionStoreTargetResolved = + !string.IsNullOrWhiteSpace(filter?.TrustEvaluationPath), + }; + } + + private NativeModeSignatureTrust InspectPackageTrust( + NativeModeDriverPackageInfo package) + { + if (package == null || !package.Found) + return null; + + try + { + return verifier.VerifyDriverPackage(package); + } + catch (Exception ex) + { + return NativeModeSignatureTrust.Untrusted( + "trust verification threw: " + ex.Message); + } + } + + private NativeModeSignatureTrust InspectFileTrust(string filePath, + NativeModeUsbipClientInfo client) + { + if (client == null || !client.Found) + return null; + + try + { + return verifier.VerifyFile(filePath); + } + catch (Exception ex) + { + return NativeModeSignatureTrust.Untrusted( + "trust verification threw: " + ex.Message); + } + } + + private NativeModeDriverRelease FindMatchingRelease( + NativeModeDriverPackageInfo host, NativeModeDriverPackageInfo filter) + { + foreach (NativeModeDriverRelease release in manifest.Releases) + { + if (IdentityMatches(host, release.UdeHostController, release) && + IdentityMatches(filter, release.FilterExtension, release)) + { + return release; + } + } + + return null; + } + + private static bool IdentityMatches(NativeModeDriverPackageInfo package, + NativeModeDriverPackageSpec spec, NativeModeDriverRelease release) + { + return package != null && package.Found && + spec.MatchesProvider(package.Provider) && + spec.MatchesInf(package.InfName) && + spec.MatchesVersion(package.DriverVersion) && + release.SupportsArchitecture(package.Architecture); + } + + /// + /// Produces the most specific non-matching diagnostic against a + /// reference release: missing, wrong provider, wrong INF, mixed pair, + /// wrong version, or wrong architecture. + /// + private static NativeModeDriverValidationResult DescribeIdentityFailure( + NativeModeDriverPackageInfo host, NativeModeDriverPackageInfo filter, + NativeModeDriverRelease reference) + { + NativeModeDriverFailureReason hostCheck = + CheckIdentity(host, reference.UdeHostController, reference); + NativeModeDriverFailureReason filterCheck = + CheckIdentity(filter, reference.FilterExtension, reference); + + if (hostCheck == NativeModeDriverFailureReason.NotFound && + filterCheck == NativeModeDriverFailureReason.NotFound) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UdeHostController, + NativeModeDriverFailureReason.NotFound, + "No usbip-win2 driver packages were found. Install the " + + $"supported release {reference.ReleaseLabel} using the " + + "official usbip-win2 installer."); + } + + if (hostCheck == NativeModeDriverFailureReason.NotFound) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UdeHostController, + NativeModeDriverFailureReason.NotFound, + "The usbip-win2 UDE host controller was not found. Install " + + $"the supported release {reference.ReleaseLabel}."); + } + + if (filterCheck == NativeModeDriverFailureReason.NotFound) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.FilterExtension, + NativeModeDriverFailureReason.NotFound, + "The usbip-win2 filter extension package was not found. " + + "Reinstall the supported release so both packages are " + + "present."); + } + + if (hostCheck == NativeModeDriverFailureReason.WrongProvider) + return ProviderFailure(NativeModeDriverComponent.UdeHostController); + if (filterCheck == NativeModeDriverFailureReason.WrongProvider) + return ProviderFailure(NativeModeDriverComponent.FilterExtension); + + if (hostCheck == NativeModeDriverFailureReason.WrongInf) + return InfFailure(NativeModeDriverComponent.UdeHostController); + if (filterCheck == NativeModeDriverFailureReason.WrongInf) + return InfFailure(NativeModeDriverComponent.FilterExtension); + + bool hostVersionWrong = hostCheck == NativeModeDriverFailureReason.WrongVersion; + bool filterVersionWrong = filterCheck == NativeModeDriverFailureReason.WrongVersion; + if (hostVersionWrong ^ filterVersionWrong) + { + NativeModeDriverComponent mixedComponent = hostVersionWrong + ? NativeModeDriverComponent.UdeHostController + : NativeModeDriverComponent.FilterExtension; + return NativeModeDriverValidationResult.Fail(mixedComponent, + NativeModeDriverFailureReason.MixedPair, + "The usbip-win2 UDE host controller and filter extension " + + "are from different releases. Reinstall a single supported " + + "release so both packages match."); + } + + if (hostVersionWrong && filterVersionWrong) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UdeHostController, + NativeModeDriverFailureReason.WrongVersion, + "The installed usbip-win2 driver packages are not a " + + $"supported release. Install release {reference.ReleaseLabel}; " + + "older, newer, or unknown packages are refused."); + } + + if (hostCheck == NativeModeDriverFailureReason.WrongArchitecture) + return ArchitectureFailure(NativeModeDriverComponent.UdeHostController); + if (filterCheck == NativeModeDriverFailureReason.WrongArchitecture) + return ArchitectureFailure(NativeModeDriverComponent.FilterExtension); + + // Should not happen: no release matched yet no component-level + // difference was identified. Fail closed. + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UdeHostController, + NativeModeDriverFailureReason.WrongVersion, + "The installed usbip-win2 driver packages do not match a " + + "supported release."); + } + + private static NativeModeDriverFailureReason CheckIdentity( + NativeModeDriverPackageInfo package, NativeModeDriverPackageSpec spec, + NativeModeDriverRelease release) + { + if (package == null || !package.Found) + return NativeModeDriverFailureReason.NotFound; + if (!spec.MatchesProvider(package.Provider)) + return NativeModeDriverFailureReason.WrongProvider; + if (!spec.MatchesInf(package.InfName)) + return NativeModeDriverFailureReason.WrongInf; + if (!spec.MatchesVersion(package.DriverVersion)) + return NativeModeDriverFailureReason.WrongVersion; + if (!release.SupportsArchitecture(package.Architecture)) + return NativeModeDriverFailureReason.WrongArchitecture; + return NativeModeDriverFailureReason.None; + } + + private NativeModeDriverValidationResult ValidateDriverTrust( + NativeModeDriverPackageInfo package, + NativeModeDriverComponent component, + NativeModeDriverSignerPolicy policy) + { + NativeModeSignatureTrust trust; + try + { + trust = verifier.VerifyDriverPackage(package); + } + catch (Exception ex) + { + return NativeModeDriverValidationResult.Fail(component, + NativeModeDriverFailureReason.InspectionFailed, + $"Could not verify the {Describe(component)} signature: " + + ex.Message); + } + + string reject = RejectTrust(trust); + if (reject != null) + { + return NativeModeDriverValidationResult.Fail(component, + NativeModeDriverFailureReason.UntrustedSignature, + $"The {Describe(component)} signature is not acceptable: " + + reject); + } + + if (policy == + NativeModeDriverSignerPolicy.MicrosoftHardwareCompatibilityPublisher && + !trust.IsMicrosoftHardwareCompatibilityPublisher) + { + return NativeModeDriverValidationResult.Fail(component, + NativeModeDriverFailureReason.UntrustedSignature, + $"The {Describe(component)} is trusted but is not signed by " + + "the Microsoft Hardware Compatibility Publisher required for " + + "this release."); + } + + if (policy == NativeModeDriverSignerPolicy.MicrosoftWhqlCertified && + !trust.IsMicrosoftHardwareCompatibilityPublisher) + { + return NativeModeDriverValidationResult.Fail(component, + NativeModeDriverFailureReason.UntrustedSignature, + $"The {Describe(component)} does not satisfy the required " + + "WHQL certification policy."); + } + + return null; + } + + private NativeModeDriverValidationResult ValidateUsbipClient( + string usbipExecutablePath, NativeModeUsbipClientSpec spec) + { + NativeModeUsbipClientInfo client; + try + { + client = inspector.InspectUsbipClient(usbipExecutablePath); + } + catch (Exception ex) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UsbipClient, + NativeModeDriverFailureReason.InspectionFailed, + $"Could not read the usbip.exe client: {ex.Message}"); + } + + if (client == null || !client.Found) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UsbipClient, + NativeModeDriverFailureReason.NotFound, + "The usbip.exe userspace client was not found at the " + + "configured location."); + } + + if (!spec.MatchesFileName(client.FileName)) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UsbipClient, + NativeModeDriverFailureReason.WrongInf, + "The configured client is not named usbip.exe."); + } + + if (!spec.MatchesProductVersion(client.ProductVersion)) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UsbipClient, + NativeModeDriverFailureReason.WrongVersion, + "The usbip.exe client product version does not match the " + + $"supported release {spec.ProductVersion}."); + } + + if (spec.RequireAuthenticode) + { + NativeModeSignatureTrust trust; + try + { + trust = verifier.VerifyFile(usbipExecutablePath); + } + catch (Exception ex) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UsbipClient, + NativeModeDriverFailureReason.InspectionFailed, + $"Could not verify the usbip.exe signature: {ex.Message}"); + } + + string reject = RejectTrust(trust); + if (reject != null) + { + return NativeModeDriverValidationResult.Fail( + NativeModeDriverComponent.UsbipClient, + NativeModeDriverFailureReason.UntrustedSignature, + "The usbip.exe Authenticode signature is not " + + $"acceptable: {reject}"); + } + } + + return null; + } + + /// + /// Returns a non-null rejection reason when the trust result is not + /// clean under normal chain policy; null when acceptable. + /// + private static string RejectTrust(NativeModeSignatureTrust trust) + { + if (trust == null) + return "no trust result was produced"; + if (trust.Revoked) + return "the signing certificate is revoked"; + if (trust.Expired) + return "the signing certificate is expired"; + if (trust.TestSigned) + return "the package is test-signed"; + if (trust.DeveloperSigned) + return "the package is developer-signed / not chained to a trusted root"; + if (!trust.Trusted) + { + return string.IsNullOrWhiteSpace(trust.Diagnostic) + ? "the signature is not trusted" + : trust.Diagnostic; + } + + return null; + } + + private static string Describe(NativeModeDriverComponent component) => + component switch + { + NativeModeDriverComponent.UdeHostController => "UDE host controller", + NativeModeDriverComponent.FilterExtension => "filter extension", + NativeModeDriverComponent.UsbipClient => "usbip.exe client", + _ => "component", + }; + + private static NativeModeDriverValidationResult ProviderFailure( + NativeModeDriverComponent component) => + NativeModeDriverValidationResult.Fail(component, + NativeModeDriverFailureReason.WrongProvider, + $"The {Describe(component)} reports an unexpected provider. " + + "Only the supported usbip-win2 packages are accepted."); + + private static NativeModeDriverValidationResult InfFailure( + NativeModeDriverComponent component) => + NativeModeDriverValidationResult.Fail(component, + NativeModeDriverFailureReason.WrongInf, + $"The {Describe(component)} is bound to an unexpected INF."); + + private static NativeModeDriverValidationResult ArchitectureFailure( + NativeModeDriverComponent component) => + NativeModeDriverValidationResult.Fail(component, + NativeModeDriverFailureReason.WrongArchitecture, + $"The {Describe(component)} architecture is not supported for " + + "this release."); + } + + /// + /// Composition + wiring point for the driver validation gate. The gate must + /// PASS before DS4Windows releases the physical controller or requests + /// elevation to attach (policy §4). Holds all OS-touching implementations + /// behind the two interfaces so it is trivially replaced in tests. + /// + public sealed class NativeModeDriverGate + { + private readonly NativeModeDriverValidator validator; + + public NativeModeDriverGate(NativeModeDriverValidator validator) + { + this.validator = validator ?? + throw new ArgumentNullException(nameof(validator)); + } + + /// The shared production gate wired to the real OS inspectors. + public static NativeModeDriverGate Default { get; } = CreateDefault(); + + public static NativeModeDriverGate CreateDefault() + { + var validator = new NativeModeDriverValidator( + NativeModeDriverManifest.Supported, + new SetupApiDriverPackageInspector(), + new WinTrustAuthenticodeVerifier()); + return new NativeModeDriverGate(validator); + } + + /// + /// Full validation with rich result. Callers must not release the + /// controller unless + /// is true. + /// + public NativeModeDriverValidationResult Validate(string usbipExecutablePath) => + validator.Validate(usbipExecutablePath); + + /// + /// Read-only diagnostic pass used by the -validatedriver command. + /// Nothing is released, elevated, attached, or modified; the gate only + /// reads device, driver, and file state. + /// + public NativeModeDriverValidationReport Inspect(string usbipExecutablePath) => + validator.Inspect(usbipExecutablePath); + + /// + /// Elevation-path guard. Returns null when validation passes; otherwise + /// a fail-closed the broker returns + /// before requesting elevation. + /// + public NativeModeAttachResult ValidateBeforeElevation(string usbipExecutablePath) + { + NativeModeDriverValidationResult result = Validate(usbipExecutablePath); + return result.Passed + ? null + : NativeModeAttachResult.Failed( + NativeModeAttachFailureKind.DriverValidationFailed, + result.Diagnostic); + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeDriverValidationCommand.cs b/DS4Windows/DS4Control/NativeModeDriverValidationCommand.cs new file mode 100644 index 0000000..edece6b --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeDriverValidationCommand.cs @@ -0,0 +1,308 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace DS4Windows +{ + /// + /// Implements the -validatedriver switch: a read-only smoke test of + /// the Native Mode driver-validation gate against whatever usbip-win2 + /// install is on the machine. + /// + /// Strictly read-only. It never releases or suppresses the physical + /// controller, never requests elevation, never runs a usbip attach, never + /// starts the helper, the USB/IP server, or the ControlService, and never + /// writes a setting or touches the driver. It only reads device, driver, and + /// file state, and it runs as a normal user (the report states whether it ran + /// elevated so a restricted read is visible instead of being papered over). + /// + public static class NativeModeDriverValidationCommand + { + /// Validation passed. + public const int ExitCodePassed = 0; + + /// Validation failed; Native Mode would refuse to start. + public const int ExitCodeFailed = 1; + + /// The diagnostic itself could not run. + public const int ExitCodeError = 2; + + private const int AttachParentProcess = -1; + private const string ReportDirectoryName = "DS4Windows"; + private const string ReportFilePrefix = "nativemode-driver-validation-"; + private const string WindowTitle = + "DS4Windows - Native Mode driver validation"; + + /// + /// Runs the diagnostic and returns the process exit code: 0 when + /// validation passed, non-zero otherwise, so the command is scriptable. + /// + public static int Run() + { + bool console = TryAttachParentConsole(); + + string text; + int exitCode; + try + { + exitCode = BuildReport(out text); + } + catch (Exception ex) + { + text = "DS4Windows Native Mode driver validation could not run: " + + ex.Message; + exitCode = ExitCodeError; + } + + Emit(text, console); + return exitCode; + } + + private static int BuildReport(out string text) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + string usbipPath = ResolveUsbipExecutablePath(); + string reportPath = BuildReportFilePath(now); + + NativeModeDriverValidationReport report = + NativeModeDriverGate.Default.Inspect(usbipPath); + + var context = new NativeModeDriverReportContext + { + TimestampUtc = now, + AppVersion = ReadAppVersion(), + OsVersion = Environment.OSVersion.VersionString, + ProcessArchitecture = + RuntimeInformation.ProcessArchitecture.ToString(), + Elevated = ReadElevated(), + UsbipExecutablePath = + NativeModeDriverReportFormatter.RedactUserPath(usbipPath), + ReportFilePath = DisplayReportPath(reportPath), + }; + + text = NativeModeDriverReportFormatter.Format(report, context); + string writeError = TryWriteReport(reportPath, text); + if (writeError != null) + { + text += Environment.NewLine + + " The report could not be saved to " + + DisplayReportPath(reportPath) + ": " + writeError + + Environment.NewLine; + } + + return report.Result != null && report.Result.Passed + ? ExitCodePassed + : ExitCodeFailed; + } + + /// + /// Reads the configured usbip.exe location without saving anything. The + /// stored setting is loaded read-only so the report checks the same path + /// Native Mode would use; the packaged default is used if it cannot be + /// read. + /// + private static string ResolveUsbipExecutablePath() + { + try + { + Global.FindConfigLocation(); + if (!Global.firstRun) + Global.Load(); + } + catch (Exception) + { + // Fall through to whatever default the backing store holds; the + // resolved path is printed either way. + } + + try + { + return Global.UsbipExePath; + } + catch (Exception) + { + return BackingStore.DEFAULT_USBIP_EXE_PATH; + } + } + + private static bool ReadElevated() + { + try + { + return Global.IsAdministrator(); + } + catch (Exception) + { + return false; + } + } + + private static string ReadAppVersion() + { + try + { + return Global.exeversion; + } + catch (Exception) + { + return null; + } + } + + private static string BuildReportFilePath(DateTimeOffset timestamp) + { + string fileName = ReportFilePrefix + + timestamp.ToUniversalTime().ToString("yyyyMMdd-HHmmss") + + "Z.txt"; + return Path.Combine(Path.GetTempPath(), ReportDirectoryName, fileName); + } + + /// + /// Report location in a form a tester can paste into a bug report: + /// %TEMP% expands in Explorer and in a shell, and carries no user name. + /// + private static string DisplayReportPath(string reportPath) + { + if (string.IsNullOrWhiteSpace(reportPath)) + return null; + + string temp = Path.GetTempPath(); + if (!string.IsNullOrWhiteSpace(temp) && reportPath.StartsWith(temp, + StringComparison.OrdinalIgnoreCase)) + { + return Path.Combine("%TEMP%", + reportPath.Substring(temp.Length).TrimStart('\\')); + } + + return NativeModeDriverReportFormatter.RedactUserPath(reportPath); + } + + /// Returns null on success, or the failure message. + private static string TryWriteReport(string reportPath, string text) + { + try + { + string directory = Path.GetDirectoryName(reportPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + File.WriteAllText(reportPath, text); + return null; + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException || ex is NotSupportedException || + ex is ArgumentException) + { + return ex.Message; + } + } + + private static void Emit(string text, bool console) + { + if (console) + { + try + { + Console.WriteLine(); + Console.WriteLine(text); + Console.Out.Flush(); + return; + } + catch (IOException) + { + // The attached console went away; fall back to the dialog. + } + } + + ShowReportWindow(text); + } + + /// + /// DS4Windows is a WPF application with no console of its own, so a + /// GUI-launched run gets the same text in a selectable, scrollable window + /// it can be copied out of. + /// + private static void ShowReportWindow(string text) + { + try + { + var view = new System.Windows.Controls.TextBox + { + Text = text, + IsReadOnly = true, + IsReadOnlyCaretVisible = true, + AcceptsReturn = true, + TextWrapping = System.Windows.TextWrapping.NoWrap, + VerticalScrollBarVisibility = + System.Windows.Controls.ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = + System.Windows.Controls.ScrollBarVisibility.Auto, + FontFamily = new System.Windows.Media.FontFamily("Consolas"), + Margin = new System.Windows.Thickness(4), + }; + + var window = new System.Windows.Window + { + Title = WindowTitle, + Width = 940, + Height = 620, + WindowStartupLocation = + System.Windows.WindowStartupLocation.CenterScreen, + Content = view, + }; + window.ShowDialog(); + } + catch (Exception) + { + System.Windows.MessageBox.Show(text, WindowTitle, + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + } + } + + /// + /// Attaches to the console of the launching terminal, if any, and rebinds + /// stdout to it. Returns false when the process has no parent console. + /// + private static bool TryAttachParentConsole() + { + try + { + if (!AttachConsole(AttachParentProcess)) + return false; + + var output = new StreamWriter(Console.OpenStandardOutput()) + { + AutoFlush = true, + }; + Console.SetOut(output); + return true; + } + catch (Exception) + { + return false; + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachConsole(int processId); + } +} diff --git a/DS4Windows/DS4Control/NativeModeElevationBroker.cs b/DS4Windows/DS4Control/NativeModeElevationBroker.cs new file mode 100644 index 0000000..4ddc0d7 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeElevationBroker.cs @@ -0,0 +1,927 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace DS4Windows +{ + public enum NativeModeAttachFailureKind + { + None, + UsbipMissing, + InvalidUsbipPath, + SetupRequired, + LegacyTaskCleanupFailed, + CommandFailed, + DeviceArrivalTimeout, + DriverValidationFailed, + } + + public sealed class NativeModeAttachResult + { + private NativeModeAttachResult(bool success, + NativeModeAttachFailureKind failureKind, string reason, + Task pendingCommandCompletion = null) + { + Success = success; + FailureKind = failureKind; + Reason = reason; + PendingCommandCompletion = pendingCommandCompletion; + } + + public bool Success { get; } + public NativeModeAttachFailureKind FailureKind { get; } + public string Reason { get; } + + /// + /// When non-null, an elevated command was already handed to Windows + /// but had not reached a terminal state when this result was returned. + /// Native Mode must retain its helper and teardown protections until + /// this task completes, because consent can still be granted late. + /// + internal Task PendingCommandCompletion { get; } + + public static NativeModeAttachResult Succeeded(string reason) => + new NativeModeAttachResult(true, NativeModeAttachFailureKind.None, reason); + + public static NativeModeAttachResult Failed( + NativeModeAttachFailureKind kind, string reason, + Task pendingCommandCompletion = null) => + new NativeModeAttachResult(false, kind, reason, + pendingCommandCompletion); + } + + internal sealed class NativeModeCommandExecution + { + public NativeModeCommandExecution(NativeModeCommandResult result, + Task pendingCompletion = null) + { + Result = result ?? throw new ArgumentNullException(nameof(result)); + PendingCompletion = pendingCompletion; + } + + public NativeModeCommandResult Result { get; } + public Task PendingCompletion { get; } + } + + internal sealed class NativeModeCommandResult + { + public NativeModeCommandResult(int exitCode, string output, string error) + { + ExitCode = exitCode; + Output = output ?? string.Empty; + Error = error ?? string.Empty; + } + + public int ExitCode { get; } + public string Output { get; } + public string Error { get; } + } + + internal interface INativeModeCommandRunner + { + Task RunAsync(string executable, + IReadOnlyList arguments, bool elevate, + CancellationToken cancellationToken); + } + + internal sealed class NativeModeProcessRunner : INativeModeCommandRunner + { + public async Task RunAsync(string executable, + IReadOnlyList arguments, bool elevate, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + // ShellExecute can remain inside the UAC broker until the user + // answers the prompt. Dispatch both process creation and Start + // explicitly so this call can never block a captured UI context. + Process process = await Task.Run( + () => CreateAndStartProcess(executable, arguments, elevate), + CancellationToken.None).ConfigureAwait(false); + if (process == null) + { + return new NativeModeCommandResult(-1, string.Empty, + $"Could not start {Path.GetFileName(executable)}."); + } + + using (process) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + Task outputTask = elevate + ? Task.FromResult(string.Empty) + : process.StandardOutput.ReadToEndAsync(); + Task errorTask = elevate + ? Task.FromResult(string.Empty) + : process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(cancellationToken) + .ConfigureAwait(false); + return new NativeModeCommandResult(process.ExitCode, + await outputTask.ConfigureAwait(false), + await errorTask.ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + // This is the short-lived usbip/schtasks command client, + // never the attached Native Mode helper process. + TryTerminate(process); + // Do not complete this runner until the elevated + // client is definitely gone. A failed termination can + // otherwise leave a late usbip attach racing cleanup. + await WaitForConfirmedExitAsync(process) + .ConfigureAwait(false); + throw; + } + catch + { + // No error after Process.Start is allowed to make the + // command look terminal while its process might live. + TryTerminate(process); + await WaitForConfirmedExitAsync(process) + .ConfigureAwait(false); + throw; + } + } + } + catch (Win32Exception ex) + { + string reason = ex.NativeErrorCode == 1223 + ? "The elevation prompt was canceled." + : ex.Message; + return new NativeModeCommandResult(ex.NativeErrorCode, + string.Empty, reason); + } + } + + private static Process CreateAndStartProcess(string executable, + IReadOnlyList arguments, bool elevate) + { + Process process = CreateProcess(executable, arguments, elevate); + try + { + if (process.Start()) + return process; + process.Dispose(); + return null; + } + catch + { + process.Dispose(); + throw; + } + } + + private static void TryTerminate(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException || + ex is Win32Exception || ex is NotSupportedException) + { + // The command may have exited between the probe and Kill, or + // an elevated process handle may not grant termination rights. + } + } + + private static async Task WaitForConfirmedExitAsync(Process process) + { + while (true) + { + try + { + if (process.HasExited) + return; + + await process.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException || + ex is Win32Exception || ex is NotSupportedException) + { + // Losing query or wait rights is not proof of exit. Keep + // the completion barrier pending and retry fail-closed. + await Task.Delay(TimeSpan.FromMilliseconds(250)) + .ConfigureAwait(false); + } + } + } + + private static Process CreateProcess(string executable, + IReadOnlyList arguments, bool elevate) + { + var startInfo = new ProcessStartInfo(executable) + { + UseShellExecute = elevate, + CreateNoWindow = !elevate, + WindowStyle = elevate ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal, + RedirectStandardOutput = !elevate, + RedirectStandardError = !elevate, + Verb = elevate ? "runas" : string.Empty, + }; + foreach (string argument in arguments) + startInfo.ArgumentList.Add(argument); + + return new Process { StartInfo = startInfo }; + } + } + + public sealed class NativeModeElevationBroker + { + private const string UsbipExecutableName = "usbip.exe"; + internal const string LegacyAttachTaskName = + @"DS4Windows\NativeDualSenseAttach"; + private static readonly TimeSpan DefaultArrivalTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ArrivalPollInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30); + + private readonly Func fileExists; + private readonly Func> trustedProgramFilesRoots; + private readonly Func legacyAttachTaskPresent; + private readonly Func isAdministrator; + private readonly INativeModeCommandRunner commandRunner; + private readonly Func virtualDevicePresent; + private readonly Func delay; + private readonly string taskSchedulerExecutable; + private readonly TimeSpan commandTimeout; + private readonly NativeModeDriverGate driverGate; + + public NativeModeElevationBroker() : this((NativeModeDriverGate)null) + { + } + + /// + /// Production constructor. The driver gate re-validates the usbip-win2 + /// packages before this broker requests elevation, so a direct broker + /// caller cannot elevate on an unvalidated driver set (policy §4). + /// + public NativeModeElevationBroker(NativeModeDriverGate driverGate) + : this(File.Exists, + GetTrustedProgramFilesRoots, IsLegacyAttachTaskPresent, + Global.IsAdministrator, new NativeModeProcessRunner(), + NativeModeDevicePresence.IsVirtualDualSensePresent, + Task.Delay, Path.Combine(Environment.GetFolderPath( + Environment.SpecialFolder.System), "schtasks.exe"), + commandTimeout: null, driverGate: driverGate) + { + } + + internal NativeModeElevationBroker(Func fileExists, + Func> trustedProgramFilesRoots, + Func legacyAttachTaskPresent, Func isAdministrator, + INativeModeCommandRunner commandRunner, + Func virtualDevicePresent, + Func delay, + string taskSchedulerExecutable, TimeSpan? commandTimeout = null, + NativeModeDriverGate driverGate = null) + { + this.driverGate = driverGate; + this.fileExists = fileExists ?? throw new ArgumentNullException(nameof(fileExists)); + this.trustedProgramFilesRoots = trustedProgramFilesRoots ?? + throw new ArgumentNullException(nameof(trustedProgramFilesRoots)); + this.legacyAttachTaskPresent = legacyAttachTaskPresent ?? + throw new ArgumentNullException(nameof(legacyAttachTaskPresent)); + this.isAdministrator = isAdministrator ?? + throw new ArgumentNullException(nameof(isAdministrator)); + this.commandRunner = commandRunner ?? + throw new ArgumentNullException(nameof(commandRunner)); + this.virtualDevicePresent = virtualDevicePresent ?? + throw new ArgumentNullException(nameof(virtualDevicePresent)); + this.delay = delay ?? throw new ArgumentNullException(nameof(delay)); + this.taskSchedulerExecutable = taskSchedulerExecutable ?? + throw new ArgumentNullException(nameof(taskSchedulerExecutable)); + this.commandTimeout = commandTimeout ?? DefaultCommandTimeout; + if (this.commandTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(commandTimeout)); + } + + public bool IsUsbipInstalled(string usbipPath) => + ValidateUsbipExecutable(usbipPath, out _) == null; + + /// + /// Retains the former setup API as a prerequisite check and removes the + /// fixed legacy elevated task if an earlier build installed it. No task, + /// service, helper, or other reusable privileged artifact is installed. + /// Each attach receives a fresh UAC decision instead. + /// + public async Task EnsureAttachTaskAsync( + string usbipPath, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + NativeModeAttachResult cleanup = + await EnsureLegacyAttachTaskRemovedAsync(cancellationToken) + .ConfigureAwait(false); + if (!cleanup.Success) + return cleanup; + + NativeModeAttachResult validation = + ValidateUsbipExecutable(usbipPath, out _); + return validation ?? + NativeModeAttachResult.Succeeded( + "Native mode is ready and no legacy elevated attach task " + + "remains. Windows will request administrator " + + "approval for the fixed usbip.exe attach action each time " + + "Native Mode starts."); + } + + public async Task RunAttachAsync(string usbipPath, + CancellationToken cancellationToken = default) + { + // Fail closed before any elevation: neither the legacy task cleanup + // (which elevates) nor the attach may proceed on an unvalidated + // usbip-win2 driver set. + NativeModeAttachResult driverValidation = + driverGate?.ValidateBeforeElevation(usbipPath); + if (driverValidation != null) + return driverValidation; + + NativeModeAttachResult cleanup = + await EnsureLegacyAttachTaskRemovedAsync(cancellationToken) + .ConfigureAwait(false); + if (!cleanup.Success) + return cleanup; + + NativeModeAttachResult validation = + ValidateUsbipExecutable(usbipPath, out string executable); + if (validation != null) + return validation; + + IReadOnlyList arguments = BuildDirectAttachArguments(); + bool elevate = !isAdministrator(); + + NativeModeCommandExecution command = await RunCommandAsync( + executable, arguments, elevate, cancellationToken) + .ConfigureAwait(false); + NativeModeCommandResult commandResult = command.Result; + if (commandResult.ExitCode != 0) + { + return NativeModeAttachResult.Failed( + NativeModeAttachFailureKind.CommandFailed, + CommandFailure("USB/IP attach could not be started", + commandResult), + command.PendingCompletion); + } + + bool arrived; + try + { + arrived = await WaitForDeviceArrivalAsync(virtualDevicePresent, + delay, DefaultArrivalTimeout, ArrivalPollInterval, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is Win32Exception || + ex is UnauthorizedAccessException) + { + return NativeModeAttachResult.Failed( + NativeModeAttachFailureKind.DeviceArrivalTimeout, + "Could not query present devices while confirming the " + + $"virtual DualSense attachment: {ex.Message}"); + } + return arrived + ? NativeModeAttachResult.Succeeded("Virtual DualSense attached.") + : NativeModeAttachResult.Failed( + NativeModeAttachFailureKind.DeviceArrivalTimeout, + "Virtual DualSense did not appear within 10 seconds; check the native server log and USB/IP driver state."); + } + + internal static string[] BuildDirectAttachArguments() => + new[] + { + "attach", "-r", "127.0.0.1", "-b", "1-1", + "--serial", NativeModeDevicePresence.VirtualDualSenseSerial, + "--once", + }; + + internal static string[] BuildLegacyTaskDeleteArguments() => + new[] { "/Delete", "/TN", LegacyAttachTaskName, "/F" }; + + internal static async Task WaitForDeviceArrivalAsync( + Func devicePresent, + Func delay, + TimeSpan timeout, TimeSpan pollInterval, + CancellationToken cancellationToken) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; + do + { + if (devicePresent()) + return true; + if (DateTimeOffset.UtcNow >= deadline) + return false; + await delay(pollInterval, cancellationToken).ConfigureAwait(false); + } + while (true); + } + + private async Task + EnsureLegacyAttachTaskRemovedAsync( + CancellationToken cancellationToken) + { + bool taskPresent; + try + { + taskPresent = legacyAttachTaskPresent(); + } + catch (Exception ex) + { + return LegacyTaskCleanupFailure( + "Could not check for the legacy elevated Native Mode " + + $"attach task: {ex.Message}"); + } + + if (!taskPresent) + { + return NativeModeAttachResult.Succeeded( + "No legacy elevated Native Mode attach task is installed."); + } + + NativeModeCommandExecution deleteCommand = + await RunCommandAsync(taskSchedulerExecutable, + BuildLegacyTaskDeleteArguments(), elevate: true, + cancellationToken).ConfigureAwait(false); + NativeModeCommandResult deleteResult = deleteCommand.Result; + if (deleteResult.ExitCode != 0) + { + return NativeModeAttachResult.Failed( + NativeModeAttachFailureKind.LegacyTaskCleanupFailed, + CommandFailure( + "The legacy elevated Native Mode attach task must be " + + "removed before Native Mode can start", + deleteResult), + deleteCommand.PendingCompletion); + } + + try + { + if (legacyAttachTaskPresent()) + { + return LegacyTaskCleanupFailure( + "The legacy elevated Native Mode attach task still " + + "exists after Windows reported successful removal. " + + "Native Mode is blocked."); + } + } + catch (Exception ex) + { + return LegacyTaskCleanupFailure( + "Windows reported that the legacy elevated Native Mode " + + "attach task was removed, but its absence could not be " + + $"confirmed: {ex.Message}"); + } + + return NativeModeAttachResult.Succeeded( + "The legacy elevated Native Mode attach task was removed."); + } + + private async Task RunCommandAsync( + string executable, IReadOnlyList arguments, bool elevate, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(commandTimeout); + + Task commandTask = commandRunner.RunAsync( + executable, arguments, elevate, timeout.Token); + try + { + NativeModeCommandResult result = await commandTask.WaitAsync( + commandTimeout, cancellationToken).ConfigureAwait(false); + return new NativeModeCommandExecution(result); + } + catch (TimeoutException) + { + timeout.Cancel(); + ObserveAbandonedCommand(commandTask); + return new NativeModeCommandExecution( + CommandTimedOutResult(), commandTask); + } + catch (OperationCanceledException) + { + timeout.Cancel(); + ObserveAbandonedCommand(commandTask); + if (cancellationToken.IsCancellationRequested) + { + return new NativeModeCommandExecution( + CommandCanceledResult(), commandTask); + } + return new NativeModeCommandExecution( + CommandTimedOutResult(), commandTask); + } + } + + private static NativeModeCommandResult CommandCanceledResult() => + new NativeModeCommandResult(-1, string.Empty, + "The elevated action was canceled; cleanup is waiting for " + + "Windows to confirm that the command has stopped."); + + private NativeModeCommandResult CommandTimedOutResult() + { + string duration = commandTimeout.TotalSeconds >= 1 + ? $"{commandTimeout.TotalSeconds:0.#} seconds" + : $"{commandTimeout.TotalMilliseconds:0} milliseconds"; + return new NativeModeCommandResult(-1, string.Empty, + $"The elevated action did not finish within {duration}."); + } + + private static void ObserveAbandonedCommand( + Task commandTask) + { + _ = commandTask.ContinueWith(task => + { + _ = task.Exception; + }, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private static IReadOnlyList GetTrustedProgramFilesRoots() => + new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + } + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool IsLegacyAttachTaskPresent() + { + using var taskService = + new Microsoft.Win32.TaskScheduler.TaskService(); + return taskService.GetTask(@"\" + LegacyAttachTaskName) != null; + } + + private NativeModeAttachResult ValidateUsbipExecutable( + string usbipPath, out string executable) + { + executable = null; + if (string.IsNullOrWhiteSpace(usbipPath)) + return MissingUsbipResult(usbipPath); + + string candidate = usbipPath.Trim(); + if (!Path.IsPathFullyQualified(candidate) || + candidate.StartsWith(@"\\", StringComparison.Ordinal)) + { + return InvalidUsbipResult( + "UsbipExePath must be a fully qualified local path."); + } + + try + { + executable = Path.GetFullPath(candidate); + } + catch (Exception ex) when (ex is ArgumentException || + ex is NotSupportedException || ex is PathTooLongException || + ex is IOException) + { + executable = null; + return InvalidUsbipResult( + $"UsbipExePath is invalid: {ex.Message}"); + } + + if (!string.Equals(candidate, executable, + StringComparison.OrdinalIgnoreCase)) + { + executable = null; + return InvalidUsbipResult( + "UsbipExePath must already be a canonical local path " + + "without relative segments."); + } + + if (!string.Equals(Path.GetFileName(executable), + UsbipExecutableName, StringComparison.OrdinalIgnoreCase)) + { + executable = null; + return InvalidUsbipResult( + "UsbipExePath must point to a local executable named usbip.exe."); + } + + IReadOnlyList trustedRoots; + try + { + trustedRoots = trustedProgramFilesRoots() ?? + Array.Empty(); + } + catch (Exception ex) + { + executable = null; + return InvalidUsbipResult( + $"Could not determine trusted Program Files roots: {ex.Message}"); + } + + string validatedExecutable = executable; + if (!trustedRoots.Any(root => IsPathWithinTrustedRoot( + validatedExecutable, root))) + { + executable = null; + return InvalidUsbipResult( + "UsbipExePath must be a canonical path under a trusted " + + "Windows Program Files directory."); + } + + bool exists; + try + { + exists = fileExists(executable); + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException) + { + executable = null; + return InvalidUsbipResult( + $"Could not verify UsbipExePath: {ex.Message}"); + } + + if (!exists) + { + string missingPath = executable; + executable = null; + return MissingUsbipResult(missingPath); + } + + return null; + } + + private static bool IsPathWithinTrustedRoot(string path, string root) + { + if (string.IsNullOrWhiteSpace(root)) + return false; + + string candidateRoot = root.Trim(); + if (!Path.IsPathFullyQualified(candidateRoot) || + candidateRoot.StartsWith(@"\\", StringComparison.Ordinal)) + { + return false; + } + + try + { + string canonicalRoot = Path.GetFullPath(candidateRoot) + .TrimEnd(Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar); + if (!string.Equals(candidateRoot.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar), canonicalRoot, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return path.StartsWith(canonicalRoot + + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is ArgumentException || + ex is NotSupportedException || ex is PathTooLongException || + ex is IOException) + { + return false; + } + } + + private static NativeModeAttachResult MissingUsbipResult(string path) => + NativeModeAttachResult.Failed(NativeModeAttachFailureKind.UsbipMissing, + $"usbip.exe was not found at '{path}'. Install the supported " + + "usbip-win2 userspace client under Program Files as described " + + "in doc/dev/native_dualsense_mode.md, or correct UsbipExePath."); + + private static NativeModeAttachResult InvalidUsbipResult(string reason) => + NativeModeAttachResult.Failed( + NativeModeAttachFailureKind.InvalidUsbipPath, reason); + + private static NativeModeAttachResult LegacyTaskCleanupFailure( + string reason) => NativeModeAttachResult.Failed( + NativeModeAttachFailureKind.LegacyTaskCleanupFailed, reason); + + private static string CommandFailure(string prefix, + NativeModeCommandResult result) + { + string detail = string.IsNullOrWhiteSpace(result.Error) + ? result.Output.Trim() + : result.Error.Trim(); + return string.IsNullOrWhiteSpace(detail) + ? $"{prefix} (exit code {result.ExitCode})." + : $"{prefix} (exit code {result.ExitCode}): {detail}"; + } + } + + internal static class NativeModeDevicePresence + { + internal const string VirtualDualSenseSerial = "DS4WSPKCOMP001"; + internal const string VirtualDualSenseParentInstanceId = + @"USB\VID_054C&PID_0CE6\DS4WSPKCOMP001"; + private const string DualSenseUsbInstancePrefix = + @"USB\VID_054C&PID_0CE6"; + private const int ErrorInsufficientBuffer = 122; + private const int ErrorNoMoreItems = 259; + internal const int PresentAllClassesFlags = + NativeMethods.DIGCF_PRESENT | NativeMethods.DIGCF_ALLCLASSES; + private static readonly IntPtr InvalidHandleValue = new IntPtr(-1); + + // Cleanup callers deliberately receive probe failures. Treating a failed + // query as "absent" could release the retained render pin while a stale + // or still-removing composite child exists. The attach broker catches + // expected SetupAPI failures and returns a normal attach failure instead. + public static bool IsVirtualDualSensePresent() => + GetPresentVirtualDualSenseInstanceIds().Count != 0; + + internal static IReadOnlyList + GetPresentVirtualDualSenseInstanceIds() => + GetPresentVirtualDualSenseInstanceIds( + EnumeratePresentDeviceInstanceIds); + + internal static IReadOnlyList + GetPresentVirtualDualSenseInstanceIds( + Func> enumerateDeviceInstanceIds) + { + if (enumerateDeviceInstanceIds == null) + { + throw new ArgumentNullException( + nameof(enumerateDeviceInstanceIds)); + } + + return (enumerateDeviceInstanceIds(PresentAllClassesFlags) ?? + Array.Empty()) + .Where(IsVirtualDualSenseParentInstanceId) + .ToArray(); + } + + private static IReadOnlyList + EnumeratePresentDeviceInstanceIds(int flags) + { + IntPtr deviceInfoSet = NativeMethods.SetupDiGetClassDevs( + IntPtr.Zero, null, 0, flags); + if (deviceInfoSet == InvalidHandleValue) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "SetupDiGetClassDevs could not enumerate present devices."); + } + + var instanceIds = new List(); + try + { + for (int index = 0; ; index++) + { + var deviceInfo = new NativeMethods.SP_DEVINFO_DATA + { + cbSize = Marshal.SizeOf(), + }; + if (!NativeMethods.SetupDiEnumDeviceInfo(deviceInfoSet, + index, ref deviceInfo)) + { + int error = Marshal.GetLastWin32Error(); + if (error == ErrorNoMoreItems) + break; + throw new Win32Exception(error, + "SetupDiEnumDeviceInfo could not enumerate a present device."); + } + + instanceIds.Add(GetDeviceInstanceId(deviceInfoSet, + ref deviceInfo)); + } + } + finally + { + NativeMethods.SetupDiDestroyDeviceInfoList(deviceInfoSet); + } + + return instanceIds; + } + + private static string GetDeviceInstanceId(IntPtr deviceInfoSet, + ref NativeMethods.SP_DEVINFO_DATA deviceInfo) + { + int requiredSize = 0; + if (!NativeMethods.SetupDiGetDeviceInstanceId(deviceInfoSet, + ref deviceInfo, null, 0, ref requiredSize)) + { + int error = Marshal.GetLastWin32Error(); + if (error != ErrorInsufficientBuffer) + { + throw new Win32Exception(error, + "SetupDiGetDeviceInstanceId could not size an instance ID."); + } + } + + if (requiredSize <= 1) + { + throw new Win32Exception( + "SetupDiGetDeviceInstanceId returned an empty instance ID."); + } + + var instanceId = new StringBuilder(requiredSize); + if (!NativeMethods.SetupDiGetDeviceInstanceId(deviceInfoSet, + ref deviceInfo, instanceId, instanceId.Capacity, + ref requiredSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "SetupDiGetDeviceInstanceId could not read an instance ID."); + } + + return instanceId.ToString(); + } + + internal static bool IsVirtualDualSenseParentInstanceId( + string instanceId) => + string.Equals(instanceId, VirtualDualSenseParentInstanceId, + StringComparison.OrdinalIgnoreCase); + + internal static bool IsVirtualDualSenseInstanceOrDescendant( + string instanceId) + { + if (string.IsNullOrWhiteSpace(instanceId) || + !instanceId.StartsWith(DualSenseUsbInstancePrefix, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + int separator = instanceId.LastIndexOf('\\'); + if (separator < 0 || separator == instanceId.Length - 1) + return false; + + string instanceSuffix = instanceId.Substring(separator + 1); + return string.Equals(instanceSuffix, VirtualDualSenseSerial, + StringComparison.OrdinalIgnoreCase) || + instanceSuffix.StartsWith(VirtualDualSenseSerial + "&", + StringComparison.OrdinalIgnoreCase); + } + + internal static Guid? TryGetContainerId(string deviceInstanceId) + { + if (string.IsNullOrWhiteSpace(deviceInstanceId)) + return null; + + NativeMethods.SP_DEVINFO_DATA deviceInfo = + new NativeMethods.SP_DEVINFO_DATA + { + cbSize = System.Runtime.InteropServices.Marshal.SizeOf( + typeof(NativeMethods.SP_DEVINFO_DATA)) + }; + IntPtr deviceInfoSet = NativeMethods.SetupDiCreateDeviceInfoList( + IntPtr.Zero, 0); + if (deviceInfoSet == new IntPtr(-1)) + return null; + + try + { + if (!NativeMethods.SetupDiOpenDeviceInfo(deviceInfoSet, + deviceInstanceId, IntPtr.Zero, 0, ref deviceInfo)) + { + return null; + } + + ulong propertyType = 0; + int requiredSize = 0; + byte[] data = new byte[16]; + NativeMethods.DEVPROPKEY key = + NativeMethods.DEVPKEY_Device_ContainerId; + if (!NativeMethods.SetupDiGetDeviceProperty(deviceInfoSet, + ref deviceInfo, ref key, ref propertyType, data, + data.Length, ref requiredSize, 0) || requiredSize != 16) + { + return null; + } + + return new Guid(data); + } + finally + { + NativeMethods.SetupDiDestroyDeviceInfoList(deviceInfoSet); + } + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeIsoTelemetryParser.cs b/DS4Windows/DS4Control/NativeModeIsoTelemetryParser.cs new file mode 100644 index 0000000..8b06610 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeIsoTelemetryParser.cs @@ -0,0 +1,104 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Globalization; + +namespace DS4Windows +{ + public readonly struct NativeModeIsoTelemetry + { + public NativeModeIsoTelemetry(double channel3RmsPercent, + double channel4RmsPercent, long bluetoothErrorCount) + { + Channel3RmsPercent = channel3RmsPercent; + Channel4RmsPercent = channel4RmsPercent; + BluetoothErrorCount = bluetoothErrorCount; + } + + public double Channel3RmsPercent { get; } + public double Channel4RmsPercent { get; } + public long BluetoothErrorCount { get; } + + /// + /// True when either native haptic channel has non-zero RMS after the + /// producer's two-decimal percentage formatting. + /// + public bool HasNativeHapticSignal => + Channel3RmsPercent > 0.0 || Channel4RmsPercent > 0.0; + } + + /// + /// Parses the periodic production-shaped ISO OUT telemetry emitted by + /// VirtualDualSenseUsbip without owning native-mode process or UI state. + /// + public static class NativeModeIsoTelemetryParser + { + public static bool TryParse(string line, out NativeModeIsoTelemetry telemetry) + { + telemetry = default; + if (string.IsNullOrWhiteSpace(line) || + !line.Contains(" ISO OUT ", StringComparison.Ordinal) || + !TryReadToken(line, "rms%=", out string rmsValue) || + !TryReadToken(line, "bt-errors=", out string errorValue)) + { + return false; + } + + string[] channels = rmsValue.Split('/'); + if (channels.Length != 4 || + !TryParseRmsPercent(channels[2], out double channel3) || + !TryParseRmsPercent(channels[3], out double channel4) || + !long.TryParse(errorValue, NumberStyles.None, + CultureInfo.InvariantCulture, out long bluetoothErrors)) + { + return false; + } + + telemetry = new NativeModeIsoTelemetry( + channel3, channel4, bluetoothErrors); + return true; + } + + private static bool TryParseRmsPercent(string value, out double result) + { + return double.TryParse(value, NumberStyles.Float, + CultureInfo.InvariantCulture, out result) && + double.IsFinite(result) && result >= 0.0 && result <= 100.0; + } + + private static bool TryReadToken(string line, string prefix, out string value) + { + value = null; + int prefixIndex = line.IndexOf(prefix, StringComparison.Ordinal); + if (prefixIndex < 0) + return false; + + int valueStart = prefixIndex + prefix.Length; + int valueEnd = line.IndexOf(' ', valueStart); + if (valueEnd < 0) + valueEnd = line.Length; + + if (valueEnd == valueStart) + return false; + + value = line.Substring(valueStart, valueEnd - valueStart); + return true; + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeLifecycleOrchestration.cs b/DS4Windows/DS4Control/NativeModeLifecycleOrchestration.cs new file mode 100644 index 0000000..a86fd08 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeLifecycleOrchestration.cs @@ -0,0 +1,285 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace DS4Windows +{ + /// + /// Keeps the ordering-sensitive part of native-mode startup explicit and + /// independently testable. The audio-default listener must exist before + /// any operation in can enumerate the + /// virtual USB audio endpoints. + /// + internal static class NativeModeStartupOrchestration + { + public static async Task RunWithAudioDefaultProtectionAsync( + Func captureAudioDefaults, + Action beginAudioDefaultGuard, + Func protectedStartup) + { + if (captureAudioDefaults == null) + throw new ArgumentNullException(nameof(captureAudioDefaults)); + if (beginAudioDefaultGuard == null) + throw new ArgumentNullException(nameof(beginAudioDefaultGuard)); + if (protectedStartup == null) + throw new ArgumentNullException(nameof(protectedStartup)); + + NativeModeAudioDefaultsSnapshot snapshot = captureAudioDefaults() ?? + throw new InvalidOperationException( + "Native Mode cannot start without an audio-default snapshot."); + beginAudioDefaultGuard(snapshot); + await protectedStartup().ConfigureAwait(false); + } + + public static async Task ObserveUnexpectedTerminationAsync( + Task unexpectedTermination, + Func isCurrentSession, + Func markFaulted) + { + if (unexpectedTermination == null) + throw new ArgumentNullException(nameof(unexpectedTermination)); + if (isCurrentSession == null) + throw new ArgumentNullException(nameof(isCurrentSession)); + if (markFaulted == null) + throw new ArgumentNullException(nameof(markFaulted)); + + Exception failure; + try + { + failure = await unexpectedTermination.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // BeginTeardown cancels the observer by design. + return; + } + + if (failure != null && isCurrentSession()) + await markFaulted(failure).ConfigureAwait(false); + } + + /// + /// Runs recovery only after an already-dispatched command reaches a + /// terminal state. Its eventual success, cancellation, or failure is + /// deliberately observed rather than propagated: all three permit the + /// owner to begin ordered teardown, while a still-pending UAC launch + /// does not. + /// + public static async Task RunAfterCommandTerminationAsync( + Task commandCompletion, Func recover) + { + if (commandCompletion == null) + throw new ArgumentNullException(nameof(commandCompletion)); + if (recover == null) + throw new ArgumentNullException(nameof(recover)); + + try + { + await commandCompletion.ConfigureAwait(false); + } + catch + { + // The command is terminal; its outcome is reported by the + // original startup failure. Recovery still has to run. + } + + await recover().ConfigureAwait(false); + } + } + + internal sealed class NativeModeTeardownAttempt + { + public NativeModeTeardownAttempt(bool released, Exception failure, + string deferredReason) + { + Released = released; + Failure = failure; + DeferredReason = deferredReason; + } + + public bool Released { get; } + public Exception Failure { get; } + public string DeferredReason { get; } + } + + /// + /// Encodes the fail-closed native-mode teardown rule. Releasing the audio + /// guard, controller suppression, or rescanning is safe only after the + /// server is no longer owned, the exact virtual child is absent, and the + /// render keepalive has confirmed its tracked endpoint is absent too. + /// + internal static class NativeModeTeardownOrchestration + { + public static async Task StopAndTryReleaseAsync( + Action beginRenderTeardown, + Func stopServer, + Func ownsServerProcess, + Func completeRenderTeardown, + Func isExactVirtualChildPresent, + Func hasRenderKeepaliveSession, + Action releaseProtections) + { + if (beginRenderTeardown == null) + throw new ArgumentNullException(nameof(beginRenderTeardown)); + if (stopServer == null) + throw new ArgumentNullException(nameof(stopServer)); + if (ownsServerProcess == null) + throw new ArgumentNullException(nameof(ownsServerProcess)); + if (completeRenderTeardown == null) + throw new ArgumentNullException(nameof(completeRenderTeardown)); + if (isExactVirtualChildPresent == null) + throw new ArgumentNullException(nameof(isExactVirtualChildPresent)); + if (hasRenderKeepaliveSession == null) + throw new ArgumentNullException(nameof(hasRenderKeepaliveSession)); + if (releaseProtections == null) + throw new ArgumentNullException(nameof(releaseProtections)); + + beginRenderTeardown(); + Exception failure = null; + try + { + await stopServer().ConfigureAwait(false); + } + catch (Exception ex) + { + failure = ex; + } + + try + { + await completeRenderTeardown().ConfigureAwait(false); + } + catch (Exception ex) + { + failure ??= ex; + } + + // A failed stop/confirmation must be retried by the owner. Even if + // a later probe happens to look clear, the failed operation did not + // establish a trustworthy teardown boundary. + if (failure != null) + { + return new NativeModeTeardownAttempt(false, failure, + "native-mode stop or removal confirmation failed"); + } + + return TryReleaseIfConfirmed(ownsServerProcess, + isExactVirtualChildPresent, hasRenderKeepaliveSession, + releaseProtections); + } + + public static NativeModeTeardownAttempt TryReleaseIfConfirmed( + Func ownsServerProcess, + Func isExactVirtualChildPresent, + Func hasRenderKeepaliveSession, + Action releaseProtections) + { + if (ownsServerProcess == null) + throw new ArgumentNullException(nameof(ownsServerProcess)); + if (isExactVirtualChildPresent == null) + throw new ArgumentNullException(nameof(isExactVirtualChildPresent)); + if (hasRenderKeepaliveSession == null) + throw new ArgumentNullException(nameof(hasRenderKeepaliveSession)); + if (releaseProtections == null) + throw new ArgumentNullException(nameof(releaseProtections)); + + var pending = new List(); + try + { + if (ownsServerProcess()) + pending.Add("server process ownership remains"); + } + catch (Exception ex) + { + return ProbeFailed("server process ownership", ex); + } + + try + { + if (isExactVirtualChildPresent()) + pending.Add("the exact virtual child is still present"); + } + catch (Exception ex) + { + return ProbeFailed("exact virtual-child removal", ex); + } + + try + { + if (hasRenderKeepaliveSession()) + pending.Add("the virtual audio endpoint is still present"); + } + catch (Exception ex) + { + return ProbeFailed("virtual audio-endpoint removal", ex); + } + + if (pending.Count != 0) + { + return new NativeModeTeardownAttempt(false, null, + string.Join("; ", pending)); + } + + releaseProtections(); + return new NativeModeTeardownAttempt(true, null, null); + } + + /// + /// Runs a bounded deferred-removal poll. The caller supplies a probe + /// which serializes with the owning lifecycle gate and releases exactly + /// once when every removal signal is confirmed. + /// + public static async Task WaitForConfirmedReleaseAsync( + Func> tryRelease, + Func delay, + int maximumAttempts, TimeSpan pollInterval, + CancellationToken cancellationToken) + { + if (tryRelease == null) + throw new ArgumentNullException(nameof(tryRelease)); + if (delay == null) + throw new ArgumentNullException(nameof(delay)); + if (maximumAttempts <= 0) + throw new ArgumentOutOfRangeException(nameof(maximumAttempts)); + if (pollInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(pollInterval)); + + for (int attempt = 0; attempt < maximumAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (await tryRelease().ConfigureAwait(false)) + return true; + if (attempt + 1 < maximumAttempts) + { + await delay(pollInterval, cancellationToken) + .ConfigureAwait(false); + } + } + + return false; + } + + private static NativeModeTeardownAttempt ProbeFailed(string probe, + Exception exception) => new NativeModeTeardownAttempt(false, null, + $"could not confirm {probe}: {exception.Message}"); + } +} diff --git a/DS4Windows/DS4Control/NativeModeLifecyclePolicy.cs b/DS4Windows/DS4Control/NativeModeLifecyclePolicy.cs new file mode 100644 index 0000000..7e4b5f8 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeLifecyclePolicy.cs @@ -0,0 +1,85 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; + +namespace DS4Windows +{ + internal static class NativeModeStartupSafety + { + public static void EnsureNoExistingVirtualDevice( + Func isExactVirtualDevicePresent) + { + if (isExactVirtualDevicePresent == null) + { + throw new ArgumentNullException( + nameof(isExactVirtualDevicePresent)); + } + + bool present; + try + { + present = isExactVirtualDevicePresent(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "Native Mode could not verify that an earlier virtual " + + "DualSense session is absent. Startup is blocked so a " + + "second device cannot be attached.", ex); + } + + if (present) + { + throw new InvalidOperationException( + "A virtual DualSense from an earlier Native Mode session " + + "is still present. Native Mode will not attach another " + + "device until the earlier session is removed."); + } + } + } + + public static class NativeModeLifecyclePolicy + { + public static bool IsSessionActive(NativeModeState state, + bool suppressionActive, bool ownsChildProcess) + { + return suppressionActive || ownsChildProcess || + state == NativeModeState.Starting || + state == NativeModeState.Serving || + state == NativeModeState.Attached; + } + + public static bool RequiresAutomaticCleanup(NativeModeState state, + bool suppressionActive) + { + return suppressionActive && + (state == NativeModeState.PadLost || + state == NativeModeState.Faulted); + } + + /// + /// Must be evaluated while holding the lifecycle gate, not only when a + /// cleanup request is queued. A UAC launch can become pending while a + /// previously queued cleanup waits for that gate. + /// + public static bool CanBeginTeardown( + bool pendingCommandBarrierActive) => + !pendingCommandBarrierActive; + } +} diff --git a/DS4Windows/DS4Control/NativeModeLogClassifier.cs b/DS4Windows/DS4Control/NativeModeLogClassifier.cs new file mode 100644 index 0000000..a5ccbbc --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeLogClassifier.cs @@ -0,0 +1,115 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; + +namespace DS4Windows +{ + public enum NativeModeLogKind + { + Other, + ServerListening, + RenderKeepaliveReady, + RenderKeepaliveFailure, + PadOpenFailure, + PadLost, + FatalUsbIpSession, + IsochronousOutStats, + AudioStats, + SpeakerRebuffer, + } + + /// + /// Classifies VirtualDualSenseUsbip console output without owning any + /// process or application state. Keep marker text synchronized with the + /// emulator; tests intentionally use complete production-shaped lines. + /// + public static class NativeModeLogClassifier + { + public static NativeModeLogKind Classify(string line) + { + if (string.IsNullOrEmpty(line)) + return NativeModeLogKind.Other; + + if (line.Contains("The physical pad is gone", StringComparison.Ordinal)) + return NativeModeLogKind.PadLost; + + if (line.Contains("No physical Bluetooth DualSense", StringComparison.Ordinal)) + return NativeModeLogKind.PadOpenFailure; + + if (line.Contains("NativeRenderKeepaliveReady:", + StringComparison.Ordinal)) + { + return NativeModeLogKind.RenderKeepaliveReady; + } + + if (line.Contains("NativeRenderKeepaliveFailed:", + StringComparison.Ordinal)) + { + return NativeModeLogKind.RenderKeepaliveFailure; + } + + if (line.Contains(nameof(NativeModeLogKind.FatalUsbIpSession), + StringComparison.Ordinal)) + { + return NativeModeLogKind.FatalUsbIpSession; + } + + if (line.Contains("USB/IP server listening", StringComparison.Ordinal)) + return NativeModeLogKind.ServerListening; + + if (line.Contains(" ISO OUT ", StringComparison.Ordinal)) + return NativeModeLogKind.IsochronousOutStats; + + if (line.Contains(" AUDIO ", StringComparison.Ordinal)) + return NativeModeLogKind.AudioStats; + + if (line.Contains("Speaker rebuffer #", StringComparison.Ordinal)) + return NativeModeLogKind.SpeakerRebuffer; + + return NativeModeLogKind.Other; + } + } + + /// + /// Keeps redundant audio summaries out of the WPF log while retaining + /// them in the manager snapshot. The periodic ISO snapshot is forwarded + /// because its four-channel levels distinguish speaker-routing failures + /// from haptic-relay failures without exposing device identifiers. + /// + public static class NativeModeLogPolicy + { + public static bool ShouldForwardToGui(NativeModeLogKind kind, + bool fromStandardError) + { + if (kind == NativeModeLogKind.AudioStats) + { + return false; + } + + return fromStandardError || kind == NativeModeLogKind.ServerListening || + kind == NativeModeLogKind.RenderKeepaliveReady || + kind == NativeModeLogKind.RenderKeepaliveFailure || + kind == NativeModeLogKind.PadOpenFailure || + kind == NativeModeLogKind.PadLost || + kind == NativeModeLogKind.FatalUsbIpSession || + kind == NativeModeLogKind.IsochronousOutStats || + kind == NativeModeLogKind.SpeakerRebuffer; + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeManager.cs b/DS4Windows/DS4Control/NativeModeManager.cs new file mode 100644 index 0000000..6e45942 --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeManager.cs @@ -0,0 +1,823 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace DS4Windows +{ + public enum NativeModeState + { + Starting, + Serving, + Attached, + PadLost, + SetupRequired, + Stopped, + Faulted, + } + + public sealed class NativeModeStateChangedEventArgs : EventArgs + { + public NativeModeStateChangedEventArgs(NativeModeState state, string detail) + { + State = state; + Detail = detail; + } + + public NativeModeState State { get; } + public string Detail { get; } + } + + public sealed class NativeModeStatsSnapshot + { + public NativeModeStatsSnapshot(string isochronousOut, string audio, + string speakerRebuffer, DateTimeOffset updatedAt) + { + IsochronousOut = isochronousOut; + Audio = audio; + SpeakerRebuffer = speakerRebuffer; + UpdatedAt = updatedAt; + } + + public string IsochronousOut { get; } + public string Audio { get; } + public string SpeakerRebuffer { get; } + public DateTimeOffset UpdatedAt { get; } + } + + /// + /// Owns the VirtualDualSenseUsbip child process and its protocol-version + /// handshake. ControlService coordinates controller release, elevated + /// attach, audio guarding, and ordered teardown around this process. + /// + public sealed class NativeModeManager : IAsyncDisposable + { + private const string ServerExecutableName = "VirtualDualSenseUsbip.exe"; + internal const string ExpectedServerCapability = + "DS4WINDOWS_NATIVE_USBIP_PROTOCOL=2"; + private static readonly TimeSpan GracefulShutdownTimeout = + TimeSpan.FromSeconds(15); + private static readonly TimeSpan DeviceRemovalTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DeviceRemovalPollInterval = TimeSpan.FromMilliseconds(200); + + private readonly SemaphoreSlim lifecycleGate = new SemaphoreSlim(1, 1); + private readonly object stateGate = new object(); + private readonly object statsGate = new object(); + private volatile Process serverProcess; + private Task standardOutputTask = Task.CompletedTask; + private Task standardErrorTask = Task.CompletedTask; + private Task exitMonitorTask = Task.CompletedTask; + private readonly NativeModeRenderReadiness renderKeepaliveReadiness = + new NativeModeRenderReadiness(); + private volatile bool stopping; + private NativeModeState state = NativeModeState.Stopped; + private string stateDetail = "Native mode server stopped."; + private NativeModeStatsSnapshot latestStats = + new NativeModeStatsSnapshot(null, null, null, DateTimeOffset.MinValue); + + public event EventHandler StateChanged; + public event EventHandler StatsChanged; + + public NativeModeState State + { + get + { + lock (stateGate) + return state; + } + } + + public NativeModeStatsSnapshot LatestStats + { + get + { + lock (statsGate) + return latestStats; + } + } + + /// + /// Remains true until teardown has disposed the child, including after + /// an unexpected exit or a failed stop that should be retried. + /// + public bool HasOwnedProcess => serverProcess != null; + + public async Task WaitForServingAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) + { + await NativeModeReadinessAwaiter.WaitForServingAsync(GetStateSnapshot, + handler => StateChanged += handler, + handler => StateChanged -= handler, + timeout, cancellationToken).ConfigureAwait(false); + } + + public async Task WaitForRenderKeepaliveAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout)); + + Task readyTask = renderKeepaliveReadiness.GetCurrentTask(); + + try + { + await readyTask.WaitAsync(timeout, cancellationToken) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + throw new TimeoutException( + "The helper-owned virtual DualSense render keepalive did not " + + $"become ready within {timeout.TotalSeconds:0.#} seconds."); + } + } + + public static string LocateServerExecutable() + { + string baseDirectory = AppContext.BaseDirectory; + IEnumerable packagedCandidates = new[] + { + Path.Combine(baseDirectory, "native", ServerExecutableName), + Path.Combine(baseDirectory, ServerExecutableName), + }; + + foreach (string candidate in packagedCandidates) + { + if (File.Exists(candidate)) + return Path.GetFullPath(candidate); + } + +#if DEBUG + DirectoryInfo directory = new DirectoryInfo(baseDirectory); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, "utils", + "VirtualDualSenseUsbip", "bin", "Release", "net8.0", + ServerExecutableName); + if (File.Exists(candidate)) + return candidate; + + directory = directory.Parent; + } +#endif + + throw new FileNotFoundException( + $"Could not find {ServerExecutableName}. Expected it under the application " + + "native directory."); + } + + public async Task StartAsync(IEnumerable arguments, + CancellationToken cancellationToken = default) + { + if (arguments == null) + throw new ArgumentNullException(nameof(arguments)); + + await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (serverProcess != null && !serverProcess.HasExited) + throw new InvalidOperationException("Native mode server is already running."); + + if (serverProcess != null) + { + await Task.WhenAll(standardOutputTask, standardErrorTask, + exitMonitorTask).ConfigureAwait(false); + serverProcess.Dispose(); + serverProcess = null; + } + + lock (statsGate) + { + latestStats = new NativeModeStatsSnapshot( + null, null, null, DateTimeOffset.MinValue); + } + long sessionGeneration = + renderKeepaliveReadiness.BeginSession(); + + string executablePath; + try + { + executablePath = LocateServerExecutable(); + await ValidateServerExecutableAsync(executablePath, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + renderKeepaliveReadiness.TrySetFailure( + sessionGeneration, ex); + throw; + } + var startInfo = new ProcessStartInfo(executablePath) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + }; + foreach (string argument in arguments) + startInfo.ArgumentList.Add(argument); + + var process = new Process + { + StartInfo = startInfo, + }; + + stopping = false; + SetState(NativeModeState.Starting, "Starting native mode server."); + try + { + if (!process.Start()) + throw new InvalidOperationException("Native mode server did not start."); + } + catch (Exception ex) + { + process.Dispose(); + SetState(NativeModeState.Faulted, ex.Message); + throw; + } + + serverProcess = process; + standardOutputTask = PumpLinesAsync(process.StandardOutput, false, + sessionGeneration); + standardErrorTask = PumpLinesAsync(process.StandardError, true, + sessionGeneration); + exitMonitorTask = MonitorExitAsync(process, sessionGeneration); + } + finally + { + lifecycleGate.Release(); + } + } + + internal static bool HasExpectedServerCapability(string output) => + string.Equals(output?.Trim(), ExpectedServerCapability, + StringComparison.Ordinal); + + private static async Task ValidateServerExecutableAsync( + string executablePath, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo(executablePath) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("capabilities"); + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + throw new InvalidDataException( + "Native mode helper capability probe did not start."); + } + + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + + throw new InvalidDataException( + "Native mode helper capability probe timed out."); + } + + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + if (process.ExitCode != 0 || + !HasExpectedServerCapability(output)) + { + throw new InvalidDataException( + "Native mode helper is missing the required protocol capability" + + (string.IsNullOrWhiteSpace(error) + ? "." + : $": {error.Trim()}")); + } + } + + /// + /// Records PnP attach completion. The elevation broker added in Phase 4 + /// will call this only after observing the virtual device. + /// + public void MarkAttached() + { + NativeModeState current = State; + if (current != NativeModeState.Serving && current != NativeModeState.Attached) + throw new InvalidOperationException( + $"Cannot mark native mode attached while state is {current}."); + + SetState(NativeModeState.Attached, "Virtual DualSense attached."); + } + + public void MarkSetupRequired(string detail) + { + SetState(NativeModeState.SetupRequired, detail); + } + + public void MarkPadLost(string detail) + { + SetState(NativeModeState.PadLost, detail); + } + + public void MarkFaulted(string detail) + { + SetState(NativeModeState.Faulted, detail); + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + Process process = serverProcess; + stopping = true; + + if (process != null) + { + await NativeModeProcessShutdown.RequestAsync( + () => process.HasExited, + async () => + { + await process.StandardInput.WriteLineAsync("stop") + .ConfigureAwait(false); + await process.StandardInput.FlushAsync() + .ConfigureAwait(false); + }, + () => process.WaitForExitAsync(CancellationToken.None), + GracefulShutdownTimeout).ConfigureAwait(false); + + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + await Task.WhenAll(standardOutputTask, standardErrorTask, + exitMonitorTask).ConfigureAwait(false); + process.Dispose(); + serverProcess = null; + } + + if (process != null) + { + // Once teardown starts, finish the bounded removal check even + // if the initiating UI operation is canceled. + await WaitForVirtualDeviceRemovalAsync(CancellationToken.None) + .ConfigureAwait(false); + } + SetState(NativeModeState.Stopped, "Native mode server stopped."); + } + finally + { + stopping = false; + lifecycleGate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + lifecycleGate.Dispose(); + } + + private async Task PumpLinesAsync(StreamReader reader, bool warning, + long sessionGeneration) + { + string line; + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + if (ProcessLogLine(line, warning, sessionGeneration)) + AppLogger.LogToGui($"[native] {line}", warning); + } + } + + internal bool ProcessLogLine(string line, bool warning) + { + return ProcessLogLine(line, warning, + renderKeepaliveReadiness.CurrentGeneration); + } + + internal bool ProcessLogLine(string line, bool warning, + long sessionGeneration) + { + if (!renderKeepaliveReadiness.IsCurrent(sessionGeneration)) + return false; + + NativeModeLogKind kind = NativeModeLogClassifier.Classify(line); + if (!HandleLogLine(line, kind, sessionGeneration)) + return false; + return NativeModeLogPolicy.ShouldForwardToGui(kind, warning); + } + + private bool HandleLogLine(string line, NativeModeLogKind kind, + long sessionGeneration) + { + return renderKeepaliveReadiness.TryRunForCurrent( + sessionGeneration, () => + { + switch (kind) + { + case NativeModeLogKind.ServerListening: + SetState(NativeModeState.Serving, line); + break; + case NativeModeLogKind.RenderKeepaliveReady: + renderKeepaliveReadiness.TrySetReady(sessionGeneration); + break; + case NativeModeLogKind.RenderKeepaliveFailure: + renderKeepaliveReadiness.TrySetFailure(sessionGeneration, + new InvalidOperationException(line)); + if (!stopping) + SetState(NativeModeState.Faulted, line); + break; + case NativeModeLogKind.PadOpenFailure: + SetState(NativeModeState.Faulted, line); + break; + case NativeModeLogKind.PadLost: + SetState(NativeModeState.PadLost, + "The physical pad was lost; press PS and start native mode again."); + break; + case NativeModeLogKind.FatalUsbIpSession: + if (!stopping && + (State == NativeModeState.Serving || + State == NativeModeState.Attached)) + { + SetState(NativeModeState.Faulted, line); + } + break; + case NativeModeLogKind.IsochronousOutStats: + UpdateStats(isochronousOut: line); + break; + case NativeModeLogKind.AudioStats: + UpdateStats(audio: line); + break; + case NativeModeLogKind.SpeakerRebuffer: + UpdateStats(speakerRebuffer: line); + break; + } + }); + } + + private async Task MonitorExitAsync(Process process, + long sessionGeneration) + { + try + { + await process.WaitForExitAsync().ConfigureAwait(false); + await Task.WhenAll(standardOutputTask, standardErrorTask).ConfigureAwait(false); + } + catch (Exception ex) + { + if (!stopping && + renderKeepaliveReadiness.IsCurrent(sessionGeneration) && + ReferenceEquals(serverProcess, process)) + SetState(NativeModeState.Faulted, ex.Message); + return; + } + + if (!stopping && + renderKeepaliveReadiness.IsCurrent(sessionGeneration) && + ReferenceEquals(serverProcess, process) && + State != NativeModeState.PadLost && State != NativeModeState.Faulted) + { + renderKeepaliveReadiness.TrySetFailure(sessionGeneration, + new InvalidOperationException( + $"Native mode server exited unexpectedly with code {process.ExitCode}.")); + SetState(NativeModeState.Faulted, + $"Native mode server exited unexpectedly with code {process.ExitCode}."); + } + } + + private void UpdateStats(string isochronousOut = null, string audio = null, + string speakerRebuffer = null) + { + EventHandler handler; + lock (statsGate) + { + latestStats = new NativeModeStatsSnapshot( + isochronousOut ?? latestStats.IsochronousOut, + audio ?? latestStats.Audio, + speakerRebuffer ?? latestStats.SpeakerRebuffer, + DateTimeOffset.Now); + handler = StatsChanged; + } + + handler?.Invoke(this, EventArgs.Empty); + } + + private void SetState(NativeModeState newState, string detail) + { + renderKeepaliveReadiness.CompleteForTerminalState(newState, detail); + + EventHandler handler; + lock (stateGate) + { + if (state == newState) + return; + + state = newState; + stateDetail = detail; + handler = StateChanged; + } + + handler?.Invoke(this, new NativeModeStateChangedEventArgs(newState, detail)); + } + + private static async Task WaitForVirtualDeviceRemovalAsync( + CancellationToken cancellationToken) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + DeviceRemovalTimeout; + while (NativeModeDevicePresence.IsVirtualDualSensePresent() && + DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(DeviceRemovalPollInterval, cancellationToken) + .ConfigureAwait(false); + } + + if (NativeModeDevicePresence.IsVirtualDualSensePresent()) + { + AppLogger.LogToGui( + "[native] Virtual DualSense is still present after server shutdown.", true); + } + } + + private (NativeModeState State, string Detail) GetStateSnapshot() + { + lock (stateGate) + return (state, stateDetail); + } + } + + /// + /// Tracks helper render-pin readiness for exactly one Native Mode process + /// generation. Delayed output from an earlier helper cannot satisfy or fault + /// a later session's waiter. + /// + internal sealed class NativeModeRenderReadiness + { + private readonly object gate = new object(); + private long generation; + private TaskCompletionSource completion = + NewCompletion(); + + public long CurrentGeneration + { + get + { + lock (gate) + return generation; + } + } + + public long BeginSession() + { + TaskCompletionSource previous; + long current; + lock (gate) + { + previous = completion; + current = checked(++generation); + completion = NewCompletion(); + } + + // A superseded waiter must never drift into the next session. + previous.TrySetCanceled(); + return current; + } + + public Task GetCurrentTask() + { + lock (gate) + return completion.Task; + } + + public bool IsCurrent(long candidateGeneration) + { + lock (gate) + return candidateGeneration == generation; + } + + public bool TryRunForCurrent(long candidateGeneration, Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + + lock (gate) + { + if (candidateGeneration != generation) + return false; + + action(); + return true; + } + } + + public bool TrySetReady(long candidateGeneration) + { + TaskCompletionSource current; + lock (gate) + { + if (candidateGeneration != generation) + return false; + current = completion; + } + + return current.TrySetResult(true); + } + + public bool TrySetFailure(long candidateGeneration, Exception failure) + { + if (failure == null) + throw new ArgumentNullException(nameof(failure)); + + TaskCompletionSource current; + lock (gate) + { + if (candidateGeneration != generation) + return false; + current = completion; + } + + return current.TrySetException(failure); + } + + public void CompleteForTerminalState(NativeModeState terminalState, + string detail) + { + TaskCompletionSource current; + lock (gate) + current = completion; + + switch (terminalState) + { + case NativeModeState.Stopped: + current.TrySetCanceled(); + break; + case NativeModeState.PadLost: + case NativeModeState.SetupRequired: + case NativeModeState.Faulted: + current.TrySetException(new InvalidOperationException( + string.IsNullOrWhiteSpace(detail) + ? $"Native mode entered {terminalState} before the " + + "helper render keepalive became ready." + : detail)); + break; + } + } + + private static TaskCompletionSource NewCompletion() + { + var source = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _ = source.Task.ContinueWith( + task => + { + _ = task.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + return source; + } + } + + internal static class NativeModeProcessShutdown + { + public static async Task RequestAsync(Func hasExited, + Func sendStop, Func waitForExit, TimeSpan timeout) + { + if (hasExited == null) + throw new ArgumentNullException(nameof(hasExited)); + if (sendStop == null) + throw new ArgumentNullException(nameof(sendStop)); + if (waitForExit == null) + throw new ArgumentNullException(nameof(waitForExit)); + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout)); + + if (hasExited()) + return; + + try + { + await sendStop().ConfigureAwait(false); + } + catch (Exception ex) when ( + ex is IOException || ex is InvalidOperationException) + { + if (!hasExited()) + { + throw new IOException( + "Could not request an ordered Native Mode helper shutdown. " + + "The helper was left running so its render-pin protection " + + "remains active.", ex); + } + return; + } + + if (hasExited()) + return; + + try + { + await waitForExit().WaitAsync(timeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + throw new TimeoutException( + "The Native Mode helper did not confirm ordered shutdown. " + + "It was left running so the virtual render pin is not closed " + + "while the device may still be attached."); + } + } + } + + internal static class NativeModeReadinessAwaiter + { + public static async Task WaitForServingAsync( + Func<(NativeModeState State, string Detail)> getState, + Action> subscribe, + Action> unsubscribe, + TimeSpan timeout, CancellationToken cancellationToken) + { + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout)); + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + void ApplyState(NativeModeState state, string detail) + { + switch (state) + { + case NativeModeState.Serving: + case NativeModeState.Attached: + completion.TrySetResult(true); + break; + case NativeModeState.PadLost: + case NativeModeState.SetupRequired: + case NativeModeState.Stopped: + case NativeModeState.Faulted: + completion.TrySetException(new InvalidOperationException( + string.IsNullOrWhiteSpace(detail) + ? $"Native mode entered {state} before the server became ready." + : detail)); + break; + } + } + + EventHandler handler = + (_, e) => ApplyState(e.State, e.Detail); + subscribe(handler); + try + { + (NativeModeState current, string detail) = getState(); + ApplyState(current, detail); + try + { + await completion.Task.WaitAsync(timeout, cancellationToken) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + throw new TimeoutException( + $"Native mode server did not become ready within {timeout.TotalSeconds:0.#} seconds."); + } + } + finally + { + unsubscribe(handler); + } + } + } +} diff --git a/DS4Windows/DS4Control/NativeModeRenderKeepalive.cs b/DS4Windows/DS4Control/NativeModeRenderKeepalive.cs new file mode 100644 index 0000000..2e31ffc --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeRenderKeepalive.cs @@ -0,0 +1,900 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace DS4Windows +{ + internal interface INativeModeRenderOutput : IDisposable + { + event EventHandler + UnexpectedTermination; + void Start(); + } + + internal sealed class NativeModeRenderOutputTerminatedEventArgs : EventArgs + { + public NativeModeRenderOutputTerminatedEventArgs(Exception exception) + { + Exception = exception; + } + + public Exception Exception { get; } + } + + internal interface INativeModeRenderOutputFactory + { + INativeModeRenderOutput Create(string endpointId); + } + + internal interface INativeModeVirtualDeviceIdentity + { + bool IsPresent(); + bool OwnsEndpoint(NativeModeAudioEndpoint endpoint); + } + + /// + /// Keeps one shared-mode render client open on the virtual DualSense for + /// the complete composite session. The game may close and reopen its own + /// client during focus changes; this client prevents that transition from + /// closing the kernel USB audio render pin. + /// + internal sealed class NativeModeRenderKeepalive + { + private static readonly TimeSpan DefaultReadinessPollInterval = + TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan DefaultRemovalPollInterval = + TimeSpan.FromSeconds(1); + + private readonly object sessionGate = new object(); + private readonly INativeModeAudioEndpointAccessor endpointAccessor; + private readonly INativeModeAudioNotificationSource notificationSource; + private readonly INativeModeRenderOutputFactory outputFactory; + private readonly INativeModeVirtualDeviceIdentity deviceIdentity; + private readonly Action log; + private readonly TimeSpan readinessPollInterval; + private readonly TimeSpan removalPollInterval; + private Session currentSession; + + public NativeModeRenderKeepalive() : this( + new WindowsNativeModeAudioEndpointAccessor(), + new WindowsNativeModeAudioNotificationSource(), + new WasapiNativeModeRenderOutputFactory(), + new WindowsNativeModeVirtualDeviceIdentity(), + (message, warning) => AppLogger.LogToGui(message, warning), + DefaultReadinessPollInterval, DefaultRemovalPollInterval) + { + } + + internal NativeModeRenderKeepalive( + INativeModeAudioEndpointAccessor endpointAccessor, + INativeModeAudioNotificationSource notificationSource, + INativeModeRenderOutputFactory outputFactory, + INativeModeVirtualDeviceIdentity deviceIdentity, + Action log, + TimeSpan? readinessPollInterval = null, + TimeSpan? removalPollInterval = null) + { + this.endpointAccessor = endpointAccessor ?? + throw new ArgumentNullException(nameof(endpointAccessor)); + this.notificationSource = notificationSource ?? + throw new ArgumentNullException(nameof(notificationSource)); + this.outputFactory = outputFactory ?? + throw new ArgumentNullException(nameof(outputFactory)); + this.deviceIdentity = deviceIdentity ?? + throw new ArgumentNullException(nameof(deviceIdentity)); + this.log = log ?? throw new ArgumentNullException(nameof(log)); + this.readinessPollInterval = readinessPollInterval ?? + DefaultReadinessPollInterval; + this.removalPollInterval = removalPollInterval ?? + DefaultRemovalPollInterval; + if (this.readinessPollInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(readinessPollInterval)); + } + if (this.removalPollInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(removalPollInterval)); + } + + public bool HasSession + { + get + { + lock (sessionGate) + return currentSession != null; + } + } + + /// + /// Captures active render endpoints and registers for endpoint changes. + /// Call this before USB/IP attach so only the newly-added virtual + /// DualSense can be selected. + /// + public void BeginSession() + { + lock (sessionGate) + { + if (currentSession != null) + { + throw new InvalidOperationException( + "A native-mode render keepalive session is already active."); + } + } + + HashSet activeBeforeAttach = endpointAccessor + .GetActiveEndpoints(NativeModeAudioFlow.Render) + .Select(endpoint => endpoint.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var session = new Session(activeBeforeAttach, endpointAccessor, + notificationSource, outputFactory, deviceIdentity, log, + SessionReleased, readinessPollInterval, removalPollInterval); + + lock (sessionGate) + { + if (currentSession != null) + { + throw new InvalidOperationException( + "A native-mode render keepalive session is already active."); + } + currentSession = session; + } + + try + { + session.StartMonitoring(); + } + catch + { + lock (sessionGate) + { + if (ReferenceEquals(currentSession, session)) + currentSession = null; + } + session.ReleaseBeforeOutputOpen(); + throw; + } + } + + /// + /// Does not complete until an infinite silent render client is running + /// on the newly-added DualSense endpoint. Consequently the caller must + /// not publish Attached before this task succeeds. + /// + public Task WaitForReadyAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) + { + Session session; + lock (sessionGate) + session = currentSession; + if (session == null) + { + throw new InvalidOperationException( + "The native-mode render keepalive session has not started."); + } + + return session.WaitForReadyAsync(timeout, cancellationToken); + } + + /// + /// Completes with the fatal output error if the mandatory silent + /// render stream dies after readiness. Expected teardown cancels this + /// task. The keepalive never reopens the stream automatically. + /// + public Task WaitForUnexpectedTerminationAsync() + { + Session session; + lock (sessionGate) + session = currentSession; + if (session == null) + { + throw new InvalidOperationException( + "The native-mode render keepalive session has not started."); + } + + return session.UnexpectedTermination; + } + + /// + /// Completes only after endpoint and fixed virtual-parent removal have + /// both been confirmed and the retained output has been released. + /// Capture this task before starting teardown. + /// + public Task WaitForReleaseAsync() + { + Session session; + lock (sessionGate) + session = currentSession; + return session?.ReleaseCompletion ?? Task.CompletedTask; + } + + /// + /// Arms deferred release before ordered helper shutdown is requested. + /// This does not stop or dispose the render client while the + /// endpoint/device is still present. + /// + public void BeginTeardown() + { + Session session; + lock (sessionGate) + session = currentSession; + session?.BeginTeardown(); + } + + /// + /// Rechecks removal after the child-process teardown has completed. If + /// removal timed out, the session remains subscribed and owns the + /// playing output. One deliberately retained, rate-limited monitor then + /// survives the caller's timeout until a later notification/poll confirms + /// that both the endpoint and present virtual parent are gone. + /// + public Task CompleteTeardownAsync() + { + Session session; + lock (sessionGate) + session = currentSession; + return session?.CheckRemovalNowAsync() ?? Task.CompletedTask; + } + + private void SessionReleased(Session session) + { + lock (sessionGate) + { + if (ReferenceEquals(currentSession, session)) + currentSession = null; + } + } + + private sealed class Session + { + private readonly object stateGate = new object(); + private readonly HashSet activeBeforeAttach; + private readonly INativeModeAudioEndpointAccessor endpointAccessor; + private readonly INativeModeAudioNotificationSource notificationSource; + private readonly INativeModeRenderOutputFactory outputFactory; + private readonly INativeModeVirtualDeviceIdentity deviceIdentity; + private readonly Action log; + private readonly Action released; + private readonly TimeSpan readinessPollInterval; + private readonly TimeSpan removalPollInterval; + private readonly SemaphoreSlim endpointChanged = new SemaphoreSlim(0, 1); + private readonly SemaphoreSlim operationGate = new SemaphoreSlim(1, 1); + private readonly CancellationTokenSource readinessCancellation = + new CancellationTokenSource(); + private readonly TaskCompletionSource + unexpectedTermination = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource releaseCompletion = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + private IDisposable notificationRegistration; + private INativeModeRenderOutput output; + private string endpointId; + private Exception startupFailure; + private Exception pendingOutputTermination; + private Exception outputTermination; + private bool removalMonitorStarted; + private bool ready; + private bool teardownStarted; + private bool releaseCompleted; + private bool retentionLogged; + private bool probeFailureLogged; + + public Session(HashSet activeBeforeAttach, + INativeModeAudioEndpointAccessor endpointAccessor, + INativeModeAudioNotificationSource notificationSource, + INativeModeRenderOutputFactory outputFactory, + INativeModeVirtualDeviceIdentity deviceIdentity, + Action log, Action released, + TimeSpan readinessPollInterval, + TimeSpan removalPollInterval) + { + this.activeBeforeAttach = activeBeforeAttach; + this.endpointAccessor = endpointAccessor; + this.notificationSource = notificationSource; + this.outputFactory = outputFactory; + this.deviceIdentity = deviceIdentity; + this.log = log; + this.released = released; + this.readinessPollInterval = readinessPollInterval; + this.removalPollInterval = removalPollInterval; + } + + public Task UnexpectedTermination => + unexpectedTermination.Task; + + public Task ReleaseCompletion => releaseCompletion.Task; + + public void StartMonitoring() + { + IDisposable registration = notificationSource.Subscribe(SignalChanged); + lock (stateGate) + { + if (releaseCompleted) + { + registration.Dispose(); + return; + } + notificationRegistration = registration; + } + } + + public async Task WaitForReadyAsync(TimeSpan timeout, + CancellationToken cancellationToken) + { + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout)); + + using var timeoutCancellation = new CancellationTokenSource(timeout); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, timeoutCancellation.Token, + readinessCancellation.Token); + + try + { + while (true) + { + linkedCancellation.Token.ThrowIfCancellationRequested(); + if (await TryStartOutputAsync().ConfigureAwait(false)) + return; + + await endpointChanged.WaitAsync(readinessPollInterval, + linkedCancellation.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when ( + timeoutCancellation.IsCancellationRequested && + !cancellationToken.IsCancellationRequested && + !readinessCancellation.IsCancellationRequested) + { + throw new TimeoutException( + "The virtual DualSense render endpoint did not become ready."); + } + } + + public void BeginTeardown() + { + lock (stateGate) + { + if (releaseCompleted || teardownStarted) + return; + teardownStarted = true; + unexpectedTermination.TrySetCanceled(); + } + + readinessCancellation.Cancel(); + } + + public async Task CheckRemovalNowAsync() + { + BeginTeardown(); + if (await TryReleaseAfterRemovalAsync().ConfigureAwait(false)) + return; + + bool startMonitor = false; + lock (stateGate) + { + if (!releaseCompleted && !removalMonitorStarted) + { + removalMonitorStarted = true; + startMonitor = true; + } + } + + if (startMonitor) + { + // Discard attach/readiness pulses already reflected in the + // explicit probe above. Fresh removal notifications still + // wake the slower retained monitor immediately. + try + { + endpointChanged.Wait(0); + } + catch (ObjectDisposedException) + { + return; + } + // Intentional fire-and-retain: the Task roots this Session + // and is not canceled merely because a stop caller timed + // out. Confirmed removal is its only successful terminus. + _ = Task.Run(MonitorRemovalAsync); + } + } + + public void ReleaseBeforeOutputOpen() + { + readinessCancellation.Cancel(); + lock (stateGate) + releaseCompleted = true; + ReleaseResources(null, null); + } + + private async Task TryStartOutputAsync() + { + await operationGate.WaitAsync().ConfigureAwait(false); + try + { + lock (stateGate) + { + if (releaseCompleted || teardownStarted) + throw new OperationCanceledException( + "Native-mode render keepalive teardown has started."); + if (outputTermination != null) + { + throw new InvalidOperationException( + "The native-mode render keepalive stopped unexpectedly.", + outputTermination); + } + if (ready) + return true; + if (startupFailure != null) + { + throw new InvalidOperationException( + "The native-mode render keepalive could not start.", + startupFailure); + } + } + + NativeModeAudioEndpoint candidate = endpointAccessor + .GetActiveEndpoints(NativeModeAudioFlow.Render) + .FirstOrDefault(endpoint => + !activeBeforeAttach.Contains(endpoint.Id) && + deviceIdentity.OwnsEndpoint(endpoint)); + if (candidate == null) + return false; + + INativeModeRenderOutput createdOutput = null; + try + { + createdOutput = outputFactory.Create(candidate.Id); + createdOutput.UnexpectedTermination += + OnOutputUnexpectedTermination; + lock (stateGate) + { + endpointId = candidate.Id; + // Publish ownership before Init/Play. If either + // partially activates the pin and then throws, the + // failed lease must survive until PnP removal too. + output = createdOutput; + } + + createdOutput.Start(); + bool publishPendingTermination; + Exception pendingTermination; + lock (stateGate) + { + ready = true; + pendingTermination = pendingOutputTermination; + publishPendingTermination = pendingTermination != null && + PublishOutputTerminationUnderLock( + pendingTermination); + } + log($"[native] Holding the virtual DualSense render pin open " + + $"on '{candidate.FriendlyName}'.", false); + if (publishPendingTermination) + LogUnexpectedTermination(pendingTermination); + return true; + } + catch (Exception ex) + { + lock (stateGate) + startupFailure = ex; + // Do not dispose a possibly activated WASAPI client while + // the composite device is attached. The startup caller + // will remove the virtual device, then teardown releases it. + throw new InvalidOperationException( + "Could not start the mandatory virtual DualSense render " + + $"keepalive on '{candidate.FriendlyName}': {ex.Message}", ex); + } + } + finally + { + operationGate.Release(); + } + } + + private void OnOutputUnexpectedTermination(object sender, + NativeModeRenderOutputTerminatedEventArgs args) + { + Exception failure = args?.Exception ?? new InvalidOperationException( + "The mandatory native-mode WASAPI render stream stopped."); + bool publish = false; + lock (stateGate) + { + if (releaseCompleted || teardownStarted || + !ReferenceEquals(output, sender)) + { + return; + } + + if (!ready) + pendingOutputTermination ??= failure; + else + publish = PublishOutputTerminationUnderLock(failure); + } + + if (publish) + LogUnexpectedTermination(failure); + } + + private bool PublishOutputTerminationUnderLock(Exception failure) + { + outputTermination ??= failure; + return unexpectedTermination.TrySetResult(outputTermination); + } + + private void LogUnexpectedTermination(Exception failure) => + log("[native] The mandatory DualSense render keepalive stopped " + + $"unexpectedly; Native Mode must be torn down: {failure.Message}", + true); + + private async Task MonitorRemovalAsync() + { + while (true) + { + try + { + await endpointChanged.WaitAsync(removalPollInterval) + .ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + + if (await TryReleaseAfterRemovalAsync().ConfigureAwait(false)) + return; + } + } + + private async Task TryReleaseAfterRemovalAsync() + { + await operationGate.WaitAsync().ConfigureAwait(false); + try + { + INativeModeRenderOutput outputToDispose; + IDisposable registrationToDispose; + string trackedEndpointId; + lock (stateGate) + { + if (releaseCompleted) + return true; + if (!teardownStarted) + return false; + + outputToDispose = output; + trackedEndpointId = endpointId; + } + + // No output was ever opened, so there is no render pin whose + // close must be deferred until PnP removal. + if (outputToDispose == null) + { + lock (stateGate) + { + registrationToDispose = notificationRegistration; + notificationRegistration = null; + releaseCompleted = true; + } + ReleaseResources(registrationToDispose, null); + return true; + } + + bool endpointPresent; + bool virtualDevicePresent; + try + { + endpointPresent = endpointAccessor + .GetActiveEndpoints(NativeModeAudioFlow.Render) + .Any(endpoint => string.Equals(endpoint.Id, + trackedEndpointId, + StringComparison.OrdinalIgnoreCase)); + virtualDevicePresent = deviceIdentity.IsPresent(); + } + catch (Exception ex) + { + bool shouldLog; + lock (stateGate) + { + shouldLog = !probeFailureLogged; + probeFailureLogged = true; + } + if (shouldLog) + { + log($"[native] Could not confirm virtual audio removal; " + + $"the render keepalive remains active: {ex.Message}", + true); + } + return false; + } + + lock (stateGate) + probeFailureLogged = false; + + // Require both signals. An endpoint can become inactive while + // the parent still exists; disposing at that point could be + // the very voluntary alt-setting transition this guard avoids. + if (endpointPresent || virtualDevicePresent) + { + bool shouldLog; + lock (stateGate) + { + shouldLog = !retentionLogged; + retentionLogged = true; + } + if (shouldLog) + { + log("[native] Virtual audio removal is not complete; " + + "retaining the render keepalive until the endpoint and " + + "device are both gone.", true); + } + return false; + } + + lock (stateGate) + { + registrationToDispose = notificationRegistration; + notificationRegistration = null; + output = null; + releaseCompleted = true; + } + ReleaseResources(registrationToDispose, outputToDispose); + log("[native] Released the render keepalive after confirmed " + + "virtual endpoint and device removal.", false); + return true; + } + finally + { + operationGate.Release(); + } + } + + private void ReleaseResources(IDisposable registration, + INativeModeRenderOutput outputToDispose) + { + try + { + registration?.Dispose(); + } + catch (Exception ex) + { + log($"[native] Could not unregister render endpoint monitoring: " + + ex.Message, true); + } + + try + { + if (outputToDispose != null) + { + outputToDispose.UnexpectedTermination -= + OnOutputUnexpectedTermination; + } + outputToDispose?.Dispose(); + } + catch (Exception ex) + { + log($"[native] Could not dispose the removed render endpoint: " + + ex.Message, true); + } + + readinessCancellation.Dispose(); + endpointChanged.Dispose(); + released(this); + unexpectedTermination.TrySetCanceled(); + releaseCompletion.TrySetResult(true); + } + + private void SignalChanged() + { + lock (stateGate) + { + if (releaseCompleted) + return; + } + + try + { + endpointChanged.Release(); + } + catch (SemaphoreFullException) + { + // One pending pulse is enough to force a fresh enumeration. + } + catch (ObjectDisposedException) + { + // A final callback can race notification unregistration. + } + } + } + } + + internal sealed class WasapiNativeModeRenderOutputFactory : + INativeModeRenderOutputFactory + { + public INativeModeRenderOutput Create(string endpointId) => + new WasapiNativeModeRenderOutput(endpointId); + + private sealed class WasapiNativeModeRenderOutput : + INativeModeRenderOutput + { + private readonly string endpointId; + private MMDevice endpoint; + private WasapiOut output; + private int started; + private int disposed; + + public WasapiNativeModeRenderOutput(string endpointId) + { + this.endpointId = !string.IsNullOrWhiteSpace(endpointId) + ? endpointId + : throw new ArgumentException("An endpoint id is required.", + nameof(endpointId)); + } + + public event EventHandler + UnexpectedTermination; + + public void Start() + { + if (Interlocked.Exchange(ref started, 1) != 0) + throw new InvalidOperationException("The render output already started."); + + using var enumerator = new MMDeviceEnumerator(); + endpoint = enumerator.GetDevice(endpointId); + using AudioClient audioClient = endpoint.AudioClient; + WaveFormat mixFormat = audioClient.MixFormat; + + output = new WasapiOut(endpoint, AudioClientShareMode.Shared, + useEventSync: true, latency: 20); + output.PlaybackStopped += OnPlaybackStopped; + output.Init(new SilenceProvider(mixFormat)); + output.Play(); + // WasapiOut.Play publishes Playing before its worker calls + // IAudioClient.Start. Clock movement proves the render pin is + // actually running before Native Mode can report Attached. + DateTimeOffset deadline = DateTimeOffset.UtcNow + + TimeSpan.FromSeconds(3); + Exception lastStartFailure = null; + while (DateTimeOffset.UtcNow < deadline && + output.PlaybackState == PlaybackState.Playing) + { + try + { + if (output.GetPosition() > 0) + return; + } + catch (Exception ex) + { + lastStartFailure = ex; + } + Thread.Sleep(10); + } + + if (lastStartFailure != null) + throw lastStartFailure; + if (output.PlaybackState == PlaybackState.Playing) + throw new TimeoutException(); + if (output.PlaybackState != PlaybackState.Playing) + { + throw new InvalidOperationException( + "WASAPI did not enter the playing state."); + } + } + + private void OnPlaybackStopped(object sender, StoppedEventArgs args) + { + if (Volatile.Read(ref disposed) != 0) + return; + + Exception failure = args.Exception ?? new InvalidOperationException( + "The mandatory native-mode WASAPI render stream stopped."); + UnexpectedTermination?.Invoke(this, + new NativeModeRenderOutputTerminatedEventArgs(failure)); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + return; + + // Deliberately do not call Stop. The owner invokes Dispose only + // after endpoint and parent removal, when no alt-setting change + // can be sent to the virtual USB device. + if (output != null) + output.PlaybackStopped -= OnPlaybackStopped; + output?.Dispose(); + endpoint?.Dispose(); + } + } + } + + internal sealed class WindowsNativeModeVirtualDeviceIdentity : + INativeModeVirtualDeviceIdentity + { + private readonly Func> + getPresentVirtualInstanceIds; + private readonly Func getParentInstanceId; + private readonly Func getContainerId; + + public WindowsNativeModeVirtualDeviceIdentity() : this( + NativeModeDevicePresence.GetPresentVirtualDualSenseInstanceIds, + instanceId => Global.GetStringDeviceProperty(instanceId, + NativeMethods.DEVPKEY_Device_Parent), + NativeModeDevicePresence.TryGetContainerId) + { + } + + internal WindowsNativeModeVirtualDeviceIdentity( + Func> getPresentVirtualInstanceIds, + Func getParentInstanceId, + Func getContainerId) + { + this.getPresentVirtualInstanceIds = getPresentVirtualInstanceIds ?? + throw new ArgumentNullException(nameof(getPresentVirtualInstanceIds)); + this.getParentInstanceId = getParentInstanceId ?? + throw new ArgumentNullException(nameof(getParentInstanceId)); + this.getContainerId = getContainerId ?? + throw new ArgumentNullException(nameof(getContainerId)); + } + + public bool IsPresent() => GetExactPresentParents().Count != 0; + + public bool OwnsEndpoint(NativeModeAudioEndpoint endpoint) + { + if (endpoint == null) + throw new ArgumentNullException(nameof(endpoint)); + + IReadOnlyList virtualParents = GetExactPresentParents(); + if (virtualParents.Count == 0) + return false; + + string current = endpoint.DeviceInstanceId; + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int depth = 0; depth < 16 && + !string.IsNullOrWhiteSpace(current) && visited.Add(current); depth++) + { + if (NativeModeDevicePresence + .IsVirtualDualSenseInstanceOrDescendant(current)) + { + return true; + } + current = getParentInstanceId(current); + } + + if (!endpoint.ContainerId.HasValue) + return false; + + return virtualParents.Any(parent => + getContainerId(parent) is Guid virtualContainer && + virtualContainer == endpoint.ContainerId.Value); + } + + private IReadOnlyList GetExactPresentParents() => + (getPresentVirtualInstanceIds() ?? Array.Empty()) + .Where(NativeModeDevicePresence + .IsVirtualDualSenseParentInstanceId) + .ToArray(); + } +} diff --git a/DS4Windows/DS4Control/NativeModeSessionCancellation.cs b/DS4Windows/DS4Control/NativeModeSessionCancellation.cs new file mode 100644 index 0000000..be998ad --- /dev/null +++ b/DS4Windows/DS4Control/NativeModeSessionCancellation.cs @@ -0,0 +1,131 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Threading; + +namespace DS4Windows +{ + /// + /// Owns the cancellation source for one Native Mode startup/session. + /// Stop registration is serialized with startup registration so a stop + /// cannot miss a startup which has not reached the lifecycle gate yet. + /// + internal sealed class NativeModeSessionCancellation : IDisposable + { + private readonly object syncRoot = new object(); + private CancellationTokenSource activeSource; + private bool stopRequested; + + public CancellationTokenSource Begin(CancellationToken callerToken) + { + lock (syncRoot) + { + if (stopRequested) + { + throw new InvalidOperationException( + "Native mode is stopping; startup was canceled."); + } + + if (activeSource != null) + { + throw new InvalidOperationException( + "A Native Mode startup or session is already active."); + } + + activeSource = CancellationTokenSource + .CreateLinkedTokenSource(callerToken); + return activeSource; + } + } + + public void RequestStop() + { + lock (syncRoot) + { + stopRequested = true; + activeSource?.Cancel(); + } + } + + public void CompleteStop(bool allowFutureStarts) + { + if (!allowFutureStarts) + return; + + lock (syncRoot) + stopRequested = false; + } + + public void CompleteSession(CancellationTokenSource expectedSource) + { + if (expectedSource == null) + return; + + bool dispose = false; + lock (syncRoot) + { + if (ReferenceEquals(activeSource, expectedSource)) + { + activeSource = null; + dispose = true; + } + } + + if (dispose) + expectedSource.Dispose(); + } + + public void CompleteCurrentSession() + { + CancellationTokenSource source; + lock (syncRoot) + { + source = activeSource; + activeSource = null; + } + + source?.Dispose(); + } + + public void ResetForServiceStart() + { + lock (syncRoot) + { + // A failed/deferred teardown keeps the active source as an + // explicit barrier against starting a second session. + if (activeSource == null) + stopRequested = false; + } + } + + public void Dispose() + { + CancellationTokenSource source; + lock (syncRoot) + { + stopRequested = true; + source = activeSource; + activeSource = null; + source?.Cancel(); + } + + source?.Dispose(); + } + } +} diff --git a/DS4Windows/DS4Control/ScpUtil.cs b/DS4Windows/DS4Control/ScpUtil.cs index 400c570..34186c6 100644 --- a/DS4Windows/DS4Control/ScpUtil.cs +++ b/DS4Windows/DS4Control/ScpUtil.cs @@ -1915,6 +1915,17 @@ public static string CustomSteamFolder get { return m_Config.customSteamFolder; } } + public static string UsbipExePath + { + set + { + m_Config.usbipExePath = string.IsNullOrWhiteSpace(value) + ? BackingStore.DEFAULT_USBIP_EXE_PATH + : value.Trim(); + } + get { return m_Config.usbipExePath; } + } + public static bool AutoProfileRevertDefaultProfile { set { m_Config.autoProfileRevertDefaultProfile = value; } @@ -3391,6 +3402,7 @@ private static string ParseChangelogString(string changelog) public class BackingStore { + public const string DEFAULT_USBIP_EXE_PATH = @"C:\Program Files\USBip\usbip.exe"; public const double DEFAULT_UDP_SMOOTH_MINCUTOFF = 0.4; public const double DEFAULT_UDP_SMOOTH_BETA = 0.2; // Use 15 minutes for default Idle Disconnect when initially enabling the option @@ -3883,6 +3895,7 @@ public void setSZOutCurveMode(int index, int value) public double udpSmoothingBeta = DEFAULT_UDP_SMOOTH_BETA; public bool useCustomSteamFolder; public string customSteamFolder; + public string usbipExePath = DEFAULT_USBIP_EXE_PATH; public AppThemeChoice useCurrentTheme; public string fakeExeFileName = string.Empty; public string absDisplayEDID = string.Empty; diff --git a/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml b/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml index 062d5c3..699e6e2 100644 --- a/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml +++ b/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml @@ -62,6 +62,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +