diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 9956ec1..964447d 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -1,103 +1,126 @@ name: CI Build -# Autobuild that runs on every push and pull request, plus manual triggers. -# Complements release.yml (which only runs when a GitHub Release is published). -# Produces a runnable x64 package as a downloadable build artifact. - on: push: - branches: [ "**" ] - tags-ignore: [ "**" ] # tag pushes are handled by release.yml + branches: ["**"] + tags-ignore: ["**"] pull_request: - branches: [ "**" ] + branches: ["**"] workflow_dispatch: permissions: contents: read -jobs: - test: - runs-on: windows-2022 - steps: - - uses: actions/checkout@v4 +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x +env: + DOTNET_NOLOGO: "1" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" - - name: Restore dependencies - run: dotnet restore - - - name: Run tests (x64) - # NOTE: Three tests are excluded below. They are exact-string XML round-trip - # "snapshot" tests whose hardcoded expected XML predates several fields the - # current serializer emits (ProcessPriority, ProfileChangedNotification, - # UseMoonlight, DebouncingMs, LeftStickDriftXAxis, ...) and whose fixture - # source files use LF while the serializer writes CRLF. They fail on a clean - # checkout of upstream, independently of any feature change, so they are not - # used to gate the build. All other tests run and must pass. - # Remove the --filter once the upstream snapshot fixtures are regenerated. - run: dotnet test .\DS4WindowsTests\DS4WindowsTests.csproj -c Release -p:Platform=x64 --verbosity normal --filter "Name!=CheckSettingsSave&Name!=CheckWriteProfile&Name!=CheckJaysProfileRead" - - build: +jobs: + verify: runs-on: windows-2022 - needs: test - strategy: - fail-fast: false - matrix: - platform: [ x64 ] + timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - name: Checkout exact source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Setup .NET 8 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: 8.0.x - - name: Set up Python 3.10 - uses: actions/setup-python@v5 + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: "3.10" - - - name: Restore dependencies - run: dotnet restore + python-version: "3.12" - - name: Compute version - id: ver - shell: bash + - name: Restore managed projects + shell: pwsh run: | - VERSION="$(date +%Y.%m.%d)-ci-${GITHUB_SHA::7}" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + dotnet restore .\DS4WindowsTests\DS4WindowsTests.csproj + if ($LASTEXITCODE -ne 0) { throw "Test restore failed." } + dotnet restore .\installer\DS4Windows.SetupActions\DS4Windows.SetupActions.csproj + if ($LASTEXITCODE -ne 0) { throw "SetupActions restore failed." } + dotnet restore .\installer\DS4Windows.Bootstrapper\DS4Windows.Bootstrapper.csproj + if ($LASTEXITCODE -ne 0) { throw "Bootstrapper restore failed." } + + - name: Validate native package contract (Windows PowerShell 5.1) + shell: powershell + run: .\extras\Test-ViiperNativePackageContract.ps1 + + - name: Validate native package contract (PowerShell 7) + shell: pwsh + run: .\extras\Test-ViiperNativePackageContract.ps1 + + - name: Validate Windows 11 bundle contract (Windows PowerShell 5.1) + shell: powershell + run: .\extras\validation\Test-ViiperWin11ValidationContract.ps1 + + - name: Validate Windows 11 bundle contract (PowerShell 7) + shell: pwsh + run: .\extras\validation\Test-ViiperWin11ValidationContract.ps1 - - name: Build ${{ matrix.platform }} - run: dotnet publish .\DS4Windows\DS4WinWPF.csproj -c Release /p:platform=${{ matrix.platform }} -o .\bin\${{ matrix.platform }}\Release\output + - name: Validate installer security contracts (Windows PowerShell 5.1) + shell: powershell + run: .\installer\Test-InstallerSecurityContracts.ps1 - - name: Package ${{ matrix.platform }} - run: python .\utils\post-build.py .\bin\${{ matrix.platform }}\Release\output . ${{ env.VERSION }} + - name: Validate installer security contracts (PowerShell 7) + shell: pwsh + run: .\installer\Test-InstallerSecurityContracts.ps1 - - name: Stage ${{ matrix.platform }} artifact folder + - name: Validate installer source and state machine shell: pwsh run: | - $artifactRoot = ".\bin\${{ matrix.platform }}\Release\artifact" - New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null - Copy-Item -Path ".\bin\${{ matrix.platform }}\Release\DS4Windows" -Destination $artifactRoot -Recurse -Force + python .\utils\test-installer-state-machine.py + if ($LASTEXITCODE -ne 0) { throw "Installer state-machine tests failed." } + python .\utils\validate-installer.py --source-only --bundle-source .\installer\DS4Windows.Bundle\Bundle.wxs --setup-actions-source .\installer\DS4Windows.SetupActions\Program.cs --bootstrapper-source .\installer\DS4Windows.Bootstrapper\InstallerApplication.cs + if ($LASTEXITCODE -ne 0) { throw "Installer source validation failed." } - - name: Upload ${{ matrix.platform }} artifact - id: upload - uses: actions/upload-artifact@v4 - with: - name: DS4Windows_${{ env.VERSION }}_${{ matrix.platform }} - path: .\bin\${{ matrix.platform }}\Release\artifact - if-no-files-found: error + - name: Prove production build fails without signing credentials + shell: pwsh + run: | + foreach ($name in @("DS4W_SIGN_CERT_PATH", "DS4W_SIGN_CERT_PASSWORD", "DS4W_SIGN_CERTIFICATE_SHA256", "DS4W_SIGN_TIMESTAMP_URL")) { + Remove-Item -LiteralPath ("Env:\" + $name) -ErrorAction SilentlyContinue + } + try { + & .\installer\build-installer.ps1 -PublishRoot (Join-Path $env:RUNNER_TEMP "must-not-publish") + throw "Production build unexpectedly accepted missing signing credentials." + } catch { + if ($_.Exception.Message -notmatch "requires environment variable") { throw } + } + + - name: Compile installer executables + shell: pwsh + run: | + dotnet build .\installer\DS4Windows.SetupActions\DS4Windows.SetupActions.csproj -c Release -p:Platform=x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw "SetupActions build failed." } + dotnet build .\installer\DS4Windows.Bootstrapper\DS4Windows.Bootstrapper.csproj -c Release -p:Platform=x64 --no-restore + if ($LASTEXITCODE -ne 0) { throw "Bootstrapper build failed." } - - name: Publish artifact link to run summary - shell: bash + - name: Compile disposable MSI and Burn composition + shell: pwsh + run: | + $publishRoot = Join-Path $env:RUNNER_TEMP "ds4windows-installer-composition-publish" + dotnet publish .\DS4Windows\DS4WinWPF.csproj -c Release -p:Platform=x64 -o $publishRoot + if ($LASTEXITCODE -ne 0) { throw "Composition publish failed." } + & .\installer\Test-InstallerComposition.ps1 -PublishRoot $publishRoot + + - name: Run DS4Windows tests (x64) + shell: pwsh run: | - echo "### DS4Windows ${{ matrix.platform }} build" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Version: \`${{ env.VERSION }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "[Download DS4Windows_${{ env.VERSION }}_${{ matrix.platform }} (contains DS4Windows folder)](${{ steps.upload.outputs.artifact-url }})" >> "$GITHUB_STEP_SUMMARY" + dotnet test .\DS4WindowsTests\DS4WindowsTests.csproj -c Release -p:Platform=x64 --no-restore --verbosity normal + if ($LASTEXITCODE -ne 0) { throw "DS4Windows tests failed." } + - name: Verify source checkout remains unchanged + shell: pwsh + run: | + $changes = @(git status --short) + if ($changes.Count -ne 0) { + $changes | Write-Host + throw "CI validation changed tracked or untracked source." + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3112f07..d302258 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,67 +1,232 @@ -name: .NET Release +name: Signed Installer Release on: - release: - types: [published] + push: + tags: + - "v*.*.*" permissions: - contents: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + DOTNET_NOLOGO: "1" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" jobs: - release: + test: runs-on: windows-2022 - #this makes it not run if tests failed. - #needs: test + timeout-minutes: 45 + steps: + - name: Checkout tagged source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup .NET 8 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: 8.0.x + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Verify immutable tag provenance + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + if ($tag -cnotmatch '^v\d+\.\d+\.\d+(?:\.\d+)?$') { + throw "Release tag must use a numeric Burn-compatible version: $tag" + } + $tagObjectType = (git cat-file -t ("refs/tags/" + $tag)).Trim() + if ($tagObjectType -cne "tag") { throw "Release requires an annotated tag." } + $tagCommit = (git rev-list -n 1 $tag).Trim() + $headCommit = (git rev-parse HEAD).Trim() + if ($tagCommit -cne $headCommit -or $headCommit -cne $env:GITHUB_SHA) { + throw "Checked-out source does not equal the tagged commit." + } + git fetch --no-tags origin main + if ($LASTEXITCODE -ne 0) { throw "Could not fetch the protected main branch." } + git merge-base --is-ancestor $headCommit origin/main + if ($LASTEXITCODE -ne 0) { throw "Release tag is not reachable from main." } + if (@(git status --short).Count -ne 0) { throw "Tagged checkout is dirty." } + + - name: Restore and test managed code + shell: pwsh + run: | + dotnet restore .\DS4WindowsTests\DS4WindowsTests.csproj + if ($LASTEXITCODE -ne 0) { throw "Restore failed." } + dotnet test .\DS4WindowsTests\DS4WindowsTests.csproj -c Release -p:Platform=x64 --no-restore --verbosity normal + if ($LASTEXITCODE -ne 0) { throw "DS4Windows tests failed." } + + - name: Validate native and installer source contracts + shell: pwsh + run: | + powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\extras\Test-ViiperNativePackageContract.ps1 + if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell native contract failed." } + .\extras\Test-ViiperNativePackageContract.ps1 + powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\extras\validation\Test-ViiperWin11ValidationContract.ps1 + if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell validation-bundle contract failed." } + .\extras\validation\Test-ViiperWin11ValidationContract.ps1 + powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\installer\Test-InstallerSecurityContracts.ps1 + if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell installer security contract failed." } + .\installer\Test-InstallerSecurityContracts.ps1 + python .\utils\test-installer-state-machine.py + if ($LASTEXITCODE -ne 0) { throw "Installer state-machine tests failed." } + python .\utils\validate-installer.py --source-only --bundle-source .\installer\DS4Windows.Bundle\Bundle.wxs --setup-actions-source .\installer\DS4Windows.SetupActions\Program.cs --bootstrapper-source .\installer\DS4Windows.Bootstrapper\InstallerApplication.cs + if ($LASTEXITCODE -ne 0) { throw "Installer source validation failed." } + + - name: Compile protected installer callers + shell: pwsh + run: | + dotnet build .\installer\DS4Windows.SetupActions\DS4Windows.SetupActions.csproj -c Release -p:Platform=x64 + if ($LASTEXITCODE -ne 0) { throw "SetupActions build failed." } + dotnet build .\installer\DS4Windows.Bootstrapper\DS4Windows.Bootstrapper.csproj -c Release -p:Platform=x64 + if ($LASTEXITCODE -ne 0) { throw "Bootstrapper build failed." } + + release: + needs: test + runs-on: [self-hosted, Windows, X64, ds4w-release] + environment: production + timeout-minutes: 90 + permissions: + contents: write + id-token: write + attestations: write steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.tag_name }} - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Restore dependencies - run: dotnet restore - - name: Set envars for versions - run: | - TAG=${{ github.event.release.tag_name }} - VERSION=${TAG#v} - PROJECT_ASSEMBLY_VERSION=$(python -c "import xml.etree.ElementTree as ET; print(ET.parse('DS4Windows/DS4WinWPF.csproj').find('.//AssemblyVersion').text)") - PROJECT_VERSION=$(python -c "import xml.etree.ElementTree as ET; print(ET.parse('DS4Windows/DS4WinWPF.csproj').find('.//Version').text)") - if [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}(-.*)?$ ]]; then - BINARY_VERSION=${VERSION%%-*} - PACKAGE_VERSION=$VERSION - else - BINARY_VERSION=$PROJECT_ASSEMBLY_VERSION - PACKAGE_VERSION=$PROJECT_VERSION - fi - echo "Release: $VERSION; binary: $BINARY_VERSION; package: $PACKAGE_VERSION" - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "BINARY_VERSION=$BINARY_VERSION" >> $GITHUB_ENV - echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV - shell: bash - - name: Build X64 - run: dotnet publish .\DS4Windows\DS4WinWPF.csproj -c Release /p:platform=x64 /p:AssemblyVersion=${{ env.BINARY_VERSION }} /p:FileVersion=${{ env.BINARY_VERSION }} /p:Version=${{ env.PACKAGE_VERSION }} /p:InformationalVersion=${{ env.VERSION }} -o .\bin\x64\Release\output - - name: Post-Build script X64 - run: python .\utils\post-build.py .\bin\x64\Release\output . ${{env.VERSION}} - - name: Name release asset - shell: pwsh - run: | - $source = ".\bin\x64\Release\DS4Windows_${{ env.VERSION }}_x64.zip" - $asset = if ("${{ env.VERSION }}" -like "VIIPER*") { - ".\bin\x64\Release\DS4Windows_VIIPER_x64.zip" - } else { - $source - } - if ($asset -ne $source) { - Move-Item -LiteralPath $source -Destination $asset -Force - } - "RELEASE_ASSET=$asset" >> $env:GITHUB_ENV - - name: Publish release Build X64 - run: gh release upload ${{github.event.release.tag_name}} ${{ env.RELEASE_ASSET }} --clobber - env: - GITHUB_TOKEN: ${{ github.TOKEN }} + - name: Checkout the tested tag + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Re-verify exact release checkout + shell: pwsh + run: | + $tagCommit = (git rev-list -n 1 $env:GITHUB_REF_NAME).Trim() + $headCommit = (git rev-parse HEAD).Trim() + if ($tagCommit -cne $headCommit -or $headCommit -cne $env:GITHUB_SHA) { + throw "Release worker checkout does not equal the tested tagged commit." + } + if (@(git status --short).Count -ne 0) { throw "Release worker checkout is dirty." } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: 8.0.x + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Import protected production native package + shell: pwsh + env: + DS4W_NATIVE_PACKAGE_ROOT: ${{ vars.DS4W_NATIVE_PACKAGE_ROOT }} + DS4W_NATIVE_PACKAGE_PROVENANCE: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE }} + DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256 }} + run: | + & .\installer\Import-ProductionNativePackage.ps1 -SourceRoot $env:DS4W_NATIVE_PACKAGE_ROOT -ProvenanceManifest $env:DS4W_NATIVE_PACKAGE_PROVENANCE -ExpectedProvenanceSha256 $env:DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256 + if ($LASTEXITCODE -ne 0) { throw "Production native package import failed." } + powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\extras\Test-ViiperNativePackageContract.ps1 -RequireProduction -RequirePackage + if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell production package gate failed." } + .\extras\Test-ViiperNativePackageContract.ps1 -RequireProduction -RequirePackage + + - name: Materialize protected Authenticode credential + shell: pwsh + env: + DS4W_SIGN_PFX_BASE64: ${{ secrets.DS4W_SIGN_PFX_BASE64 }} + run: | + if ([string]::IsNullOrWhiteSpace($env:DS4W_SIGN_PFX_BASE64)) { + throw "DS4W_SIGN_PFX_BASE64 is required." + } + try { + $bytes = [Convert]::FromBase64String($env:DS4W_SIGN_PFX_BASE64) + } catch { + throw "DS4W_SIGN_PFX_BASE64 is malformed." + } + if ($bytes.Length -eq 0) { throw "Signing certificate is empty." } + $path = Join-Path $env:RUNNER_TEMP "ds4windows-release-signing.pfx" + [IO.File]::WriteAllBytes($path, $bytes) + "DS4W_SIGN_CERT_PATH=$path" >> $env:GITHUB_ENV + + - name: Build, sign, and verify production installer + shell: pwsh + env: + DS4W_SIGN_CERT_PASSWORD: ${{ secrets.DS4W_SIGN_CERT_PASSWORD }} + DS4W_SIGN_CERTIFICATE_SHA256: ${{ secrets.DS4W_SIGN_CERTIFICATE_SHA256 }} + DS4W_SIGN_TIMESTAMP_URL: ${{ secrets.DS4W_SIGN_TIMESTAMP_URL }} + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $productVersion = ($version -split '-', 2)[0] + & .\installer\build-installer.ps1 -PublishRoot .\bin\x64\Release\installer-publish -OutputDirectory .\bin\x64\Release\installer -ProductVersion $productVersion -BundleVersion $version -DisplayVersion $version + if ($LASTEXITCODE -ne 0) { throw "Signed installer build failed." } + + - name: Remove ephemeral signing credential + if: always() + shell: pwsh + run: | + $runnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') + $path = [IO.Path]::GetFullPath((Join-Path $runnerTemp "ds4windows-release-signing.pfx")) + if ((Split-Path -Parent $path) -cne $runnerTemp) { + throw "Refusing unsafe signing credential cleanup." + } + if (Test-Path -LiteralPath $path -PathType Leaf) { + Remove-Item -LiteralPath $path -Force + } + + - name: Assemble immutable release inventory + shell: pwsh + env: + DS4W_NATIVE_PACKAGE_PROVENANCE: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE }} + DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256 }} + run: | + $releaseRoot = Resolve-Path .\bin\x64\Release\installer + $nativeProvenance = Join-Path $releaseRoot "VIIPER-native-package.provenance.json" + Copy-Item -LiteralPath $env:DS4W_NATIVE_PACKAGE_PROVENANCE -Destination $nativeProvenance + if ((Get-FileHash -LiteralPath $nativeProvenance -Algorithm SHA256).Hash -cne $env:DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256.ToUpperInvariant()) { + throw "Copied native provenance manifest differs from its release pin." + } + $files = @(Get-ChildItem -LiteralPath $releaseRoot -File | Sort-Object Name) + if ($files.Count -ne 3) { throw "Release inventory must contain installer and two provenance manifests before checksums." } + $checksumPath = Join-Path $releaseRoot "SHA256SUMS.txt" + $lines = @($files | ForEach-Object { + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $hash + " *" + $_.Name + }) + [IO.File]::WriteAllLines($checksumPath, $lines, [Text.UTF8Encoding]::new($false)) + + - name: Attest release provenance + uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 + with: + subject-path: bin/x64/Release/installer/* + + - name: Retain signed release inventory + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: DS4Windows_${{ github.ref_name }}_signed-installer + path: bin/x64/Release/installer/* + if-no-files-found: error + retention-days: 90 + + - name: Publish new immutable GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = $env:GITHUB_REF_NAME + gh release view $tag *> $null + if ($LASTEXITCODE -eq 0) { throw "Release already exists; assets will not be overwritten." } + $assets = @(Get-ChildItem -LiteralPath .\bin\x64\Release\installer -File | ForEach-Object FullName) + if ($assets.Count -ne 4) { throw "Release asset inventory changed after attestation." } + gh release create $tag @assets --draft --verify-tag --title ("DS4Windows " + $tag.Substring(1)) --generate-notes + if ($LASTEXITCODE -ne 0) { throw "GitHub draft release creation or asset upload failed." } + gh release edit $tag --draft=false + if ($LASTEXITCODE -ne 0) { throw "GitHub release remained a non-public draft." } diff --git a/.gitignore b/.gitignore index 2bad0e5..5062994 100644 --- a/.gitignore +++ b/.gitignore @@ -333,3 +333,10 @@ ASALocalRun/ !DS4Windows/libs/x64/ !DS4Windows/libs/x86/ +# Native VIIPER driver/broker media is supplied by the signed release pipeline. +# Never commit local-test certificates or package binaries into DS4Windows. +extras/viiper-native-package/ + +# Deterministic WiX inventory generated from the signed publish tree. +installer/DS4Windows.Package/GeneratedFiles.wxs + diff --git a/DS4Windows/DS4Control/ControlService.cs b/DS4Windows/DS4Control/ControlService.cs index 2cedc14..3a1167f 100644 --- a/DS4Windows/DS4Control/ControlService.cs +++ b/DS4Windows/DS4Control/ControlService.cs @@ -752,27 +752,11 @@ public void ShutDown() private void DS4Devices_RequestElevation(RequestElevationArgs args) { - // Launches an elevated child process to re-enable device - ProcessStartInfo startInfo = - new ProcessStartInfo(Global.exelocation); - startInfo.Verb = "runas"; - startInfo.Arguments = "re-enabledevice " + args.InstanceId; - startInfo.UseShellExecute = true; - - try - { - Process child = Process.Start(startInfo); - if (!child.WaitForExit(30000)) - { - child.Kill(); - } - else - { - args.StatusCode = child.ExitCode; - } - child.Dispose(); - } - catch { } + // Never elevate the mutable portable executable. The caller keeps + // the default failure status and falls back to non-exclusive input. + // A signed, machine-installed maintenance entry owns privileged + // device repair in production builds. + args.StatusCode = RequestElevationArgs.STATUS_INIT_FAILURE; } public void CheckHidHidePresence(string ExePath = "", string ExeName = "Autoprofile Exe", bool AddExe = true) // Default value for D4W Startup @@ -1187,11 +1171,12 @@ private void EnsureHidHideForVirtualOutput(int index, DS4Device device, OutContT } /// - /// A VIIPER Sony output is a complete USB/IP HID, so an instance path - /// accidentally retained in HidHide's persistent blacklist makes the - /// virtual controller healthy and writable inside DS4Windows while it - /// is invisible to games. Remove only the exact before/after paths that - /// this process just created; physical Sony controllers stay cloaked. + /// A VIIPER Sony output is a complete USB composite device, so an + /// instance path accidentally retained in HidHide's persistent + /// blacklist makes the virtual controller healthy and writable inside + /// DS4Windows while it remains invisible to games. Remove only paths + /// already correlated to the output's exact PnP lifetime; physical + /// Sony controllers stay cloaked. /// private void EnsureHidHideDoesNotCloakVirtualSonyOutputs( IReadOnlyCollection devicePaths) @@ -1710,15 +1695,14 @@ public bool Start(bool showlog = true) AssignInitialDevices(); StartupDiag("AssignInitialDevices end"); - // A force-closed prior development build can leave its - // USB/IP output imported. Remove those ports before HID - // discovery or DS4Windows will ingest its own VIIPER DS4, - // create a second output/UAC endpoint, and recurse. - ViiperUsbipPortManager.DetachStaleLocalViiperPorts(); - // Let usbccgp/HID finish publishing removal before the - // first input snapshot; otherwise a detached interface can - // remain enumerable for one final discovery pass. - Thread.Sleep(250); + if (ViiperTransportSettings.GetManagedMode() == + ViiperTransportMode.Usbip) + { + // USB/IP is an explicit ABBA/dev-validation mode. Only + // that mode may inspect or detach legacy imported ports. + ViiperUsbipPortManager.DetachStaleLocalViiperPorts(); + Thread.Sleep(250); + } StartupDiag("DS4Devices.findControllers dispatch begin"); eventDispatcher.Invoke(() => @@ -2441,7 +2425,8 @@ private ViiperOutDevice EnsurePlayStationFeatureOutput( ViiperOutDevice existing = playStationFeatureOutputDevices[index]; if (existing?.IsRuntimeConnected == true && - existing.OutputType == desiredSidecar) + existing.OutputType == desiredSidecar && + ViiperPnPOwnershipRegistry.GetToken(existing) > 0) { existing.BindPhysicalController(index); return existing; @@ -2450,7 +2435,7 @@ private ViiperOutDevice EnsurePlayStationFeatureOutput( if (existing != null) { playStationFeatureOutputDevices[index] = null; - existing.Disconnect(); + DisconnectOwnedViiperSource(existing); } ViiperOutDevice sidecar = new ViiperOutDevice( @@ -2463,15 +2448,30 @@ private ViiperOutDevice EnsurePlayStationFeatureOutput( StartupDiag( $"PlayStation audio sidecar connect begin index={index} type={desiredSidecar}"); sidecar.Connect(); + int ownerToken = ViiperPnPOwnershipRegistry. + AttachOrUpdate(sidecar); + if (ownerToken <= 0) + { + throw new InvalidOperationException( + "VIIPER did not return an exact native PnP identity for the audio sidecar."); + } sidecar.BindPhysicalController(index); playStationFeatureOutputDevices[index] = sidecar; StartupDiag( - $"PlayStation audio sidecar ready index={index} type={desiredSidecar} port={sidecar.DirectSpeakerUsbipPort}"); + $"PlayStation audio sidecar ready index={index} type={desiredSidecar} ownerToken={ownerToken}"); return sidecar; } catch (Exception ex) { - sidecar.Disconnect(); + try + { + DisconnectOwnedViiperSource(sidecar); + } + catch (Exception disconnectException) + { + StartupDiag( + $"PlayStation audio sidecar rollback disconnect failed index={index} {disconnectException.GetType().Name}: {disconnectException.Message}"); + } AppLogger.LogToGui( $"Could not create the {desiredSidecar.ToDisplayName()} audio interface for controller #{index + 1}: {ex.Message}", true); @@ -2499,7 +2499,20 @@ private void DisconnectPlayStationFeatureOutput(int index) { StartupDiag( $"PlayStation audio sidecar disconnect index={index} type={sidecar.OutputType}"); - sidecar.Disconnect(); + DisconnectOwnedViiperSource(sidecar); + } + } + + private static void DisconnectOwnedViiperSource( + ViiperOutDevice source) + { + try + { + source?.Disconnect(); + } + finally + { + ViiperPnPOwnershipRegistry.Detach(source); } } @@ -2685,7 +2698,8 @@ public void CheckProfileOptions(int ind, DS4Device device, bool startUp = false) Global.store.audioHapticsSettings[ind], playStationFeatureOutputType, DualSenseAudioSpeakerEndpointId[ind], - playStationFeatureOutput?.DirectSpeakerUsbipPort ?? -1); + ViiperPnPOwnershipRegistry.GetToken( + playStationFeatureOutput)); if (!startUp) { @@ -3285,7 +3299,7 @@ private OutputDevice GetReportOutputDevice(int index) // The companion pointer is published before routing becomes active // and routing is disabled before the pointer is withdrawn. This // keeps the report path valid throughout VIIPER's comparatively - // slow USB/IP plug and unplug operations. + // slow virtual-device PnP plug and unplug operations. if (Volatile.Read(ref gameBarCompatibilityRoutingActive[index]) == 1) { OutputDevice compatibilityOutput = Volatile.Read( @@ -3410,7 +3424,7 @@ private void ActivateGameBarCompatibilityOutputCore(int index) ref gameBarCompatibilityOutputDevices[index], compatibilityOutput); // Commit routing only after the companion is fully connected // and published. The native output continues receiving reports - // during the whole USB/IP creation interval. + // during the whole virtual-device creation interval. Interlocked.Exchange(ref gameBarCompatibilityRoutingActive[index], 1); try { diff --git a/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs b/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs index 642fa2f..23cf755 100644 --- a/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs +++ b/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs @@ -53,6 +53,7 @@ public sealed class DualSenseAudioPassthrough : IDisposable private WaveFormat captureFormat; private string captureEndpointId = string.Empty; private ControllerAudioEndpointKind captureEndpointKind; + private int captureOwnerToken = -1; private bool disposed; public ControllerRuntimeLaneState GetStatus(int slot) @@ -91,6 +92,9 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume, return; } + int virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken( + directSpeakerSource); + lock (syncRoot) { startFailed[slot] = false; @@ -140,8 +144,11 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume, return; } + Global.TryInspectViiperPnPTopology( + dualSenseDevice?.HidDevice?.DevicePath, + out ViiperPnPTopologyIdentity physicalTopology); MMDevice endpoint = FindControllerEndpoint(slot, - requestedSpeakerEndpointId); + requestedSpeakerEndpointId, physicalTopology); if (endpoint == null) { AppLogger.LogToGui( @@ -164,7 +171,8 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume, slots[slot].SpeakerVolume = speakerVolume; EnsureCaptureStarted(requestedCaptureEndpointId, endpoint.ID, - GetEndpointKind(emulatedControllerType)); + GetEndpointKind(emulatedControllerType), + virtualOwnerToken); return; } else @@ -192,7 +200,8 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume, provider, outputFormat, speakerVolume); EnsureCaptureStarted(requestedCaptureEndpointId, endpoint.ID, - GetEndpointKind(emulatedControllerType)); + GetEndpointKind(emulatedControllerType), + virtualOwnerToken); AppLogger.LogToGui( $"DualSense audio passthrough started for controller {slot + 1}: {endpoint.FriendlyName}", false); @@ -336,12 +345,15 @@ private void StartBluetooth(int slot, DualSenseDevice device, byte speakerVolume { requestedCaptureEndpointId ??= string.Empty; ControllerAudioEndpointKind endpointKind = GetEndpointKind(emulatedControllerType); + int virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken( + directSpeakerSource); DirectSpeakerRouteDecision initialRoute = EvaluateDirectSpeakerRoute(requestedCaptureEndpointId, endpointKind, directSpeakerSource); if (initialRoute == DirectSpeakerRouteDecision.Loopback) { directSpeakerSource = null; + virtualOwnerToken = -1; } int generation; @@ -359,7 +371,7 @@ private void StartBluetooth(int slot, DualSenseDevice device, byte speakerVolume if (bluetoothSlots[slot]?.Matches(device, speakerVolume, speakerCompression, speakerBassBoost, requestedCaptureEndpointId, endpointKind, - directSpeakerSource) == true) + directSpeakerSource, virtualOwnerToken) == true) { return; } @@ -383,13 +395,15 @@ private void StartBluetooth(int slot, DualSenseDevice device, byte speakerVolume _ = Task.Run(() => StartBluetoothWithRetry(slot, device, speakerVolume, speakerCompression, speakerBassBoost, requestedCaptureEndpointId, - endpointKind, directSpeakerSource, generation)); + endpointKind, directSpeakerSource, virtualOwnerToken, + generation)); } private void StartBluetoothWithRetry(int slot, DualSenseDevice device, byte speakerVolume, DualSenseSpeakerCompression speakerCompression, byte speakerBassBoost, string requestedCaptureEndpointId, ControllerAudioEndpointKind endpointKind, - ViiperOutDevice directSpeakerSource, int generation) + ViiperOutDevice directSpeakerSource, int virtualOwnerToken, + int generation) { Exception lastError = null; for (int attempt = 0; attempt < BluetoothStartRetryAttempts; @@ -398,7 +412,8 @@ private void StartBluetoothWithRetry(int slot, DualSenseDevice device, byte spea if (TryStartBluetoothOnce(slot, device, speakerVolume, speakerCompression, speakerBassBoost, requestedCaptureEndpointId, endpointKind, - directSpeakerSource, generation, out lastError)) + directSpeakerSource, virtualOwnerToken, generation, + out lastError)) { return; } @@ -436,7 +451,8 @@ private bool TryStartBluetoothOnce(int slot, DualSenseSpeakerCompression speakerCompression, byte speakerBassBoost, string requestedCaptureEndpointId, ControllerAudioEndpointKind endpointKind, - ViiperOutDevice directSpeakerSource, int generation, + ViiperOutDevice directSpeakerSource, int virtualOwnerToken, + int generation, out Exception lastError) { lastError = null; @@ -456,6 +472,8 @@ private bool TryStartBluetoothOnce(int slot, DirectSpeakerRouteDecision route = EvaluateDirectSpeakerRoute(requestedCaptureEndpointId, endpointKind, directSpeakerSource); + virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken( + directSpeakerSource); ViiperOutDevice activeDirectSpeakerSource = route == DirectSpeakerRouteDecision.Direct ? @@ -463,7 +481,7 @@ private bool TryStartBluetoothOnce(int slot, var bluetoothPlayback = new DualSenseBluetoothSpeakerPassthrough(device, speakerVolume, speakerCompression, speakerBassBoost, requestedCaptureEndpointId, endpointKind, - activeDirectSpeakerSource); + activeDirectSpeakerSource, virtualOwnerToken); try { bluetoothPlayback.Start(); @@ -516,12 +534,14 @@ private void StopBluetooth(int slot) } private void EnsureCaptureStarted(string requestedCaptureEndpointId, - string speakerEndpointId, ControllerAudioEndpointKind endpointKind) + string speakerEndpointId, ControllerAudioEndpointKind endpointKind, + int virtualOwnerToken) { requestedCaptureEndpointId ??= string.Empty; speakerEndpointId ??= string.Empty; if (capture != null && captureEndpointKind == endpointKind && + captureOwnerToken == virtualOwnerToken && string.Equals(captureEndpointId, requestedCaptureEndpointId, StringComparison.Ordinal)) { return; @@ -536,6 +556,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId, automaticSlot); captureEndpointId = requestedCaptureEndpointId; captureEndpointKind = endpointKind; + captureOwnerToken = virtualOwnerToken; captureFormat = capture.WaveFormat; capture.DataAvailable += Capture_DataAvailable; capture.RecordingStopped += Capture_RecordingStopped; @@ -552,6 +573,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId, capture = new ProcessLoopbackWaveCapture(processId); captureEndpointId = requestedCaptureEndpointId; captureEndpointKind = endpointKind; + captureOwnerToken = virtualOwnerToken; captureFormat = capture.WaveFormat; capture.DataAvailable += Capture_DataAvailable; capture.RecordingStopped += Capture_RecordingStopped; @@ -570,7 +592,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId, } MMDevice sourceEndpoint = FindCaptureEndpoint(requestedCaptureEndpointId, - speakerEndpointId, endpointKind); + speakerEndpointId, endpointKind, virtualOwnerToken); bool expectsControllerEndpoint = string.Equals(requestedCaptureEndpointId, AutoDetectGameAudioEndpointId, StringComparison.Ordinal) || @@ -593,6 +615,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId, capture = sourceEndpoint != null ? new WasapiLoopbackCapture(sourceEndpoint) : new WasapiLoopbackCapture(); captureEndpointId = requestedCaptureEndpointId; captureEndpointKind = endpointKind; + captureOwnerToken = virtualOwnerToken; captureFormat = capture.WaveFormat; capture.DataAvailable += Capture_DataAvailable; capture.RecordingStopped += Capture_RecordingStopped; @@ -609,6 +632,7 @@ private void StopCapture() captureFormat = null; captureEndpointId = string.Empty; captureEndpointKind = ControllerAudioEndpointKind.Any; + captureOwnerToken = -1; if (oldCapture == null) { @@ -657,7 +681,9 @@ private void Capture_DataAvailable(object sender, WaveInEventArgs e) } } - private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpointId) + private MMDevice FindControllerEndpoint(int slot, + string requestedSpeakerEndpointId, + ViiperPnPTopologyIdentity physicalControllerTopology) { HashSet usedIds = slots .Where((item, index) => item != null && index != slot) @@ -670,7 +696,10 @@ private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpoin try { MMDevice requested = enumerator.GetDevice(requestedSpeakerEndpointId); - if (requested.State == DeviceState.Active && !usedIds.Contains(requested.ID)) + if (requested.State == DeviceState.Active && + !usedIds.Contains(requested.ID) && + EndpointMatchesUsbDevice(requested, + physicalControllerTopology)) { return requested; } @@ -681,9 +710,14 @@ private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpoin } } - MMDevice autoEndpoint = enumerator + List matchingEndpoints = enumerator .EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active) - .FirstOrDefault(device => !usedIds.Contains(device.ID) && IsDualSenseEndpoint(device)); + .Where(device => !usedIds.Contains(device.ID) && + IsDualSenseEndpoint(device) && + EndpointMatchesUsbDevice(device, + physicalControllerTopology)).ToList(); + MMDevice autoEndpoint = matchingEndpoints.Count == 1 ? + matchingEndpoints[0] : null; if (autoEndpoint == null) { @@ -702,8 +736,10 @@ private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpoin return autoEndpoint; } - private static MMDevice FindCaptureEndpoint(string endpointId, string speakerEndpointId, - ControllerAudioEndpointKind endpointKind) + private static MMDevice FindCaptureEndpoint(string endpointId, + string speakerEndpointId, + ControllerAudioEndpointKind endpointKind, + int virtualOwnerToken) { bool useSystemDefault = string.Equals(endpointId, DefaultSystemAudioEndpointId, StringComparison.Ordinal) || @@ -721,13 +757,21 @@ private static MMDevice FindCaptureEndpoint(string endpointId, string speakerEnd string.Equals(endpointId, AutoDetectGameAudioEndpointId, StringComparison.Ordinal); MMDevice endpoint = autoDetect ? - FindActiveGameAudioEndpoint(enumerator, null, endpointKind) : + FindActiveGameAudioEndpoint(enumerator, null, endpointKind, + virtualOwnerToken) : enumerator.GetDevice(endpointId); if (endpoint?.State != DeviceState.Active) { return null; } + if (IsControllerAudioEndpoint(endpoint) && + !EndpointMatchesOwnerToken(endpoint, + virtualOwnerToken)) + { + return null; + } + if (string.Equals(endpoint.ID, speakerEndpointId, StringComparison.Ordinal)) { AppLogger.LogToGui("DualSense audio passthrough capture source cannot be the same as the speaker endpoint. Falling back to default audio endpoint.", true); @@ -834,13 +878,13 @@ private static bool LooksLikeAmbiguousControllerIdentity(string identity) internal static MMDevice FindActiveGameAudioEndpoint(MMDeviceEnumerator enumerator, string previousEndpointId = null, ControllerAudioEndpointKind preferredKind = ControllerAudioEndpointKind.Any, - int preferredUsbipPort = -1, + int preferredOwnerToken = -1, bool requireActive = true) { DeviceState endpointState = requireActive ? DeviceState.Active : DeviceState.All; - IEnumerable endpoints = enumerator + List endpoints = enumerator .EnumerateAudioEndPoints(DataFlow.Render, endpointState) - .Where(IsControllerAudioEndpoint); + .Where(IsControllerAudioEndpoint).ToList(); if (preferredKind != ControllerAudioEndpointKind.Any) { @@ -848,38 +892,85 @@ internal static MMDevice FindActiveGameAudioEndpoint(MMDeviceEnumerator enumerat { ControllerAudioEndpointKind kind = ClassifyEndpoint(endpoint); return kind == preferredKind || kind == ControllerAudioEndpointKind.Any; - }); + }).ToList(); + } + + if (preferredOwnerToken > 0) + { + List ownedEndpoints = endpoints.Where(endpoint => + EndpointMatchesOwnerToken(endpoint, + preferredOwnerToken)).ToList(); + // The source contract binds to controller root + UdeCx port, + // not to a Windows MMDevice generation. During endpoint + // replacement both generations can briefly share that anchor; + // do not let a stale saved endpoint ID choose between them. + return SelectUnambiguousControllerEndpoint(ownedEndpoints, + null, null); } - return endpoints - .OrderByDescending(endpoint => - EndpointMatchesUsbipPort(endpoint, preferredUsbipPort)) - .ThenByDescending(endpoint => EndpointScore(endpoint, - preferredKind, previousEndpointId)) - .FirstOrDefault(); + return SelectUnambiguousControllerEndpoint(endpoints, + endpoint => string.Equals(endpoint.ID, previousEndpointId, + StringComparison.Ordinal), + endpoint => !string.IsNullOrEmpty(previousEndpointId) && + EndpointReplaces(endpoint, previousEndpointId)); } - private static bool EndpointMatchesUsbipPort(MMDevice endpoint, - int preferredUsbipPort) + // A concrete endpoint ID is exact even when multiple physical and + // virtual Sony devices coexist. A replacement-history match is safe + // only when exactly one candidate claims it. With no exact evidence, + // retain the historical single-controller behavior but fail closed as + // soon as the machine has an ambiguous second Sony endpoint. + internal static T SelectUnambiguousControllerEndpoint( + IReadOnlyList endpoints, Func exactIdMatch, + Func replacementMatch) where T : class { - if (preferredUsbipPort < 0 || endpoint == null) + if (endpoints == null || endpoints.Count == 0) { - return false; + return null; } - string interfacePath = GetEndpointProperty(endpoint, - PropertyKeys.PKEY_Device_InterfaceKey); - int pathStart = interfacePath.IndexOf(@"\\?\", - StringComparison.Ordinal); - if (pathStart > 0) + List exact = exactIdMatch == null ? new List() : + endpoints.Where(endpoint => endpoint != null && + exactIdMatch(endpoint)).ToList(); + if (exact.Count != 0) { - interfacePath = interfacePath.Substring(pathStart); + return exact.Count == 1 ? exact[0] : null; } - return !string.IsNullOrEmpty(interfacePath) && - Global.TryResolveUsbIpWin2Device(interfacePath, - out bool usbIpAncestor, out int endpointPort) && - usbIpAncestor && endpointPort == preferredUsbipPort; + List replacements = replacementMatch == null ? + new List() : endpoints.Where(endpoint => + endpoint != null && replacementMatch(endpoint)).ToList(); + if (replacements.Count != 0) + { + return replacements.Count == 1 ? replacements[0] : null; + } + + // Never use a missing (-1) owner token as "any Sony endpoint". + return endpoints.Count == 1 ? endpoints[0] : null; + } + + internal static bool EndpointMatchesOwnerToken(MMDevice endpoint, + int preferredOwnerToken) + { + if (preferredOwnerToken <= 0 || endpoint == null) + { + return false; + } + + return TryGetEndpointPnPTopology(endpoint, out _, + out ViiperPnPTopologyIdentity topology) && + topology.IsResolved && + ViiperPnPOwnershipRegistry.Matches(preferredOwnerToken, + topology); + } + + private static bool EndpointMatchesUsbDevice(MMDevice endpoint, + ViiperPnPTopologyIdentity expectedUsbDevice) + { + return expectedUsbDevice.IsUsbDeviceResolved && + TryGetEndpointPnPTopology(endpoint, out _, + out ViiperPnPTopologyIdentity endpointTopology) && + expectedUsbDevice.IsSameUsbDevice(endpointTopology); } internal static ControllerAudioEndpointKind GetEndpointKind(OutContType outputType) @@ -945,7 +1036,7 @@ internal static DirectSpeakerRouteDecision DecideDirectSpeakerRoute( if (automatic) { return directStreamActive ? DirectSpeakerRouteDecision.Direct : - DirectSpeakerRouteDecision.Loopback; + DirectSpeakerRouteDecision.Pending; } return explicitEndpointOwnership switch @@ -953,10 +1044,10 @@ internal static DirectSpeakerRouteDecision DecideDirectSpeakerRoute( DirectSpeakerEndpointOwnership.Owned when directStreamActive => DirectSpeakerRouteDecision.Direct, DirectSpeakerEndpointOwnership.Owned => - DirectSpeakerRouteDecision.Loopback, + DirectSpeakerRouteDecision.Pending, DirectSpeakerEndpointOwnership.Unowned => DirectSpeakerRouteDecision.Loopback, - _ => DirectSpeakerRouteDecision.Loopback, + _ => DirectSpeakerRouteDecision.Pending, }; } @@ -1008,47 +1099,44 @@ private static DirectSpeakerEndpointOwnership StringComparison.Ordinal)); if (exactEndpoint != null) { - if (IsControllerEndpointSelection( - ClassifyEndpoint(exactEndpoint), endpointKind)) - { - return DirectSpeakerEndpointOwnership.Owned; - } - return ResolveEndpointOwnership(exactEndpoint, endpointKind, directSpeakerSource); } - DirectSpeakerEndpointOwnership replacementResult = - DirectSpeakerEndpointOwnership.Unresolved; + int ownedReplacementCount = 0; + bool hasUnownedReplacement = false; foreach (MMDevice candidate in activeEndpoints.Where( endpoint => EndpointReplaces(endpoint, endpointId))) { - if (IsControllerEndpointSelection( - ClassifyEndpoint(candidate), endpointKind)) - { - return DirectSpeakerEndpointOwnership.Owned; - } - DirectSpeakerEndpointOwnership candidateResult = ResolveEndpointOwnership(candidate, endpointKind, directSpeakerSource); if (candidateResult == DirectSpeakerEndpointOwnership.Owned) { - return candidateResult; + ownedReplacementCount++; } - if (candidateResult == + else if (candidateResult == DirectSpeakerEndpointOwnership.Unowned) { - replacementResult = candidateResult; + hasUnownedReplacement = true; } } - if (replacementResult != - DirectSpeakerEndpointOwnership.Unresolved) + if (ownedReplacementCount == 1) + { + return DirectSpeakerEndpointOwnership.Owned; + } + + if (ownedReplacementCount > 1) + { + return DirectSpeakerEndpointOwnership.Unresolved; + } + + if (hasUnownedReplacement) { - return replacementResult; + return DirectSpeakerEndpointOwnership.Unowned; } } finally @@ -1063,14 +1151,7 @@ private static DirectSpeakerEndpointOwnership { using MMDevice savedEndpoint = enumerator.GetDevice(endpointId); - if (savedEndpoint != null && - IsControllerEndpointSelection( - ClassifyEndpoint(savedEndpoint), endpointKind)) - { - return DirectSpeakerEndpointOwnership.Owned; - } - - if (savedEndpoint?.State == DeviceState.Active) + if (savedEndpoint != null) { return ResolveEndpointOwnership(savedEndpoint, endpointKind, directSpeakerSource); @@ -1113,34 +1194,25 @@ private static DirectSpeakerEndpointOwnership ResolveEndpointOwnership( ViiperOutDevice directSpeakerSource) { bool identityMatches = EndpointKindMatches(endpoint, endpointKind); - string interfacePath = GetEndpointProperty(endpoint, - PropertyKeys.PKEY_Device_InterfaceKey); - int pathStart = interfacePath.IndexOf(@"\\?\", - StringComparison.Ordinal); - if (pathStart > 0) - { - interfacePath = interfacePath.Substring(pathStart); - } - - bool interfacePathAvailable = - !string.IsNullOrEmpty(interfacePath); - int endpointPort = -1; - bool usbIpAncestor = false; - bool usbIpQueryResolved = interfacePathAvailable && - Global.TryResolveUsbIpWin2Device(interfacePath, - out usbIpAncestor, out endpointPort); + bool topologyAvailable = TryGetEndpointPnPTopology(endpoint, + out bool ancestryComplete, + out ViiperPnPTopologyIdentity topology) && + topology.IsResolved; + int ownerToken = ViiperPnPOwnershipRegistry.GetToken( + directSpeakerSource); + bool exactOwnerMatch = topologyAvailable && + ViiperPnPOwnershipRegistry.Matches(ownerToken, topology); return ClassifyDirectSpeakerEndpointOwnership( endpoint?.State == DeviceState.Active, identityMatches, - interfacePathAvailable, usbIpQueryResolved, usbIpAncestor, - endpointPort, directSpeakerSource?.DirectSpeakerUsbipPort ?? -1); + ownerToken > 0 && ancestryComplete && topologyAvailable, + exactOwnerMatch); } internal static DirectSpeakerEndpointOwnership ClassifyDirectSpeakerEndpointOwnership(bool endpointActive, - bool controllerIdentityMatches, bool interfacePathAvailable, - bool usbIpQueryResolved, bool usbIpAncestor, int endpointPort, - int sourcePort) + bool controllerIdentityMatches, bool pnpIdentityResolved, + bool exactOwnerMatch) { if (!endpointActive) { @@ -1152,27 +1224,12 @@ internal static DirectSpeakerEndpointOwnership return DirectSpeakerEndpointOwnership.Unowned; } - if (!interfacePathAvailable) - { - return DirectSpeakerEndpointOwnership.Unresolved; - } - - if (!usbIpQueryResolved) - { - return DirectSpeakerEndpointOwnership.Unresolved; - } - - if (!usbIpAncestor) - { - return DirectSpeakerEndpointOwnership.Unowned; - } - - if (endpointPort < 0 || sourcePort < 0) + if (!pnpIdentityResolved) { return DirectSpeakerEndpointOwnership.Unresolved; } - return endpointPort == sourcePort ? + return exactOwnerMatch ? DirectSpeakerEndpointOwnership.Owned : DirectSpeakerEndpointOwnership.Unowned; } @@ -1264,51 +1321,85 @@ private static string GetEndpointProperty(MMDevice endpoint, } } - private static void AddEndpointProperty(List values, MMDevice endpoint, - PropertyKey propertyKey) + private static bool TryGetEndpointPnPTopology(MMDevice endpoint, + out bool ancestryComplete, + out ViiperPnPTopologyIdentity topology) { - try + ancestryComplete = false; + topology = default; + if (endpoint == null) { - object value = endpoint.Properties[propertyKey]?.Value; - if (value != null) - { - values.Add(value.ToString()); - } + return false; } - catch + + string[] candidates = { - // Endpoint property availability differs by Windows audio driver. + GetEndpointProperty(endpoint, + PropertyKeys.PKEY_Device_InterfaceKey), + GetEndpointProperty(endpoint, + PropertyKeys.PKEY_Device_ControllerDeviceId), + GetEndpointProperty(endpoint, + PropertyKeys.PKEY_Device_InstanceId), + }; + + foreach (string rawCandidate in candidates) + { + string candidate = NormalizeEndpointPnPPath(rawCandidate); + if (string.IsNullOrEmpty(candidate)) + { + continue; + } + + if (!Global.TryInspectViiperPnPTopology(candidate, + out ViiperPnPTopologyIdentity inspected)) + { + continue; + } + + ancestryComplete = true; + if (inspected.IsUsbDeviceResolved) + { + topology = inspected; + return true; + } } + + return ancestryComplete; } - private static int EndpointScore(MMDevice endpoint, - ControllerAudioEndpointKind preferredKind, string previousEndpointId) + private static string NormalizeEndpointPnPPath(string value) { - int score = 0; - ControllerAudioEndpointKind actualKind = ClassifyEndpoint(endpoint); - if (preferredKind != ControllerAudioEndpointKind.Any && actualKind == preferredKind) + if (string.IsNullOrWhiteSpace(value)) { - score += 100; + return string.Empty; } - else if (actualKind != ControllerAudioEndpointKind.Any) + + string result = value.Trim().TrimEnd('\0'); + int pathStart = result.IndexOf(@"\\?\", + StringComparison.Ordinal); + if (pathStart > 0) { - score += 20; + result = result.Substring(pathStart); } - string identity = GetEndpointIdentity(endpoint); - if (identity.IndexOf("VIIPER", StringComparison.OrdinalIgnoreCase) >= 0) + return result; + } + + private static void AddEndpointProperty(List values, MMDevice endpoint, + PropertyKey propertyKey) + { + try { - score += 10; + object value = endpoint.Properties[propertyKey]?.Value; + if (value != null) + { + values.Add(value.ToString()); + } } - - if (!string.IsNullOrEmpty(previousEndpointId) && - (string.Equals(endpoint.ID, previousEndpointId, StringComparison.Ordinal) || - EndpointReplaces(endpoint, previousEndpointId))) + catch { - score += 1000; + // Endpoint property availability differs by Windows audio driver. } - - return score; } private static bool EndpointReplaces(MMDevice endpoint, string previousEndpointId) diff --git a/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs b/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs index c60a5d5..147715a 100644 --- a/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs +++ b/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs @@ -460,6 +460,7 @@ internal sealed class DualSenseBluetoothSpeakerPassthrough : IDisposable private readonly byte speakerBassBoost; private readonly DualSenseSpeakerProcessor speakerProcessor; private readonly ViiperOutDevice directSpeakerSource; + private readonly int virtualOwnerToken; private readonly int directSpeakerSampleRate; private readonly DualSensePcm16SourceRateConverter directPcmRateConverter; private readonly DualSenseSpeakerFrameResampler speakerFrameResampler = @@ -565,7 +566,8 @@ internal sealed class DualSenseBluetoothSpeakerPassthrough : IDisposable public DualSenseBluetoothSpeakerPassthrough(DualSenseDevice device, byte speakerVolume, DualSenseSpeakerCompression speakerCompression, byte speakerBassBoost, string sourceEndpointId, ControllerAudioEndpointKind sourceEndpointKind, - ViiperOutDevice directSpeakerSource = null) + ViiperOutDevice directSpeakerSource = null, + int virtualOwnerToken = -1) { this.device = device ?? throw new ArgumentNullException(nameof(device)); speakerSessionId = this.device.CreateBluetoothSpeakerSession(); @@ -580,6 +582,7 @@ public DualSenseBluetoothSpeakerPassthrough(DualSenseDevice device, byte speaker this.sourceEndpointId = sourceEndpointId ?? string.Empty; this.sourceEndpointKind = sourceEndpointKind; this.directSpeakerSource = directSpeakerSource; + this.virtualOwnerToken = virtualOwnerToken; directSpeakerSampleRate = directSpeakerSource?.DirectSpeakerPcmSampleRate ?? 0; if (directSpeakerSampleRate > 0) { @@ -634,7 +637,8 @@ public bool Matches(DualSenseDevice candidateDevice, byte candidateVolume, DualSenseSpeakerCompression candidateCompression, byte candidateBassBoost, string candidateSourceEndpointId, ControllerAudioEndpointKind candidateSourceEndpointKind, - ViiperOutDevice candidateDirectSpeakerSource = null) + ViiperOutDevice candidateDirectSpeakerSource = null, + int candidateVirtualOwnerToken = -1) { return !stopping && ReferenceEquals(device, candidateDevice) && speakerVolume == candidateVolume && @@ -644,6 +648,7 @@ public bool Matches(DualSenseDevice candidateDevice, byte candidateVolume, speakerBassBoost == Math.Min(candidateBassBoost, DualSenseSpeakerProcessor.MaximumBassBoostDb) && sourceEndpointKind == candidateSourceEndpointKind && + virtualOwnerToken == candidateVirtualOwnerToken && ReferenceEquals(directSpeakerSource, candidateDirectSpeakerSource) && string.Equals(sourceEndpointId, candidateSourceEndpointId ?? string.Empty, @@ -715,7 +720,7 @@ public void Start() } capture = CreateCapture(sourceEndpointId, sourceEndpointKind, - out string sourceName); + virtualOwnerToken, out string sourceName); isGameAudioEndpoint = IsLikelyGameAudioEndpoint(sourceName); captureBuffer = new BufferedWaveProvider(capture.WaveFormat) { @@ -764,7 +769,8 @@ public void Start() } private static IWaveIn CreateCapture(string endpointId, - ControllerAudioEndpointKind endpointKind, out string sourceName) + ControllerAudioEndpointKind endpointKind, int virtualOwnerToken, + out string sourceName) { if (ProcessLoopbackWaveCapture.TryParseAutomaticEndpointId( endpointId, out int automaticSlot)) @@ -817,6 +823,15 @@ private static IWaveIn CreateCapture(string endpointId, endpoint?.Dispose(); endpoint = null; } + else if (DualSenseAudioPassthrough. + IsControllerAudioEndpoint(endpoint) && + !DualSenseAudioPassthrough. + EndpointMatchesOwnerToken(endpoint, + virtualOwnerToken)) + { + endpoint.Dispose(); + endpoint = null; + } } catch (COMException) { @@ -827,7 +842,8 @@ private static IWaveIn CreateCapture(string endpointId, if (endpoint == null) { endpoint = DualSenseAudioPassthrough.FindActiveGameAudioEndpoint(enumerator, - autoDetectGameAudio ? null : endpointId, endpointKind); + autoDetectGameAudio ? null : endpointId, endpointKind, + virtualOwnerToken); } if (endpoint == null) diff --git a/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs b/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs index 6edf7c5..35b0348 100644 --- a/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs +++ b/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs @@ -60,25 +60,29 @@ public void Start(int slot, DS4Device device, byte speakerVolume, int generation; ControllerAudioEndpointKind endpointKind = DualSenseAudioPassthrough.GetEndpointKind(emulatedControllerType); + int virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken( + directSpeakerSource); DirectSpeakerRouteDecision initialRoute = DualSenseAudioPassthrough.EvaluateDirectSpeakerRoute( captureEndpointId, endpointKind, directSpeakerSource); if (initialRoute == DirectSpeakerRouteDecision.Loopback) { directSpeakerSource = null; + virtualOwnerToken = -1; } lock (syncRoot) { if (slots[slot]?.Matches(device, speakerVolume, compression, bassBoost, captureEndpointId, endpointKind, - directSpeakerSource) == true) + directSpeakerSource, virtualOwnerToken) == true) { return; } if (pendingStarts[slot]?.Matches(device, speakerVolume, compression, bassBoost, captureEndpointId, - endpointKind, directSpeakerSource) == true) + endpointKind, directSpeakerSource, + virtualOwnerToken) == true) { return; } @@ -89,7 +93,7 @@ public void Start(int slot, DS4Device device, byte speakerVolume, generation = ++startGenerations[slot]; pendingStarts[slot] = new StartRequest(device, speakerVolume, compression, bassBoost, captureEndpointId, endpointKind, - directSpeakerSource, generation); + directSpeakerSource, virtualOwnerToken, generation); } AppLogger.LogToGui( @@ -103,7 +107,7 @@ public void Start(int slot, DS4Device device, byte speakerVolume, previous?.Dispose(); StartWorker(slot, device, speakerVolume, compression, bassBoost, captureEndpointId, endpointKind, - directSpeakerSource, generation); + directSpeakerSource, virtualOwnerToken, generation); } }, $"DualShock 4 audio startup {slot + 1}"); } @@ -186,7 +190,8 @@ public void Dispose() private void StartWorker(int slot, DS4Device device, byte speakerVolume, DualSenseSpeakerCompression compression, byte bassBoost, string captureEndpointId, ControllerAudioEndpointKind endpointKind, - ViiperOutDevice directSpeakerSource, int generation) + ViiperOutDevice directSpeakerSource, int virtualOwnerToken, + int generation) { const int attempts = 20; Exception lastError = null; @@ -216,12 +221,15 @@ private void StartWorker(int slot, DS4Device device, byte speakerVolume, DirectSpeakerRouteDecision route = DualSenseAudioPassthrough.EvaluateDirectSpeakerRoute( captureEndpointId, endpointKind, directSpeakerSource); + virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken( + directSpeakerSource); ViiperOutDevice activeDirectSpeakerSource = route == DirectSpeakerRouteDecision.Direct ? directSpeakerSource : null; var playback = new DualShock4BluetoothSpeakerPassthrough(device, speakerVolume, compression, bassBoost, captureEndpointId, - endpointKind, activeDirectSpeakerSource); + endpointKind, activeDirectSpeakerSource, + virtualOwnerToken); try { playback.Start(); @@ -308,12 +316,14 @@ private sealed class StartRequest private readonly string sourceEndpointId; private readonly ControllerAudioEndpointKind sourceEndpointKind; private readonly ViiperOutDevice directSpeakerSource; + private readonly int virtualOwnerToken; public StartRequest(DS4Device device, byte speakerVolume, DualSenseSpeakerCompression compression, byte bassBoost, string sourceEndpointId, ControllerAudioEndpointKind sourceEndpointKind, - ViiperOutDevice directSpeakerSource, int generation) + ViiperOutDevice directSpeakerSource, int virtualOwnerToken, + int generation) { this.device = device; this.speakerVolume = speakerVolume; @@ -326,6 +336,7 @@ public StartRequest(DS4Device device, byte speakerVolume, this.sourceEndpointId = sourceEndpointId ?? string.Empty; this.sourceEndpointKind = sourceEndpointKind; this.directSpeakerSource = directSpeakerSource; + this.virtualOwnerToken = virtualOwnerToken; Generation = generation; } @@ -335,7 +346,8 @@ public bool Matches(DS4Device candidate, byte candidateVolume, DualSenseSpeakerCompression candidateCompression, byte candidateBassBoost, string candidateSourceEndpointId, ControllerAudioEndpointKind candidateSourceEndpointKind, - ViiperOutDevice candidateDirectSpeakerSource) + ViiperOutDevice candidateDirectSpeakerSource, + int candidateVirtualOwnerToken) { return ReferenceEquals(device, candidate) && speakerVolume == candidateVolume && @@ -347,6 +359,7 @@ public bool Matches(DS4Device candidate, byte candidateVolume, bassBoost == Math.Min(candidateBassBoost, DualSenseSpeakerProcessor.MaximumBassBoostDb) && sourceEndpointKind == candidateSourceEndpointKind && + virtualOwnerToken == candidateVirtualOwnerToken && ReferenceEquals(directSpeakerSource, candidateDirectSpeakerSource) && string.Equals(sourceEndpointId, diff --git a/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs b/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs index 1315d0f..7cd07f3 100644 --- a/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs +++ b/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs @@ -285,6 +285,7 @@ private enum CreditBufferedSubmissionResult private readonly string sourceEndpointId; private readonly ControllerAudioEndpointKind sourceEndpointKind; private readonly ViiperOutDevice directSpeakerSource; + private readonly int virtualOwnerToken; private readonly int directSpeakerSampleRate; private readonly DualShock4AudioDriftMode directDriftMode; private readonly DualShock4AudioTransportMode directTransportMode; @@ -432,7 +433,8 @@ private readonly StereoPcm16FractionalResampler public DualShock4BluetoothSpeakerPassthrough(DS4Device device, byte speakerVolume, DualSenseSpeakerCompression compression, byte bassBoost, string sourceEndpointId, ControllerAudioEndpointKind sourceEndpointKind, - ViiperOutDevice directSpeakerSource = null) + ViiperOutDevice directSpeakerSource = null, + int virtualOwnerToken = -1) { this.device = device ?? throw new ArgumentNullException(nameof(device)); this.speakerVolume = speakerVolume; @@ -444,6 +446,7 @@ public DualShock4BluetoothSpeakerPassthrough(DS4Device device, byte speakerVolum this.sourceEndpointId = sourceEndpointId ?? string.Empty; this.sourceEndpointKind = sourceEndpointKind; this.directSpeakerSource = directSpeakerSource; + this.virtualOwnerToken = virtualOwnerToken; directSpeakerSampleRate = directSpeakerSource?. DirectSpeakerPcmSampleRate ?? 0; directDriftMode = DualShock4AudioDriftSettings.Parse( @@ -477,7 +480,8 @@ public bool Matches(DS4Device candidate, byte candidateVolume, DualSenseSpeakerCompression candidateCompression, byte candidateBassBoost, string candidateSourceEndpointId, ControllerAudioEndpointKind candidateSourceEndpointKind, - ViiperOutDevice candidateDirectSpeakerSource = null) + ViiperOutDevice candidateDirectSpeakerSource = null, + int candidateVirtualOwnerToken = -1) { return !stopping && ReferenceEquals(device, candidate) && speakerVolume == candidateVolume && @@ -487,6 +491,7 @@ public bool Matches(DS4Device candidate, byte candidateVolume, bassBoost == Math.Min(candidateBassBoost, DualSenseSpeakerProcessor.MaximumBassBoostDb) && sourceEndpointKind == candidateSourceEndpointKind && + virtualOwnerToken == candidateVirtualOwnerToken && ReferenceEquals(directSpeakerSource, candidateDirectSpeakerSource) && string.Equals(sourceEndpointId, candidateSourceEndpointId ?? string.Empty, @@ -554,7 +559,7 @@ public void Start() try { capture = CreateCapture(sourceEndpointId, sourceEndpointKind, - out string sourceName); + virtualOwnerToken, out string sourceName); captureBuffer = new BufferedWaveProvider(capture.WaveFormat) { BufferDuration = TimeSpan.FromMilliseconds(CaptureBufferMs), @@ -595,7 +600,8 @@ public void Start() } private static WasapiCapture CreateCapture(string endpointId, - ControllerAudioEndpointKind endpointKind, out string sourceName) + ControllerAudioEndpointKind endpointKind, int virtualOwnerToken, + out string sourceName) { bool useSystemDefault = string.Equals(endpointId, DualSenseAudioPassthrough.DefaultSystemAudioEndpointId, @@ -624,6 +630,15 @@ private static WasapiCapture CreateCapture(string endpointId, endpoint?.Dispose(); endpoint = null; } + else if (DualSenseAudioPassthrough. + IsControllerAudioEndpoint(endpoint) && + !DualSenseAudioPassthrough. + EndpointMatchesOwnerToken(endpoint, + virtualOwnerToken)) + { + endpoint.Dispose(); + endpoint = null; + } } catch (COMException) { @@ -634,7 +649,8 @@ private static WasapiCapture CreateCapture(string endpointId, if (endpoint == null) { endpoint = DualSenseAudioPassthrough.FindActiveGameAudioEndpoint( - enumerator, autoDetect ? null : endpointId, endpointKind); + enumerator, autoDetect ? null : endpointId, endpointKind, + virtualOwnerToken); } if (endpoint == null) diff --git a/DS4Windows/DS4Control/OutputSlotManager.cs b/DS4Windows/DS4Control/OutputSlotManager.cs index 846e848..774bd0b 100644 --- a/DS4Windows/DS4Control/OutputSlotManager.cs +++ b/DS4Windows/DS4Control/OutputSlotManager.cs @@ -173,15 +173,15 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla ControlService.StartupDiag($"OutputSlotManager.DeferredPlugin emptySlot={slot + 1} inIdx={inIdx} contType={contType}"); if (slot != -1) { - // Record every VIIPER Sony output so its complete USB/IP - // HID cannot be re-ingested as a physical input. - HashSet beforeVirtualSony = null; - if (contType == OutContType.ViiperDS4 || + // Fence Sony creation until its authoritative VIIPER + // lifetime can be registered. This prevents the arriving + // native HID from racing input discovery. + bool registerVirtualSony = + contType == OutContType.ViiperDS4 || contType == OutContType.ViiperDualSense || - contType == OutContType.ViiperDualSenseEdge) + contType == OutContType.ViiperDualSenseEdge; + if (registerVirtualSony) { - beforeVirtualSony = DS4Devices. - SnapshotBeforeOwnVirtualSony(); DS4Devices.BeginOwnVirtualSonyConnect(); } @@ -198,7 +198,7 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla //queuedTasks--; AppLogger.LogToGui($"Failed to plug in virtual {contType.ToDisplayName()} controller: {e.Message}", true); - if (beforeVirtualSony != null) + if (registerVirtualSony) { DS4Devices.EndOwnVirtualSonyConnect(); } @@ -208,7 +208,7 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla catch (Exception e) { AppLogger.LogToGui($"Failed to plug in virtual {contType.ToDisplayName()} controller: {e.Message}", true); - if (beforeVirtualSony != null) + if (registerVirtualSony) { DS4Devices.EndOwnVirtualSonyConnect(); } @@ -216,13 +216,38 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla return; } - if (beforeVirtualSony != null) + if (registerVirtualSony) { - DS4Devices.RegisterOwnVirtualSonyAsync( - beforeVirtualSony, virtualSonyRegisteredCallback); + ViiperOutDevice viiperSource = + outputDevice as ViiperOutDevice; + int ownerToken = + ViiperPnPOwnershipRegistry.AttachOrUpdate( + viiperSource); + if (ownerToken <= 0) + { + try + { + outputDevice.Disconnect(); + } + catch (Exception disconnectException) + { + ControlService.StartupDiag( + $"OutputSlotManager exact-identity rollback failed slot={slot + 1} type={contType} {disconnectException.GetType().Name}: {disconnectException.Message}"); + } + + ViiperPnPOwnershipRegistry.Detach(viiperSource); + DS4Devices.EndOwnVirtualSonyConnect(); + AppLogger.LogToGui( + $"Failed to plug in virtual {contType.ToDisplayName()} controller: VIIPER did not return the exact native PnP identity required to contain its Sony HID and audio interfaces.", + true); + return; + } + + DS4Devices.RegisterOwnVirtualSonyAsync(viiperSource, + virtualSonyRegisteredCallback); // The asynchronous registration worker now owns the // matching EndOwnVirtualSonyConnect call. - beforeVirtualSony = null; + registerVirtualSony = false; } AppLogger.LogToGui($"Plugging in virtual {contType.ToDisplayName()} Controller in output slot #{slot + 1}", false); @@ -271,7 +296,7 @@ public void DeferredRemoval(OutputDevice outputDevice, int inIdx, outputDevice.RemoveFeedbacks(); ControlService.StartupDiag($"OutputSlotManager.RemoveFeedbacks end slot={slot + 1}"); ControlService.StartupDiag($"OutputSlotManager.Disconnect begin slot={slot + 1}"); - outputDevice.Disconnect(); + DisconnectAndDetachOwnership(outputDevice); ControlService.StartupDiag($"OutputSlotManager.Disconnect end slot={slot + 1}"); if (inIdx != -1) @@ -387,7 +412,7 @@ public void UnplugRemainingControllers(bool immediate=false) if (device.OutputDevice != null) { outputDevices[slotIdx] = null; - device.OutputDevice.Disconnect(); + DisconnectAndDetachOwnership(device.OutputDevice); device.DetachDevice(); SlotUnassigned?.Invoke(this, slotIdx, outputSlots[slotIdx]); @@ -403,5 +428,19 @@ public void UnplugRemainingControllers(bool immediate=false) //queuedTasks--; } + + private static void DisconnectAndDetachOwnership( + OutputDevice outputDevice) + { + try + { + outputDevice?.Disconnect(); + } + finally + { + ViiperPnPOwnershipRegistry.Detach( + outputDevice as ViiperOutDevice); + } + } } } diff --git a/DS4Windows/DS4Control/ScpUtil.cs b/DS4Windows/DS4Control/ScpUtil.cs index 68fc1a1..792db60 100644 --- a/DS4Windows/DS4Control/ScpUtil.cs +++ b/DS4Windows/DS4Control/ScpUtil.cs @@ -1346,92 +1346,213 @@ public static bool CheckIfVirtualDevice(string devicePath) return result; } - // VIIPER exposes its USB devices through usbip-win2's emulated host - // controller. Moonlight mode deliberately accepts some virtual DS4s, - // so CheckIfVirtualDevice alone cannot be used to reject our own - // VIIPER output. Keep this identity check separate and explicit. - public static bool CheckIfUsbIpWin2Device(string devicePath) + private const string ViiperNativeUdeHardwareId = @"ROOT\VIIPERUDE"; + private const string UsbIpWin2HardwareId = @"ROOT\USBIP_WIN2\UDE"; + + // Moonlight mode deliberately accepts some virtual DS4s, so the broad + // virtual-device classifier cannot reject DS4Windows' own output. Walk + // the complete PnP ancestry and retain the exact emulated USB device, + // controller root, and port instead. The result is transport-neutral: + // native UdeCx is the production path and USB/IP remains available only + // to the explicit legacy validation path. + internal static bool TryResolveViiperPnPTopology(string devicePath, + out ViiperPnPTopologyIdentity topology) { - return TryResolveUsbIpWin2Device(devicePath, - out bool usbIpWin2AncestorFound, out _) && - usbIpWin2AncestorFound; + return TryInspectViiperPnPTopology(devicePath, out topology) && + topology.IsResolved; } - internal static bool TryGetUsbIpWin2Port(string devicePath, out int port) + internal static bool TryInspectViiperPnPTopology(string devicePath, + out ViiperPnPTopologyIdentity topology) { - return TryResolveUsbIpWin2Device(devicePath, - out bool usbIpWin2AncestorFound, out port) && - usbIpWin2AncestorFound && port >= 0; + topology = default; + if (string.IsNullOrWhiteSpace(devicePath)) + { + return false; + } + + string testInstanceId = LooksLikeDeviceInstanceId(devicePath) ? + devicePath : GetInstanceIdFromDevicePath(devicePath); + if (string.IsNullOrWhiteSpace(testInstanceId)) + { + return false; + } + + var ancestry = new List(); + bool complete = false; + for (int depth = 0; depth < 32 && + !string.IsNullOrWhiteSpace(testInstanceId); depth++) + { + string[] hardwareIds = GetStringArrayDeviceProperty( + testInstanceId, NativeMethods.DEVPKEY_Device_HardwareIds) ?? + Array.Empty(); + string location = GetStringDeviceProperty(testInstanceId, + NativeMethods.DEVPKEY_Device_LocationInfo); + string parentInstanceId = GetStringDeviceProperty( + testInstanceId, NativeMethods.DEVPKEY_Device_Parent); + + ancestry.Add(new ViiperPnPAncestryNode(testInstanceId, + parentInstanceId, hardwareIds, location)); + + if (parentInstanceId.Equals(@"HTREE\ROOT\0", + StringComparison.OrdinalIgnoreCase)) + { + complete = true; + break; + } + + if (string.IsNullOrWhiteSpace(parentInstanceId)) + { + // A disappearing interface or a PnP tree still being + // published is unresolved, not a physical controller. + return false; + } + + testInstanceId = parentInstanceId; + } + + if (!complete) + { + return false; + } + + TryClassifyViiperPnPAncestry(ancestry, out topology); + return true; } - internal static bool TryResolveUsbIpWin2Device(string devicePath, - out bool usbIpWin2AncestorFound, out int port) + internal static bool TryClassifyViiperPnPAncestry( + IEnumerable ancestry, + out ViiperPnPTopologyIdentity topology) { - const string usbIpWin2HardwareId = @"ROOT\USBIP_WIN2\UDE"; - string testInstanceId = GetInstanceIdFromDevicePath(devicePath); - usbIpWin2AncestorFound = false; - port = -1; - if (string.IsNullOrEmpty(testInstanceId)) + topology = default; + if (ancestry == null) { return false; } - int discoveredPort = -1; + string usbDeviceInstanceId = string.Empty; + int usbPort = -1; + string rootInstanceId = string.Empty; + ViiperPnPTransport transport = ViiperPnPTransport.Unknown; - for (int depth = 0; depth < 16 && !string.IsNullOrEmpty(testInstanceId); depth++) + foreach (ViiperPnPAncestryNode node in ancestry) { - string[] hardwareIds = GetStringArrayDeviceProperty(testInstanceId, - NativeMethods.DEVPKEY_Device_HardwareIds); - if (hardwareIds != null && hardwareIds.Any(id => - string.Equals(id, usbIpWin2HardwareId, - StringComparison.OrdinalIgnoreCase))) + string instanceId = node.InstanceId ?? string.Empty; + if (string.IsNullOrEmpty(rootInstanceId) && + string.Equals(node.ParentInstanceId, @"HTREE\ROOT\0", + StringComparison.OrdinalIgnoreCase)) { - usbIpWin2AncestorFound = true; - port = discoveredPort; - return true; + rootInstanceId = instanceId; } - if (discoveredPort < 0 && - testInstanceId.StartsWith(@"USB\VID_", + if (string.IsNullOrEmpty(usbDeviceInstanceId) && + instanceId.StartsWith(@"USB\VID_", StringComparison.OrdinalIgnoreCase) && - testInstanceId.IndexOf("&MI_", + instanceId.IndexOf("&MI_", StringComparison.OrdinalIgnoreCase) < 0) { - string location = GetStringDeviceProperty(testInstanceId, - NativeMethods.DEVPKEY_Device_LocationInfo); - Match match = Regex.Match(location ?? string.Empty, + usbDeviceInstanceId = instanceId; + Match match = Regex.Match(node.LocationInfo ?? string.Empty, @"Port_#(?\d+)", RegexOptions.IgnoreCase); if (match.Success && int.TryParse( - match.Groups["port"].Value, - NumberStyles.None, CultureInfo.InvariantCulture, - out int parsedPort)) + match.Groups["port"].Value, NumberStyles.None, + CultureInfo.InvariantCulture, out int parsedPort)) { - discoveredPort = parsedPort; + usbPort = parsedPort; } } - string parentInstanceId = GetStringDeviceProperty(testInstanceId, - NativeMethods.DEVPKEY_Device_Parent); - if (parentInstanceId.Equals(@"HTREE\ROOT\0", - StringComparison.OrdinalIgnoreCase)) + if (NodeHasIdentity(node, ViiperNativeUdeHardwareId)) { - port = discoveredPort; - return true; + transport = ViiperPnPTransport.NativeUdeCx; + rootInstanceId = instanceId; + break; } - if (string.IsNullOrEmpty(parentInstanceId)) + if (NodeHasIdentity(node, UsbIpWin2HardwareId)) { - // A missing parent before HTREE\ROOT means the PnP tree is - // not ready (or the interface vanished mid-query). Do not - // misclassify that transient as a physical USB endpoint. - return false; + transport = ViiperPnPTransport.LegacyUsbIp; + rootInstanceId = instanceId; + break; } + } - testInstanceId = parentInstanceId; + topology = new ViiperPnPTopologyIdentity(transport, + rootInstanceId, usbDeviceInstanceId, usbPort); + return topology.IsResolved; + } + + private static bool NodeHasIdentity(ViiperPnPAncestryNode node, + string hardwareId) + { + string instanceId = node.InstanceId ?? string.Empty; + if (string.Equals(instanceId, hardwareId, + StringComparison.OrdinalIgnoreCase) || + instanceId.StartsWith(hardwareId + @"\", + StringComparison.OrdinalIgnoreCase)) + { + return true; } - // Depth exhaustion is not a completed ancestry query. - return false; + return node.HardwareIds != null && node.HardwareIds.Any(id => + string.Equals(id?.TrimEnd('\0'), hardwareId, + StringComparison.OrdinalIgnoreCase)); + } + + private static bool LooksLikeDeviceInstanceId(string value) + { + return value.StartsWith(@"HID\", + StringComparison.OrdinalIgnoreCase) || + value.StartsWith(@"USB\", + StringComparison.OrdinalIgnoreCase) || + value.StartsWith(@"ROOT\", + StringComparison.OrdinalIgnoreCase) || + value.StartsWith(@"SWD\", + StringComparison.OrdinalIgnoreCase); + } + + public static bool CheckIfViiperNativeUdeDevice(string devicePath) + { + return TryResolveViiperPnPTopology(devicePath, + out ViiperPnPTopologyIdentity topology) && + topology.Transport == ViiperPnPTransport.NativeUdeCx; + } + + // Compatibility wrappers for the explicitly selected legacy USB/IP + // ABBA/dev-validation path. Production ownership does not call these. + public static bool CheckIfUsbIpWin2Device(string devicePath) + { + return TryResolveUsbIpWin2Device(devicePath, + out bool usbIpWin2AncestorFound, out _) && + usbIpWin2AncestorFound; + } + + internal static bool TryGetUsbIpWin2Port(string devicePath, out int port) + { + return TryResolveUsbIpWin2Device(devicePath, + out bool usbIpWin2AncestorFound, out port) && + usbIpWin2AncestorFound && port >= 0; + } + + internal static bool TryResolveUsbIpWin2Device(string devicePath, + out bool usbIpWin2AncestorFound, out int port) + { + usbIpWin2AncestorFound = false; + port = -1; + if (!TryInspectViiperPnPTopology(devicePath, + out ViiperPnPTopologyIdentity topology)) + { + return false; + } + + usbIpWin2AncestorFound = + topology.Transport == ViiperPnPTransport.LegacyUsbIp; + if (usbIpWin2AncestorFound) + { + port = topology.UsbPortNumber; + } + + return true; } public static void FindConfigLocation() diff --git a/DS4Windows/DS4Control/Util.cs b/DS4Windows/DS4Control/Util.cs index 346f7e1..048833e 100644 --- a/DS4Windows/DS4Control/Util.cs +++ b/DS4Windows/DS4Control/Util.cs @@ -287,48 +287,6 @@ public static void LogAssistBackgroundTask(Task task) }); } - public static int ElevatedCopyUpdater(string tmpUpdaterPath, bool deleteUpdatesDir=false) - { - int result = -1; - string tmpPath = Path.Combine(Path.GetTempPath(), "updatercopy.bat"); - //string tmpPath = Path.GetTempFileName(); - // Create temporary bat script that will later be executed - using (StreamWriter w = new StreamWriter(new FileStream(tmpPath, - FileMode.Create, FileAccess.Write))) - { - w.WriteLine("@echo off"); // Turn off echo - w.WriteLine("@echo Attempting to replace updater, please wait..."); - // Move temp downloaded file to destination - w.WriteLine($"@mov /Y \"{tmpUpdaterPath}\" \"{Global.exedirpath}\\DS4Updater.exe\""); - if (deleteUpdatesDir) - { - w.WriteLine($"@del /S \"{Global.exedirpath}\\Update Files\\DS4Windows\""); - } - - w.Close(); - } - - // Execute temp batch script with admin privileges - ProcessStartInfo startInfo = new ProcessStartInfo(); - startInfo.FileName = tmpPath; - startInfo.WindowStyle = ProcessWindowStyle.Hidden; - startInfo.Verb = "runas"; - startInfo.UseShellExecute = true; - startInfo.CreateNoWindow = true; - try - { - // Launch process, wait and then save exit code - using (Process temp = Process.Start(startInfo)) - { - temp.WaitForExit(); - result = temp.ExitCode; - } - } - catch { } - - return result; - } - public static string GetOSProductName() { string productName = diff --git a/DS4Windows/DS4Control/Viiper/ViiperAuthenticatedStream.cs b/DS4Windows/DS4Control/Viiper/ViiperAuthenticatedStream.cs new file mode 100644 index 0000000..495415d --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/ViiperAuthenticatedStream.cs @@ -0,0 +1,667 @@ +/* + DS4Windows + Copyright (C) 2026 hbashton + + 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. +*/ + +using System; +using System.Buffers.Binary; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; + +namespace DS4Windows +{ + /// + /// Implements the current VIIPER authentication handshake. The constants, + /// byte order, and key schedule are shared with + /// internal/server/api/auth in the VIIPER source tree. + /// + internal static class ViiperAuthProtocol + { + internal const int NonceSize = 32; + internal const int SessionKeySize = 32; + internal const int Pbkdf2Iterations = 100000; + internal const string HandshakeMagic = "eVI1\0"; + internal const string AuthenticationContext = "VIIPER-Auth-v1"; + internal const string SessionContext = "VIIPER-Session-v1"; + internal const string Pbkdf2Salt = "VIIPER-Key-v1"; + + internal static Stream AuthenticateClient(Stream transport, + string password) + { + byte[] clientNonce = new byte[NonceSize]; + RandomNumberGenerator.Fill(clientNonce); + return AuthenticateClient(transport, password, clientNonce); + } + + internal static Stream AuthenticateClient(Stream transport, + string password, byte[] clientNonce) + { + if (transport == null) + { + throw new ArgumentNullException(nameof(transport)); + } + if (string.IsNullOrEmpty(password)) + { + throw new IOException("The VIIPER API credential is empty."); + } + if (clientNonce == null || clientNonce.Length != NonceSize) + { + throw new ArgumentException( + $"The VIIPER client nonce must be exactly {NonceSize} bytes.", + nameof(clientNonce)); + } + + byte[] passwordKey = DerivePasswordKey(password); + byte[] authenticationData = new byte[ + Encoding.UTF8.GetByteCount(AuthenticationContext) + NonceSize]; + byte[] handshake = new byte[ + Encoding.UTF8.GetByteCount(HandshakeMagic) + NonceSize + + SessionKeySize]; + byte[] serverNonce = new byte[NonceSize]; + byte[] sessionKey = null; + try + { + int contextLength = Encoding.UTF8.GetBytes( + AuthenticationContext, authenticationData); + Buffer.BlockCopy(clientNonce, 0, authenticationData, + contextLength, NonceSize); + + byte[] authenticationTag = HMACSHA256.HashData(passwordKey, + authenticationData); + try + { + int magicLength = Encoding.UTF8.GetBytes(HandshakeMagic, + handshake); + Buffer.BlockCopy(clientNonce, 0, handshake, magicLength, + NonceSize); + Buffer.BlockCopy(authenticationTag, 0, handshake, + magicLength + NonceSize, authenticationTag.Length); + transport.Write(handshake, 0, handshake.Length); + } + finally + { + CryptographicOperations.ZeroMemory(authenticationTag); + } + + byte[] responsePrefix = new byte[3]; + ReadExactly(transport, responsePrefix, 0, + responsePrefix.Length, + "VIIPER closed during the authentication response."); + if (responsePrefix[0] != (byte)'O' || + responsePrefix[1] != (byte)'K' || responsePrefix[2] != 0) + { + throw ReadAuthenticationError(transport, responsePrefix); + } + + ReadExactly(transport, serverNonce, 0, serverNonce.Length, + "VIIPER closed before returning its server nonce."); + sessionKey = DeriveSessionKey(passwordKey, serverNonce, + clientNonce); + Stream encrypted = new ViiperEncryptedStream(transport, + sessionKey); + CryptographicOperations.ZeroMemory(sessionKey); + sessionKey = null; + return encrypted; + } + catch + { + transport.Dispose(); + throw; + } + finally + { + CryptographicOperations.ZeroMemory(passwordKey); + CryptographicOperations.ZeroMemory(authenticationData); + CryptographicOperations.ZeroMemory(handshake); + CryptographicOperations.ZeroMemory(serverNonce); + if (sessionKey != null) + { + CryptographicOperations.ZeroMemory(sessionKey); + } + } + } + + internal static byte[] DerivePasswordKey(string password) + { + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentException("Password cannot be empty.", + nameof(password)); + } + + return Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + Encoding.UTF8.GetBytes(Pbkdf2Salt), + Pbkdf2Iterations, + HashAlgorithmName.SHA256, + SessionKeySize); + } + + internal static byte[] DeriveSessionKey(byte[] passwordKey, + byte[] serverNonce, byte[] clientNonce) + { + if (passwordKey == null || passwordKey.Length != SessionKeySize) + { + throw new ArgumentException( + $"The VIIPER password key must be {SessionKeySize} bytes.", + nameof(passwordKey)); + } + if (serverNonce == null || serverNonce.Length != NonceSize) + { + throw new ArgumentException( + $"The VIIPER server nonce must be {NonceSize} bytes.", + nameof(serverNonce)); + } + if (clientNonce == null || clientNonce.Length != NonceSize) + { + throw new ArgumentException( + $"The VIIPER client nonce must be {NonceSize} bytes.", + nameof(clientNonce)); + } + + byte[] context = Encoding.UTF8.GetBytes(SessionContext); + byte[] material = new byte[passwordKey.Length + + serverNonce.Length + clientNonce.Length + context.Length]; + try + { + int offset = 0; + Buffer.BlockCopy(passwordKey, 0, material, offset, + passwordKey.Length); + offset += passwordKey.Length; + Buffer.BlockCopy(serverNonce, 0, material, offset, + serverNonce.Length); + offset += serverNonce.Length; + Buffer.BlockCopy(clientNonce, 0, material, offset, + clientNonce.Length); + offset += clientNonce.Length; + Buffer.BlockCopy(context, 0, material, offset, + context.Length); + return SHA256.HashData(material); + } + finally + { + CryptographicOperations.ZeroMemory(material); + } + } + + private static ViiperAuthenticationException ReadAuthenticationError( + Stream transport, + byte[] prefix) + { + const int maximumErrorBytes = 64 * 1024; + using MemoryStream response = new MemoryStream(); + response.Write(prefix, 0, prefix.Length); + byte[] buffer = new byte[1024]; + try + { + while (response.Length < maximumErrorBytes) + { + int read = transport.Read(buffer, 0, Math.Min(buffer.Length, + maximumErrorBytes - (int)response.Length)); + if (read == 0) + { + break; + } + response.Write(buffer, 0, read); + } + } + catch (IOException) + { + // Preserve the server bytes already received. Authentication + // still fails closed even if the diagnostic tail was cut off. + } + finally + { + CryptographicOperations.ZeroMemory(buffer); + } + + string detail = Encoding.UTF8.GetString(response.ToArray()) + .TrimEnd('\0', '\r', '\n'); + return new ViiperAuthenticationException( + string.IsNullOrEmpty(detail) ? + "VIIPER rejected the authentication handshake." : + $"VIIPER rejected the authentication handshake: {detail}"); + } + + private static void ReadExactly(Stream stream, byte[] buffer, + int offset, int count, string closedMessage) + { + int total = 0; + while (total < count) + { + int read = stream.Read(buffer, offset + total, count - total); + if (read == 0) + { + throw new IOException(closedMessage); + } + total += read; + } + } + } + + /// + /// VIIPER authenticated record stream. Client records use nonce domain 0; + /// server records must use nonce domain 1. Both directions require an + /// exact monotonically increasing 64-bit counter. + /// + internal sealed class ViiperEncryptedStream : Stream + { + internal const int MaximumRecordSize = 2 * 1024 * 1024; + internal const int NonceSize = 12; + internal const int TagSize = 16; + internal const int RecordOverhead = NonceSize + TagSize; + internal const int MaximumPlaintextSize = MaximumRecordSize - + RecordOverhead; + internal const uint ClientNoncePrefix = 0; + internal const uint ServerNoncePrefix = 1; + + private readonly Stream transport; + private readonly byte[] sessionKey; + private readonly object sendLock = new object(); + private readonly object receiveLock = new object(); + private ChaCha20Poly1305 sendCipher; + private ChaCha20Poly1305 receiveCipher; + private byte[] sendRecord = Array.Empty(); + private readonly byte[] receiveHeader = new byte[4]; + private byte[] receiveRecord = Array.Empty(); + private byte[] receivePlaintext = Array.Empty(); + private int receiveHeaderRead; + private int receiveRecordRead; + private int receiveRecordLength; + private int receivePlaintextOffset; + private int receivePlaintextLength; + private ulong sendCounter; + private ulong receiveCounter; + private bool sendExhausted; + private bool receiveExhausted; + private Exception sendError; + private Exception receiveError; + private int disposed; + + internal ViiperEncryptedStream(Stream transport, byte[] sessionKey) + : this(transport, sessionKey, 0, 0) + { + } + + internal ViiperEncryptedStream(Stream transport, byte[] sessionKey, + ulong sendCounter, ulong receiveCounter) + { + this.transport = transport ?? + throw new ArgumentNullException(nameof(transport)); + if (sessionKey == null || + sessionKey.Length != ViiperAuthProtocol.SessionKeySize) + { + throw new ArgumentException( + $"The VIIPER session key must be {ViiperAuthProtocol.SessionKeySize} bytes.", + nameof(sessionKey)); + } + + this.sessionKey = (byte[])sessionKey.Clone(); + this.sendCounter = sendCounter; + this.receiveCounter = receiveCounter; + sendCipher = new ChaCha20Poly1305(this.sessionKey); + receiveCipher = new ChaCha20Poly1305(this.sessionKey); + } + + public override bool CanRead => Volatile.Read(ref disposed) == 0 && + transport.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => Volatile.Read(ref disposed) == 0 && + transport.CanWrite; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + ThrowIfDisposed(); + transport.Flush(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateArrayArguments(buffer, offset, count); + if (count == 0) + { + return 0; + } + + lock (receiveLock) + { + ThrowIfDisposed(); + if (receiveError != null) + { + throw new IOException( + "The VIIPER authenticated receive lane has failed.", + receiveError); + } + + while (receivePlaintextOffset == receivePlaintextLength) + { + if (!ReadRecord()) + { + return 0; + } + } + + int copied = Math.Min(count, + receivePlaintextLength - receivePlaintextOffset); + Buffer.BlockCopy(receivePlaintext, receivePlaintextOffset, + buffer, offset, copied); + CryptographicOperations.ZeroMemory( + receivePlaintext.AsSpan(receivePlaintextOffset, copied)); + receivePlaintextOffset += copied; + if (receivePlaintextOffset == receivePlaintextLength) + { + receivePlaintextOffset = 0; + receivePlaintextLength = 0; + } + return copied; + } + } + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateArrayArguments(buffer, offset, count); + lock (sendLock) + { + ThrowIfDisposed(); + if (sendError != null) + { + throw new IOException( + "The VIIPER authenticated send lane has failed.", + sendError); + } + if (sendExhausted) + { + throw new IOException( + "The VIIPER authenticated send nonce space is exhausted."); + } + if (count > MaximumPlaintextSize) + { + throw new IOException( + "The VIIPER authenticated stream packet is too large."); + } + + int recordLength = RecordOverhead + count; + int wireLength = sizeof(uint) + recordLength; + if (sendRecord.Length < wireLength) + { + sendRecord = new byte[wireLength]; + } + Span record = sendRecord.AsSpan(0, wireLength); + BinaryPrimitives.WriteUInt32BigEndian(record, + (uint)recordLength); + Span nonce = record.Slice(sizeof(uint), NonceSize); + BinaryPrimitives.WriteUInt32BigEndian(nonce, + ClientNoncePrefix); + BinaryPrimitives.WriteUInt64BigEndian(nonce.Slice(4), + sendCounter); + Span ciphertext = record.Slice(sizeof(uint) + NonceSize, + count); + Span tag = record.Slice(sizeof(uint) + NonceSize + count, + TagSize); + try + { + sendCipher.Encrypt(nonce, + buffer.AsSpan(offset, count), ciphertext, tag); + transport.Write(sendRecord, 0, wireLength); + AdvanceSendCounter(); + } + catch (Exception ex) when (ex is IOException || + ex is ObjectDisposedException || + ex is CryptographicException) + { + throw LatchSendFailure(ex); + } + } + } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + throw new NotSupportedException(); + + private bool ReadRecord() + { + try + { + if (receiveHeaderRead < receiveHeader.Length && + !FillReceiveBuffer(receiveHeader, + ref receiveHeaderRead, receiveHeader.Length, + allowCleanEndOfStream: receiveHeaderRead == 0)) + { + return false; + } + + if (receiveRecordLength == 0) + { + uint encodedLength = + BinaryPrimitives.ReadUInt32BigEndian(receiveHeader); + if (encodedLength < RecordOverhead) + { + throw new InvalidDataException( + "The VIIPER authenticated stream packet is too short."); + } + if (encodedLength > MaximumRecordSize) + { + throw new InvalidDataException( + "The VIIPER authenticated stream packet is too large."); + } + + receiveRecordLength = checked((int)encodedLength); + if (receiveRecord.Length < receiveRecordLength) + { + if (receiveRecord.Length != 0) + { + CryptographicOperations.ZeroMemory(receiveRecord); + } + receiveRecord = new byte[receiveRecordLength]; + } + } + FillReceiveBuffer(receiveRecord, ref receiveRecordRead, + receiveRecordLength, allowCleanEndOfStream: false); + + int plaintextLength = receiveRecordLength - RecordOverhead; + if (receivePlaintext.Length < plaintextLength) + { + if (receivePlaintext.Length != 0) + { + CryptographicOperations.ZeroMemory(receivePlaintext); + } + receivePlaintext = new byte[plaintextLength]; + } + ReadOnlySpan nonce = receiveRecord.AsSpan(0, + NonceSize); + ReadOnlySpan ciphertext = receiveRecord.AsSpan( + NonceSize, plaintextLength); + ReadOnlySpan tag = receiveRecord.AsSpan( + NonceSize + plaintextLength, TagSize); + receiveCipher.Decrypt(nonce, ciphertext, tag, + receivePlaintext.AsSpan(0, plaintextLength)); + + uint prefix = BinaryPrimitives.ReadUInt32BigEndian(nonce); + ulong counter = BinaryPrimitives.ReadUInt64BigEndian( + nonce.Slice(4)); + if (prefix != ServerNoncePrefix) + { + throw new InvalidDataException( + $"VIIPER authenticated stream nonce direction={prefix}, expected {ServerNoncePrefix}."); + } + if (receiveExhausted) + { + throw new InvalidDataException( + "The VIIPER authenticated receive nonce space is exhausted."); + } + if (counter != receiveCounter) + { + throw new InvalidDataException( + $"VIIPER authenticated stream nonce counter={counter}, expected {receiveCounter}."); + } + + AdvanceReceiveCounter(); + receiveHeaderRead = 0; + receiveRecordRead = 0; + receiveRecordLength = 0; + receivePlaintextOffset = 0; + receivePlaintextLength = plaintextLength; + return true; + } + catch (Exception ex) when (ex is InvalidDataException || + ex is CryptographicException || ex is OverflowException) + { + if (receivePlaintext.Length != 0) + { + CryptographicOperations.ZeroMemory(receivePlaintext); + } + receivePlaintextOffset = 0; + receivePlaintextLength = 0; + receiveError = ex; + throw new IOException( + "The VIIPER authenticated receive record was rejected.", + ex); + } + } + + private bool FillReceiveBuffer(byte[] buffer, ref int offset, + int count, bool allowCleanEndOfStream) + { + while (offset < count) + { + int read = transport.Read(buffer, offset, count - offset); + if (read == 0) + { + if (allowCleanEndOfStream && offset == 0) + { + return false; + } + throw new EndOfStreamException( + "VIIPER closed in the middle of an authenticated record."); + } + offset += read; + } + return true; + } + + private void AdvanceSendCounter() + { + if (sendCounter == ulong.MaxValue) + { + sendExhausted = true; + } + else + { + sendCounter++; + } + } + + private void AdvanceReceiveCounter() + { + if (receiveCounter == ulong.MaxValue) + { + receiveExhausted = true; + } + else + { + receiveCounter++; + } + } + + private IOException LatchSendFailure(Exception failure) + { + sendError ??= failure; + try + { + transport.Dispose(); + } + catch + { + } + return failure as IOException ?? new IOException( + "The VIIPER authenticated send record failed.", failure); + } + + private static void ValidateArrayArguments(byte[] buffer, int offset, + int count) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + if (offset < 0 || count < 0 || offset > buffer.Length - count) + { + throw new ArgumentOutOfRangeException( + offset < 0 ? nameof(offset) : nameof(count)); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref disposed) != 0) + { + throw new ObjectDisposedException(nameof(ViiperEncryptedStream)); + } + } + + protected override void Dispose(bool disposing) + { + if (!disposing || Interlocked.Exchange(ref disposed, 1) != 0) + { + base.Dispose(disposing); + return; + } + + // Close the transport before joining the lanes. This wakes a + // blocked network read/write without racing cipher cleanup. + try + { + transport.Dispose(); + } + catch + { + } + + lock (sendLock) + { + lock (receiveLock) + { + sendCipher?.Dispose(); + receiveCipher?.Dispose(); + sendCipher = null; + receiveCipher = null; + CryptographicOperations.ZeroMemory(sessionKey); + CryptographicOperations.ZeroMemory(sendRecord); + CryptographicOperations.ZeroMemory(receiveHeader); + CryptographicOperations.ZeroMemory(receiveRecord); + CryptographicOperations.ZeroMemory(receivePlaintext); + sendRecord = Array.Empty(); + receiveRecord = Array.Empty(); + receivePlaintext = Array.Empty(); + receiveHeaderRead = 0; + receiveRecordRead = 0; + receiveRecordLength = 0; + sendError ??= new ObjectDisposedException( + nameof(ViiperEncryptedStream)); + receiveError ??= new ObjectDisposedException( + nameof(ViiperEncryptedStream)); + } + } + base.Dispose(disposing); + } + } +} diff --git a/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs b/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs index 9189dfc..73e6199 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs @@ -203,9 +203,10 @@ private void RunPrerequisiteProbe() { ViiperPrerequisiteStatus status = ViiperSetupManager.GetStatus(tryStartServer: true); Log($"Prerequisite status ready={status.Ready} display='{status.DisplayText}'"); - Log($"VIIPER installed={status.ViiperInstalled} path='{status.ViiperPath}'"); - Log($"usbip-win2 installed={status.UsbipInstalled}"); - Log($"VIIPER server running={status.ServerRunning} endpoint={ViiperSetupManager.ApiHost}:{ViiperSetupManager.ApiPort}"); + Log($"Native metadata found={status.MetadataFound} eligible={status.MetadataEligible} localTest={status.LocalTestMetadata} path='{status.MetadataPath}'"); + Log($"Native broker installed={status.BrokerInstalled} exactHash={status.BrokerHashMatches} path='{status.BrokerPath}'"); + Log($"VIIPERNativeBroker installed={status.BrokerServiceInstalled} configured={status.BrokerServiceConfigured} running={status.BrokerServiceRunning}"); + Log($"Protected credential readable={status.CredentialReadable} authenticatedPing={status.AuthenticatedPingSucceeded} identityCompatible={status.RuntimeContractCompatible} endpoint={ViiperSetupManager.ApiHost}:{ViiperSetupManager.ApiPort}"); Log($"Bundled setup script found={status.SetupScriptFound} path='{status.SetupScriptPath}'"); } @@ -228,6 +229,7 @@ private void RunDeviceProbe(ViiperVirtualDeviceType type, CancellationToken canc using ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(type); Log($"Device={type} create/open stream OK"); + LogDeviceIdentity(type, stream.VirtualDeviceIdentity); WritePacket(stream, type, "neutral", ViiperStatePacketBuilder.CreateNeutralState(), cancellationToken); WritePacket(stream, type, "buttons", BuildButtonState(type), cancellationToken); @@ -238,6 +240,32 @@ private void RunDeviceProbe(ViiperVirtualDeviceType type, CancellationToken canc }, cancellationToken); } + private void LogDeviceIdentity(ViiperVirtualDeviceType type, + ViiperVirtualDeviceIdentity identity) + { + if (identity == null) + { + Log($"Device={type} authoritative virtual-device identity unavailable"); + return; + } + + Log($"Device={type} transport={identity.TransportMode} busId={identity.BusId} devId={identity.DevId} deviceType={identity.DeviceType} vid={identity.Vid} pid={identity.Pid} logicalLifetime={identity.LogicalLifetimeId} streamGeneration={identity.StreamGeneration}"); + if (identity.TransportMode == ViiperTransportMode.NativeUde) + { + ViiperNativePnpAnchor anchor = identity.NativePnpAnchor; + if (anchor == null) + { + Log($"Device={type} native PnP anchor unavailable"); + return; + } + + Log($"Device={type} nativeDeviceId={anchor.NativeDeviceId} driverGeneration={anchor.NativeDeviceGeneration} controllerSessionId={anchor.ControllerSessionId} controllerInstance='{anchor.ControllerInstanceId}' usb20Port={anchor.Usb20PortNumber} usb30Port={anchor.Usb30PortNumber} exact={anchor.IsExact}"); + return; + } + + Log($"Device={type} legacyUsbipPort={identity.LegacyUsbipPort} legacyOwnerIdentityPresent={!string.IsNullOrWhiteSpace(identity.LegacyUsbipOwnerSerial)}"); + } + private void WritePacket(ViiperDeviceStream stream, ViiperVirtualDeviceType type, string label, DS4State state, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/DS4Windows/DS4Control/Viiper/ViiperLiveValidation.cs b/DS4Windows/DS4Control/Viiper/ViiperLiveValidation.cs new file mode 100644 index 0000000..fbf81b3 --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/ViiperLiveValidation.cs @@ -0,0 +1,118 @@ +/* +DS4Windows +Copyright (C) 2026 hbashton + +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. +*/ + +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace DS4Windows +{ + /// + /// Capability token for the separately-built, headless native live gate. + /// The ordinary DS4Windows process never creates one. Requiring the same + /// canonical nonce at the command line and in the environment prevents an + /// accidental invocation from acquiring validation-only hooks. + /// + internal sealed class ViiperLiveValidationLease + { + internal const string NonceEnvironmentVariable = + "DS4WINDOWS_VIIPER_LIVE_VALIDATION_NONCE"; + internal const int NonceLength = 64; + + private ViiperLiveValidationLease(byte[] nonceFingerprint) + { + NonceFingerprint = nonceFingerprint; + } + + internal byte[] NonceFingerprint { get; } + + internal static ViiperLiveValidationLease Create(string commandNonce) + { + string environmentNonce = Environment.GetEnvironmentVariable( + NonceEnvironmentVariable); + ValidateNonce(commandNonce, "command-line"); + ValidateNonce(environmentNonce, NonceEnvironmentVariable); + + byte[] command = Encoding.ASCII.GetBytes(commandNonce); + byte[] environment = Encoding.ASCII.GetBytes(environmentNonce); + try + { + if (!CryptographicOperations.FixedTimeEquals(command, + environment)) + { + throw new ViiperIdentityException( + $"The command-line live-validation nonce does not match {NonceEnvironmentVariable}."); + } + + return new ViiperLiveValidationLease( + SHA256.HashData(command)); + } + finally + { + CryptographicOperations.ZeroMemory(command); + CryptographicOperations.ZeroMemory(environment); + } + } + + private static void ValidateNonce(string value, string source) + { + if (value == null || value.Length != NonceLength || + value.Any(character => + !(character >= '0' && character <= '9') && + !(character >= 'a' && character <= 'f'))) + { + throw new ViiperIdentityException( + $"The {source} live-validation nonce must be exactly {NonceLength} lowercase hexadecimal characters."); + } + } + } + + internal sealed class ViiperLiveValidationSnapshot + { + internal string HandlerName { get; init; } + internal string StreamProtocol { get; init; } + internal byte StreamFrameVersion { get; init; } + internal bool Connected { get; init; } + internal bool SupportsMicrophone { get; init; } + internal bool SupportsDirectSpeaker { get; init; } + internal bool SupportsAtomicAudioHaptics { get; init; } + internal ViiperNativeBackendIdentity BackendIdentity { get; init; } + internal ViiperVirtualDeviceIdentity DeviceIdentity { get; init; } + internal long StatePacketsSubmitted { get; init; } + internal long StatePacketsWritten { get; init; } + internal long StatePacketsCoalesced { get; init; } + internal int StreamRecoveryAttempts { get; init; } + internal long FeedbackFramesObserved { get; init; } + internal byte[] LastFeedbackPayload { get; init; } + internal long ValidationMicrophoneFramesSubmitted { get; init; } + internal long ValidationMicrophoneBytesSubmitted { get; init; } + internal long ValidationTransportInterruptions { get; init; } + internal long ValidationStreamRecoveriesCompleted { get; init; } + internal long SpeakerFramesEnqueued { get; init; } + internal long SpeakerFramesDequeued { get; init; } + internal long SpeakerFramesDropped { get; init; } + internal long SpeakerFramesExpired { get; init; } + internal long SpeakerFramesDelivered { get; init; } + internal long SpeakerFramesStale { get; init; } + internal long SpeakerNoSubscriberDeferrals { get; init; } + internal long SpeakerCallbackFailures { get; init; } + internal long ControlFramesEnqueued { get; init; } + internal long ControlFramesDequeued { get; init; } + internal long ControlFramesDropped { get; init; } + internal long OrderedControlFramesEnqueued { get; init; } + internal long OrderedControlFramesDequeued { get; init; } + internal long OrderedControlFramesDropped { get; init; } + internal long OrderedControlFramesExpired { get; init; } + internal long ControlFramesDelivered { get; init; } + internal long ControlFramesStale { get; init; } + internal long ControlCallbackFailures { get; init; } + } +} diff --git a/DS4Windows/DS4Control/Viiper/ViiperNativeRuntime.cs b/DS4Windows/DS4Control/Viiper/ViiperNativeRuntime.cs new file mode 100644 index 0000000..24ebb8a --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/ViiperNativeRuntime.cs @@ -0,0 +1,1348 @@ +/* + DS4Windows + Copyright (C) 2026 hbashton + + 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. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; + +namespace DS4Windows +{ + internal enum ViiperTransportMode + { + NativeUde, + Usbip, + } + + internal static class ViiperTransportSettings + { + internal const string TransportEnvironmentVariable = + "DS4WINDOWS_VIIPER_TRANSPORT"; + internal const string LocalTestEnvironmentVariable = + "DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST"; + + internal static ViiperTransportMode GetManagedMode() + { + return Parse(Environment.GetEnvironmentVariable( + TransportEnvironmentVariable)); + } + + internal static ViiperTransportMode Parse(string value) + { + string normalized = value?.Trim(); + if (string.IsNullOrEmpty(normalized) || + string.Equals(normalized, "native-ude", + StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, "native", + StringComparison.OrdinalIgnoreCase)) + { + return ViiperTransportMode.NativeUde; + } + if (string.Equals(normalized, "usbip", + StringComparison.OrdinalIgnoreCase)) + { + return ViiperTransportMode.Usbip; + } + + throw new ViiperNativeMetadataException( + $"Unsupported {TransportEnvironmentVariable} value '{normalized}'. Expected native-ude or usbip."); + } + + internal static bool AllowsLocalTestMetadata(string value = null) + { + value ??= Environment.GetEnvironmentVariable( + LocalTestEnvironmentVariable); + return string.Equals(value, "1", StringComparison.Ordinal); + } + } + + internal sealed class ViiperNativeMetadataException : IOException + { + internal ViiperNativeMetadataException(string message, + Exception inner = null) : base(message, inner) + { + } + } + + internal sealed class ViiperCredentialException : IOException + { + internal ViiperCredentialException(string message, + Exception inner = null) : base(message, inner) + { + } + } + + internal sealed class ViiperAuthenticationException : IOException + { + internal ViiperAuthenticationException(string message, + Exception inner = null) : base(message, inner) + { + } + } + + internal sealed class ViiperIdentityException : IOException + { + internal ViiperIdentityException(string message, + Exception inner = null) : base(message, inner) + { + } + } + + internal sealed class ViiperNativeControllerRegistration + { + internal string Type { get; init; } + internal string DefaultVid { get; init; } + internal string DefaultPid { get; init; } + internal string Ds4WindowsPid { get; init; } + internal string InterfaceProfile { get; init; } + internal string StreamProtocol { get; init; } + + internal ushort Ds4WindowsPidValue => ushort.Parse( + Ds4WindowsPid.AsSpan(2), NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture); + + internal bool HasSameContract( + ViiperNativeControllerRegistration other) + { + return other != null && + string.Equals(Type, other.Type, StringComparison.Ordinal) && + string.Equals(DefaultVid, other.DefaultVid, + StringComparison.Ordinal) && + string.Equals(DefaultPid, other.DefaultPid, + StringComparison.Ordinal) && + string.Equals(Ds4WindowsPid, other.Ds4WindowsPid, + StringComparison.Ordinal) && + string.Equals(InterfaceProfile, other.InterfaceProfile, + StringComparison.Ordinal) && + string.Equals(StreamProtocol, other.StreamProtocol, + StringComparison.Ordinal); + } + } + + internal sealed class ViiperNativeRuntimeMetadata + { + internal const string FileName = "ViiperNativeRuntimeMetadata.json"; + internal const string ProductionEligibility = "production"; + internal const string LocalTestEligibility = + "local-test-evidence-only"; + + internal int SchemaVersion { get; init; } + internal string SourcePath { get; init; } + internal string SourceRevision { get; init; } + internal string ReleaseEligibility { get; init; } + internal string DriverPackageVersion { get; init; } + internal ushort AbiMajor { get; init; } + internal ushort AbiMinor { get; init; } + internal uint RequiredCapabilities { get; init; } + internal string RequiredCapabilitiesHex { get; init; } + internal string LoadedDriverBuildIdentity { get; init; } + internal IReadOnlyDictionary ControllerApiContract + { get; init; } + + internal static ViiperNativeRuntimeMetadata LoadBundled( + string baseDirectory = null, string localTestOptIn = null) + { + baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? + AppContext.BaseDirectory : Path.GetFullPath(baseDirectory); + string[] candidates = + { + Path.Combine(baseDirectory, "extras", FileName), + Path.Combine(baseDirectory, FileName), + }; + string[] existing = candidates.Where(File.Exists) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (existing.Length == 0) + { + throw new ViiperNativeMetadataException( + $"Bundled {FileName} is missing. Native VIIPER admission requires package-generated metadata."); + } + + ViiperNativeRuntimeMetadata selected = Parse(existing[0], + localTestOptIn); + for (int index = 1; index < existing.Length; index++) + { + ViiperNativeRuntimeMetadata duplicate = Parse(existing[index], + localTestOptIn); + if (!selected.HasSameContract(duplicate)) + { + throw new ViiperNativeMetadataException( + $"Conflicting native VIIPER metadata files were bundled at '{selected.SourcePath}' and '{duplicate.SourcePath}'."); + } + } + return selected; + } + + internal static ViiperNativeRuntimeMetadata Parse(string path, + string localTestOptIn = null) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("A metadata path is required.", + nameof(path)); + } + + string fullPath = Path.GetFullPath(path); + try + { + using FileStream file = new FileStream(fullPath, + FileMode.Open, FileAccess.Read, FileShare.Read, + 4096, FileOptions.SequentialScan); + using JsonDocument document = JsonDocument.Parse(file, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new ViiperNativeMetadataException( + "Native VIIPER metadata must be a JSON object."); + } + + int schemaVersion = GetRequiredInt32(root, "schemaVersion"); + if (schemaVersion != 1) + { + throw new ViiperNativeMetadataException( + $"Unsupported native VIIPER metadata schemaVersion={schemaVersion}."); + } + string eligibility = GetRequiredString(root, + "releaseEligibility"); + string localTestOptInEnvironment = GetRequiredString(root, + "localTestOptInEnvironment"); + if (!string.Equals(localTestOptInEnvironment, + ViiperTransportSettings.LocalTestEnvironmentVariable, + StringComparison.Ordinal)) + { + throw new ViiperNativeMetadataException( + "Native VIIPER metadata names an unexpected local-test opt-in boundary."); + } + bool eligibilityAccepted = string.Equals(eligibility, + ProductionEligibility, StringComparison.Ordinal); + if (string.Equals(eligibility, LocalTestEligibility, + StringComparison.Ordinal)) + { + eligibilityAccepted = + ViiperTransportSettings.AllowsLocalTestMetadata( + localTestOptIn); + if (!eligibilityAccepted) + { + throw new ViiperNativeMetadataException( + $"Bundled native VIIPER metadata is '{LocalTestEligibility}'. Set {ViiperTransportSettings.LocalTestEnvironmentVariable}=1 only inside the disposable VM/laptop test environment."); + } + } + if (!eligibilityAccepted) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER metadata releaseEligibility='{eligibility}' is not admissible."); + } + + JsonElement abi = GetRequiredObject(root, "driverAbi"); + int abiMajor = GetRequiredInt32(abi, "major"); + int abiMinor = GetRequiredInt32(abi, "minor"); + uint capabilities = GetRequiredUInt32(root, + "requiredCapabilities"); + string capabilitiesHex = GetRequiredString(root, + "requiredCapabilitiesHex"); + string packageVersion = GetRequiredString(root, + "driverPackageVersion"); + string buildIdentity = GetRequiredString(root, + "loadedDriverBuildIdentity"); + string sourceRevision = GetRequiredString(root, + "sourceRevision"); + if (abiMajor <= 0 || abiMajor > ushort.MaxValue || + abiMinor < 0 || abiMinor > ushort.MaxValue) + { + throw new ViiperNativeMetadataException( + "Native VIIPER metadata contains an invalid driver ABI."); + } + if (capabilities == 0) + { + throw new ViiperNativeMetadataException( + "Native VIIPER metadata requires zero capabilities."); + } + string canonicalCapabilitiesHex = string.Format( + CultureInfo.InvariantCulture, "0x{0:x8}", capabilities); + if (!string.Equals(capabilitiesHex, + canonicalCapabilitiesHex, StringComparison.Ordinal)) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER metadata requiredCapabilitiesHex='{capabilitiesHex}' does not exactly encode requiredCapabilities as '{canonicalCapabilitiesHex}'."); + } + ValidatePackageVersion(packageVersion); + ValidateLowercaseSha256(buildIdentity, + "loadedDriverBuildIdentity"); + ValidateSourceRevision(sourceRevision); + ValidateManagedBroker(root); + IReadOnlyDictionary controllerApi = + ParseControllerApiContract(root, sourceRevision); + + return new ViiperNativeRuntimeMetadata + { + SchemaVersion = schemaVersion, + SourcePath = fullPath, + SourceRevision = sourceRevision, + ReleaseEligibility = eligibility, + DriverPackageVersion = packageVersion, + AbiMajor = (ushort)abiMajor, + AbiMinor = (ushort)abiMinor, + RequiredCapabilities = capabilities, + RequiredCapabilitiesHex = capabilitiesHex, + LoadedDriverBuildIdentity = buildIdentity, + ControllerApiContract = controllerApi, + }; + } + catch (ViiperNativeMetadataException) + { + throw; + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException || ex is JsonException || + ex is FormatException || ex is OverflowException) + { + throw new ViiperNativeMetadataException( + $"Could not read native VIIPER metadata '{fullPath}': {ex.Message}", + ex); + } + } + + private bool HasSameContract(ViiperNativeRuntimeMetadata other) + { + return other != null && SchemaVersion == other.SchemaVersion && + string.Equals(SourceRevision, other.SourceRevision, + StringComparison.Ordinal) && + string.Equals(ReleaseEligibility, other.ReleaseEligibility, + StringComparison.Ordinal) && + string.Equals(DriverPackageVersion, + other.DriverPackageVersion, StringComparison.Ordinal) && + AbiMajor == other.AbiMajor && AbiMinor == other.AbiMinor && + RequiredCapabilities == other.RequiredCapabilities && + string.Equals(RequiredCapabilitiesHex, + other.RequiredCapabilitiesHex, StringComparison.Ordinal) && + string.Equals(LoadedDriverBuildIdentity, + other.LoadedDriverBuildIdentity, + StringComparison.Ordinal) && + HasSameControllerApiContract(other); + } + + private bool HasSameControllerApiContract( + ViiperNativeRuntimeMetadata other) + { + if (ControllerApiContract == null || + other.ControllerApiContract == null || + ControllerApiContract.Count != + other.ControllerApiContract.Count) + { + return false; + } + foreach (KeyValuePair entry in + ControllerApiContract) + { + if (!other.ControllerApiContract.TryGetValue(entry.Key, + out ViiperNativeControllerRegistration candidate) || + !entry.Value.HasSameContract(candidate)) + { + return false; + } + } + return true; + } + + private static JsonElement GetRequiredObject(JsonElement parent, + string name) + { + if (!parent.TryGetProperty(name, out JsonElement value) || + value.ValueKind != JsonValueKind.Object) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER metadata field '{name}' must be an object."); + } + return value; + } + + private static void ValidateManagedBroker(JsonElement root) + { + JsonElement broker = GetRequiredObject(root, "managedBroker"); + bool valid = string.Equals(GetRequiredString(broker, + "serviceName"), "VIIPERNativeBroker", + StringComparison.Ordinal) && + string.Equals(GetRequiredString(broker, "serviceAccount"), + "LocalSystem", StringComparison.Ordinal) && + string.Equals(GetRequiredString(broker, "startMode"), + "automatic", StringComparison.Ordinal) && + string.Equals(GetRequiredString(broker, "transport"), + "native-ude", StringComparison.Ordinal) && + string.Equals(GetRequiredString(broker, "apiHost"), + "127.0.0.1", StringComparison.Ordinal) && + GetRequiredInt32(broker, "apiPort") == 3242 && + string.Equals(GetRequiredString(broker, "credentialPath"), + "%ProgramData%/VIIPER/viiper.key.txt", + StringComparison.Ordinal); + if (!valid) + { + throw new ViiperNativeMetadataException( + "Native VIIPER metadata managedBroker does not match the protected loopback service contract."); + } + } + + private static IReadOnlyDictionary ParseControllerApiContract( + JsonElement root, string sourceRevision) + { + JsonElement contract = GetRequiredObject(root, + "controllerApiContract"); + if (GetRequiredInt32(contract, "schemaVersion") != 1 || + !string.Equals(GetRequiredString(contract, + "sourceRevision"), sourceRevision, + StringComparison.Ordinal)) + { + throw new ViiperNativeMetadataException( + "Native VIIPER controller API contract is not bound to the package source revision."); + } + _ = GetRequiredString(contract, "implementation"); + if (!contract.TryGetProperty("registrations", + out JsonElement registrations) || + registrations.ValueKind != JsonValueKind.Array || + registrations.GetArrayLength() == 0) + { + throw new ViiperNativeMetadataException( + "Native VIIPER controller API contract has no registrations."); + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach (JsonElement registration in registrations.EnumerateArray()) + { + if (registration.ValueKind != JsonValueKind.Object) + { + throw new ViiperNativeMetadataException( + "Native VIIPER controller API registration must be an object."); + } + string type = GetRequiredString(registration, "type"); + string defaultVid = GetRequiredString(registration, + "defaultVid"); + string defaultPid = GetRequiredString(registration, + "defaultPid"); + string clientPid = GetRequiredString(registration, + "ds4WindowsPid"); + ValidateControllerType(type); + ValidateUsbId(defaultVid, "defaultVid"); + ValidateUsbId(defaultPid, "defaultPid"); + ValidateUsbId(clientPid, "ds4WindowsPid"); + var parsed = new ViiperNativeControllerRegistration + { + Type = type, + DefaultVid = defaultVid, + DefaultPid = defaultPid, + Ds4WindowsPid = clientPid, + InterfaceProfile = GetRequiredString(registration, + "interfaceProfile"), + StreamProtocol = GetRequiredString(registration, + "streamProtocol"), + }; + if (!result.TryAdd(type, parsed)) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER controller API type '{type}' is duplicated."); + } + } + return result; + } + + private static void ValidateControllerType(string value) + { + if (value.Length > 64 || value.Any(character => + !(character >= 'a' && character <= 'z') && + !(character >= '0' && character <= '9'))) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER controller API type '{value}' is not canonical."); + } + } + + private static void ValidateUsbId(string value, string fieldName) + { + if (value.Length != 6 || value[0] != '0' || value[1] != 'x' || + !value.AsSpan(2).ToArray().All(IsLowerHex)) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER controller API {fieldName} '{value}' is not canonical 0xhhhh."); + } + } + + private static string GetRequiredString(JsonElement parent, + string name) + { + if (!parent.TryGetProperty(name, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(value.GetString())) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER metadata field '{name}' must be a non-empty string."); + } + string text = value.GetString(); + if (!string.Equals(text, text.Trim(), StringComparison.Ordinal)) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER metadata field '{name}' contains surrounding whitespace."); + } + return text; + } + + private static int GetRequiredInt32(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out JsonElement value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt32(out int result)) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER metadata field '{name}' must be a 32-bit integer."); + } + return result; + } + + private static uint GetRequiredUInt32(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out JsonElement value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetUInt32(out uint result)) + { + throw new ViiperNativeMetadataException( + $"Native VIIPER metadata field '{name}' must be a 32-bit unsigned integer."); + } + return result; + } + + private static void ValidatePackageVersion(string value) + { + string[] components = value.Split('.'); + if (components.Length != 4 || components.Any(component => + component.Length == 0 || !uint.TryParse(component, + NumberStyles.None, CultureInfo.InvariantCulture, + out _))) + { + throw new ViiperNativeMetadataException( + "Native VIIPER metadata driverPackageVersion must contain four numeric parts."); + } + } + + private static void ValidateSourceRevision(string value) + { + if ((value.Length != 40 && value.Length != 64) || + !value.All(IsLowerHex)) + { + throw new ViiperNativeMetadataException( + "Native VIIPER metadata sourceRevision must be 40 or 64 lowercase hexadecimal digits."); + } + } + + internal static void ValidateLowercaseSha256(string value, + string fieldName) + { + if (value == null || value.Length != 64 || + !value.All(IsLowerHex) || value.All(character => character == '0')) + { + throw new ViiperIdentityException( + $"VIIPER {fieldName} must be a non-zero lowercase SHA-256 value."); + } + } + + private static bool IsLowerHex(char value) => + value >= '0' && value <= '9' || value >= 'a' && value <= 'f'; + } + + internal sealed class ViiperNativeBackendIdentity + { + internal string Server { get; init; } + internal string Version { get; init; } + internal string Transport { get; init; } + internal ushort AbiMajor { get; init; } + internal ushort AbiMinor { get; init; } + internal uint Capabilities { get; init; } + internal string DriverPackageVersion { get; init; } + internal string DriverBuildIdentity { get; init; } + internal string ControllerInstanceId { get; init; } + internal ulong ControllerSessionId { get; init; } + + internal bool HasSameGeneration( + ViiperNativeBackendIdentity other) + { + return other != null && + string.Equals(Server, other.Server, StringComparison.Ordinal) && + string.Equals(Version, other.Version, StringComparison.Ordinal) && + string.Equals(Transport, other.Transport, + StringComparison.Ordinal) && + AbiMajor == other.AbiMajor && AbiMinor == other.AbiMinor && + Capabilities == other.Capabilities && + string.Equals(DriverPackageVersion, + other.DriverPackageVersion, StringComparison.Ordinal) && + string.Equals(DriverBuildIdentity, + other.DriverBuildIdentity, StringComparison.Ordinal) && + string.Equals(ControllerInstanceId, + other.ControllerInstanceId, StringComparison.Ordinal) && + ControllerSessionId == other.ControllerSessionId; + } + } + + internal sealed class ViiperNativePingResponse + { + [JsonPropertyName("server")] + public string Server { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } + + [JsonPropertyName("transport")] + public string Transport { get; set; } + + [JsonPropertyName("ready")] + public bool? Ready { get; set; } + + [JsonPropertyName("nativeUde")] + public ViiperNativeUdePingInfo NativeUde { get; set; } + } + + internal sealed class ViiperNativeUdePingInfo + { + [JsonPropertyName("abiMajor")] + public ushort AbiMajor { get; set; } + + [JsonPropertyName("abiMinor")] + public ushort AbiMinor { get; set; } + + [JsonPropertyName("capabilities")] + public uint Capabilities { get; set; } + + [JsonPropertyName("expectedDriverPackageVersion")] + public string ExpectedDriverPackageVersion { get; set; } + + [JsonPropertyName("loadedDriverBuildIdentity")] + public string LoadedDriverBuildIdentity { get; set; } + + [JsonPropertyName("controllerInstanceId")] + public string ControllerInstanceId { get; set; } + + [JsonPropertyName("controllerSessionId")] + public string ControllerSessionId { get; set; } + } + + internal sealed class ViiperNativeRuntimeContract + { + private static readonly JsonSerializerOptions JsonOptions = + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + internal ViiperNativeRuntimeContract( + ViiperNativeRuntimeMetadata metadata) + { + Metadata = metadata ?? + throw new ArgumentNullException(nameof(metadata)); + } + + internal ViiperNativeRuntimeMetadata Metadata { get; } + + internal ViiperNativeControllerRegistration GetControllerRegistration( + string type) + { + if (type == null || Metadata.ControllerApiContract == null || + !Metadata.ControllerApiContract.TryGetValue(type, + out ViiperNativeControllerRegistration registration)) + { + throw new ViiperIdentityException( + $"VIIPER native controller type '{type ?? ""}' is not in the source-bound controller API contract."); + } + return registration; + } + + internal ushort ValidateControllerRequest(string type, + ushort? requestedPid) + { + ViiperNativeControllerRegistration registration = + GetControllerRegistration(type); + ushort expected = registration.Ds4WindowsPidValue; + if (requestedPid.HasValue && requestedPid.Value != expected) + { + throw new ViiperIdentityException( + $"VIIPER native controller type '{type}' cannot override its source-bound DS4Windows product ID."); + } + return expected; + } + + internal bool HasExactControllerIdentity(string type, string vid, + string pid) + { + ViiperNativeControllerRegistration registration = + GetControllerRegistration(type); + return string.Equals(vid, registration.DefaultVid, + StringComparison.Ordinal) && + string.Equals(pid, registration.Ds4WindowsPid, + StringComparison.Ordinal); + } + + internal ViiperNativeBackendIdentity ValidatePing(string raw) + { + ViiperNativePingResponse ping; + try + { + ValidateNoDuplicateJsonProperties(raw, "ping"); + ping = JsonSerializer.Deserialize( + raw, JsonOptions); + } + catch (JsonException ex) + { + throw new ViiperIdentityException( + "VIIPER ping was not valid JSON.", ex); + } + if (ping == null) + { + throw new ViiperIdentityException( + "VIIPER returned an empty ping identity."); + } + if (!string.Equals(ping.Server, "VIIPER", + StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + $"Unexpected VIIPER server identity '{ping.Server ?? ""}'."); + } + if (string.IsNullOrWhiteSpace(ping.Version)) + { + throw new ViiperIdentityException( + "VIIPER ping omitted the broker version."); + } + if (!string.Equals(ping.Transport, "native-ude", + StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + $"VIIPER transport is '{ping.Transport ?? ""}', expected native-ude. USB/IP responses are not admitted on the managed native path."); + } + if (ping.Ready != true) + { + throw new ViiperIdentityException( + "VIIPER native-ude transport is not ready."); + } + if (ping.NativeUde == null) + { + throw new ViiperIdentityException( + "VIIPER ping omitted the nativeUde contract."); + } + + ViiperNativeUdePingInfo native = ping.NativeUde; + if (native.AbiMajor != Metadata.AbiMajor || + native.AbiMinor != Metadata.AbiMinor) + { + throw new ViiperIdentityException( + $"VIIPER native ABI is {native.AbiMajor}.{native.AbiMinor}, expected {Metadata.AbiMajor}.{Metadata.AbiMinor} from bundled metadata."); + } + if (native.Capabilities != Metadata.RequiredCapabilities) + { + throw new ViiperIdentityException( + $"VIIPER native capabilities are 0x{native.Capabilities:x}, expected exact 0x{Metadata.RequiredCapabilities:x} from bundled metadata."); + } + if (!string.Equals(native.ExpectedDriverPackageVersion, + Metadata.DriverPackageVersion, StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + $"VIIPER expects driver package '{native.ExpectedDriverPackageVersion ?? ""}', bundled metadata expects '{Metadata.DriverPackageVersion}'."); + } + ViiperNativeRuntimeMetadata.ValidateLowercaseSha256( + native.LoadedDriverBuildIdentity, + "loadedDriverBuildIdentity"); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(native.LoadedDriverBuildIdentity), + Encoding.ASCII.GetBytes( + Metadata.LoadedDriverBuildIdentity))) + { + throw new ViiperIdentityException( + "VIIPER loaded-driver build identity does not match bundled metadata."); + } + ValidateControllerInstanceId(native.ControllerInstanceId); + ulong controllerSessionId = ParseCanonicalNonZeroUInt64( + native.ControllerSessionId, + "nativeUde.controllerSessionId"); + + return new ViiperNativeBackendIdentity + { + Server = ping.Server, + Version = ping.Version, + Transport = ping.Transport, + AbiMajor = native.AbiMajor, + AbiMinor = native.AbiMinor, + Capabilities = native.Capabilities, + DriverPackageVersion = native.ExpectedDriverPackageVersion, + DriverBuildIdentity = native.LoadedDriverBuildIdentity, + ControllerInstanceId = native.ControllerInstanceId, + ControllerSessionId = controllerSessionId, + }; + } + + internal static void ValidateNoDuplicateJsonProperties(string raw, + string responseName) + { + try + { + using JsonDocument document = JsonDocument.Parse(raw, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + ValidateNoDuplicateJsonProperties(document.RootElement, + responseName ?? "response"); + } + catch (JsonException ex) + { + throw new ViiperIdentityException( + $"VIIPER {responseName ?? "response"} was not valid JSON.", + ex); + } + } + + private static void ValidateNoDuplicateJsonProperties( + JsonElement element, string path) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new ViiperIdentityException( + $"VIIPER {path} contains duplicate JSON property '{property.Name}'."); + } + ValidateNoDuplicateJsonProperties(property.Value, + path + "." + property.Name); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + ValidateNoDuplicateJsonProperties(item, + $"{path}[{index++}]"); + } + } + } + + internal static ulong ParseCanonicalNonZeroUInt64(string value, + string fieldName) + { + if (!ulong.TryParse(value, NumberStyles.None, + CultureInfo.InvariantCulture, out ulong parsed) || + parsed == 0 || + !string.Equals(parsed.ToString(CultureInfo.InvariantCulture), + value, StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + $"VIIPER {fieldName} is not canonical non-zero decimal uint64 text."); + } + return parsed; + } + + internal static void ValidateControllerInstanceId(string value) + { + if (string.IsNullOrWhiteSpace(value) || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + value.IndexOf('/') >= 0 || value.IndexOf('\0') >= 0 || + !value.StartsWith(@"ROOT\VIIPERUDE\", + StringComparison.OrdinalIgnoreCase) || + !string.Equals(value, value.ToUpperInvariant(), + StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + "VIIPER nativeUde.controllerInstanceId is not a canonical VIIPER UdeCx controller instance ID."); + } + } + } + + internal sealed class ViiperCredential : IDisposable + { + private int disposed; + + internal ViiperCredential(string password, byte[] fingerprint) + { + Password = password ?? throw new ArgumentNullException( + nameof(password)); + Fingerprint = fingerprint ?? throw new ArgumentNullException( + nameof(fingerprint)); + } + + internal string Password { get; private set; } + internal byte[] Fingerprint { get; private set; } + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + if (Fingerprint != null) + { + CryptographicOperations.ZeroMemory(Fingerprint); + Fingerprint = null; + } + Password = null; + } + } + + internal interface IViiperCredentialProvider + { + ViiperCredential Read(); + } + + internal sealed class ViiperProgramDataCredentialProvider : + IViiperCredentialProvider + { + internal const string CredentialFileName = "viiper.key.txt"; + internal const int ManagedCredentialLength = 16; + + internal ViiperProgramDataCredentialProvider(string path = null) + { + CredentialPath = string.IsNullOrWhiteSpace(path) ? + Path.Combine(Environment.GetFolderPath( + Environment.SpecialFolder.CommonApplicationData), + "VIIPER", CredentialFileName) : Path.GetFullPath(path); + } + + internal string CredentialPath { get; } + + public ViiperCredential Read() + { + byte[] bytes = null; + try + { + FileAttributes attributes = File.GetAttributes(CredentialPath); + if ((attributes & FileAttributes.ReparsePoint) != 0 || + (attributes & FileAttributes.Directory) != 0) + { + throw new ViiperCredentialException( + "The managed VIIPER credential is not a regular file."); + } + using FileStream file = new FileStream(CredentialPath, + FileMode.Open, FileAccess.Read, FileShare.Read, + ManagedCredentialLength, + FileOptions.SequentialScan); + if (file.Length != ManagedCredentialLength) + { + throw new ViiperCredentialException( + $"The managed VIIPER credential must be exactly {ManagedCredentialLength} bytes."); + } + bytes = new byte[ManagedCredentialLength]; + int total = 0; + while (total < bytes.Length) + { + int read = file.Read(bytes, total, bytes.Length - total); + if (read == 0) + { + throw new ViiperCredentialException( + "The managed VIIPER credential changed while it was read."); + } + total += read; + } + if (file.ReadByte() != -1) + { + throw new ViiperCredentialException( + "The managed VIIPER credential changed while it was read."); + } + if (bytes.Any(value => !IsBase62(value))) + { + throw new ViiperCredentialException( + "The managed VIIPER credential is not canonical base62."); + } + string password = Encoding.ASCII.GetString(bytes); + byte[] fingerprint = SHA256.HashData(bytes); + return new ViiperCredential(password, fingerprint); + } + catch (ViiperCredentialException) + { + throw; + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException) + { + throw new ViiperCredentialException( + $"The managed VIIPER credential is missing or unreadable at '{CredentialPath}'.", + ex); + } + finally + { + if (bytes != null) + { + CryptographicOperations.ZeroMemory(bytes); + } + } + } + + private static bool IsBase62(byte value) => + value >= (byte)'0' && value <= (byte)'9' || + value >= (byte)'A' && value <= (byte)'Z' || + value >= (byte)'a' && value <= (byte)'z'; + } + + internal sealed class ViiperNativeSession + { + private readonly object sessionLock = new object(); + private readonly object authenticationLock = new object(); + private readonly IViiperCredentialProvider credentialProvider; + private readonly ViiperNativeRuntimeContract contract; + private byte[] credentialFingerprint; + private ViiperNativeBackendIdentity identity; + private Exception fatalError; + private bool hasAuthenticatedConnection; + + internal ViiperNativeSession(ViiperNativeRuntimeContract contract, + IViiperCredentialProvider credentialProvider) + { + this.contract = contract ?? + throw new ArgumentNullException(nameof(contract)); + this.credentialProvider = credentialProvider ?? + throw new ArgumentNullException(nameof(credentialProvider)); + } + + internal ViiperNativeBackendIdentity Identity + { + get + { + lock (sessionLock) + { + return identity; + } + } + } + + internal ViiperNativeRuntimeContract Contract => contract; + + internal bool HasAuthenticatedConnection + { + get + { + lock (sessionLock) + { + return hasAuthenticatedConnection; + } + } + } + + internal Stream Authenticate(Stream transport) + { + lock (authenticationLock) + { + return AuthenticateSerialized(transport); + } + } + + private Stream AuthenticateSerialized(Stream transport) + { + lock (sessionLock) + { + ThrowIfFatal(); + } + using ViiperCredential credential = credentialProvider.Read(); + lock (sessionLock) + { + ThrowIfFatal(); + if (credentialFingerprint == null) + { + credentialFingerprint = + (byte[])credential.Fingerprint.Clone(); + } + else if (!CryptographicOperations.FixedTimeEquals( + credentialFingerprint, + credential.Fingerprint)) + { + ViiperAuthenticationException changed = new + ViiperAuthenticationException( + "The managed VIIPER credential generation changed during an active controller lifetime."); + fatalError = changed; + throw changed; + } + } + + try + { + Stream authenticated = + ViiperAuthProtocol.AuthenticateClient(transport, + credential.Password); + lock (sessionLock) + { + try + { + ThrowIfFatal(); + hasAuthenticatedConnection = true; + } + catch + { + authenticated.Dispose(); + throw; + } + } + return authenticated; + } + catch (ViiperAuthenticationException ex) + { + lock (sessionLock) + { + fatalError ??= ex; + } + throw; + } + catch (Exception ex) when (ex is IOException || + ex is CryptographicException || + ex is UnauthorizedAccessException) + { + ViiperAuthenticationException wrapped = new + ViiperAuthenticationException( + "VIIPER connection authentication failed.", ex); + throw wrapped; + } + } + + internal void InvalidateIdentity(Exception failure) + { + if (failure == null) + { + throw new ArgumentNullException(nameof(failure)); + } + lock (sessionLock) + { + fatalError ??= failure; + } + } + + internal ViiperNativeBackendIdentity AdmitPing(string raw, + bool reconnect) + { + ViiperNativeBackendIdentity candidate; + try + { + candidate = contract.ValidatePing(raw); + } + catch (Exception ex) when (ex is ViiperIdentityException || + ex is JsonException) + { + lock (sessionLock) + { + fatalError ??= ex; + } + throw; + } + + lock (sessionLock) + { + ThrowIfFatal(); + if (identity == null) + { + identity = candidate; + } + else if (!identity.HasSameGeneration(candidate)) + { + ViiperIdentityException changed = new + ViiperIdentityException( + "VIIPER backend identity changed during an active controller lifetime."); + fatalError = changed; + throw changed; + } + else if (reconnect && credentialFingerprint == null) + { + ViiperIdentityException changed = new + ViiperIdentityException( + "VIIPER reconnect has no pinned credential generation."); + fatalError = changed; + throw changed; + } + return identity; + } + } + + private void ThrowIfFatal() + { + if (fatalError != null) + { + throw new ViiperIdentityException( + "The VIIPER native session is permanently invalid after a credential, authentication, or identity failure.", + fatalError); + } + } + } + + internal sealed class ViiperNativeStatusProbe + { + internal bool Ready { get; init; } + internal bool MetadataPresent { get; init; } + internal bool MetadataEligible { get; init; } + internal bool CredentialReadable { get; init; } + internal bool Authenticated { get; init; } + internal bool IdentityValid { get; init; } + internal string FailureReason { get; init; } + internal ViiperNativeBackendIdentity Identity { get; init; } + } + + internal static class ViiperNativeRuntime + { + internal static ViiperNativeStatusProbe GetStatusProbe(string host, + int port) + { + ViiperNativeRuntimeMetadata metadata; + bool metadataPresent = MetadataExists(); + try + { + metadata = ViiperNativeRuntimeMetadata.LoadBundled(); + } + catch (Exception ex) + { + return new ViiperNativeStatusProbe + { + MetadataPresent = metadataPresent, + MetadataEligible = false, + FailureReason = ex.Message, + }; + } + + IViiperCredentialProvider credentialProvider = + new ViiperProgramDataCredentialProvider(); + try + { + using ViiperCredential ignored = credentialProvider.Read(); + } + catch (Exception ex) + { + return new ViiperNativeStatusProbe + { + MetadataPresent = true, + MetadataEligible = true, + FailureReason = ex.Message, + }; + } + + ViiperClient client = null; + try + { + client = new ViiperClient(host, port, + ViiperTransportMode.NativeUde, metadata, + credentialProvider); + ViiperNativeBackendIdentity identity = + client.ValidateNativeBackend(); + return new ViiperNativeStatusProbe + { + Ready = true, + MetadataPresent = true, + MetadataEligible = true, + CredentialReadable = true, + Authenticated = true, + IdentityValid = true, + Identity = identity, + }; + } + catch (ViiperAuthenticationException ex) + { + return new ViiperNativeStatusProbe + { + MetadataPresent = true, + MetadataEligible = true, + CredentialReadable = true, + FailureReason = ex.Message, + }; + } + catch (Exception ex) + { + return new ViiperNativeStatusProbe + { + MetadataPresent = true, + MetadataEligible = true, + CredentialReadable = true, + Authenticated = client?.HasAuthenticatedNativeConnection == + true, + FailureReason = ex.Message, + }; + } + } + + private static bool MetadataExists() + { + return File.Exists(Path.Combine(AppContext.BaseDirectory, + ViiperNativeRuntimeMetadata.FileName)) || + File.Exists(Path.Combine(AppContext.BaseDirectory, "extras", + ViiperNativeRuntimeMetadata.FileName)); + } + } + + internal sealed class ViiperNativePnpAnchor + { + internal ulong NativeDeviceId { get; init; } + internal uint NativeDeviceGeneration { get; init; } + internal ulong ControllerSessionId { get; init; } + internal string PnpRootInstanceId => ControllerInstanceId; + internal string PnpUsbDeviceInstanceId { get; init; } + internal string ControllerInstanceId { get; init; } + internal uint Usb20PortNumber { get; init; } + internal uint Usb30PortNumber { get; init; } + internal uint UdecxUsbPortNumber => Usb20PortNumber != 0 ? + Usb20PortNumber : Usb30PortNumber; + + internal bool IsExact => NativeDeviceId != 0 && + NativeDeviceGeneration != 0 && + ControllerSessionId != 0 && + !string.IsNullOrWhiteSpace(ControllerInstanceId) && + (Usb20PortNumber != 0 ^ Usb30PortNumber != 0); + } + + internal sealed class ViiperVirtualDeviceIdentity + { + internal ViiperTransportMode TransportMode { get; init; } + internal uint BusId { get; init; } + internal string DevId { get; init; } + internal string DeviceType { get; init; } + internal string Vid { get; init; } + internal string Pid { get; init; } + internal string DeviceSerialNumber { get; init; } + internal string BrokerBuildIdentity { get; init; } + internal string LogicalLifetimeId { get; init; } + internal long StreamGeneration { get; init; } + internal int LegacyUsbipPort { get; init; } = -1; + internal string LegacyUsbipOwnerSerial { get; init; } + internal ViiperNativePnpAnchor NativePnpAnchor { get; init; } + + internal ViiperVirtualDeviceIdentity WithStreamGeneration( + long streamGeneration) + { + return new ViiperVirtualDeviceIdentity + { + TransportMode = TransportMode, + BusId = BusId, + DevId = DevId, + DeviceType = DeviceType, + Vid = Vid, + Pid = Pid, + DeviceSerialNumber = DeviceSerialNumber, + BrokerBuildIdentity = BrokerBuildIdentity, + LogicalLifetimeId = LogicalLifetimeId, + StreamGeneration = streamGeneration, + LegacyUsbipPort = LegacyUsbipPort, + LegacyUsbipOwnerSerial = LegacyUsbipOwnerSerial, + NativePnpAnchor = NativePnpAnchor, + }; + } + } +} diff --git a/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs b/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs index 150f4d8..2c686e7 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs @@ -12,7 +12,9 @@ it under the terms of the GNU General Public License as published by using System.Buffers.Binary; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; +using System.Linq; using System.Net.Sockets; using System.Text; using System.Text.Json; @@ -127,6 +129,8 @@ public sealed class ViiperOutDevice : OutputDevice private const int DualSenseCombinedBluetoothReportLength = 398; private const int DualSenseCombinedBluetoothReportOffset = DualSenseNativeOutputReportOffset + DualSenseNativeOutputReportLength; private const int DualSenseCombinedExtendedFeedbackLength = DualSenseCombinedBluetoothReportOffset + DualSenseCombinedBluetoothReportLength; + private const int DualSenseV5SpeakerPcmLength = + 480 * sizeof(short) * 2; internal const int DualSenseAtomicFeedbackLength = DualSenseCombinedExtendedFeedbackLength; private const int DualSenseMicrophoneOpusFrameLength = 71; @@ -151,9 +155,11 @@ public sealed class ViiperOutDevice : OutputDevice private const byte ViiperStreamFrameOutputState = 0x81; private const byte ViiperStreamFrameSpeakerPcm = 0x82; private const byte ViiperStreamFrameAtomicAudioHaptics = 0x83; + private const byte ViiperStreamFrameRealtimeHaptics = 0x84; private const byte ViiperStreamFrameVersionV2 = 0x02; private const byte ViiperStreamFrameVersionV3 = 0x03; private const byte ViiperStreamFrameVersionV4 = 0x04; + private const byte ViiperStreamFrameVersionV5 = 0x05; private const byte FeedbackSpeakerKindPcm = 0; private const byte FeedbackSpeakerKindAtomicAudioHaptics = 1; private const int AtomicAudioHapticsFeedbackLengthPrefix = 2; @@ -183,6 +189,8 @@ public sealed class ViiperOutDevice : OutputDevice private readonly ViiperVirtualDeviceType viiperType; private readonly bool audioOnlySidecar; private readonly ViiperClient client; + private readonly ViiperLiveValidationLease liveValidationLease; + private readonly object liveValidationObservationLock = new object(); private readonly object pendingPacketLock = new object(); private readonly object microphoneQueueLock = new object(); private readonly object microphoneProcessingLock = new object(); @@ -343,6 +351,12 @@ private readonly MicrophoneDisableRetryTracker private bool physicalDualSenseIdentityVerified; private readonly byte[] lastR2TriggerFeedback = new byte[DualSenseTriggerEffectLength]; private readonly byte[] lastL2TriggerFeedback = new byte[DualSenseTriggerEffectLength]; + private byte[] liveValidationLastFeedback; + private long liveValidationFeedbackFrames; + private long liveValidationMicrophoneFramesSubmitted; + private long liveValidationMicrophoneBytesSubmitted; + private long liveValidationTransportInterruptions; + private long liveValidationStreamRecoveriesCompleted; private enum MicrophoneCodec : byte { @@ -369,10 +383,24 @@ public PendingMicrophoneFrame(MicrophoneCodec codec, byte[] data, public ViiperOutDevice(OutContType outputType, ViiperVirtualDeviceType viiperType, bool audioOnlySidecar = false) + : this(outputType, viiperType, audioOnlySidecar, null, null) { + } + + internal ViiperOutDevice(OutContType outputType, + ViiperVirtualDeviceType viiperType, bool audioOnlySidecar, + ViiperLiveValidationLease validationLease, + ViiperNativeRuntimeMetadata validationMetadata) + { + if ((validationLease == null) != (validationMetadata == null)) + { + throw new ArgumentException( + "A live-validation lease and its exact metadata must be supplied together."); + } this.outputType = outputType; this.viiperType = viiperType; this.audioOnlySidecar = audioOnlySidecar; + liveValidationLease = validationLease; feedbackDispatchBuffer = new ViiperFeedbackDispatchBuffer( // The buffer implementation requires one preallocated slot. // Non-audio devices never enqueue it; their public policy is @@ -385,7 +413,170 @@ public ViiperOutDevice(OutContType outputType, GetFeedbackSpeakerMaximumAgeMilliseconds(viiperType), IsDualSenseVirtualType(viiperType) ? FeedbackOrderedControlMaximumAgeMilliseconds : 0); - client = new ViiperClient(DefaultHost, DefaultPort); + client = validationLease == null ? + new ViiperClient(DefaultHost, DefaultPort) : + new ViiperClient(DefaultHost, DefaultPort, + ViiperTransportMode.NativeUde, validationMetadata); + } + + internal ViiperLiveValidationSnapshot GetLiveValidationSnapshot( + ViiperLiveValidationLease lease) + { + DemandLiveValidationLease(lease); + byte[] feedback; + lock (liveValidationObservationLock) + { + feedback = liveValidationLastFeedback == null ? null : + (byte[])liveValidationLastFeedback.Clone(); + } + + return new ViiperLiveValidationSnapshot + { + HandlerName = GetLiveValidationHandlerName(), + StreamProtocol = activeStreamFrameVersion switch + { + ViiperStreamFrameVersionV3 => "framed-v3", + ViiperStreamFrameVersionV5 => "framed-v5", + _ => "inactive", + }, + StreamFrameVersion = activeStreamFrameVersion, + Connected = connected, + SupportsMicrophone = activeStreamSupportsMicrophone, + SupportsDirectSpeaker = activeStreamSupportsDirectSpeaker, + SupportsAtomicAudioHaptics = + activeStreamSupportsAtomicAudioHaptics, + BackendIdentity = client.NativeBackendIdentity, + DeviceIdentity = VirtualDeviceIdentity, + StatePacketsSubmitted = Interlocked.Read( + ref submittedPacketCount), + StatePacketsWritten = Interlocked.Read(ref writtenPacketCount), + StatePacketsCoalesced = Interlocked.Read( + ref replacedPendingPacketCount), + StreamRecoveryAttempts = Volatile.Read( + ref streamRecoveryAttempts), + FeedbackFramesObserved = Interlocked.Read( + ref liveValidationFeedbackFrames), + LastFeedbackPayload = feedback, + ValidationMicrophoneFramesSubmitted = Interlocked.Read( + ref liveValidationMicrophoneFramesSubmitted), + ValidationMicrophoneBytesSubmitted = Interlocked.Read( + ref liveValidationMicrophoneBytesSubmitted), + ValidationTransportInterruptions = Interlocked.Read( + ref liveValidationTransportInterruptions), + ValidationStreamRecoveriesCompleted = Interlocked.Read( + ref liveValidationStreamRecoveriesCompleted), + SpeakerFramesEnqueued = feedbackDispatchBuffer.SpeakerEnqueued, + SpeakerFramesDequeued = feedbackDispatchBuffer.SpeakerDequeued, + SpeakerFramesDropped = feedbackDispatchBuffer.SpeakerDropped, + SpeakerFramesExpired = feedbackDispatchBuffer.SpeakerExpired, + SpeakerFramesDelivered = Interlocked.Read( + ref feedbackSpeakerDelivered), + SpeakerFramesStale = Interlocked.Read( + ref feedbackSpeakerStale), + SpeakerNoSubscriberDeferrals = Interlocked.Read( + ref feedbackSpeakerNoSubscriberDeferrals), + SpeakerCallbackFailures = Interlocked.Read( + ref feedbackSpeakerCallbackFailures), + ControlFramesEnqueued = feedbackDispatchBuffer.ControlEnqueued, + ControlFramesDequeued = feedbackDispatchBuffer.ControlDequeued, + ControlFramesDropped = feedbackDispatchBuffer.ControlDropped, + OrderedControlFramesEnqueued = + feedbackDispatchBuffer.OrderedControlEnqueued, + OrderedControlFramesDequeued = + feedbackDispatchBuffer.OrderedControlDequeued, + OrderedControlFramesDropped = + feedbackDispatchBuffer.OrderedControlDropped, + OrderedControlFramesExpired = + feedbackDispatchBuffer.OrderedControlExpired, + ControlFramesDelivered = Interlocked.Read( + ref feedbackControlDelivered), + ControlFramesStale = Interlocked.Read( + ref feedbackControlStale), + ControlCallbackFailures = Interlocked.Read( + ref feedbackControlCallbackFailures), + }; + } + + internal void SubmitLiveValidationMicrophonePcm( + ViiperLiveValidationLease lease, byte[] pcm) + { + DemandLiveValidationLease(lease); + int expectedLength = viiperType == + ViiperVirtualDeviceType.DualShock4 ? + DualShock4VirtualMicrophonePcmFrameLength : + DualSenseMicrophonePcmFrameLength; + if (!connected || !activeStreamSupportsMicrophone || + !activeStreamUsesFramedProtocol || pcm == null || + pcm.Length != expectedLength || pcm.All(value => value == 0)) + { + throw new ViiperIdentityException( + $"Live validation requires one non-silent {expectedLength}-byte microphone PCM frame on an active framed PlayStation stream."); + } + + ViiperDeviceStream stream = Volatile.Read(ref deviceStream) ?? + throw new ObjectDisposedException(nameof(ViiperDeviceStream)); + stream.WriteFrame(activeStreamFrameVersion, + ViiperStreamFrameMicrophonePcm, pcm); + Interlocked.Increment( + ref liveValidationMicrophoneFramesSubmitted); + Interlocked.Add(ref liveValidationMicrophoneBytesSubmitted, + pcm.Length); + } + + internal void InterruptLiveValidationTransport( + ViiperLiveValidationLease lease) + { + DemandLiveValidationLease(lease); + ViiperDeviceStream stream = Volatile.Read(ref deviceStream) ?? + throw new ObjectDisposedException(nameof(ViiperDeviceStream)); + Interlocked.Increment(ref liveValidationTransportInterruptions); + stream.CloseTransport(); + } + + private void DemandLiveValidationLease( + ViiperLiveValidationLease lease) + { + if (liveValidationLease == null || + !ReferenceEquals(liveValidationLease, lease)) + { + throw new ViiperIdentityException( + "The VIIPER live-validation hook is unavailable without the exact opt-in lease."); + } + } + + private string GetLiveValidationHandlerName() + { + return viiperType switch + { + ViiperVirtualDeviceType.DualShock4 => + audioOnlySidecar ? "dualshock4audioonlyduplexv3" : + "dualshock4audioduplexv3", + ViiperVirtualDeviceType.DualSense => + audioOnlySidecar ? "dualsenseaudioonlyduplexv5" : + "dualsensecombinedaudioduplexv5", + ViiperVirtualDeviceType.DualSenseEdge => + "dualsenseedgecombinedaudioduplexv5", + _ => ViiperStatePacketBuilder.GetViiperDeviceName(viiperType), + }; + } + + private void ObserveLiveValidationFeedback(byte[] feedback, + int feedbackLength) + { + if (liveValidationLease == null || feedback == null || + feedbackLength <= 0 || feedbackLength > feedback.Length || + feedbackLength > DualSenseCombinedExtendedFeedbackLength) + { + return; + } + + byte[] copy = new byte[feedbackLength]; + Buffer.BlockCopy(feedback, 0, copy, 0, feedbackLength); + lock (liveValidationObservationLock) + { + liveValidationLastFeedback = copy; + } + Interlocked.Increment(ref liveValidationFeedbackFrames); } internal static int GetFeedbackSpeakerQueueCapacity( @@ -461,6 +652,14 @@ internal static bool TryGetAtomicAudioHapticsLayout(byte[] payload, (speakerPcmLength & (sizeof(short) * 2 - 1)) == 0; } + internal static bool IsValidV5RealtimeHapticsFrame(byte version, + byte frameType, int payloadLength) + { + return version == ViiperStreamFrameVersionV5 && + frameType == ViiperStreamFrameRealtimeHaptics && + payloadLength == DualSenseCombinedExtendedFeedbackLength; + } + private Action virtualSpeakerPcmReceived; private ViiperAtomicAudioHapticsHandler @@ -532,6 +731,9 @@ private ViiperAtomicAudioHapticsHandler internal bool IsRuntimeConnected => connected && Volatile.Read(ref deviceStream) != null; + internal ViiperVirtualDeviceIdentity VirtualDeviceIdentity => + Volatile.Read(ref deviceStream)?.VirtualDeviceIdentity; + internal bool SupportsAtomicAudioHaptics => connected && activeStreamSupportsAtomicAudioHaptics; @@ -548,8 +750,15 @@ internal void ApplyAtomicAudioHapticsFeedback(byte[] feedback, SupportsDirectSpeakerPcm ? GetVirtualSpeakerPcmSampleRate(viiperType) : 0; - internal int DirectSpeakerUsbipPort => - Volatile.Read(ref deviceStream)?.UsbipPort ?? -1; + internal int DirectSpeakerUsbipPort + { + get + { + ViiperDeviceStream stream = Volatile.Read(ref deviceStream); + return stream?.TransportMode == ViiperTransportMode.Usbip ? + stream.UsbipPort : -1; + } + } internal bool SupportsActiveVirtualMicrophone => connected && activeStreamSupportsMicrophone; @@ -580,11 +789,9 @@ public override void Connect() { Disconnect(); - ViiperPrerequisiteStatus status = ViiperSetupManager.GetStatus(tryStartServer: true); - if (!status.Ready) + if (client.TransportMode == ViiperTransportMode.NativeUde) { - throw new IOException( - $"{status.DisplayText}. Use Settings > VIIPER Virtual Controller Support to install or repair VIIPER and usbip-win2."); + client.ValidateNativeBackend(); } deviceStream = CreateDeviceStreamWithServerFallback(); @@ -653,6 +860,19 @@ public override void Connect() Interlocked.Exchange(ref feedbackControlDelivered, 0); Interlocked.Exchange(ref feedbackControlStale, 0); Interlocked.Exchange(ref feedbackControlCallbackFailures, 0); + Interlocked.Exchange(ref liveValidationFeedbackFrames, 0); + Interlocked.Exchange( + ref liveValidationMicrophoneFramesSubmitted, 0); + Interlocked.Exchange( + ref liveValidationMicrophoneBytesSubmitted, 0); + Interlocked.Exchange( + ref liveValidationTransportInterruptions, 0); + Interlocked.Exchange( + ref liveValidationStreamRecoveriesCompleted, 0); + lock (liveValidationObservationLock) + { + liveValidationLastFeedback = null; + } feedbackDispatchBuffer.Reset(); lock (physicalDualSenseIdentityLock) { @@ -702,277 +922,56 @@ private ViiperDeviceStream CreateDeviceStream() if (viiperType == ViiperVirtualDeviceType.DualSense) { - if (audioOnlySidecar) - { - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualsenseaudioonlyduplexv4"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamSupportsAtomicAudioHaptics = true; - activeStreamUsesAudioOnlyDescriptor = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV4; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualSense audio-only sidecar V4 unavailable, trying V3: {ex.Message}", - false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualsenseaudioonlyduplexv3"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamUsesAudioOnlyDescriptor = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV3; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualSense audio-only sidecar unavailable: {ex.Message}", - true); - throw new IOException( - "The installed VIIPER build does not support the DualSense audio-only interface. Update VIIPER from Settings before using PlayStation audio with an Xbox or Switch output.", - ex); - } - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualsensecombinedaudioduplexv4"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamSupportsAtomicAudioHaptics = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV4; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualSense atomic audio/haptics stream unavailable, trying V3: {ex.Message}", - false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualsensecombinedaudioduplexv3"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV3; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualSense direct speaker stream unavailable, trying microphone V2: {ex.Message}", - false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsensecombinedmicv2"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV2; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui($"VIIPER DualSense microphone input unavailable, continuing without mic-in: {ex.Message}", false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsensecombinedext"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - return stream; - } - catch (IOException ex) - { - try - { - AppLogger.LogToGui($"VIIPER DualSense combined haptics feedback unavailable, using legacy extended feedback: {ex.Message}", false); - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseext"); - activeFeedbackLength = DualSenseExtendedFeedbackLength; - return stream; - } - catch (IOException legacyEx) - { - AppLogger.LogToGui($"VIIPER DualSense adaptive trigger feedback unavailable, falling back to base DualSense output: {legacyEx.Message}", false); - activeFeedbackLength = DualSenseBaseFeedbackLength; - return client.CreateDeviceAndOpenStream("dualsense"); - } - } + ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( + audioOnlySidecar ? "dualsenseaudioonlyduplexv5" : + "dualsensecombinedaudioduplexv5"); + ConfigureDualSenseV5Stream(audioOnlySidecar); + return stream; } if (viiperType == ViiperVirtualDeviceType.DualSenseEdge) { - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualsenseedgecombinedaudioduplexv4"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamSupportsAtomicAudioHaptics = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV4; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualSense Edge atomic audio/haptics stream unavailable, trying V3: {ex.Message}", - false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualsenseedgecombinedaudioduplexv3"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV3; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualSense Edge direct speaker stream unavailable, trying microphone V2: {ex.Message}", - false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseedgecombinedmicv2"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV2; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui($"VIIPER DualSense Edge microphone input unavailable, continuing without mic-in: {ex.Message}", false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseedgecombinedext"); - activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; - return stream; - } - catch (IOException ex) + if (audioOnlySidecar) { - try - { - AppLogger.LogToGui($"VIIPER DualSense Edge combined haptics feedback unavailable, using legacy extended feedback: {ex.Message}", false); - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseedgeext"); - activeFeedbackLength = DualSenseExtendedFeedbackLength; - return stream; - } - catch (IOException legacyEx) - { - AppLogger.LogToGui($"VIIPER DualSense Edge adaptive trigger feedback unavailable, falling back to base DualSense Edge output: {legacyEx.Message}", false); - activeFeedbackLength = DualSenseBaseFeedbackLength; - return client.CreateDeviceAndOpenStream("dualsenseedge"); - } + throw new ViiperIdentityException( + "The authoritative VIIPER contract does not expose a DualSense Edge audio-only V5 handler."); } + ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( + "dualsenseedgecombinedaudioduplexv5"); + ConfigureDualSenseV5Stream(audioOnly: false); + return stream; } if (viiperType == ViiperVirtualDeviceType.DualShock4) { - if (audioOnlySidecar) - { - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualshock4audioonlyduplexv3", 0x05C4); - activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength( - viiperType); - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamUsesAudioOnlyDescriptor = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV3; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualShock 4 audio-only sidecar unavailable: {ex.Message}", - true); - throw new IOException( - "The installed VIIPER build does not support the DualShock 4 audio-only interface. Update VIIPER from Settings before using PlayStation audio with an Xbox or Switch output.", - ex); - } - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualshock4audioduplexv3", 0x05C4); - activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength( - viiperType); - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamSupportsDirectSpeaker = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV3; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualShock 4 direct speaker stream unavailable, trying microphone V2: {ex.Message}", - false); - } - - try - { - ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( - "dualshock4micv2", 0x05C4); - activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength( - viiperType); - activeStreamUsesFramedProtocol = true; - activeStreamSupportsMicrophone = true; - activeStreamFrameVersion = ViiperStreamFrameVersionV2; - return stream; - } - catch (IOException ex) - { - AppLogger.LogToGui( - $"VIIPER DualShock 4 microphone input unavailable, continuing without mic-in: {ex.Message}", - false); - } - + ViiperDeviceStream stream = client.CreateDeviceAndOpenStream( + audioOnlySidecar ? "dualshock4audioonlyduplexv3" : + "dualshock4audioduplexv3", 0x05C4); activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength( viiperType); - return client.CreateDeviceAndOpenStream("dualshock4", 0x05C4); + activeStreamUsesFramedProtocol = true; + activeStreamSupportsMicrophone = true; + activeStreamSupportsDirectSpeaker = true; + activeStreamUsesAudioOnlyDescriptor = audioOnlySidecar; + activeStreamFrameVersion = ViiperStreamFrameVersionV3; + return stream; } activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength(viiperType); return client.CreateDeviceAndOpenStream(viiperType); } + private void ConfigureDualSenseV5Stream(bool audioOnly) + { + activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength; + activeStreamUsesFramedProtocol = true; + activeStreamSupportsMicrophone = true; + activeStreamSupportsDirectSpeaker = true; + activeStreamSupportsAtomicAudioHaptics = true; + activeStreamUsesAudioOnlyDescriptor = audioOnly; + activeStreamFrameVersion = ViiperStreamFrameVersionV5; + } + private ViiperDeviceStream CreateDeviceStreamWithServerFallback() { try @@ -981,13 +980,9 @@ private ViiperDeviceStream CreateDeviceStreamWithServerFallback() } catch (IOException first) { - ViiperPrerequisiteStatus status = ViiperSetupManager.GetStatus(tryStartServer: true); - if (!status.Ready) - { - throw; - } - - AppLogger.LogToGui($"VIIPER {viiperType} stream open failed once; server is available, retrying: {first.Message}", false); + AppLogger.LogToGui( + $"VIIPER {viiperType} stream open failed once; retrying the selected {client.TransportMode} transport: {first.Message}", + false); Thread.Sleep(250); return CreateDeviceStream(); } @@ -2062,8 +2057,9 @@ private bool TryRecoverStream(string reason, long failedStreamGeneration, $"VIIPER {viiperType} stream interrupted; reopening the existing virtual device: {reason}", true); - // Closing only the TCP transport wakes the old feedback reader - // without detaching usbip or removing the virtual controller. + // Closing only the authenticated/raw TCP transport wakes the + // old feedback reader without releasing transport ownership or + // removing the virtual controller. // Keep the published generation and lifetime intact until a // replacement transport has actually opened. interruptedStream.CloseTransport(); @@ -2091,7 +2087,6 @@ private bool TryRecoverStream(string reason, long failedStreamGeneration, client.OpenExistingDeviceStream( interruptedStream.BusId, interruptedStream.DevId, - interruptedStream.UsbipPort, interruptedStream.DeviceLifetime); if (writerStopRequested || !connected) { @@ -2104,6 +2099,11 @@ private bool TryRecoverStream(string reason, long failedStreamGeneration, { deviceStream = replacement; Interlocked.Increment(ref streamGeneration); + if (liveValidationLease != null) + { + Interlocked.Increment(ref + liveValidationStreamRecoveriesCompleted); + } Interlocked.Exchange(ref streamRecoveryAttempts, 0); // Publish the new timeline only after every old // callback and reader admission has left its read @@ -2842,7 +2842,8 @@ private void FeedbackReadLoop(int feedbackLength, break; } - if (frameType == ViiperStreamFrameOutputState) + if (frameType == ViiperStreamFrameOutputState && + payloadLength == feedbackLength) { int targetDeviceIndex = Volatile.Read( ref lastInputDeviceIndex); @@ -2863,6 +2864,8 @@ private void FeedbackReadLoop(int feedbackLength, } else if (frameType == ViiperStreamFrameSpeakerPcm && + activeStreamFrameVersion == + ViiperStreamFrameVersionV3 && payloadLength > 0 && payloadLength % (sizeof(short) * 2) == 0) { @@ -2878,6 +2881,8 @@ private void FeedbackReadLoop(int feedbackLength, else if (frameType == ViiperStreamFrameAtomicAudioHaptics && activeStreamSupportsAtomicAudioHaptics && + activeStreamFrameVersion == + ViiperStreamFrameVersionV5 && payloadLength > AtomicAudioHapticsFeedbackLengthPrefix) { @@ -2888,12 +2893,15 @@ private void FeedbackReadLoop(int feedbackLength, int speakerPcmLength = payloadLength - AtomicAudioHapticsFeedbackLengthPrefix - atomicFeedbackLength; - if (atomicFeedbackLength == - DualSenseCombinedExtendedFeedbackLength && - speakerPcmLength > 0 && - (speakerPcmLength & - (sizeof(short) * 2 - 1)) == 0 && - feedbackDispatchBuffer.TryEnqueueSpeaker( + if (atomicFeedbackLength != + DualSenseCombinedExtendedFeedbackLength || + speakerPcmLength != + DualSenseV5SpeakerPcmLength) + { + throw new IOException( + "VIIPER returned a malformed V5 atomic audio/haptics payload."); + } + if (feedbackDispatchBuffer.TryEnqueueSpeaker( framedPayload, payloadLength, readStreamGeneration, FeedbackSpeakerKindAtomicAudioHaptics, @@ -2902,6 +2910,25 @@ private void FeedbackReadLoop(int feedbackLength, feedbackSpeakerSignal.Set(); } } + else if (IsValidV5RealtimeHapticsFrame( + activeStreamFrameVersion, frameType, + payloadLength)) + { + if (feedbackDispatchBuffer + .TryEnqueueOrderedControl( + framedPayload, payloadLength, + readStreamGeneration, + Volatile.Read( + ref lastInputDeviceIndex))) + { + feedbackControlSignal.Set(); + } + } + else + { + throw new IOException( + $"VIIPER returned an unsupported {activeStreamFrameVersion:X2}/{frameType:X2} framed feedback packet of {payloadLength} bytes."); + } } finally { @@ -2956,8 +2983,14 @@ private void ApplyFeedback(byte[] feedback, int feedbackLength, int expectedDeviceIndex = -1) { int deviceIndex = Volatile.Read(ref lastInputDeviceIndex); - if ((expectedDeviceIndex >= 0 && - expectedDeviceIndex != deviceIndex) || + if (expectedDeviceIndex >= 0 && + expectedDeviceIndex != deviceIndex) + { + return; + } + + ObserveLiveValidationFeedback(feedback, feedbackLength); + if ( deviceIndex < 0 || Program.rootHub == null || deviceIndex >= Program.rootHub.DS4Controllers.Length || @@ -4554,13 +4587,111 @@ internal sealed class ViiperClient PropertyNameCaseInsensitive = true, }; + private static readonly JsonSerializerOptions NativeJsonOptions = + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + private readonly string host; private readonly int port; + private readonly object nativeSessionLock = new object(); + private readonly ViiperNativeRuntimeMetadata deferredNativeMetadata; + private readonly IViiperCredentialProvider deferredCredentialProvider; + private ViiperNativeSession nativeSession; public ViiperClient(string host, int port) + : this(host, port, ViiperTransportSettings.GetManagedMode(), null, + null) + { + } + + internal ViiperClient(string host, int port, + ViiperTransportMode transportMode, + ViiperNativeRuntimeMetadata metadata = null, + IViiperCredentialProvider credentialProvider = null) { - this.host = host; + this.host = string.IsNullOrWhiteSpace(host) ? + throw new ArgumentException("A VIIPER host is required.", + nameof(host)) : host.Trim(); + if (port <= 0 || port > ushort.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(port)); + } this.port = port; + TransportMode = transportMode; + if (transportMode == ViiperTransportMode.NativeUde) + { + if (!string.Equals(this.host, "localhost", + StringComparison.OrdinalIgnoreCase) && + (!System.Net.IPAddress.TryParse(this.host, + out System.Net.IPAddress address) || + !System.Net.IPAddress.IsLoopback(address))) + { + throw new ViiperIdentityException( + "Managed native VIIPER connections must use a loopback address."); + } + deferredNativeMetadata = metadata; + deferredCredentialProvider = credentialProvider; + } + } + + internal ViiperTransportMode TransportMode { get; } + + internal bool HasAuthenticatedNativeConnection => + nativeSession?.HasAuthenticatedConnection == true; + + internal ViiperNativeBackendIdentity NativeBackendIdentity => + nativeSession?.Identity; + + private ViiperNativeSession GetNativeSession() + { + if (TransportMode != ViiperTransportMode.NativeUde) + { + throw new ViiperIdentityException( + "An authenticated native session was requested for the explicit USB/IP client."); + } + lock (nativeSessionLock) + { + if (nativeSession == null) + { + ViiperNativeRuntimeMetadata metadata = + deferredNativeMetadata ?? + ViiperNativeRuntimeMetadata.LoadBundled(); + nativeSession = new ViiperNativeSession( + new ViiperNativeRuntimeContract(metadata), + deferredCredentialProvider ?? + new ViiperProgramDataCredentialProvider()); + } + return nativeSession; + } + } + + internal ViiperNativeBackendIdentity ValidateNativeBackend() + { + if (TransportMode != ViiperTransportMode.NativeUde) + { + throw new ViiperIdentityException( + "Native VIIPER validation was requested for the explicit USB/IP client."); + } + string raw = SendRequestRawCore("ping", null); + return GetNativeSession().AdmitPing(raw, reconnect: false); + } + + private ViiperNativeBackendIdentity RevalidateNativeBackend() + { + string raw = SendRequestRawCore("ping", null); + return GetNativeSession().AdmitPing(raw, reconnect: true); + } + + private void EnsureNativeBackendValidated() + { + if (TransportMode == ViiperTransportMode.NativeUde && + nativeSession?.Identity == null) + { + ValidateNativeBackend(); + } } public ViiperDeviceStream CreateDeviceAndOpenStream(ViiperVirtualDeviceType deviceType) @@ -4571,35 +4702,100 @@ public ViiperDeviceStream CreateDeviceAndOpenStream(ViiperVirtualDeviceType devi public ViiperDeviceStream CreateDeviceAndOpenStream(string deviceName, ushort? idProduct = null) { - ViiperUsbipPortManager.DetachStaleLocalViiperPorts(); + EnsureNativeBackendValidated(); + ushort? effectiveProductId = idProduct; + if (TransportMode == ViiperTransportMode.NativeUde) + { + effectiveProductId = GetNativeSession().Contract + .ValidateControllerRequest(deviceName, idProduct); + } + if (TransportMode == ViiperTransportMode.Usbip) + { + ViiperUsbipPortManager.DetachStaleLocalViiperPorts(); + } - ViiperBusCreateResponse bus = SendRequest("bus/create", "0"); + ViiperBusCreateResponse bus; + try + { + bus = SendRequest("bus/create", "0"); + if (bus == null || bus.BusId == 0) + { + throw new ViiperIdentityException( + "VIIPER returned an invalid bus identity."); + } + } + catch (Exception failure) when ( + TransportMode == ViiperTransportMode.NativeUde && + (failure is ViiperIdentityException || failure is JsonException)) + { + GetNativeSession().InvalidateIdentity(failure); + throw; + } ViiperDeviceResponse device = null; + ViiperVirtualDeviceIdentity nativeIdentity = null; int usbipPort = -1; try { string payload = JsonSerializer.Serialize(new ViiperDeviceCreateRequest { Type = deviceName, - IdProduct = idProduct, + IdProduct = effectiveProductId, }, JsonOptions); device = SendRequest($"bus/{bus.BusId}/add", payload); - usbipPort = ViiperUsbipPortManager.FindLocalViiperPort(bus.BusId, device.DevId); + if (TransportMode == ViiperTransportMode.NativeUde) + { + nativeIdentity = + ValidateNativeDeviceIdentity(bus.BusId, device, + deviceName); + var lifetime = new ViiperVirtualDeviceLifetime( + nativeIdentity, RemoveNativeDevice); + return OpenStream(bus.BusId, device.DevId, lifetime); + } + + ValidateUsbipDeviceResponse(device); + usbipPort = ViiperUsbipPortManager.FindLocalViiperPort( + bus.BusId, device.DevId); ViiperUsbipPortManager.RegisterActivePort(usbipPort); - ViiperUsbipPortManager.DetachDuplicateLocalViiperPorts(bus.BusId, device.DevId, usbipPort); - return OpenStream(bus.BusId, device.DevId, usbipPort); + ViiperUsbipPortManager.DetachDuplicateLocalViiperPorts( + bus.BusId, device.DevId, usbipPort); + ViiperVirtualDeviceIdentity usbipIdentity = + CreateUsbipDeviceIdentity(bus.BusId, device, deviceName, + usbipPort); + var usbipLifetime = new ViiperVirtualDeviceLifetime( + usbipIdentity, RemoveDevice); + return OpenStream(bus.BusId, device.DevId, usbipLifetime); } - catch + catch (Exception failure) { - ViiperUsbipPortManager.UnregisterActivePort(usbipPort); + if (TransportMode == ViiperTransportMode.Usbip) + { + ViiperUsbipPortManager.UnregisterActivePort(usbipPort); + } - if (device != null && !string.IsNullOrEmpty(device.DevId)) + if (TransportMode == ViiperTransportMode.Usbip && + device != null && !string.IsNullOrEmpty(device.DevId)) { TryRemoveDevice(bus.BusId, device.DevId); } - TryRemoveBus(bus.BusId); + if (TransportMode == ViiperTransportMode.Usbip) + { + TryRemoveBus(bus.BusId); + } + else if (nativeIdentity != null) + { + TryRemoveNativeDevice(nativeIdentity); + } + if (TransportMode == ViiperTransportMode.NativeUde && + (failure is ViiperIdentityException || + failure is JsonException)) + { + // Keep the authenticated session usable long enough to + // remove any partially-created topology, then permanently + // fence it before control returns to the caller. + GetNativeSession().InvalidateIdentity(failure); + } throw; } } @@ -4684,6 +4880,12 @@ internal ViiperMicrophoneInterfaceStatus GetMicrophoneInterfaceStatus( public ViiperDeviceStream OpenExistingDeviceStream(uint busId, string devId, int usbipPort) { + if (TransportMode != ViiperTransportMode.Usbip) + { + throw new ArgumentException( + "A USB/IP port may only be supplied to an explicit USB/IP VIIPER client.", + nameof(usbipPort)); + } return OpenExistingDeviceStream(busId, devId, usbipPort, null); } @@ -4691,6 +4893,11 @@ internal ViiperDeviceStream OpenExistingDeviceStream(uint busId, string devId, int usbipPort, ViiperVirtualDeviceLifetime deviceLifetime) { + if (TransportMode != ViiperTransportMode.Usbip) + { + throw new ViiperIdentityException( + "Native VIIPER stream reopen requires the exact captured virtual-device lifetime receipt."); + } if (string.IsNullOrWhiteSpace(devId)) { throw new ArgumentException( @@ -4708,25 +4915,75 @@ internal ViiperDeviceStream OpenExistingDeviceStream(uint busId, nameof(deviceLifetime)); } - return OpenStream(busId, devId, usbipPort, deviceLifetime); + deviceLifetime ??= new ViiperVirtualDeviceLifetime(busId, + devId, usbipPort, RemoveDevice); + return OpenStream(busId, devId, deviceLifetime); + } + + internal ViiperDeviceStream OpenExistingDeviceStream(uint busId, + string devId, ViiperVirtualDeviceLifetime deviceLifetime) + { + if (deviceLifetime == null) + { + throw new ArgumentNullException(nameof(deviceLifetime)); + } + if (string.IsNullOrWhiteSpace(devId) || + deviceLifetime.BusId != busId || + !string.Equals(deviceLifetime.DevId, devId, + StringComparison.Ordinal) || + deviceLifetime.TransportMode != TransportMode) + { + throw new ArgumentException( + "The VIIPER stream identity must match its virtual-device lifetime.", + nameof(deviceLifetime)); + } + + if (TransportMode == ViiperTransportMode.NativeUde) + { + try + { + RevalidateNativeBackend(); + ValidateExistingNativeDevice(deviceLifetime); + } + catch (Exception failure) when ( + failure is ViiperIdentityException || + failure is JsonException) + { + GetNativeSession().InvalidateIdentity(failure); + throw; + } + } + return OpenStream(busId, devId, deviceLifetime); } private ViiperDeviceStream OpenStream(uint busId, string devId, - int usbipPort, - ViiperVirtualDeviceLifetime deviceLifetime = null) + ViiperVirtualDeviceLifetime deviceLifetime) { - TcpClient tcp = Connect(StreamReceiveTimeoutMs); + TcpClient tcp = Connect(TransportMode == + ViiperTransportMode.NativeUde ? ApiReceiveTimeoutMs : + StreamReceiveTimeoutMs); + Stream stream = null; try { - NetworkStream stream = tcp.GetStream(); + stream = tcp.GetStream(); + if (TransportMode == ViiperTransportMode.NativeUde) + { + stream = GetNativeSession().Authenticate(stream); + tcp.ReceiveTimeout = StreamReceiveTimeoutMs; + } byte[] request = Encoding.UTF8.GetBytes($"bus/{busId}/{devId}\0"); stream.Write(request, 0, request.Length); - deviceLifetime ??= new ViiperVirtualDeviceLifetime(busId, - devId, usbipPort, RemoveDevice); - return new ViiperDeviceStream(tcp, stream, deviceLifetime); + return new ViiperDeviceStream(stream, tcp, deviceLifetime); } catch { + try + { + stream?.Dispose(); + } + catch + { + } tcp.Dispose(); throw; } @@ -4738,6 +4995,113 @@ private void RemoveDevice(uint busId, string devId) TryRemoveBus(busId); } + private void RemoveNativeDevice( + ViiperVirtualDeviceIdentity identity) + { + if (identity?.TransportMode != ViiperTransportMode.NativeUde || + identity.NativePnpAnchor?.IsExact != true || + identity.BusId == 0 || + string.IsNullOrWhiteSpace(identity.DevId)) + { + throw new ViiperIdentityException( + "Exact native VIIPER identity is required for conditional removal."); + } + + ViiperNativePnpAnchor anchor = identity.NativePnpAnchor; + string payload = JsonSerializer.Serialize( + new ViiperNativeRemoveRequest + { + DevId = identity.DevId, + Transport = "native-ude", + NativeUde = new ViiperNativeDeviceResponse + { + DeviceId = anchor.NativeDeviceId.ToString( + CultureInfo.InvariantCulture), + DeviceGeneration = anchor.NativeDeviceGeneration, + ControllerSessionId = + anchor.ControllerSessionId.ToString( + CultureInfo.InvariantCulture), + ControllerInstanceId = anchor.ControllerInstanceId, + Usb20PortNumber = anchor.Usb20PortNumber, + Usb30PortNumber = anchor.Usb30PortNumber, + }, + }, NativeJsonOptions); + string path = $"bus/{identity.BusId}/remove-native"; + string raw = SendRequestRaw(path, payload); + if (string.IsNullOrWhiteSpace(raw)) + { + throw new IOException( + "VIIPER returned an empty native removal response."); + } + + ViiperNativeRuntimeContract.ValidateNoDuplicateJsonProperties( + raw, path); + using JsonDocument document = JsonDocument.Parse(raw, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 8, + }); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new IOException( + "VIIPER returned a non-object native removal response."); + } + + JsonProperty[] properties = root.EnumerateObject().ToArray(); + if (root.TryGetProperty("status", out JsonElement status)) + { + if (properties.Length == 3 && + properties.All(property => property.Name is "status" or + "title" or "detail") && + status.ValueKind == JsonValueKind.Number && + status.TryGetInt32(out int statusCode) && + statusCode == 409 && + root.TryGetProperty("title", out JsonElement title) && + title.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(title.GetString()) && + root.TryGetProperty("detail", out JsonElement detail) && + detail.ValueKind == JsonValueKind.String) + { + // The receipt is stale and a successor owns this bus/dev + // key. Never retry the legacy ID-only endpoint. + return; + } + throw new IOException( + "VIIPER native removal returned a non-canonical API error."); + } + + if (properties.Length != 2 || + properties.Any(property => property.Name is not "busId" and + not "devId") || + !root.TryGetProperty("busId", out JsonElement returnedBus) || + returnedBus.ValueKind != JsonValueKind.Number || + !returnedBus.TryGetUInt32(out uint returnedBusId) || + returnedBusId != identity.BusId || + !root.TryGetProperty("devId", out JsonElement returnedDevice) || + returnedDevice.ValueKind != JsonValueKind.String || + !string.Equals(returnedDevice.GetString(), identity.DevId, + StringComparison.Ordinal)) + { + throw new IOException( + "VIIPER native removal success did not echo the exact bus/device identity."); + } + } + + private void TryRemoveNativeDevice( + ViiperVirtualDeviceIdentity identity) + { + try + { + RemoveNativeDevice(identity); + } + catch + { + } + } + private void TryRemoveDevice(uint busId, string devId) { try @@ -4768,19 +5132,39 @@ private T SendRequest(string path, string payload = null) throw new IOException("VIIPER returned an empty response."); } - ViiperApiError apiError = JsonSerializer.Deserialize(raw, JsonOptions); + JsonSerializerOptions responseOptions = TransportMode == + ViiperTransportMode.NativeUde ? NativeJsonOptions : + JsonOptions; + if (TransportMode == ViiperTransportMode.NativeUde) + { + ViiperNativeRuntimeContract.ValidateNoDuplicateJsonProperties( + raw, path); + } + ViiperApiError apiError = JsonSerializer.Deserialize( + raw, responseOptions); if (apiError != null && (apiError.Status != 0 || !string.IsNullOrEmpty(apiError.Title))) { throw new IOException($"VIIPER API error {apiError.Status} {apiError.Title}: {apiError.Detail}"); } - return JsonSerializer.Deserialize(raw, JsonOptions); + return JsonSerializer.Deserialize(raw, responseOptions); } private string SendRequestRaw(string path, string payload = null) + { + if (!string.Equals(path, "ping", StringComparison.Ordinal)) + { + EnsureNativeBackendValidated(); + } + return SendRequestRawCore(path, payload); + } + + private string SendRequestRawCore(string path, string payload) { using TcpClient tcp = Connect(ApiReceiveTimeoutMs); - NetworkStream stream = tcp.GetStream(); + using Stream stream = TransportMode == ViiperTransportMode.NativeUde ? + GetNativeSession().Authenticate(tcp.GetStream()) : + tcp.GetStream(); string request = string.IsNullOrEmpty(payload) ? path : $"{path} {payload}"; byte[] requestBytes = Encoding.UTF8.GetBytes(request + "\0"); stream.Write(requestBytes, 0, requestBytes.Length); @@ -4833,8 +5217,81 @@ private sealed class ViiperBusCreateResponse private sealed class ViiperDeviceResponse { + private int? usbipPort; + private string usbipOwnerSerial; + + [JsonPropertyName("busId")] + public uint BusId { get; set; } + [JsonPropertyName("devId")] public string DevId { get; set; } + + [JsonPropertyName("vid")] + public string Vid { get; set; } + + [JsonPropertyName("pid")] + public string Pid { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("transport")] + public string Transport { get; set; } + + [JsonPropertyName("deviceSpecific")] + public JsonElement DeviceSpecific { get; set; } + + [JsonPropertyName("usbipPort")] + public int? UsbipPort + { + get => usbipPort; + set + { + HasUsbipPortProperty = true; + usbipPort = value; + } + } + + [JsonIgnore] + public bool HasUsbipPortProperty { get; private set; } + + [JsonPropertyName("usbipOwnerSerial")] + public string UsbipOwnerSerial + { + get => usbipOwnerSerial; + set + { + HasUsbipOwnerSerialProperty = true; + usbipOwnerSerial = value; + } + } + + [JsonIgnore] + public bool HasUsbipOwnerSerialProperty { get; private set; } + + [JsonPropertyName("nativeUde")] + public ViiperNativeDeviceResponse NativeUde { get; set; } + } + + private sealed class ViiperNativeDeviceResponse + { + [JsonPropertyName("deviceId")] + public string DeviceId { get; set; } + + [JsonPropertyName("deviceGeneration")] + public uint DeviceGeneration { get; set; } + + [JsonPropertyName("controllerSessionId")] + public string ControllerSessionId { get; set; } + + [JsonPropertyName("controllerInstanceId")] + public string ControllerInstanceId { get; set; } + + [JsonPropertyName("usb20PortNumber")] + public uint Usb20PortNumber { get; set; } + + [JsonPropertyName("usb30PortNumber")] + public uint Usb30PortNumber { get; set; } } private sealed class ViiperDeviceCreateRequest @@ -4847,6 +5304,18 @@ private sealed class ViiperDeviceCreateRequest public ushort? IdProduct { get; set; } } + private sealed class ViiperNativeRemoveRequest + { + [JsonPropertyName("devId")] + public string DevId { get; set; } + + [JsonPropertyName("transport")] + public string Transport { get; set; } + + [JsonPropertyName("nativeUde")] + public ViiperNativeDeviceResponse NativeUde { get; set; } + } + private sealed class ViiperBusDevicesResponse { [JsonPropertyName("devices")] @@ -4855,9 +5324,55 @@ private sealed class ViiperBusDevicesResponse private sealed class ViiperListedDevice { + private int? usbipPort; + private string usbipOwnerSerial; + [JsonPropertyName("devId")] public string DevId { get; set; } + [JsonPropertyName("transport")] + public string Transport { get; set; } + + [JsonPropertyName("vid")] + public string Vid { get; set; } + + [JsonPropertyName("pid")] + public string Pid { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("nativeUde")] + public ViiperNativeDeviceResponse NativeUde { get; set; } + + [JsonPropertyName("usbipPort")] + public int? UsbipPort + { + get => usbipPort; + set + { + HasUsbipPortProperty = true; + usbipPort = value; + } + } + + [JsonIgnore] + public bool HasUsbipPortProperty { get; private set; } + + [JsonPropertyName("usbipOwnerSerial")] + public string UsbipOwnerSerial + { + get => usbipOwnerSerial; + set + { + HasUsbipOwnerSerialProperty = true; + usbipOwnerSerial = value; + } + } + + [JsonIgnore] + public bool HasUsbipOwnerSerialProperty { get; private set; } + [JsonPropertyName("deviceSpecific")] public JsonElement DeviceSpecific { get; set; } } @@ -4882,6 +5397,242 @@ private sealed class ViiperApiError [JsonPropertyName("detail")] public string Detail { get; set; } } + + private ViiperVirtualDeviceIdentity ValidateNativeDeviceIdentity( + uint busId, ViiperDeviceResponse device, string expectedType) + { + if (device == null || string.IsNullOrWhiteSpace(device.DevId)) + { + throw new ViiperIdentityException( + "VIIPER native add response omitted the device ID."); + } + if (device.BusId != busId) + { + throw new ViiperIdentityException( + "VIIPER native add response returned the wrong bus ID."); + } + if (!string.Equals(device.Transport, "native-ude", + StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + $"VIIPER native add response transport is '{device.Transport ?? ""}'."); + } + if (device.HasUsbipPortProperty || + device.HasUsbipOwnerSerialProperty) + { + throw new ViiperIdentityException( + "VIIPER native add response contained USB/IP ownership fields."); + } + if (!string.Equals(device.Type, expectedType, + StringComparison.OrdinalIgnoreCase)) + { + throw new ViiperIdentityException( + "VIIPER native add response returned the wrong device type."); + } + if (!string.Equals(device.Type, expectedType, + StringComparison.Ordinal) || + !GetNativeSession().Contract.HasExactControllerIdentity( + expectedType, device.Vid, device.Pid)) + { + throw new ViiperIdentityException( + "VIIPER native add response did not match the source-bound controller type/VID/PID identity."); + } + if (!uint.TryParse(device.DevId, NumberStyles.None, + CultureInfo.InvariantCulture, out uint numericDevId) || + numericDevId == 0 || + !string.Equals(numericDevId.ToString( + CultureInfo.InvariantCulture), device.DevId, + StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + "VIIPER native add response devId is not canonical decimal."); + } + + ViiperNativeDeviceResponse native = device.NativeUde ?? + throw new ViiperIdentityException( + "VIIPER native add response omitted nativeUde identity."); + if (!ulong.TryParse(native.DeviceId, NumberStyles.None, + CultureInfo.InvariantCulture, out ulong nativeDeviceId) || + nativeDeviceId == 0 || + !string.Equals(nativeDeviceId.ToString( + CultureInfo.InvariantCulture), native.DeviceId, + StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + "VIIPER nativeUde.deviceId is not canonical decimal."); + } + ulong expectedNativeDeviceId = (ulong)busId << 32 | numericDevId; + if (nativeDeviceId != expectedNativeDeviceId) + { + throw new ViiperIdentityException( + $"VIIPER nativeUde.deviceId={nativeDeviceId} does not match bus/dev identity {expectedNativeDeviceId}."); + } + if (native.DeviceGeneration == 0) + { + throw new ViiperIdentityException( + "VIIPER nativeUde.deviceGeneration is zero."); + } + ulong controllerSessionId = + ViiperNativeRuntimeContract.ParseCanonicalNonZeroUInt64( + native.ControllerSessionId, + "nativeUde.controllerSessionId"); + ViiperNativeRuntimeContract.ValidateControllerInstanceId( + native.ControllerInstanceId); + ViiperNativeBackendIdentity backend = GetNativeSession().Identity ?? + throw new ViiperIdentityException( + "VIIPER native backend identity is not pinned."); + if (!string.Equals(native.ControllerInstanceId, + backend.ControllerInstanceId, StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + "VIIPER device controller instance differs from the authenticated ping identity."); + } + if (controllerSessionId != backend.ControllerSessionId) + { + throw new ViiperIdentityException( + "VIIPER device controller session differs from the authenticated ping identity."); + } + if (!(native.Usb20PortNumber != 0 ^ + native.Usb30PortNumber != 0)) + { + throw new ViiperIdentityException( + "VIIPER native device identity must contain exactly one non-zero UdeCx USB port number."); + } + + return new ViiperVirtualDeviceIdentity + { + TransportMode = ViiperTransportMode.NativeUde, + BusId = busId, + DevId = device.DevId, + DeviceType = device.Type, + Vid = device.Vid, + Pid = device.Pid, + DeviceSerialNumber = GetDeviceSerialNumber( + device.DeviceSpecific), + BrokerBuildIdentity = backend.DriverBuildIdentity, + LogicalLifetimeId = Guid.NewGuid().ToString("N"), + NativePnpAnchor = new ViiperNativePnpAnchor + { + NativeDeviceId = nativeDeviceId, + NativeDeviceGeneration = native.DeviceGeneration, + ControllerSessionId = controllerSessionId, + ControllerInstanceId = native.ControllerInstanceId, + Usb20PortNumber = native.Usb20PortNumber, + Usb30PortNumber = native.Usb30PortNumber, + }, + }; + } + + private static void ValidateUsbipDeviceResponse( + ViiperDeviceResponse device) + { + if (device == null || string.IsNullOrWhiteSpace(device.DevId)) + { + throw new IOException( + "VIIPER USB/IP add response omitted the device ID."); + } + if (!string.IsNullOrEmpty(device.Transport) && + !string.Equals(device.Transport, "usbip", + StringComparison.Ordinal)) + { + throw new IOException( + $"VIIPER explicit USB/IP add response returned transport '{device.Transport}'."); + } + if (device.NativeUde != null) + { + throw new IOException( + "VIIPER explicit USB/IP response unexpectedly contained nativeUde identity."); + } + } + + private ViiperVirtualDeviceIdentity CreateUsbipDeviceIdentity( + uint busId, ViiperDeviceResponse device, string expectedType, + int usbipPort) + { + return new ViiperVirtualDeviceIdentity + { + TransportMode = ViiperTransportMode.Usbip, + BusId = busId, + DevId = device.DevId, + DeviceType = string.IsNullOrEmpty(device.Type) ? + expectedType : device.Type, + Vid = device.Vid, + Pid = device.Pid, + DeviceSerialNumber = GetDeviceSerialNumber( + device.DeviceSpecific), + LogicalLifetimeId = Guid.NewGuid().ToString("N"), + LegacyUsbipPort = usbipPort, + LegacyUsbipOwnerSerial = device.UsbipOwnerSerial, + }; + } + + private void ValidateExistingNativeDevice( + ViiperVirtualDeviceLifetime lifetime) + { + ViiperBusDevicesResponse response = + SendRequest( + $"bus/{lifetime.BusId}/list"); + ViiperListedDevice[] matches = response?.Devices?.Where( + candidate => string.Equals(candidate.DevId, + lifetime.DevId, StringComparison.Ordinal)).ToArray(); + ViiperListedDevice listed = matches?.Length == 1 ? matches[0] : + null; + if (listed == null || + !string.Equals(listed.Transport, "native-ude", + StringComparison.Ordinal) || listed.NativeUde == null || + listed.HasUsbipPortProperty || + listed.HasUsbipOwnerSerialProperty || + !string.Equals(listed.Type, + lifetime.VirtualDeviceIdentity.DeviceType, + StringComparison.Ordinal) || + !string.Equals(listed.Vid, + lifetime.VirtualDeviceIdentity.Vid, + StringComparison.Ordinal) || + !string.Equals(listed.Pid, + lifetime.VirtualDeviceIdentity.Pid, + StringComparison.Ordinal)) + { + throw new ViiperIdentityException( + "VIIPER reconnect could not prove the original native device topology."); + } + ViiperNativePnpAnchor expected = + lifetime.VirtualDeviceIdentity.NativePnpAnchor; + ViiperNativeDeviceResponse actual = listed.NativeUde; + if (!ulong.TryParse(actual.DeviceId, NumberStyles.None, + CultureInfo.InvariantCulture, out ulong deviceId) || + deviceId != expected.NativeDeviceId || + !string.Equals(deviceId.ToString( + CultureInfo.InvariantCulture), actual.DeviceId, + StringComparison.Ordinal) || + actual.DeviceGeneration != expected.NativeDeviceGeneration || + !ulong.TryParse(actual.ControllerSessionId, + NumberStyles.None, CultureInfo.InvariantCulture, + out ulong controllerSessionId) || + !string.Equals(controllerSessionId.ToString( + CultureInfo.InvariantCulture), + actual.ControllerSessionId, StringComparison.Ordinal) || + controllerSessionId != expected.ControllerSessionId || + !string.Equals(actual.ControllerInstanceId, + expected.ControllerInstanceId, StringComparison.Ordinal) || + actual.Usb20PortNumber != expected.Usb20PortNumber || + actual.Usb30PortNumber != expected.Usb30PortNumber) + { + throw new ViiperIdentityException( + "VIIPER native device identity changed across stream reconnect."); + } + } + + private static string GetDeviceSerialNumber(JsonElement specific) + { + if (specific.ValueKind == JsonValueKind.Object && + specific.TryGetProperty("serial_number", + out JsonElement serial) && + serial.ValueKind == JsonValueKind.String) + { + return serial.GetString(); + } + return null; + } } internal static class ViiperUsbipPortManager @@ -5228,13 +5979,14 @@ public UsbipPortBlock(int port, string block) internal sealed class ViiperVirtualDeviceLifetime : IDisposable { - private readonly uint busId; - private readonly string devId; - private readonly int usbipPort; + private readonly ViiperVirtualDeviceIdentity virtualDeviceIdentity; private readonly Action detachPort; private readonly Action unregisterPort; - private readonly Action removeDevice; + private readonly Action removeUsbipDevice; + private readonly Action + removeNativeDevice; private readonly Action detachStalePorts; + private long streamGeneration; private int disposed; internal ViiperVirtualDeviceLifetime(uint busId, string devId, @@ -5242,65 +5994,169 @@ internal ViiperVirtualDeviceLifetime(uint busId, string devId, Action detachPort = null, Action unregisterPort = null, Action detachStalePorts = null) + : this(new ViiperVirtualDeviceIdentity + { + TransportMode = ViiperTransportMode.Usbip, + BusId = busId, + DevId = devId, + LogicalLifetimeId = Guid.NewGuid().ToString("N"), + LegacyUsbipPort = usbipPort, + }, removeDevice, null, detachPort, unregisterPort, + detachStalePorts) { - this.busId = busId; - this.devId = devId ?? throw new ArgumentNullException(nameof(devId)); - this.usbipPort = usbipPort; - this.removeDevice = removeDevice; - this.detachPort = detachPort ?? ViiperUsbipPortManager.DetachPort; - this.unregisterPort = unregisterPort ?? - ViiperUsbipPortManager.UnregisterActivePort; - this.detachStalePorts = detachStalePorts ?? - ViiperUsbipPortManager.DetachStaleLocalViiperPorts; } - internal uint BusId => busId; - - internal string DevId => devId; - - internal int UsbipPort => usbipPort; + internal ViiperVirtualDeviceLifetime( + ViiperVirtualDeviceIdentity virtualDeviceIdentity, + Action removeDevice, + Action detachPort = null, + Action unregisterPort = null, + Action detachStalePorts = null) + : this(virtualDeviceIdentity, removeDevice, null, detachPort, + unregisterPort, detachStalePorts) + { + } - internal bool IsDisposed => Volatile.Read(ref disposed) == 1; + internal ViiperVirtualDeviceLifetime( + ViiperVirtualDeviceIdentity virtualDeviceIdentity, + Action removeNativeDevice, + Action detachPort = null, + Action unregisterPort = null, + Action detachStalePorts = null) + : this(virtualDeviceIdentity, null, removeNativeDevice, + detachPort, unregisterPort, detachStalePorts) + { + } - public void Dispose() + private ViiperVirtualDeviceLifetime( + ViiperVirtualDeviceIdentity virtualDeviceIdentity, + Action removeUsbipDevice, + Action removeNativeDevice, + Action detachPort, + Action unregisterPort, + Action detachStalePorts) { - if (Interlocked.Exchange(ref disposed, 1) == 1) + this.virtualDeviceIdentity = virtualDeviceIdentity ?? + throw new ArgumentNullException(nameof(virtualDeviceIdentity)); + if (virtualDeviceIdentity.BusId == 0 || + string.IsNullOrWhiteSpace(virtualDeviceIdentity.DevId) || + string.IsNullOrWhiteSpace( + virtualDeviceIdentity.LogicalLifetimeId)) { - return; + throw new ArgumentException( + "A complete VIIPER logical device identity is required.", + nameof(virtualDeviceIdentity)); } - - try + if (virtualDeviceIdentity.TransportMode == + ViiperTransportMode.NativeUde && + (virtualDeviceIdentity.NativePnpAnchor?.IsExact != true || + removeNativeDevice == null || removeUsbipDevice != null)) { - detachPort?.Invoke(usbipPort, - "DS4Windows VIIPER device stopped"); + throw new ArgumentException( + "Native VIIPER lifetime identity requires exact UdeCx correlation and a receipt-conditioned removal callback.", + nameof(virtualDeviceIdentity)); } - catch + if (virtualDeviceIdentity.TransportMode == + ViiperTransportMode.Usbip) { + if (removeNativeDevice != null) + { + throw new ArgumentException( + "USB/IP lifetime identity cannot use native conditional removal.", + nameof(removeNativeDevice)); + } + this.removeUsbipDevice = removeUsbipDevice; + this.detachPort = detachPort ?? + ViiperUsbipPortManager.DetachPort; + this.unregisterPort = unregisterPort ?? + ViiperUsbipPortManager.UnregisterActivePort; + this.detachStalePorts = detachStalePorts ?? + ViiperUsbipPortManager.DetachStaleLocalViiperPorts; } - - try + else { - unregisterPort?.Invoke(usbipPort); + this.removeNativeDevice = removeNativeDevice; } - catch + } + + internal uint BusId => virtualDeviceIdentity.BusId; + + internal string DevId => virtualDeviceIdentity.DevId; + + internal int UsbipPort => virtualDeviceIdentity.LegacyUsbipPort; + + internal ViiperTransportMode TransportMode => + virtualDeviceIdentity.TransportMode; + + internal ViiperVirtualDeviceIdentity VirtualDeviceIdentity => + virtualDeviceIdentity.WithStreamGeneration( + Interlocked.Read(ref streamGeneration)); + + internal bool IsDisposed => Volatile.Read(ref disposed) == 1; + + internal ViiperVirtualDeviceIdentity NextStreamIdentity() + { + if (IsDisposed) { + throw new ObjectDisposedException( + nameof(ViiperVirtualDeviceLifetime)); } + long generation = Interlocked.Increment(ref streamGeneration); + return virtualDeviceIdentity.WithStreamGeneration(generation); + } - try + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) == 1) { - removeDevice?.Invoke(busId, devId); + return; } - catch + + if (TransportMode == ViiperTransportMode.Usbip) { + try + { + detachPort?.Invoke(UsbipPort, + "DS4Windows VIIPER device stopped"); + } + catch + { + } + + try + { + unregisterPort?.Invoke(UsbipPort); + } + catch + { + } } try { - detachStalePorts?.Invoke(); + if (TransportMode == ViiperTransportMode.NativeUde) + { + removeNativeDevice?.Invoke(virtualDeviceIdentity); + } + else + { + removeUsbipDevice?.Invoke(BusId, DevId); + } } catch { } + + if (TransportMode == ViiperTransportMode.Usbip) + { + try + { + detachStalePorts?.Invoke(); + } + catch + { + } + } } } @@ -5310,6 +6166,7 @@ internal sealed class ViiperDeviceStream : IDisposable private readonly IDisposable transport; private readonly Stream stream; private readonly ViiperVirtualDeviceLifetime deviceLifetime; + private readonly ViiperVirtualDeviceIdentity virtualDeviceIdentity; private readonly object writeLock = new object(); private readonly byte[] incomingFrameHeader = new byte[FrameV2HeaderLength]; @@ -5331,6 +6188,7 @@ internal sealed class ViiperDeviceStream : IDisposable private const byte FrameVersionV2 = 0x02; private const byte FrameVersionV3 = 0x03; private const byte FrameVersionV4 = 0x04; + private const byte FrameVersionV5 = 0x05; public ViiperDeviceStream(TcpClient tcp, Stream stream, ViiperVirtualDeviceLifetime deviceLifetime) @@ -5345,6 +6203,7 @@ internal ViiperDeviceStream(Stream stream, IDisposable transport, this.transport = transport ?? throw new ArgumentNullException(nameof(transport)); this.deviceLifetime = deviceLifetime ?? throw new ArgumentNullException(nameof(deviceLifetime)); + virtualDeviceIdentity = deviceLifetime.NextStreamIdentity(); } public uint BusId => deviceLifetime.BusId; @@ -5353,6 +6212,12 @@ internal ViiperDeviceStream(Stream stream, IDisposable transport, public int UsbipPort => deviceLifetime.UsbipPort; + internal ViiperTransportMode TransportMode => + virtualDeviceIdentity.TransportMode; + + internal ViiperVirtualDeviceIdentity VirtualDeviceIdentity => + virtualDeviceIdentity; + internal ViiperVirtualDeviceLifetime DeviceLifetime => deviceLifetime; internal bool IsTransportClosed => @@ -5387,7 +6252,7 @@ public void WriteFrame(byte version, byte frameType, byte[] data) throw new ArgumentOutOfRangeException(nameof(data)); } if (version != FrameVersionV2 && version != FrameVersionV3 && - version != FrameVersionV4) + version != FrameVersionV4 && version != FrameVersionV5) { throw new ArgumentOutOfRangeException(nameof(version)); } @@ -5637,8 +6502,9 @@ public static string GetViiperDeviceName(ViiperVirtualDeviceType type) { ViiperVirtualDeviceType.Xbox360 => "xbox360", ViiperVirtualDeviceType.DualShock4 => "dualshock4", - ViiperVirtualDeviceType.DualSense => "dualsenseext", - ViiperVirtualDeviceType.DualSenseEdge => "dualsenseedgeext", + ViiperVirtualDeviceType.DualSense => "dualsensegamepadv5", + ViiperVirtualDeviceType.DualSenseEdge => + "dualsenseedgegamepadv5", ViiperVirtualDeviceType.Switch2Pro => "ns2pro", _ => "xbox360", }; diff --git a/DS4Windows/DS4Control/Viiper/ViiperPnPOwnership.cs b/DS4Windows/DS4Control/Viiper/ViiperPnPOwnership.cs new file mode 100644 index 0000000..f6d847d --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/ViiperPnPOwnership.cs @@ -0,0 +1,688 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace DS4Windows +{ + /// + /// Identifies the Windows transport which owns a VIIPER-created USB + /// device. Unknown is intentionally not a wildcard. + /// + internal enum ViiperPnPTransport + { + Unknown, + NativeUdeCx, + LegacyUsbIp, + } + + /// + /// The exact portion of a Windows PnP ancestry used to correlate HID and + /// UAC interfaces which belong to the same emulated USB device. + /// + internal readonly struct ViiperPnPTopologyIdentity : + IEquatable + { + internal ViiperPnPTopologyIdentity(ViiperPnPTransport transport, + string rootInstanceId, string usbDeviceInstanceId, + int usbPortNumber) + { + Transport = transport; + RootInstanceId = Normalize(rootInstanceId); + UsbDeviceInstanceId = Normalize(usbDeviceInstanceId); + UsbPortNumber = usbPortNumber >= 0 ? usbPortNumber : -1; + } + + internal ViiperPnPTransport Transport { get; } + internal string RootInstanceId { get; } + internal string UsbDeviceInstanceId { get; } + internal int UsbPortNumber { get; } + + internal bool IsResolved => + Transport != ViiperPnPTransport.Unknown && + !string.IsNullOrEmpty(RootInstanceId) && + !string.IsNullOrEmpty(UsbDeviceInstanceId); + + internal bool IsUsbDeviceResolved => + !string.IsNullOrEmpty(UsbDeviceInstanceId); + + internal bool IsSameUsbDevice( + ViiperPnPTopologyIdentity other) + { + if (!IsUsbDeviceResolved || !other.IsUsbDeviceResolved) + { + return false; + } + + if (!string.Equals(UsbDeviceInstanceId, + other.UsbDeviceInstanceId, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return string.IsNullOrEmpty(RootInstanceId) || + string.IsNullOrEmpty(other.RootInstanceId) || + string.Equals(RootInstanceId, other.RootInstanceId, + StringComparison.OrdinalIgnoreCase); + } + + public bool Equals(ViiperPnPTopologyIdentity other) + { + return Transport == other.Transport && + UsbPortNumber == other.UsbPortNumber && + string.Equals(RootInstanceId, other.RootInstanceId, + StringComparison.OrdinalIgnoreCase) && + string.Equals(UsbDeviceInstanceId, + other.UsbDeviceInstanceId, + StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is ViiperPnPTopologyIdentity other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Transport, + StringComparer.OrdinalIgnoreCase.GetHashCode( + RootInstanceId ?? string.Empty), + StringComparer.OrdinalIgnoreCase.GetHashCode( + UsbDeviceInstanceId ?? string.Empty), + UsbPortNumber); + } + + private static string Normalize(string value) + { + return string.IsNullOrWhiteSpace(value) ? string.Empty : + value.Trim().TrimEnd('\0'); + } + } + + /// + /// Source-provided identity of one VIIPER virtual-device lifetime. A + /// native identity is publishable only when it carries the exclusive + /// controller-session nonce, driver's exact device ID/generation, and an + /// exact PnP correlation anchor. Stream reconnect counters are not valid + /// substitutes for any of those source-bound fields. + /// + internal readonly struct ViiperPnPCorrelation : + IEquatable + { + internal ViiperPnPCorrelation(ViiperPnPTransport transport, + ulong nativeDeviceId, uint deviceGeneration, + ulong controllerSessionId, + string rootInstanceId, string usbDeviceInstanceId, + int usbPortNumber, string legacyOwnerSerial = null) + { + Transport = transport; + NativeDeviceId = nativeDeviceId; + DeviceGeneration = deviceGeneration; + ControllerSessionId = controllerSessionId; + RootInstanceId = Normalize(rootInstanceId); + UsbDeviceInstanceId = Normalize(usbDeviceInstanceId); + UsbPortNumber = usbPortNumber >= 0 ? usbPortNumber : -1; + LegacyOwnerSerial = Normalize(legacyOwnerSerial); + } + + internal ViiperPnPTransport Transport { get; } + internal ulong NativeDeviceId { get; } + internal uint DeviceGeneration { get; } + internal ulong ControllerSessionId { get; } + internal string RootInstanceId { get; } + internal string UsbDeviceInstanceId { get; } + internal int UsbPortNumber { get; } + internal string LegacyOwnerSerial { get; } + + internal bool IsExact + { + get + { + bool hasRoot = !string.IsNullOrEmpty(RootInstanceId); + return Transport switch + { + ViiperPnPTransport.NativeUdeCx => + NativeDeviceId != 0 && DeviceGeneration != 0 && + ControllerSessionId != 0 && + hasRoot && UsbPortNumber > 0, + ViiperPnPTransport.LegacyUsbIp => + UsbPortNumber >= 0 && + !string.IsNullOrEmpty(LegacyOwnerSerial), + _ => false, + }; + } + } + + internal bool Matches(ViiperPnPTopologyIdentity candidate) + { + if (!IsExact || !candidate.IsResolved || + Transport != candidate.Transport) + { + return false; + } + + if (Transport == ViiperPnPTransport.NativeUdeCx) + { + if (!string.Equals(RootInstanceId, + candidate.RootInstanceId, + StringComparison.OrdinalIgnoreCase) || + candidate.UsbPortNumber != UsbPortNumber) + { + return false; + } + + // The authenticated create result's controller instance and + // UdeCx port are the source-bound ownership key. A concrete + // top-level USB instance, when a future contract supplies it, + // is an additional constraint rather than a port substitute. + return string.IsNullOrEmpty(UsbDeviceInstanceId) || + string.Equals(UsbDeviceInstanceId, + candidate.UsbDeviceInstanceId, + StringComparison.OrdinalIgnoreCase); + } + + return UsbPortNumber >= 0 && + candidate.UsbPortNumber == UsbPortNumber; + } + + public bool Equals(ViiperPnPCorrelation other) + { + return Transport == other.Transport && + NativeDeviceId == other.NativeDeviceId && + DeviceGeneration == other.DeviceGeneration && + ControllerSessionId == other.ControllerSessionId && + UsbPortNumber == other.UsbPortNumber && + string.Equals(RootInstanceId, other.RootInstanceId, + StringComparison.OrdinalIgnoreCase) && + string.Equals(UsbDeviceInstanceId, + other.UsbDeviceInstanceId, + StringComparison.OrdinalIgnoreCase) && + string.Equals(LegacyOwnerSerial, other.LegacyOwnerSerial, + StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is ViiperPnPCorrelation other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Transport, NativeDeviceId, + DeviceGeneration, ControllerSessionId, + StringComparer.OrdinalIgnoreCase.GetHashCode( + RootInstanceId ?? string.Empty), + StringComparer.OrdinalIgnoreCase.GetHashCode( + UsbDeviceInstanceId ?? string.Empty), + UsbPortNumber, + StringComparer.OrdinalIgnoreCase.GetHashCode( + LegacyOwnerSerial ?? string.Empty)); + } + + private static string Normalize(string value) + { + return string.IsNullOrWhiteSpace(value) ? string.Empty : + value.Trim().TrimEnd('\0'); + } + } + + /// + /// Testable projection of one node in a child-to-root PnP ancestry walk. + /// + internal readonly struct ViiperPnPAncestryNode + { + internal ViiperPnPAncestryNode(string instanceId, + string parentInstanceId, IEnumerable hardwareIds, + string locationInfo) + { + InstanceId = instanceId ?? string.Empty; + ParentInstanceId = parentInstanceId ?? string.Empty; + HardwareIds = hardwareIds == null ? Array.Empty() : + new List(hardwareIds).ToArray(); + LocationInfo = locationInfo ?? string.Empty; + } + + internal string InstanceId { get; } + internal string ParentInstanceId { get; } + internal IReadOnlyList HardwareIds { get; } + internal string LocationInfo { get; } + } + + /// + /// Generation-fenced table which turns exact PnP correlations into small + /// positive owner tokens. The int token is retained as a temporary adapter + /// for existing audio-haptics call sites; zero and negative values always + /// fail closed and never mean "any controller". + /// + internal sealed class ViiperPnPOwnershipTable + { + private readonly object syncRoot = new object(); + private readonly Dictionary entries = + new Dictionary(); + private int nextToken; + + internal int AllocateToken() + { + for (;;) + { + int token = Interlocked.Increment(ref nextToken); + if (token > 0) + { + return token; + } + + Interlocked.CompareExchange(ref nextToken, 0, token); + } + } + + internal bool Publish(int token, ViiperPnPCorrelation correlation) + { + if (token <= 0 || !correlation.IsExact) + { + return false; + } + + lock (syncRoot) + { + if (!entries.TryGetValue(token, + out ViiperPnPCorrelation current)) + { + entries[token] = correlation; + return true; + } + + if (correlation.Transport != current.Transport || + (correlation.Transport == + ViiperPnPTransport.NativeUdeCx && + (correlation.ControllerSessionId != + current.ControllerSessionId || + correlation.NativeDeviceId != + current.NativeDeviceId))) + { + // A positive token names one created device in one + // exclusive controller-file session. Never recycle it + // across a broker/controller restart or another device. + return false; + } + + if (correlation.DeviceGeneration < current.DeviceGeneration) + { + return false; + } + + if (correlation.DeviceGeneration == current.DeviceGeneration) + { + // A stream reconnect may republish the same exact lifetime. + // The same generation may never silently change its PnP + // anchor or logical device identity. + return current.Equals(correlation); + } + + entries[token] = correlation; + return true; + } + } + + internal bool Matches(int token, + ViiperPnPTopologyIdentity candidate) + { + if (token <= 0) + { + return false; + } + + lock (syncRoot) + { + return entries.TryGetValue(token, + out ViiperPnPCorrelation correlation) && + correlation.Matches(candidate); + } + } + + internal bool MatchesAny(ViiperPnPTopologyIdentity candidate) + { + if (!candidate.IsResolved) + { + return false; + } + + lock (syncRoot) + { + foreach (ViiperPnPCorrelation correlation in entries.Values) + { + if (correlation.Matches(candidate)) + { + return true; + } + } + + return false; + } + } + + internal bool TryGet(int token, out ViiperPnPCorrelation correlation) + { + if (token <= 0) + { + correlation = default; + return false; + } + + lock (syncRoot) + { + return entries.TryGetValue(token, out correlation); + } + } + + internal void Remove(int token) + { + if (token <= 0) + { + return; + } + + lock (syncRoot) + { + entries.Remove(token); + } + } + } + + internal static class ViiperPnPOwnershipRegistry + { + private static readonly ViiperPnPOwnershipTable Table = + new ViiperPnPOwnershipTable(); + private static readonly object SourcesLock = new object(); + private static readonly ConditionalWeakTable Sources = new ConditionalWeakTable< + ViiperOutDevice, SourceRegistration>(); + private static readonly Dictionary> TokenSources = new Dictionary>(); + + private sealed class SourceRegistration + { + internal SourceRegistration(int token) + { + Token = token; + } + + internal int Token { get; } + } + + internal static int AllocateToken() + { + return Table.AllocateToken(); + } + + internal static bool Publish(int token, + ViiperPnPCorrelation correlation) + { + return Table.Publish(token, correlation); + } + + internal static bool Matches(int token, + ViiperPnPTopologyIdentity candidate) + { + RefreshLiveSourceIdentities(); + return Table.Matches(token, candidate); + } + + internal static bool MatchesAny( + ViiperPnPTopologyIdentity candidate) + { + RefreshLiveSourceIdentities(); + return Table.MatchesAny(candidate); + } + + internal static int AttachOrUpdate(ViiperOutDevice source, + ViiperPnPCorrelation correlation) + { + if (source == null || !correlation.IsExact) + { + return -1; + } + + lock (SourcesLock) + { + if (!Sources.TryGetValue(source, + out SourceRegistration registration)) + { + registration = new SourceRegistration( + Table.AllocateToken()); + if (!Table.Publish(registration.Token, correlation)) + { + return -1; + } + + Sources.Add(source, registration); + TokenSources[registration.Token] = + new WeakReference(source); + return registration.Token; + } + + if (Table.TryGet(registration.Token, + out ViiperPnPCorrelation current) && + RequiresNewOwnerToken(current, correlation)) + { + // A new controller session or native device is a new + // creation lifetime even if its generation/root/port were + // numerically reused. Rotate the adapter token and retire + // the prior correlation before publishing the replacement. + Sources.Remove(source); + Table.Remove(registration.Token); + TokenSources.Remove(registration.Token); + var replacement = new SourceRegistration( + Table.AllocateToken()); + if (!Table.Publish(replacement.Token, correlation)) + { + return -1; + } + + Sources.Add(source, replacement); + TokenSources[replacement.Token] = + new WeakReference(source); + return replacement.Token; + } + + if (Table.Publish(registration.Token, correlation)) + { + TokenSources[registration.Token] = + new WeakReference(source); + return registration.Token; + } + + // The source now claims an older or conflicting lifetime. + // Retaining its previous table entry would keep stale HID/UAC + // interfaces owned even though callers correctly received a + // failed token. Withdraw the entire registration fail-closed; + // a later authoritative identity may attach as a new token. + Sources.Remove(source); + Table.Remove(registration.Token); + TokenSources.Remove(registration.Token); + return -1; + } + } + + private static void RefreshLiveSourceIdentities() + { + var liveSources = new List(); + var expiredTokens = new List(); + lock (SourcesLock) + { + foreach (KeyValuePair> entry in TokenSources) + { + if (entry.Value.TryGetTarget( + out ViiperOutDevice source)) + { + liveSources.Add(source); + } + else + { + expiredTokens.Add(entry.Key); + } + } + + foreach (int token in expiredTokens) + { + TokenSources.Remove(token); + Table.Remove(token); + } + } + + foreach (ViiperOutDevice source in liveSources) + { + // A null identity is the expected brief state during a + // transport-only reconnect; the created USB device and its + // existing correlation remain valid. Any newly published + // authoritative identity is immediately generation/session + // fenced by AttachOrUpdate. + if (source.VirtualDeviceIdentity != null) + { + GetToken(source); + } + } + } + + private static bool RequiresNewOwnerToken( + ViiperPnPCorrelation current, + ViiperPnPCorrelation replacement) + { + if (current.Transport != replacement.Transport) + { + return true; + } + + return replacement.Transport == ViiperPnPTransport.NativeUdeCx && + (current.ControllerSessionId != + replacement.ControllerSessionId || + current.NativeDeviceId != replacement.NativeDeviceId); + } + + internal static int AttachOrUpdate(ViiperOutDevice source) + { + if (!TryCreateCorrelation(source?.VirtualDeviceIdentity, + out ViiperPnPCorrelation correlation)) + { + return -1; + } + + return AttachOrUpdate(source, correlation); + } + + internal static int GetToken(ViiperOutDevice source) + { + if (source == null) + { + return -1; + } + + ViiperVirtualDeviceIdentity identity = + source.VirtualDeviceIdentity; + if (identity != null) + { + if (TryCreateCorrelation(identity, + out ViiperPnPCorrelation correlation)) + { + return AttachOrUpdate(source, correlation); + } + + // A reconnect which has not yet published its authoritative + // device generation must not retain the old endpoint binding. + Detach(source); + return -1; + } + + lock (SourcesLock) + { + return Sources.TryGetValue(source, + out SourceRegistration registration) ? + registration.Token : -1; + } + } + + internal static void Detach(ViiperOutDevice source) + { + if (source == null) + { + return; + } + + lock (SourcesLock) + { + if (!Sources.TryGetValue(source, + out SourceRegistration registration)) + { + return; + } + + Sources.Remove(source); + Table.Remove(registration.Token); + TokenSources.Remove(registration.Token); + } + } + + private static bool TryCreateCorrelation( + ViiperVirtualDeviceIdentity identity, + out ViiperPnPCorrelation correlation) + { + correlation = default; + if (identity == null) + { + return false; + } + + if (identity.TransportMode == ViiperTransportMode.NativeUde) + { + ViiperNativePnpAnchor anchor = identity.NativePnpAnchor; + if (anchor?.IsExact != true || + anchor.UdecxUsbPortNumber > int.MaxValue) + { + return false; + } + + correlation = new ViiperPnPCorrelation( + ViiperPnPTransport.NativeUdeCx, + anchor.NativeDeviceId, + anchor.NativeDeviceGeneration, + anchor.ControllerSessionId, + anchor.ControllerInstanceId, + // The authoritative contract intentionally binds by the + // exclusive controller instance plus its exact UdeCx port; + // it does not claim a top-level USB PnP instance string. + string.Empty, + (int)anchor.UdecxUsbPortNumber); + return correlation.IsExact; + } + + if (identity.TransportMode == ViiperTransportMode.Usbip) + { + correlation = new ViiperPnPCorrelation( + ViiperPnPTransport.LegacyUsbIp, 0, 0, 0, + string.Empty, string.Empty, + identity.LegacyUsbipPort, + identity.LegacyUsbipOwnerSerial); + return correlation.IsExact; + } + + return false; + } + + internal static bool TryGet(int token, + out ViiperPnPCorrelation correlation) + { + return Table.TryGet(token, out correlation); + } + + internal static void Remove(int token) + { + lock (SourcesLock) + { + TokenSources.Remove(token); + Table.Remove(token); + } + } + } +} diff --git a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs index 104e4c0..f8f65c4 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs @@ -10,26 +10,55 @@ it under the terms of the GNU General Public License as published by using Microsoft.Win32; using System; -using System.Diagnostics; +using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Net.Sockets; -using System.Text; -using System.Threading; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text.Json; using System.Windows; namespace DS4Windows { public sealed class ViiperPrerequisiteStatus { - public bool ViiperInstalled { get; set; } - public bool ServerRunning { get; set; } - public bool UsbipInstalled { get; set; } + public bool MetadataFound { get; set; } + public bool MetadataEligible { get; set; } + public bool LocalTestMetadata { get; set; } + public bool PackageBundleFound { get; set; } + public bool BrokerInstalled { get; set; } + public bool BrokerHashMatches { get; set; } + public bool BrokerServiceInstalled { get; set; } + public bool BrokerServiceConfigured { get; set; } + public bool BrokerServiceRunning { get; set; } + public bool CredentialReadable { get; set; } + public bool AuthenticatedPingSucceeded { get; set; } + public bool RuntimeContractCompatible { get; set; } public bool SetupScriptFound { get; set; } - public string ViiperPath { get; set; } + public string BrokerPath { get; set; } + public string CredentialPath { get; set; } + public string MetadataPath { get; set; } public string SetupScriptPath { get; set; } - - public bool Ready => ServerRunning && UsbipInstalled; + public string Detail { get; set; } + + // Source-compatible diagnostic aliases. USB/IP is deliberately not a + // prerequisite for native output and is never reported as installed. + public bool ViiperInstalled => BrokerInstalled; + public bool ServerRunning => + BrokerServiceRunning && AuthenticatedPingSucceeded; + public bool UsbipInstalled => false; + public string ViiperPath => BrokerPath; + + public bool Ready => + MetadataEligible && + BrokerInstalled && + BrokerHashMatches && + BrokerServiceInstalled && + BrokerServiceConfigured && + BrokerServiceRunning && + CredentialReadable && + AuthenticatedPingSucceeded && + RuntimeContractCompatible; public string DisplayText { @@ -37,25 +66,47 @@ public string DisplayText { if (Ready) { - return "VIIPER ready"; + return LocalTestMetadata + ? "VIIPER native UDE ready (disposable-VM local test)" + : "VIIPER native UDE ready"; } - - if (!UsbipInstalled && !ViiperInstalled) + if (!MetadataFound) { - return "VIIPER and usbip-win2 need setup"; + return "VIIPER native runtime metadata missing"; } - - if (!UsbipInstalled) + if (!MetadataEligible) { - return "usbip-win2 driver missing"; + return LocalTestMetadata + ? "VIIPER bundle is local-test evidence only" + : "VIIPER native runtime is not production eligible"; } - - if (!ViiperInstalled) + if (!BrokerInstalled || !BrokerServiceInstalled) { - return "VIIPER helper missing"; + return "VIIPER native UDE package is not installed"; } - - return ServerRunning ? "VIIPER status unknown" : "VIIPER server not running"; + if (!BrokerHashMatches || !BrokerServiceConfigured) + { + return "VIIPER native broker installation does not match this build"; + } + if (!CredentialReadable) + { + return "VIIPER protected API credential is unavailable"; + } + if (!BrokerServiceRunning) + { + return "VIIPERNativeBroker service is not running"; + } + if (!AuthenticatedPingSucceeded) + { + return "VIIPER native broker authentication failed"; + } + if (!RuntimeContractCompatible) + { + return "VIIPER native driver contract is incompatible"; + } + return string.IsNullOrWhiteSpace(Detail) + ? "VIIPER native UDE status unknown" + : Detail; } } } @@ -64,273 +115,664 @@ public static class ViiperSetupManager { public const string ApiHost = "127.0.0.1"; public const int ApiPort = 3242; - public const string UsbipWin2ReleasesUrl = "https://github.com/vadimgrn/usbip-win2/releases"; - public const string ViiperReleasesUrl = "https://github.com/hbashton/VIIPER/releases"; - - private const string InstallerScriptName = "install-viiper-backend.ps1"; - private static readonly object serverStartLock = new object(); - private static DateTime lastServerStartAttemptUtc = DateTime.MinValue; + public const string NativeBrokerServiceName = "VIIPERNativeBroker"; + public const string LocalTestOptInEnvironment = + "DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST"; + + private const string InstallerScriptName = + "manage-viiper-native-package.ps1"; + private const string MetadataFileName = + "ViiperNativeRuntimeMetadata.json"; private static int promptShownThisSession; - public static bool IsViiperOutputType(OutContType type) => ViiperOutDevice.IsViiperType(type); + public static bool IsViiperOutputType(OutContType type) => + ViiperOutDevice.IsViiperType(type); - public static ViiperPrerequisiteStatus GetStatus(bool tryStartServer = false) + public static ViiperPrerequisiteStatus GetStatus( + bool tryStartServer = false) { - string viiperPath = GetViiperExePath(); + // VIIPERNativeBroker is auto-started and owned by SCM. Never start + // a second per-user broker in response to a status query. + _ = tryStartServer; + + string brokerPath = GetInstalledBrokerPath(); + string credentialPath = GetCredentialPath(); + string metadataPath = GetMetadataPath(); string setupScriptPath = GetSetupScriptPath(); - ViiperPrerequisiteStatus status = new ViiperPrerequisiteStatus + NativeMetadataStatus metadata = InspectMetadata(metadataPath, + brokerPath); + NativeServiceStatus service = InspectNativeBrokerService( + brokerPath, credentialPath); + ViiperNativeStatusProbe probe = + ViiperNativeRuntime.GetStatusProbe(ApiHost, ApiPort); + + return new ViiperPrerequisiteStatus { - ViiperPath = viiperPath, - SetupScriptPath = setupScriptPath, - ViiperInstalled = File.Exists(viiperPath), + MetadataFound = probe.MetadataPresent && metadata.Found, + MetadataEligible = probe.MetadataEligible && metadata.Eligible, + LocalTestMetadata = metadata.LocalTest, + PackageBundleFound = metadata.PackageBundleFound, + BrokerInstalled = File.Exists(brokerPath), + BrokerHashMatches = metadata.BrokerHashMatches, + BrokerServiceInstalled = service.Installed, + BrokerServiceConfigured = service.Configured, + BrokerServiceRunning = service.Running, + CredentialReadable = probe.CredentialReadable, + AuthenticatedPingSucceeded = probe.Authenticated, + RuntimeContractCompatible = probe.IdentityValid, SetupScriptFound = File.Exists(setupScriptPath), - UsbipInstalled = IsUsbipWin2Installed(), - ServerRunning = CanPingServer(), + BrokerPath = brokerPath, + CredentialPath = credentialPath, + MetadataPath = metadataPath, + SetupScriptPath = setupScriptPath, + Detail = FirstNonEmpty(probe.FailureReason, service.Detail, + metadata.Detail), }; - - if (tryStartServer && !status.ServerRunning && status.ViiperInstalled) - { - TryStartServerOnce(viiperPath); - status.ServerRunning = CanPingServer(); - } - - return status; } - public static bool EnsureReadyWithPrompt(Window owner, bool forcePrompt = false) + public static bool EnsureReadyWithPrompt(Window owner, + bool forcePrompt = false) { - ViiperPrerequisiteStatus status = GetStatus(tryStartServer: true); + ViiperPrerequisiteStatus status = GetStatus(); if (status.Ready) { return true; } - - if (Volatile.Read(ref promptShownThisSession) == 1 && !forcePrompt) + if (System.Threading.Volatile.Read(ref promptShownThisSession) == 1 && + !forcePrompt) { return false; } - Interlocked.Exchange(ref promptShownThisSession, 1); + System.Threading.Interlocked.Exchange( + ref promptShownThisSession, 1); string message = - "This profile uses a VIIPER virtual controller output.\n\n" + - "DS4Windows needs two pieces installed before this can work:\n" + - "- VIIPER helper/server\n" + - "- usbip-win2 Windows USB/IP driver\n\n" + + "This profile uses a VIIPER native UDE virtual controller.\n\n" + + "DS4Windows requires the signed native package, including " + + "the UdeCx driver and the managed LocalSystem " + + "VIIPERNativeBroker service.\n\n" + $"Current status: {status.DisplayText}\n\n" + - "Install or repair VIIPER support now?"; - - MessageBoxResult result = owner != null - ? MessageBox.Show(owner, message, "VIIPER virtual controller setup", MessageBoxButton.YesNo, MessageBoxImage.Information) - : MessageBox.Show(message, "VIIPER virtual controller setup", MessageBoxButton.YesNo, MessageBoxImage.Information); + "Install or repair it with the signed DS4Windows installer " + + "or its installed maintenance entry, then restart " + + "DS4Windows. The portable runtime never elevates bundled " + + "scripts or package files."; + ShowSetupMessage(owner, message, MessageBoxImage.Information); + return false; + } - if (result != MessageBoxResult.Yes) + public static bool LaunchInstaller( + ViiperPrerequisiteStatus status = null, Window owner = null) + { + status ??= GetStatus(); + if (status.LocalTestMetadata) { + string scriptDetail = status.SetupScriptFound + ? "A developer may run the bundled package manager " + + "manually" + : "The source-bound package manager is absent, so " + + "this build must be replaced"; + ShowSetupMessage(owner, + "Local-test evidence is never installed by the normal UI. " + + scriptDetail + " " + + "with the environment opt-in plus -AllowLocalTest and " + + "-AcknowledgeDisposableTestMachine on a disposable VM.", + MessageBoxImage.Warning); return false; } - - return LaunchInstaller(status, owner); + if (!status.MetadataFound || !status.MetadataEligible || + !status.PackageBundleFound) + { + string detail = status.LocalTestMetadata + ? "This build contains verified local-test evidence, not " + + "production driver media. It can only be installed " + + "manually on a disposable VM with the explicit " + + $"{LocalTestOptInEnvironment}=1 developer opt-in." + : "This build does not contain the exact production " + + "HLK/WHCP runtime bundle. Installation is blocked " + + "instead of downloading or substituting a driver."; + ShowSetupMessage(owner, detail, MessageBoxImage.Warning); + return false; + } + ShowSetupMessage(owner, + "The portable DS4Windows runtime never elevates its mutable " + + "package directory. Install or repair VIIPER through the " + + "signed DS4Windows installer or its machine-installed, " + + "signed maintenance entry, then restart DS4Windows.", + MessageBoxImage.Information); + return false; } - public static bool LaunchInstaller(ViiperPrerequisiteStatus status = null, Window owner = null) + public static bool LaunchUninstaller( + ViiperPrerequisiteStatus status = null, Window owner = null) { status ??= GetStatus(); - if (!status.SetupScriptFound) + if (!status.MetadataEligible || !status.PackageBundleFound || + status.LocalTestMetadata) { - string message = - "DS4Windows could not find the bundled VIIPER setup script.\n\n" + - "Opening the VIIPER and usbip-win2 release pages instead."; - if (owner != null) - { - MessageBox.Show(owner, message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Warning); - } - else - { - MessageBox.Show(message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Warning); - } - - Util.StartProcessHelper(ViiperReleasesUrl); - Util.StartProcessHelper(UsbipWin2ReleasesUrl); + ShowSetupMessage(owner, + "Exact signed VIIPER package metadata and helper media " + + "are required for transactional removal.", + MessageBoxImage.Error); return false; } - try + ShowSetupMessage(owner, + "Remove VIIPER through the signed DS4Windows installer or " + + "its machine-installed, signed maintenance entry. The " + + "portable runtime will not elevate bundled scripts or " + + "helper media.", MessageBoxImage.Information); + return false; + } + + private static void ShowSetupMessage(Window owner, string message, + MessageBoxImage image) + { + if (owner != null) { - ProcessStartInfo startInfo = new ProcessStartInfo - { - FileName = "powershell.exe", - Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{status.SetupScriptPath}\"", - UseShellExecute = true, - Verb = "runas", - }; - Process.Start(startInfo); - return true; + MessageBox.Show(owner, message, "VIIPER native UDE setup", + MessageBoxButton.OK, image); } - catch (Exception ex) + else { - string message = $"Could not launch VIIPER setup: {ex.Message}"; - if (owner != null) - { - MessageBox.Show(owner, message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Error); - } - else - { - MessageBox.Show(message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Error); - } - - return false; + MessageBox.Show(message, "VIIPER native UDE setup", + MessageBoxButton.OK, image); } } - private static string GetViiperExePath() + private static string GetInstalledBrokerPath() { - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(localAppData, "VIIPER", "viiper.exe"); + string programFiles = Environment.GetFolderPath( + Environment.SpecialFolder.ProgramFiles); + return Path.Combine(programFiles, "VIIPER", "viiper.exe"); } - private static string GetSetupScriptPath() + private static string GetCredentialPath() { - return Path.Combine(Global.exedirpath, "extras", InstallerScriptName); + string programData = Environment.GetFolderPath( + Environment.SpecialFolder.CommonApplicationData); + return Path.Combine(programData, "VIIPER", "viiper.key.txt"); } - private static bool TryStartServerOnce(string viiperPath) + private static string GetMetadataPath() => + Path.Combine(Global.exedirpath, MetadataFileName); + + private static string GetSetupScriptPath() => + Path.Combine(Global.exedirpath, "extras", InstallerScriptName); + + private static NativeMetadataStatus InspectMetadata(string path, + string installedBrokerPath) { - lock (serverStartLock) + if (!File.Exists(path)) { - if (CanPingServer()) + return new NativeMetadataStatus { - return true; + Detail = "Native runtime metadata file is absent.", + }; + } + + try + { + using FileStream stream = new FileStream(path, FileMode.Open, + FileAccess.Read, FileShare.Read); + using JsonDocument document = JsonDocument.Parse(stream); + JsonElement root = document.RootElement; + string eligibility = GetRequiredString(root, + "releaseEligibility"); + bool localTest = string.Equals(eligibility, + "local-test-evidence-only", StringComparison.Ordinal); + bool eligible = string.Equals(eligibility, "production", + StringComparison.Ordinal) || + (localTest && string.Equals( + Environment.GetEnvironmentVariable( + LocalTestOptInEnvironment), "1", + StringComparison.Ordinal)); + if (root.GetProperty("schemaVersion").GetInt32() != 1) + { + throw new InvalidDataException( + "Unsupported native metadata schema."); } + if (!string.Equals(GetRequiredString(root, + "localTestOptInEnvironment"), + LocalTestOptInEnvironment, StringComparison.Ordinal)) + { + throw new InvalidDataException( + "Native metadata local-test opt-in is invalid."); + } + JsonElement managedBroker = root.GetProperty( + "managedBroker"); + if (!string.Equals(GetRequiredString(managedBroker, + "serviceName"), NativeBrokerServiceName, + StringComparison.Ordinal) || + !string.Equals(GetRequiredString(managedBroker, + "serviceAccount"), "LocalSystem", + StringComparison.Ordinal) || + !string.Equals(GetRequiredString(managedBroker, + "startMode"), "automatic", + StringComparison.Ordinal) || + !string.Equals(GetRequiredString(managedBroker, + "transport"), "native-ude", + StringComparison.Ordinal) || + !string.Equals(GetRequiredString(managedBroker, + "apiHost"), ApiHost, + StringComparison.Ordinal) || + managedBroker.GetProperty("apiPort").GetInt32() != + ApiPort || + !string.Equals(GetRequiredString(managedBroker, + "credentialPath"), + "%ProgramData%/VIIPER/viiper.key.txt", + StringComparison.Ordinal)) + { + throw new InvalidDataException( + "Native metadata managed-broker contract is invalid."); + } + ValidateControllerApiContract(root); - DateTime now = DateTime.UtcNow; - if ((now - lastServerStartAttemptUtc).TotalSeconds < 3) + JsonElement broker = GetUniqueArtifact(root, "broker"); + bool packageFound = true; + foreach (string role in new[] { - return false; + "broker", "driver-helper", "submission-manifest", + "driver-inf", "driver-sys", "driver-cat", + }) + { + JsonElement artifact = GetUniqueArtifact(root, role); + string packagePath = ResolveBundledArtifactPath( + GetRequiredString(artifact, "relativePath")); + packageFound &= File.Exists(packagePath) && + FileMatchesArtifact(packagePath, artifact); } + bool installedHashMatches = File.Exists(installedBrokerPath) && + FileMatchesArtifact(installedBrokerPath, broker); - lastServerStartAttemptUtc = now; - return TryStartServer(viiperPath); + return new NativeMetadataStatus + { + Found = true, + Eligible = eligible, + LocalTest = localTest, + PackageBundleFound = packageFound, + BrokerHashMatches = installedHashMatches, + Detail = eligible ? string.Empty : + "Native metadata is not eligible for this runtime mode.", + }; + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException || + ex is JsonException || + ex is InvalidDataException || + ex is CryptographicException || + ex is KeyNotFoundException || + ex is InvalidOperationException) + { + return new NativeMetadataStatus + { + Found = true, + Detail = $"Native runtime metadata is invalid: {ex.Message}", + }; } } - private static bool TryStartServer(string viiperPath) + private static string ResolveBundledArtifactPath(string relativePath) { - try + if (string.IsNullOrWhiteSpace(relativePath) || + Path.IsPathRooted(relativePath)) { - ProcessStartInfo startInfo = new ProcessStartInfo - { - FileName = viiperPath, - Arguments = "server", - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, - UseShellExecute = false, - }; - Process.Start(startInfo); - System.Threading.Thread.Sleep(750); - return true; + throw new InvalidDataException( + "Native artifact path must be relative."); } - catch + string extrasRoot = Path.GetFullPath(Path.Combine( + Global.exedirpath, "extras")); + string candidate = Path.GetFullPath(Path.Combine(extrasRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + string prefix = extrasRoot.TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(prefix, + StringComparison.OrdinalIgnoreCase)) { - return false; + throw new InvalidDataException( + "Native artifact path escaped the extras directory."); } + return candidate; } - private static bool CanPingServer() + private static void ValidateControllerApiContract(JsonElement root) { - try + Dictionary expected = new( + StringComparer.Ordinal) { - using TcpClient tcp = new TcpClient - { - NoDelay = true, - SendTimeout = 500, - ReceiveTimeout = 1000, - }; + ["xbox360"] = + "xbox360|0x045e|0x028e|0x028e|xusb-composite|fixed", + ["dualshock4"] = + "dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|fixed", + ["dualshock4audioduplexv3"] = + "dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|framed-v3", + ["dualshock4audioonlyduplexv3"] = + "dualshock4|0x054c|0x09cc|0x05c4|audio-duplex-only|framed-v3", + ["dualsensecombinedaudioduplexv5"] = + "dualsense|0x054c|0x0ce6|0x0ce6|hid-audio-duplex|framed-v5", + ["dualsenseaudioonlyduplexv5"] = + "dualsense|0x054c|0x0ce6|0x0ce6|audio-duplex-only|framed-v5", + ["dualsensegamepadv5"] = + "dualsense|0x054c|0x0ce6|0x0ce6|hid-gamepad-only|framed-v5", + ["dualsenseedgecombinedaudioduplexv5"] = + "dualsense-edge|0x054c|0x0df2|0x0df2|hid-audio-duplex|framed-v5", + ["dualsenseedgegamepadv5"] = + "dualsense-edge|0x054c|0x0df2|0x0df2|hid-gamepad-only|framed-v5", + ["ns2pro"] = + "switch2-pro|0x057e|0x2069|0x2069|hid-vendor-bulk|fixed", + }; + JsonElement contract = root.GetProperty( + "controllerApiContract"); + if (contract.GetProperty("schemaVersion").GetInt32() != 1 || + !string.Equals(GetRequiredString(contract, "sourceRevision"), + GetRequiredString(root, "sourceRevision"), + StringComparison.Ordinal)) + { + throw new InvalidDataException( + "Controller API contract is not source-bound."); + } - IAsyncResult result = tcp.BeginConnect(ApiHost, ApiPort, null, null); - if (!result.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(750))) + HashSet found = new(StringComparer.Ordinal); + foreach (JsonElement registration in contract.GetProperty( + "registrations").EnumerateArray()) + { + string type = GetRequiredString(registration, "type"); + string signature = string.Join("|", + GetRequiredString(registration, "persona"), + GetRequiredString(registration, "defaultVid"), + GetRequiredString(registration, "defaultPid"), + GetRequiredString(registration, "ds4WindowsPid"), + GetRequiredString(registration, "interfaceProfile"), + GetRequiredString(registration, "streamProtocol")); + if (!found.Add(type) || !expected.TryGetValue(type, + out string expectedSignature) || + !string.Equals(signature, expectedSignature, + StringComparison.Ordinal)) { - return false; + throw new InvalidDataException( + $"Controller API type {type} does not match the " + + "VIIPER HID/interface implementation."); } + } + if (found.Count != expected.Count) + { + throw new InvalidDataException( + "Controller API contract omits a DS4Windows persona."); + } + } - tcp.EndConnect(result); - NetworkStream stream = tcp.GetStream(); - byte[] request = Encoding.UTF8.GetBytes("ping\0"); - stream.Write(request, 0, request.Length); + private static bool FileMatchesArtifact(string path, + JsonElement artifact) + { + FileInfo info = new FileInfo(path); + if ((info.Attributes & FileAttributes.ReparsePoint) != 0 || + info.Length != artifact.GetProperty("length").GetInt64()) + { + return false; + } + string expected = GetRequiredString(artifact, "sha256"); + if (expected.Length != 64) + { + return false; + } + using FileStream stream = new FileStream(path, FileMode.Open, + FileAccess.Read, FileShare.Read); + string actual = Convert.ToHexString(SHA256.HashData(stream)) + .ToLowerInvariant(); + return string.Equals(actual, expected, + StringComparison.Ordinal); + } - byte[] buffer = new byte[256]; - int read = stream.Read(buffer, 0, buffer.Length); - if (read <= 0) + private static JsonElement GetUniqueArtifact(JsonElement root, + string role) + { + JsonElement selected = default; + int matches = 0; + foreach (JsonElement artifact in + root.GetProperty("artifacts").EnumerateArray()) + { + if (string.Equals(GetRequiredString(artifact, "role"), role, + StringComparison.Ordinal)) { - return false; + selected = artifact; + matches++; } - - string response = Encoding.UTF8.GetString(buffer, 0, read); - return response.IndexOf("VIIPER", StringComparison.OrdinalIgnoreCase) >= 0; } - catch + if (matches != 1) { - return false; + throw new InvalidDataException( + $"Native metadata requires exactly one {role} artifact."); } + return selected; } - private static bool IsUsbipWin2Installed() + private static string GetRequiredString(JsonElement element, + string name) { - string driverPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "drivers", "usbip2_ude.sys"); - if (File.Exists(driverPath)) + string value = element.GetProperty(name).GetString(); + if (string.IsNullOrWhiteSpace(value)) { - return true; + throw new InvalidDataException( + $"Native metadata property {name} is empty."); } - - return RegistryUninstallContains("USB/IP") || - RegistryUninstallContains("USBip") || - RegistryServiceExists("usbip2_ude") || - RegistryServiceExists("usbip2_filter"); + return value; } - private static bool RegistryServiceExists(string serviceName) + private static NativeServiceStatus InspectNativeBrokerService( + string brokerPath, string credentialPath) { try { - using RegistryKey key = Registry.LocalMachine.OpenSubKey($@"SYSTEM\CurrentControlSet\Services\{serviceName}"); - return key != null; + using RegistryKey key = Registry.LocalMachine.OpenSubKey( + $@"SYSTEM\CurrentControlSet\Services\{NativeBrokerServiceName}"); + if (key == null) + { + return new NativeServiceStatus(); + } + + string imagePath = key.GetValue("ImagePath") as string; + string objectName = key.GetValue("ObjectName") as string; + int start = Convert.ToInt32(key.GetValue("Start", -1)); + int type = Convert.ToInt32(key.GetValue("Type", -1)); + string logPath = Path.Combine( + Path.GetDirectoryName(credentialPath), + "viiper-native-broker.log"); + bool configured = start == 2 && + type == NativeMethods.ServiceWin32OwnProcess && + IsLocalSystemAccount(objectName) && + NativeMethods.CommandLineMatches(imagePath, + brokerPath, credentialPath, logPath); + return new NativeServiceStatus + { + Installed = true, + Configured = configured, + Running = NativeMethods.IsServiceRunning( + NativeBrokerServiceName), + Detail = configured ? string.Empty : + "VIIPERNativeBroker SCM configuration is not exact.", + }; } - catch + catch (Exception ex) when (ex is UnauthorizedAccessException || + ex is IOException || ex is InvalidOperationException || + ex is System.ComponentModel.Win32Exception) { - return false; + return new NativeServiceStatus + { + Detail = $"Could not inspect VIIPERNativeBroker: {ex.Message}", + }; } } - private static bool RegistryUninstallContains(string displayName) + private static bool IsLocalSystemAccount(string value) => + string.Equals(value, "LocalSystem", + StringComparison.OrdinalIgnoreCase) || + string.Equals(value, @"NT AUTHORITY\SYSTEM", + StringComparison.OrdinalIgnoreCase) || + string.Equals(value, @".\LocalSystem", + StringComparison.OrdinalIgnoreCase); + + private static string FirstNonEmpty(params string[] values) { - return RegistryHiveUninstallContains(RegistryView.Registry64, displayName) || - RegistryHiveUninstallContains(RegistryView.Registry32, displayName); + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + return string.Empty; } - private static bool RegistryHiveUninstallContains(RegistryView view, string displayName) + private sealed class NativeMetadataStatus { - try + internal bool Found { get; set; } + internal bool Eligible { get; set; } + internal bool LocalTest { get; set; } + internal bool PackageBundleFound { get; set; } + internal bool BrokerHashMatches { get; set; } + internal string Detail { get; set; } + } + + private sealed class NativeServiceStatus + { + internal bool Installed { get; set; } + internal bool Configured { get; set; } + internal bool Running { get; set; } + internal string Detail { get; set; } + } + + private static class NativeMethods + { + internal const int ServiceWin32OwnProcess = 0x10; + private const uint ScManagerConnect = 0x0001; + private const uint ServiceQueryStatus = 0x0004; + private const int ScStatusProcessInfo = 0; + private const uint ServiceRunning = 0x00000004; + + [StructLayout(LayoutKind.Sequential)] + private struct ServiceStatusProcess + { + internal uint ServiceType; + internal uint CurrentState; + internal uint ControlsAccepted; + internal uint Win32ExitCode; + internal uint ServiceSpecificExitCode; + internal uint CheckPoint; + internal uint WaitHint; + internal uint ProcessId; + internal uint ServiceFlags; + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, + SetLastError = true)] + private static extern IntPtr OpenSCManager(string machineName, + string databaseName, uint desiredAccess); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, + SetLastError = true)] + private static extern IntPtr OpenService(IntPtr manager, + string serviceName, uint desiredAccess); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool QueryServiceStatusEx(IntPtr service, + int infoLevel, out ServiceStatusProcess status, + int bufferSize, out int bytesNeeded); + + [DllImport("advapi32.dll")] + private static extern bool CloseServiceHandle(IntPtr handle); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, + SetLastError = true)] + private static extern IntPtr CommandLineToArgvW( + string commandLine, out int argumentCount); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); + + internal static bool IsServiceRunning(string serviceName) { - using RegistryKey baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); - using RegistryKey uninstallKey = baseKey.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall"); - if (uninstallKey == null) + IntPtr manager = IntPtr.Zero; + IntPtr service = IntPtr.Zero; + try { - return false; + manager = OpenSCManager(null, null, ScManagerConnect); + if (manager == IntPtr.Zero) + { + return false; + } + service = OpenService(manager, serviceName, + ServiceQueryStatus); + if (service == IntPtr.Zero) + { + return false; + } + bool ok = QueryServiceStatusEx(service, + ScStatusProcessInfo, out ServiceStatusProcess status, + Marshal.SizeOf(), out _); + return ok && status.CurrentState == ServiceRunning; } + finally + { + if (service != IntPtr.Zero) + { + CloseServiceHandle(service); + } + if (manager != IntPtr.Zero) + { + CloseServiceHandle(manager); + } + } + } - return uninstallKey.GetSubKeyNames() - .Select(name => uninstallKey.OpenSubKey(name)) - .Where(key => key != null) - .Any(key => + internal static bool CommandLineMatches(string commandLine, + string brokerPath, string credentialPath, string logPath) + { + if (string.IsNullOrWhiteSpace(commandLine)) + { + return false; + } + IntPtr arguments = CommandLineToArgvW(commandLine, + out int count); + if (arguments == IntPtr.Zero) + { + return false; + } + try + { + if (count != 8) { - using (key) + return false; + } + string[] expected = + { + brokerPath, + "service", + "--transport", + "native-ude", + "--key-file", + credentialPath, + "--log.file", + logPath, + }; + for (int i = 0; i < expected.Length; i++) + { + IntPtr value = Marshal.ReadIntPtr(arguments, + i * IntPtr.Size); + string actual = Marshal.PtrToStringUni(value); + StringComparison comparison = i == 0 || i == 5 || + i == 7 + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!string.Equals(actual, expected[i], comparison)) { - string value = key.GetValue("DisplayName") as string; - return value?.IndexOf(displayName, StringComparison.OrdinalIgnoreCase) >= 0; + return false; } - }); - } - catch - { - return false; + } + return true; + } + finally + { + LocalFree(arguments); + } } } } diff --git a/DS4Windows/DS4Forms/MainWindow.xaml b/DS4Windows/DS4Forms/MainWindow.xaml index 4e5b2c0..2db3434 100644 --- a/DS4Windows/DS4Forms/MainWindow.xaml +++ b/DS4Windows/DS4Forms/MainWindow.xaml @@ -567,7 +567,7 @@ - +