diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 47b1a327..3d60d681 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -351,8 +351,22 @@ jobs: Write-Host "Trusted driver publisher certificate $($cert.Subject) [$($cert.Thumbprint)]." } + - name: Prepare Sunshine close-applications regression test + if: runner.os == 'Windows' + id: sunshine_fixture + shell: powershell + run: | + $installer = Get-ChildItem -LiteralPath .\windows-driver-installer -Filter *.msi | + Select-Object -First 1 + $fixtureDirectory = Join-Path $env:RUNNER_TEMP "libvirtualhid-sunshine-$([Guid]::NewGuid())" + $fixtureId = .\tests\scripts\test-installer-restart-manager.ps1 ` + -MsiPath $installer.FullName -FixtureDirectory $fixtureDirectory + @("pid=$fixtureId", "exe=$fixtureDirectory\sunshine.exe") | + Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + - name: Install Windows driver installer if: runner.os == 'Windows' + id: install_windows_driver shell: pwsh timeout-minutes: 10 run: | @@ -382,6 +396,30 @@ jobs: throw "Windows driver installer exited with code $($process.ExitCode)." } + - name: Verify Windows installer closed Sunshine + if: always() && runner.os == 'Windows' && steps.sunshine_fixture.outputs.pid != '' + shell: powershell + env: + FIXTURE_PID: ${{ steps.sunshine_fixture.outputs.pid }} + FIXTURE_EXE: ${{ steps.sunshine_fixture.outputs.exe }} + INSTALL_OUTCOME: ${{ steps.install_windows_driver.outcome }} + run: | + $fixture = Get-Process -Id ([int] $env:FIXTURE_PID) -ErrorAction SilentlyContinue + $isFixture = $fixture -and $fixture.Path -eq $env:FIXTURE_EXE + try { + if ($env:INSTALL_OUTCOME -eq "success" -and $isFixture -and !$fixture.WaitForExit(15000)) { + Get-Content -LiteralPath (Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-install.log") + throw "Windows Installer did not close the registered sunshine.exe fixture." + } + } finally { + if ($isFixture -and !$fixture.HasExited) { + Stop-Process -Id $fixture.Id -Force -ErrorAction SilentlyContinue + $null = $fixture.WaitForExit(10000) + } + Remove-Item -LiteralPath $env:FIXTURE_EXE -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Split-Path -Parent $env:FIXTURE_EXE) -ErrorAction SilentlyContinue + } + - name: Enable GitHub Actions evaluation window if: runner.os == 'Windows' shell: pwsh @@ -513,6 +551,63 @@ jobs: --xml-pretty \ -o reports/coverage.xml + - name: Repair Windows driver installer with an open consumer handle + if: runner.os == 'Windows' + shell: pwsh + timeout-minutes: 10 + run: | + $installer = Get-ChildItem -LiteralPath .\windows-driver-installer -Filter *.msi | + Select-Object -First 1 + if (!$installer) { + throw "Windows driver installer artifact did not contain an MSI." + } + if ((Get-Service -Name libvirtualhid_broker).Status -ne "Running") { + throw "The broker must be running before the MSI repair regression test." + } + + $deviceHandle = [System.IO.File]::Open( + "\\.\LibVirtualHid", + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::ReadWrite + ) + $logPath = Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-repair.log" + $driverLogPath = Join-Path $env:ProgramData "libvirtualhid\install-driver.log" + $setupApiLogPath = Join-Path $env:windir "inf\setupapi.dev.log" + $repairStartedAt = Get-Date + try { + $repairArguments = @( + "/i", $installer.FullName, + "REINSTALL=ALL", "REINSTALLMODE=amus", + "/qn", "/norestart", "/L*v", $logPath + ) + $process = Start-Process ` + -FilePath msiexec.exe ` + -ArgumentList $repairArguments ` + -PassThru ` + -WindowStyle Hidden + if (!$process.WaitForExit([int] [TimeSpan]::FromMinutes(5).TotalMilliseconds)) { + Get-Content -LiteralPath $logPath -Tail 200 -ErrorAction SilentlyContinue + Get-Content -LiteralPath $driverLogPath -Tail 200 -ErrorAction SilentlyContinue + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "Windows driver installer repair timed out after 5 minutes." + } + if ($process.ExitCode -notin @(0, 3010)) { + Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue + Get-Content -LiteralPath $driverLogPath -ErrorAction SilentlyContinue + Get-WinEvent -FilterHashtable @{ + LogName = "Microsoft-Windows-PowerShell/Operational" + StartTime = $repairStartedAt + Id = 4100 + } -ErrorAction SilentlyContinue | Format-List TimeCreated, Id, Message + Get-Content -LiteralPath $setupApiLogPath -Tail 300 -ErrorAction SilentlyContinue + throw "Windows driver installer repair exited with code $($process.ExitCode)." + } + (Get-Service -Name libvirtualhid_broker).WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) + } finally { + $deviceHandle.Dispose() + } + - name: Uninstall Windows driver installer with an open consumer handle if: >- always() && diff --git a/cmake/packaging/windows_wix.cmake b/cmake/packaging/windows_wix.cmake index caacdde4..f0814324 100644 --- a/cmake/packaging/windows_wix.cmake +++ b/cmake/packaging/windows_wix.cmake @@ -12,6 +12,7 @@ set(CPACK_WIX_VERSION 4) set(CPACK_GENERATOR "WIX") set(WIX_VERSION 4.0.4) set(WIX_UI_VERSION 4.0.4) # extension versioning is independent of the WiX version +set(WIX_UTIL_VERSION 4.0.4) set(WIX_BUILD_PARENT_DIRECTORY "${CMAKE_BINARY_DIR}/wix_packaging") set(WIX_BUILD_DIRECTORY "${CPACK_PACKAGE_DIRECTORY}/_CPack_Packages/win64/WIX") @@ -55,6 +56,7 @@ function(libvirtualhid_wix_ensure_extension extension_name extension_version) endfunction() libvirtualhid_wix_ensure_extension(WixToolset.UI.wixext ${WIX_UI_VERSION}) +libvirtualhid_wix_ensure_extension(WixToolset.Util.wixext ${WIX_UTIL_VERSION}) set(CPACK_WIX_ROOT "${WIX_TOOL_PATH}") set(CPACK_WIX_UPGRADE_GUID "71D7B738-9D83-4E57-82E3-C3106D9F8053") @@ -66,7 +68,10 @@ set(CPACK_WIX_PROGRAM_MENU_FOLDER "LizardByte") # https://learn.microsoft.com/en-us/windows/win32/msi/reinstallmode set(CPACK_WIX_PROPERTY_REINSTALLMODE "amus") set(CPACK_WIX_EXTENSIONS - "WixToolset.UI.wixext") + "WixToolset.UI.wixext" + "WixToolset.Util.wixext") +set(CPACK_WIX_CUSTOM_XMLNS + "util=http://wixtoolset.org/schemas/v4/wxs/util") set(CPACK_WIX_PATCH_FILE "${CMAKE_CURRENT_LIST_DIR}/wix_resources/libvirtualhid-driver-installer-patch.xml") diff --git a/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml b/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml index 7ae86b6a..e545b2be 100644 --- a/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml +++ b/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml @@ -1,5 +1,9 @@ - + + + + + [CmdletBinding(SupportsShouldProcess)] param( @@ -24,6 +29,7 @@ $ErrorActionPreference = "Stop" $script:LibVirtualHidTranscriptStarted = $false $script:LibVirtualHidBrokerServiceName = "libvirtualhid_broker" $script:LibVirtualHidBrokerServiceDisplayName = "libvirtualhid Broker" +$script:LibVirtualHidRebootRequired = $false . (Join-Path $PSScriptRoot "libvirtualhid-driver-common.ps1") function Invoke-CheckedCommand { @@ -92,17 +98,6 @@ function Get-LibVirtualHidQuotedServiceBinaryPath { return "`"$Path`"" } -function Get-LibVirtualHidScBinaryPathArgument { - param( - [Parameter(Mandatory = $true)] - [string] $Path - ) - - # Windows PowerShell 5.1 needs triple quotes in a native-command argument to pass one literal quote - # through to sc.exe while keeping a path containing spaces in a single argv element. - return (Get-LibVirtualHidQuotedServiceBinaryPath -Path $Path).Replace('"', '"""') -} - function Assert-LibVirtualHidBrokerServiceImagePath { param( [Parameter(Mandatory = $true)] @@ -149,6 +144,61 @@ function Stop-LibVirtualHidBrokerService { } } +<# +.SYNOPSIS +Restores a previously running broker after installation or update failure. +.PARAMETER Name +The broker service to start if it is currently stopped. +#> +function Restore-LibVirtualHidBrokerService { + [CmdletBinding(SupportsShouldProcess)] + param([string] $Name) + + # Do not let a recovery failure hide the original installation error. + try { + $service = Get-Service -Name $Name -ErrorAction Stop + if ($service.Status -eq "Stopped" -and $PSCmdlet.ShouldProcess($Name, "Restore libvirtualhid broker service")) { + Start-Service -Name $Name -ErrorAction Stop + } + } catch { + Write-Warning "Unable to restore the libvirtualhid broker service: $($_.Exception.Message)" + } +} + +<# +.SYNOPSIS +Updates the broker service without native command-line quote parsing. +.PARAMETER Path +The broker executable path to store as a quoted service binary path. +.OUTPUTS +System.Boolean. True when the service configuration was updated. +#> +function Set-LibVirtualHidBrokerServiceConfiguration { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([bool])] + param([string] $Path) + + if (-not $PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Update libvirtualhid broker service")) { + return $false + } + + $service = Get-CimInstance -ClassName Win32_Service ` + -Filter "Name = '$script:LibVirtualHidBrokerServiceName'" -ErrorAction Stop + if (-not $service) { + throw "The $script:LibVirtualHidBrokerServiceName service was not found for reconfiguration." + } + + $result = Invoke-CimMethod -InputObject $service -MethodName Change -Arguments @{ + PathName = Get-LibVirtualHidQuotedServiceBinaryPath -Path $Path + DisplayName = $script:LibVirtualHidBrokerServiceDisplayName + StartMode = "Automatic" + } -ErrorAction Stop + if ($result.ReturnValue -ne 0) { + throw "Updating the libvirtualhid broker service failed with Win32_Service.Change return value $($result.ReturnValue)." + } + return $true +} + function Install-LibVirtualHidBrokerService { [CmdletBinding(SupportsShouldProcess)] param([string] $Path) @@ -159,24 +209,11 @@ function Install-LibVirtualHidBrokerService { return } - $serviceBinaryPath = Get-LibVirtualHidScBinaryPathArgument -Path $resolvedBroker $serviceRegistered = $false $service = Get-Service -Name $script:LibVirtualHidBrokerServiceName -ErrorAction SilentlyContinue if ($service) { Stop-LibVirtualHidBrokerService -Name $script:LibVirtualHidBrokerServiceName - if ($PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Update libvirtualhid broker service")) { - Invoke-CheckedCommand -FilePath "sc.exe" -Arguments @( - "config", - $script:LibVirtualHidBrokerServiceName, - "binPath=", - $serviceBinaryPath, - "start=", - "auto", - "DisplayName=", - $script:LibVirtualHidBrokerServiceDisplayName - ) - $serviceRegistered = $true - } + $serviceRegistered = Set-LibVirtualHidBrokerServiceConfiguration -Path $resolvedBroker } else { if ($PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Install libvirtualhid broker service")) { $quotedServicePath = Get-LibVirtualHidQuotedServiceBinaryPath -Path $resolvedBroker @@ -261,7 +298,7 @@ function Restart-RootDevice { [CmdletBinding(SupportsShouldProcess)] param([string] $InstanceId) - if (-not $InstanceId) { + if (-not $InstanceId -or $script:LibVirtualHidRebootRequired) { return } @@ -269,10 +306,29 @@ function Restart-RootDevice { return } - $output = @(pnputil.exe /restart-device $InstanceId 2>&1) - $exitCode = $LASTEXITCODE + $command = Get-Command pnputil.exe -ErrorAction Stop + $previousErrorActionPreference = $ErrorActionPreference + try { + # Windows PowerShell 5.1 turns redirected native stderr into error records. + # Collect them so the native exit code, including reboot statuses, decides + # whether the restart failed. + $ErrorActionPreference = "Continue" + $output = @(& $command /restart-device $InstanceId 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } foreach ($line in $output) { - Write-Information $line -InformationAction Continue + Write-Information ([string] $line) -InformationAction Continue + } + + # ERROR_NOT_SUPPORTED (50) is also returned when an earlier driver update + # left the instance pending reboot. A reboot can reload the installed driver + # even when this OS cannot restart the device in place. + if ($exitCode -in @(50, 3010)) { + $script:LibVirtualHidRebootRequired = $true + Write-Warning "Windows could not reload $InstanceId without a reboot (pnputil exit code $exitCode). Restart Windows to complete the libvirtualhid driver installation." + return } if ($exitCode -ne 0) { @@ -280,6 +336,7 @@ function Restart-RootDevice { } if ($output -match "reboot is needed") { + $script:LibVirtualHidRebootRequired = $true Write-Warning "Windows reported that a reboot is required to reload the libvirtualhid UMDF driver." } } @@ -303,6 +360,7 @@ function Update-RootDeviceDriverWithSetupApi { -Arguments @("update", $Path, $TargetHardwareId) ` -SuccessExitCodes @(0, 3010) if ($LASTEXITCODE -eq 3010) { + $script:LibVirtualHidRebootRequired = $true Write-Warning "Windows reported that a reboot is required to finish installing the libvirtualhid driver." } } @@ -323,13 +381,54 @@ function Install-RootDeviceWithSetupApi { Invoke-CheckedCommand -FilePath $SetupHelperPath -Arguments @("install", $Path, $TargetHardwareId) } -if ($MyInvocation.InvocationName -eq ".") { - return +<# +.SYNOPSIS +Records a terminating installation error even when transcription is unavailable. +.PARAMETER Path +The installation log path, if one was requested. +.PARAMETER Message +The original installation error to record without replacing it with a logging error. +#> +function Write-LibVirtualHidInstallFailure { + [CmdletBinding(SupportsShouldProcess)] + param([string] $Path, [string] $Message) + + Write-Warning "libvirtualhid driver installation failed: $Message" -WarningAction Continue + if (-not $Path -or $script:LibVirtualHidTranscriptStarted) { + return + } + + try { + if ($PSCmdlet.ShouldProcess($Path, "Record libvirtualhid installation failure")) { + Add-Content -LiteralPath $Path -Value "$(Get-Date -Format o) libvirtualhid driver installation failed: $Message" -ErrorAction Stop + } + } catch { + Write-Warning "Unable to write the libvirtualhid installation error log: $($_.Exception.Message)" -WarningAction Continue + } } -Start-LibVirtualHidTranscript -Path $LogPath +<# +.SYNOPSIS +Installs or updates the driver while the broker has released its device handles. +.DESCRIPTION +Stages the package, stops the broker before changing any device, and registers +the broker after the driver update. A required reboot skips further restarts. +Restores a previously running broker if installation fails or no broker binary +is supplied. StageOnly and WhatIf do not stop the service. +#> +function Install-LibVirtualHidDriver { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string] $InfPath, + [string] $CertificatePath, + [string] $HardwareId = "ROOT\LIBVIRTUALHID", + [string] $BrokerPath, + [string] $SetupPath, + [switch] $StageOnly + ) -try { + $script:LibVirtualHidRebootRequired = $false $resolvedInf = (Resolve-Path -LiteralPath $InfPath).Path Import-DriverCertificate -Path $CertificatePath @@ -342,48 +441,70 @@ try { } $resolvedSetup = Resolve-LibVirtualHidDriverSetupPath -Path $SetupPath + $resolvedBroker = Resolve-LibVirtualHidBrokerPath -Path $BrokerPath + $brokerService = Get-Service -Name $script:LibVirtualHidBrokerServiceName -ErrorAction SilentlyContinue + $brokerWasRunning = $brokerService -and $brokerService.Status -ne "Stopped" - $registryRootDevices = @(Get-LibVirtualHidRegistryRootDevice -TargetHardwareId $HardwareId) - foreach ($device in ($registryRootDevices | Where-Object { $_.HasCorruptHardwareId -or $_.HasLegacyHidClass })) { - Remove-DeviceInstance -InstanceId $device.InstanceId - } + try { + Stop-LibVirtualHidBrokerService -Name $script:LibVirtualHidBrokerServiceName + + $registryRootDevices = @(Get-LibVirtualHidRegistryRootDevice -TargetHardwareId $HardwareId) + foreach ($device in ($registryRootDevices | Where-Object { $_.HasCorruptHardwareId -or $_.HasLegacyHidClass })) { + Remove-DeviceInstance -InstanceId $device.InstanceId + } + + $rootDevices = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId $HardwareId) + if ($rootDevices.Count -eq 0) { + if ($PSCmdlet.ShouldProcess($HardwareId, "Create libvirtualhid development device with SetupAPI")) { + Install-RootDeviceWithSetupApi ` + -Path $resolvedInf ` + -TargetHardwareId $HardwareId ` + -SetupHelperPath $resolvedSetup + } + $rootDevices = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId $HardwareId) + } else { + Write-Information "Updating the existing $HardwareId device driver." -InformationAction Continue + } - $rootDevices = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId $HardwareId) - if ($rootDevices.Count -gt 0) { - Write-Information "Updating the existing $HardwareId device driver." -InformationAction Continue foreach ($rootDevice in $rootDevices) { Set-RootDeviceVhfMode -InstanceId $rootDevice } + Update-RootDeviceDriverWithSetupApi ` -Path $resolvedInf ` -TargetHardwareId $HardwareId ` -SetupHelperPath $resolvedSetup - foreach ($rootDevice in $rootDevices) { - Restart-RootDevice -InstanceId $rootDevice + if (-not $script:LibVirtualHidRebootRequired) { + foreach ($rootDevice in $rootDevices) { + Restart-RootDevice -InstanceId $rootDevice + } + } + Install-LibVirtualHidBrokerService -Path $resolvedBroker + } finally { + # Restore an existing broker after a failed update or a driver-only install. + if ($brokerWasRunning) { + Restore-LibVirtualHidBrokerService -Name $script:LibVirtualHidBrokerServiceName } - Install-LibVirtualHidBrokerService -Path $BrokerPath - return } +} - if ($PSCmdlet.ShouldProcess($HardwareId, "Create libvirtualhid development device with SetupAPI")) { - Install-RootDeviceWithSetupApi ` - -Path $resolvedInf ` - -TargetHardwareId $HardwareId ` - -SetupHelperPath $resolvedSetup - } +if ($MyInvocation.InvocationName -eq ".") { + return +} - $rootDevices = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId $HardwareId) - foreach ($rootDevice in $rootDevices) { - Set-RootDeviceVhfMode -InstanceId $rootDevice - } - Update-RootDeviceDriverWithSetupApi ` - -Path $resolvedInf ` - -TargetHardwareId $HardwareId ` - -SetupHelperPath $resolvedSetup - foreach ($rootDevice in $rootDevices) { - Restart-RootDevice -InstanceId $rootDevice - } - Install-LibVirtualHidBrokerService -Path $BrokerPath +Start-LibVirtualHidTranscript -Path $LogPath + +try { + Install-LibVirtualHidDriver ` + -InfPath $InfPath ` + -CertificatePath $CertificatePath ` + -HardwareId $HardwareId ` + -BrokerPath $BrokerPath ` + -SetupPath $SetupPath ` + -StageOnly:$StageOnly +} catch { + Write-LibVirtualHidInstallFailure -Path $LogPath -Message $_.Exception.Message + throw } finally { Stop-LibVirtualHidTranscript } diff --git a/tests/scripts/install-driver.Tests.ps1 b/tests/scripts/install-driver.Tests.ps1 index 761df180..de850b0d 100644 --- a/tests/scripts/install-driver.Tests.ps1 +++ b/tests/scripts/install-driver.Tests.ps1 @@ -55,6 +55,7 @@ Describe "install-driver.ps1 entry point" { } Mock Get-CimInstance { @() } Mock Get-ChildItem { @() } + Mock Get-Service { $null } try { { @@ -114,11 +115,6 @@ Describe "Service path quoting" { } | Should -Throw "*must not contain quotation marks*" } - It "formats the service path for Windows PowerShell native argument parsing" { - Get-LibVirtualHidScBinaryPathArgument -Path "C:\Program Files\libvirtualhid\broker.exe" | - Should -Be '"""C:\Program Files\libvirtualhid\broker.exe"""' - } - It "accepts an exactly quoted service ImagePath" { Mock Get-ItemProperty { [pscustomobject]@{ ImagePath = '"C:\Program Files\libvirtualhid\broker.exe"' } @@ -234,25 +230,78 @@ Describe "Broker service helpers" { It "updates and configures an existing broker service" { $path = Join-Path $TestDrive "existing-libvirtualhid_broker.exe" New-Item -ItemType File -Path $path | Out-Null + $resolvedExistingPath = (Resolve-Path $path).Path Mock Get-Service { [pscustomobject]@{ Status = "Stopped" } } Mock Stop-LibVirtualHidBrokerService {} Mock Assert-LibVirtualHidBrokerServiceImagePath {} Mock Clear-LibVirtualHidBrokerServiceEnvironment {} Mock Invoke-CheckedCommand {} + Mock Set-LibVirtualHidBrokerServiceConfiguration { $true } Mock Start-Service {} Install-LibVirtualHidBrokerService -Path $path -Confirm:$false Should -Invoke Stop-LibVirtualHidBrokerService -Times 1 -Exactly -Scope It - Should -Invoke Invoke-CheckedCommand -Times 3 -Exactly -Scope It - Should -Invoke Invoke-CheckedCommand -Times 1 -Exactly -Scope It -ParameterFilter { - $FilePath -eq "sc.exe" -and $Arguments[0] -eq "config" + Should -Invoke Invoke-CheckedCommand -Times 2 -Exactly -Scope It + Should -Invoke Set-LibVirtualHidBrokerServiceConfiguration -Times 1 -Exactly -Scope It -ParameterFilter { + $Path -eq $resolvedExistingPath } Should -Invoke Start-Service -Times 1 -Exactly -Scope It } } +Describe "Broker service reconfiguration" { + BeforeEach { + $cimService = New-CimInstance -ClassName Win32_Service -ClientOnly -Property @{ Name = "libvirtualhid_broker" } + Mock Get-CimInstance { $cimService } + Mock Invoke-CimMethod { [pscustomobject]@{ ReturnValue = 0 } } + } + + It "passes a quoted executable path and service settings directly to CIM" { + Set-LibVirtualHidBrokerServiceConfiguration -Path "C:\Program Files\libvirtualhid\broker.exe" -Confirm:$false | + Should -BeTrue + + Should -Invoke Get-CimInstance -Times 1 -Exactly -Scope It -ParameterFilter { + $ClassName -eq "Win32_Service" -and $Filter -eq "Name = 'libvirtualhid_broker'" + } + Should -Invoke Invoke-CimMethod -Times 1 -Exactly -Scope It -ParameterFilter { + $InputObject -eq $cimService -and $MethodName -eq "Change" -and + $Arguments.PathName -ceq '"C:\Program Files\libvirtualhid\broker.exe"' -and + $Arguments.DisplayName -eq "libvirtualhid Broker" -and $Arguments.StartMode -eq "Automatic" + } + } + + It "rejects a failed service configuration change" { + Mock Invoke-CimMethod { [pscustomobject]@{ ReturnValue = 2 } } + + { + Set-LibVirtualHidBrokerServiceConfiguration -Path "C:\broker.exe" -Confirm:$false + } | Should -Throw "*Win32_Service.Change return value 2*" + } + + It "rejects a service that disappeared before reconfiguration" { + Mock Get-CimInstance { $null } + + { + Set-LibVirtualHidBrokerServiceConfiguration -Path "C:\broker.exe" -Confirm:$false + } | Should -Throw "*service was not found for reconfiguration*" + + Should -Invoke Invoke-CimMethod -Times 0 -Exactly -Scope It + } + + It "does not reconfigure a service during WhatIf" { + Set-LibVirtualHidBrokerServiceConfiguration -Path "C:\broker.exe" -WhatIf | Should -BeFalse + + Should -Invoke Get-CimInstance -Times 0 -Exactly -Scope It + Should -Invoke Invoke-CimMethod -Times 0 -Exactly -Scope It + } +} + Describe "Driver installation helpers" { + BeforeEach { + $script:LibVirtualHidRebootRequired = $false + } + It "imports an existing certificate into both required stores" { $path = Join-Path $TestDrive "driver.cer" New-Item -ItemType File -Path $path | Out-Null @@ -320,6 +369,67 @@ Describe "Driver installation helpers" { Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { $Message -like "Windows reported that a reboot is required*" } + $script:LibVirtualHidRebootRequired | Should -BeTrue + } + + It "defers a restart with exit code until reboot" -TestCases @( + @{ ExitCode = 50 }, + @{ ExitCode = 3010 } + ) { + param($ExitCode) + $script:restartExitCode = $ExitCode + Mock pnputil.exe { $global:LASTEXITCODE = $script:restartExitCode } + Mock Write-Warning {} + + Restart-RootDevice -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + + $script:LibVirtualHidRebootRequired | Should -BeTrue + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -like "*Restart Windows to complete*" + } + } + + It "does not restart a device once a reboot is required" { + $script:LibVirtualHidRebootRequired = $true + Mock pnputil.exe { throw "A pending-reboot device must not be restarted" } + + Restart-RootDevice -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + + Should -Invoke pnputil.exe -Times 0 -Exactly -Scope It + } + + It "handles native stderr before interpreting a reboot exit code" { + Mock pnputil.exe { + & $env:ComSpec /c "echo Device is pending system reboot. 1>&2 & exit /b 50" + } + Mock Write-Warning {} + $ErrorActionPreference = "Stop" + + Restart-RootDevice -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + + $script:LibVirtualHidRebootRequired | Should -BeTrue + $ErrorActionPreference | Should -Be "Stop" + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It + } + + It "preserves a command invocation failure and restores the error preference" { + Mock pnputil.exe { throw "Launch failed" } + $ErrorActionPreference = "Stop" + + { + Restart-RootDevice -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + } | Should -Throw "Launch failed" + + $ErrorActionPreference | Should -Be "Stop" + } + + It "restarts a device successfully without requiring a reboot" { + Mock pnputil.exe { $global:LASTEXITCODE = 0 } + + Restart-RootDevice -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + + $script:LibVirtualHidRebootRequired | Should -BeFalse + Should -Invoke pnputil.exe -Times 1 -Exactly -Scope It } It "throws when restarting the device fails" { @@ -346,6 +456,7 @@ Describe "Driver installation helpers" { $SuccessExitCodes -contains 3010 } Should -Invoke Write-Warning -Times 1 -Exactly -Scope It + $script:LibVirtualHidRebootRequired | Should -BeTrue } It "installs a root device through the SetupAPI helper" { @@ -361,3 +472,188 @@ Describe "Driver installation helpers" { } } } + +Describe "Install-LibVirtualHidDriver" { + BeforeEach { + $infPath = Join-Path $TestDrive "libvirtualhid.inf" + Set-Content -LiteralPath $infPath -Value "test INF" + $script:installOperations = [System.Collections.Generic.List[string]]::new() + $script:brokerStatus = "Running" + Mock Import-DriverCertificate {} + Mock Invoke-CheckedCommand { + $script:installOperations.Add("stage") + } + Mock Resolve-LibVirtualHidDriverSetupPath { "setup.exe" } + Mock Resolve-LibVirtualHidBrokerPath { "broker.exe" } + Mock Get-Service { [pscustomobject]@{ Status = $script:brokerStatus } } + Mock Stop-LibVirtualHidBrokerService { + $script:installOperations.Add("stop") + $script:brokerStatus = "Stopped" + } + Mock Get-LibVirtualHidRegistryRootDevice { @() } + Mock Get-LibVirtualHidRootDeviceInstanceId { "ROOT\LIBVIRTUALHID\0000" } + Mock Install-RootDeviceWithSetupApi { + $script:installOperations.Add("create") + } + Mock Remove-DeviceInstance { + $script:installOperations.Add("remove") + } + Mock Set-RootDeviceVhfMode { + $script:installOperations.Add("vhf") + } + Mock Update-RootDeviceDriverWithSetupApi { + $script:installOperations.Add("update") + } + Mock Restart-RootDevice { + $script:installOperations.Add("restart") + } + Mock Install-LibVirtualHidBrokerService { + $script:installOperations.Add("broker") + $script:brokerStatus = "Running" + } + Mock Start-Service { + $script:installOperations.Add("restore") + } + Mock Write-Warning {} + } + + It "stops the broker before updating an existing device and starts it last" { + Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false + + $script:installOperations.ToArray() | Should -Be @("stage", "stop", "vhf", "update", "restart", "broker") + Should -Invoke Install-RootDeviceWithSetupApi -Times 0 -Exactly -Scope It + Should -Invoke Start-Service -Times 0 -Exactly -Scope It + } + + It "stops the broker before removing a stale device and creating its replacement" { + Mock Get-LibVirtualHidRegistryRootDevice { + [pscustomobject]@{ + InstanceId = "ROOT\LIBVIRTUALHID\0000" + HasCorruptHardwareId = $true + HasLegacyHidClass = $false + } + } + $script:deviceQueries = 0 + Mock Get-LibVirtualHidRootDeviceInstanceId { + $script:deviceQueries += 1 + if ($script:deviceQueries -gt 1) { + "ROOT\LIBVIRTUALHID\0001" + } + } + + Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false + + $script:installOperations.ToArray() | Should -Be @("stage", "stop", "remove", "create", "vhf", "update", "restart", "broker") + } + + It "finishes broker installation without restarting a pending-reboot device" { + Mock Update-RootDeviceDriverWithSetupApi { + $script:installOperations.Add("update") + $script:LibVirtualHidRebootRequired = $true + } + + Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false + + $script:installOperations.ToArray() | Should -Be @("stage", "stop", "vhf", "update", "broker") + Should -Invoke Restart-RootDevice -Times 0 -Exactly -Scope It + } + + It "does not stop the broker when only staging a driver" { + Install-LibVirtualHidDriver -InfPath $infPath -StageOnly -Confirm:$false + + $script:installOperations.ToArray() | Should -Be @("stage") + Should -Invoke Get-Service -Times 0 -Exactly -Scope It + Should -Invoke Resolve-LibVirtualHidDriverSetupPath -Times 0 -Exactly -Scope It + } + + It "validates the broker path before stopping the existing service" { + Mock Resolve-LibVirtualHidBrokerPath { throw "Missing broker" } + + { Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false } | Should -Throw "Missing broker" + + Should -Invoke Stop-LibVirtualHidBrokerService -Times 0 -Exactly -Scope It + } + + It "preserves a driver update failure and restores a previously running broker" { + Mock Update-RootDeviceDriverWithSetupApi { throw "Update failed" } + + { Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false } | Should -Throw "Update failed" + + $script:installOperations.ToArray() | Should -Be @("stage", "stop", "vhf", "restore") + Should -Invoke Install-LibVirtualHidBrokerService -Times 0 -Exactly -Scope It + } + + It "does not start a previously stopped broker after an update failure" { + $script:brokerStatus = "Stopped" + Mock Update-RootDeviceDriverWithSetupApi { throw "Update failed" } + + { Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false } | Should -Throw "Update failed" + + Should -Invoke Start-Service -Times 0 -Exactly -Scope It + } + + It "preserves an update failure if broker recovery also fails" { + Mock Update-RootDeviceDriverWithSetupApi { throw "Update failed" } + Mock Start-Service { throw "Recovery failed" } + + { Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false } | Should -Throw "Update failed" + + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -eq "Unable to restore the libvirtualhid broker service: Recovery failed" + } + } + + It "restores a running broker after an install without a broker executable" { + Mock Resolve-LibVirtualHidBrokerPath { $null } + Mock Install-LibVirtualHidBrokerService {} + + Install-LibVirtualHidDriver -InfPath $infPath -Confirm:$false + + $script:installOperations[-1] | Should -Be "restore" + } +} + +Describe "Installation failure logging" { + BeforeEach { + $script:LibVirtualHidTranscriptStarted = $false + Mock Write-Warning {} + } + + It "records the error if a transcript could not be started" { + $path = Join-Path $TestDrive "failure.log" + + Write-LibVirtualHidInstallFailure -Path $path -Message "Driver update failed" + + Get-Content -LiteralPath $path | Should -BeLike "*libvirtualhid driver installation failed: Driver update failed" + } + + It "writes the error to an active transcript without appending to its file" { + $script:LibVirtualHidTranscriptStarted = $true + Mock Add-Content {} + + Write-LibVirtualHidInstallFailure -Path "driver.log" -Message "Driver update failed" + + Should -Invoke Add-Content -Times 0 -Exactly -Scope It + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -eq "libvirtualhid driver installation failed: Driver update failed" + } + } + + It "does not require a log path" { + Mock Add-Content {} + + Write-LibVirtualHidInstallFailure -Message "Driver update failed" + + Should -Invoke Add-Content -Times 0 -Exactly -Scope It + } + + It "does not replace the original error when writing the log also fails" { + Mock Add-Content { throw "Log is locked" } + + { Write-LibVirtualHidInstallFailure -Path "driver.log" -Message "Driver update failed" } | Should -Not -Throw + + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -eq "Unable to write the libvirtualhid installation error log: Log is locked" + } + } +} diff --git a/tests/scripts/test-installer-restart-manager.ps1 b/tests/scripts/test-installer-restart-manager.ps1 new file mode 100644 index 00000000..26bb6e09 --- /dev/null +++ b/tests/scripts/test-installer-restart-manager.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS +Checks the MSI's native applications-to-close integration and prepares a CI fixture. +.PARAMETER MsiPath +The built Windows driver installer to inspect. +.PARAMETER FixtureDirectory +An optional temporary directory in which to build and start a harmless sunshine.exe +process. The calling CI job installs the MSI and verifies that Restart Manager closes it. +.OUTPUTS +System.Int32. The fixture process identifier when FixtureDirectory is supplied. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $MsiPath, + + [string] $FixtureDirectory +) + +$ErrorActionPreference = "Stop" +$resolvedMsi = (Resolve-Path -LiteralPath $MsiPath).Path + +<# +.SYNOPSIS +Reads the first column of a query against a Windows Installer database. +.PARAMETER Database +The read-only Windows Installer database. +.PARAMETER Query +The query whose string results should be returned. +.OUTPUTS +System.String. The first column of each matching record. +#> +function Get-MsiQueryValue { + param($Database, [string] $Query) + + $view = $Database.OpenView($Query) + try { + $null = $view.Execute() + while ($record = $view.Fetch()) { + $record.StringData(1) + } + } finally { + $null = $view.Close() + } +} + +$installer = New-Object -ComObject WindowsInstaller.Installer +$database = $installer.OpenDatabase($resolvedMsi, 0) +$resources = @(Get-MsiQueryValue -Database $database -Query 'SELECT `Resource` FROM `Wix4RestartResource`') +foreach ($resource in @("SunshineService", "sunshine.exe")) { + if ($resource -notin $resources) { + throw "The MSI does not register $resource with Restart Manager." + } +} +$dialog = Get-MsiQueryValue -Database $database -Query "SELECT ``Dialog`` FROM ``Dialog`` WHERE ``Dialog`` = 'MsiRMFilesInUse'" +if (-not $dialog) { + throw "The MSI does not contain the native Restart Manager applications-to-close dialog." +} +$action = Get-MsiQueryValue -Database $database -Query ( + "SELECT ``Action`` FROM ``CustomAction`` WHERE ``Target`` = 'WixRegisterRestartResources'" +) +if (-not $action) { + throw "The MSI does not contain the Restart Manager registration action." +} +$registrationSequence = Get-MsiQueryValue -Database $database -Query ( + "SELECT ``Sequence`` FROM ``InstallExecuteSequence`` WHERE ``Action`` = '$action'" +) +$validationSequence = Get-MsiQueryValue -Database $database -Query ( + "SELECT ``Sequence`` FROM ``InstallExecuteSequence`` WHERE ``Action`` = 'InstallValidate'" +) +if (-not $registrationSequence -or [int] $registrationSequence -ge [int] $validationSequence) { + throw "Restart resources must be registered before InstallValidate displays files in use." +} + +Write-Information "The MSI registers Sunshine before its native applications-to-close dialog." -InformationAction Continue + +if ($FixtureDirectory) { + New-Item -ItemType Directory -Path $FixtureDirectory | Out-Null + $fixtureExecutable = Join-Path $FixtureDirectory "sunshine.exe" + Add-Type -OutputAssembly $fixtureExecutable -OutputType ConsoleApplication -TypeDefinition @' +public static class SunshineRestartManagerFixture { + public static void Main() { + System.Threading.Thread.Sleep(System.Threading.Timeout.Infinite); + } +} +'@ + $fixtureProcess = Start-Process -FilePath $fixtureExecutable -PassThru -WindowStyle Hidden + if ($fixtureProcess.WaitForExit(250)) { + throw "The Sunshine fixture exited before the MSI could detect it." + } + $fixtureProcess.Id +}