wasapi 0.26.0

Bindings for the Wasapi API on Windows
Documentation
# Installs the VB-Audio Virtual Cable driver on a headless Windows runner, for the
# device backed tests in tests/device/.
#
# Ported from CamillaDSP, testscripts/e2e/wasapi/install_vbcable.ps1 in
# https://github.com/HEnquist/camilladsp, with the download moved into the script
# and pinned to a known archive.
#
# Does what `devcon install <inf> <hwid>` does, through SetupAPI directly, so this
# needs neither devcon.exe nor a third party action that bundles it, and no reboot.
# The driver's signer is added to TrustedPublisher first, since the install is
# otherwise stopped by a prompt asking whether to trust the publisher, which nobody
# is there to answer.
#
# VB-CABLE comes from VB-Audio, www.vb-cable.com, and is donationware. All
# participations are welcome. See https://vb-audio.com/Cable/ and the licence in the
# readme.txt of the archive.
#
# The licence allows copying the package as is, but not integrating it into another
# installation procedure without the author's agreement. That clause reads as aimed at
# bundling the cable into a product installer, which this is not: nothing is shipped to
# users, the driver is installed on a throwaway CI runner and thrown away with it.
# CamillaDSP does the same.
#
# The archive is downloaded at run time rather than committed, to keep a binary out
# of the repository and the crate package.
#
# Usage: install_vbcable.ps1 [-Instances <n>]

param(
    [int]$Instances = 1
)

$ErrorActionPreference = 'Stop'

# Pinned, and the hash is checked before anything is unpacked. This installs a
# kernel driver, so a silently changed archive must fail rather than be trusted.
# Driver pack 45, DriverVer 10/07/2024 3.3.1.7.
$Url = 'https://download.vb-audio.com/Download_CABLE/VBCABLE_Driver_Pack45.zip'
$Sha256 = 'B950E39F01AF1D04EA623C8F6D8EB9B6EA5C477C637295FABF20631C85116BFB'
$InfName = 'vbMmeCable64_win10.inf'
$HardwareId = 'VBAudioVACWDM'

$work = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$zip = Join-Path $work 'vbcable.zip'
$dir = Join-Path $work 'vbcable'

# vb-audio.com is a single small host, so a transient failure is worth a retry.
for ($attempt = 1; $attempt -le 3; $attempt++) {
    try {
        Invoke-WebRequest $Url -OutFile $zip
        break
    } catch {
        Write-Host "download attempt $attempt failed: $($_.Exception.Message)"
        if ($attempt -eq 3) { throw }
        Start-Sleep -Seconds (5 * $attempt)
    }
}

$hash = (Get-FileHash $zip -Algorithm SHA256).Hash
if ($hash -ne $Sha256) {
    throw "the archive hash is $hash, expected $Sha256. Refusing to install it."
}
Write-Host "downloaded $Url, hash verified"

Expand-Archive $zip -DestinationPath $dir -Force
$Inf = Join-Path (Resolve-Path $dir) $InfName
if (-not (Test-Path $Inf)) { throw "$InfName is not in the archive" }

# Trust every certificate the catalogs are signed with, rather than a .cer kept in
# the repo, so a new driver pack brings its own.
foreach ($cat in Get-ChildItem $dir -Filter *.cat) {
    $sig = Get-AuthenticodeSignature $cat.FullName
    Write-Host "$($cat.Name): $($sig.Status), $($sig.SignerCertificate.Subject)"
    if ($sig.SignerCertificate) {
        $cer = Join-Path $work "$($cat.BaseName).cer"
        Export-Certificate -Cert $sig.SignerCertificate -FilePath $cer | Out-Null
        certutil -f -addstore TrustedPublisher $cer | Out-Null
    }
}

Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;

public static class DevInstall {
    const int DICD_GENERATE_ID = 1;
    const int SPDRP_HARDWAREID = 1;
    const int DIF_REGISTERDEVICE = 0x19;
    const int INSTALLFLAG_FORCE = 1;
    static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);

    [StructLayout(LayoutKind.Sequential)]
    struct SP_DEVINFO_DATA {
        public int cbSize;
        public Guid ClassGuid;
        public int DevInst;
        public IntPtr Reserved;
    }

    [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool SetupDiGetINFClass(string infName, out Guid classGuid,
        StringBuilder className, int classNameSize, out int requiredSize);

    [DllImport("setupapi.dll", SetLastError = true)]
    static extern IntPtr SetupDiCreateDeviceInfoList(ref Guid classGuid, IntPtr hwndParent);

    [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool SetupDiCreateDeviceInfo(IntPtr set, string deviceName, ref Guid classGuid,
        string description, IntPtr hwndParent, int flags, ref SP_DEVINFO_DATA data);

    [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool SetupDiSetDeviceRegistryProperty(IntPtr set, ref SP_DEVINFO_DATA data,
        int property, byte[] buffer, int size);

    [DllImport("setupapi.dll", SetLastError = true)]
    static extern bool SetupDiCallClassInstaller(int function, IntPtr set, ref SP_DEVINFO_DATA data);

    [DllImport("setupapi.dll", SetLastError = true)]
    static extern bool SetupDiDestroyDeviceInfoList(IntPtr set);

    [DllImport("newdev.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool UpdateDriverForPlugAndPlayDevices(IntPtr hwndParent, string hardwareId,
        string infPath, int flags, out bool rebootRequired);

    static void Check(bool ok, string what) {
        if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error(), what);
    }

    // Creates a root enumerated device node with the hardware id, then points the
    // driver at every node with that id. Returns whether Windows asks for a reboot.
    public static bool Install(string inf, string hardwareId) {
        Guid classGuid;
        int required;
        StringBuilder className = new StringBuilder(32);
        Check(SetupDiGetINFClass(inf, out classGuid, className, className.Capacity, out required),
            "SetupDiGetINFClass");

        IntPtr set = SetupDiCreateDeviceInfoList(ref classGuid, IntPtr.Zero);
        if (set == INVALID_HANDLE_VALUE) Check(false, "SetupDiCreateDeviceInfoList");
        try {
            SP_DEVINFO_DATA data = new SP_DEVINFO_DATA();
            data.cbSize = Marshal.SizeOf(data);
            Check(SetupDiCreateDeviceInfo(set, className.ToString(), ref classGuid, null,
                IntPtr.Zero, DICD_GENERATE_ID, ref data), "SetupDiCreateDeviceInfo");
            // REG_MULTI_SZ, so the list ends with an extra null.
            byte[] id = Encoding.Unicode.GetBytes(hardwareId + "\0\0");
            Check(SetupDiSetDeviceRegistryProperty(set, ref data, SPDRP_HARDWAREID, id, id.Length),
                "SetupDiSetDeviceRegistryProperty");
            Check(SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set, ref data),
                "SetupDiCallClassInstaller");
        } finally {
            SetupDiDestroyDeviceInfoList(set);
        }

        bool reboot;
        Check(UpdateDriverForPlugAndPlayDevices(IntPtr.Zero, hardwareId, inf, INSTALLFLAG_FORCE,
            out reboot), "UpdateDriverForPlugAndPlayDevices");
        return reboot;
    }
}
'@

function Get-CableEndpoints {
    Get-PnpDevice -Class AudioEndpoint -Status OK -ErrorAction SilentlyContinue |
        Where-Object FriendlyName -like '*(VB-Audio Virtual Cable)'
}

$before = @(Get-CableEndpoints).Count
for ($i = 1; $i -le $Instances; $i++) {
    $reboot = [DevInstall]::Install($Inf, $HardwareId)
    Write-Host "instance $i installed, reboot requested: $reboot"
}

# The install returns before the audio endpoints exist, and a client that looks for
# them too early finds nothing. Each instance brings three: two playback, one capture.
$start = Get-Date
$wanted = $before + 3 * $Instances
while (@(Get-CableEndpoints).Count -lt $wanted) {
    if (((Get-Date) - $start).TotalSeconds -gt 60) {
        Get-CableEndpoints | Format-Table Status, FriendlyName -AutoSize
        Write-Host "::error::only $(@(Get-CableEndpoints).Count) of $wanted endpoints after 60 s"
        exit 1
    }
    Start-Sleep -Milliseconds 200
}
Write-Host "$wanted endpoints after $([int]((Get-Date) - $start).TotalMilliseconds) ms"
Get-CableEndpoints | Format-Table Status, FriendlyName -AutoSize

# Record which driver version ended up installed, so the log says so on a failure.
pnputil /enum-drivers | Select-String -Pattern 'vbMmeCable', 'VB-Audio' -Context 2, 4