diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 655a59f..5cadb37 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -17,6 +17,21 @@ on: workflow_dispatch: jobs: + windows-resources: + name: Windows Resource DLL + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Rebuild and compare resource DLL + shell: pwsh + run: | + $rebuilt = Join-Path $env:RUNNER_TEMP "axidev-osk-resources.dll" + .\packaging\windows\build-resources.ps1 ` + -OutputPath $rebuilt ` + -VerifyAgainst .\packaging\windows\axidev-osk-resources.dll + static: name: Static Packaging Checks runs-on: ubuntu-24.04 diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 99ba34f..4676a94 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -1,6 +1,7 @@ # Trusted Windows Development Install This directory builds a local Axidev OSK executable with Windows UIAccess. +It also registers Axidev OSK as a development accessibility application. It is a development workflow, not a distributable installer. UIAccess lets the on-screen keyboard stay above other applications without @@ -45,14 +46,15 @@ The script performs these steps: 1. Builds a one-directory PyInstaller bundle under `dist\axidev-osk`. 2. Creates or reuses `CN=Axidev OSK Development` in the current user's certificate store. -3. Signs `axidev-osk.exe` with SHA-256. -4. Requests elevation to trust the certificate and stage the replacement. -5. Replaces `C:\Program Files\Axidev OSK`. -6. Creates `Axidev OSK` in the current user's Start Menu. -7. Launches the installed executable without elevation. -8. Keeps the previous install until startup and UIAccess checks pass. -9. Requests elevation to commit the verified replacement. -10. Reports its signature state, UIAccess token, elevation state, and process ID. +3. Signs `axidev-osk.exe` and `axidev-osk-resources.dll` with SHA-256. +4. Requests one UAC confirmation for an elevated transaction helper. +5. Replaces `C:\Program Files\Axidev OSK` and its Start Menu shortcut. +6. Registers one Axidev accessibility application without launch arguments. +7. Keeps the elevated helper waiting while the normal process starts. +8. Verifies the installed process signature and UIAccess token. +9. Adds Axidev OSK to the current user's accessibility configuration. +10. Signals the helper to commit or rollback the replacement. +11. Reports its signature state, UIAccess token, elevation state, and process ID. The expected final output includes: @@ -61,8 +63,38 @@ Signature: Valid UIAccess: 1 ``` -The script creates no startup entry. Automatic startup belongs to the future -MSI installer. +Windows uses the same executable and normal runtime on every desktop. The +registration has no `StartParams` or alternate secure executable. Sign out +and back in after installation so Windows reloads the accessibility settings. + +The development registration uses this stable identity: + +```text +Axidev_AxidevOSK_Development_v1.0 +``` + +The registration loads its English application name and description from +`axidev-osk-resources.dll`. It does not modify the Windows `osk` +accessibility entry. Microsoft's on-screen keyboard remains available as a +fallback. + +## Rebuild Accessibility Resources + +Normal source and release installation use the committed resource DLL and do +not need a compiler. Maintainers need Visual Studio 2022 Build Tools with the +C++ build tools and a Windows 10 or Windows 11 SDK only when changing the +resource strings. + +Run the resource build from the repository root in Windows PowerShell: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\packaging\windows\build-resources.ps1 +``` + +The script replaces `packaging\windows\axidev-osk-resources.dll` with a +64-bit resource-only DLL. Packaging CI rebuilds a temporary copy and compares +resource IDs 101 and 102 with the committed DLL instead of comparing binary +bytes. ## Uninstall @@ -80,10 +112,13 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\packaging\windows\unin The script stops the installed process, removes the Start Menu shortcut and `C:\Program Files\Axidev OSK`, and removes only the certificate thumbprint -recorded by the development installer. +recorded by the development installer. It also removes only the Axidev +accessibility entry and its current-user configuration membership. ## Security Scope The development certificate is local and self-signed. Do not export it with -its private key, commit it, or use it for public releases. The future MSI must -use a production Authenticode certificate and its own installer signing flow. +its private key, commit it, or use it for public releases. Windows may run the +normal application under the `SYSTEM` account on secure desktops. The future +MSI must use a production Authenticode certificate and its own installer +signing flow. diff --git a/packaging/windows/axidev-osk-resources.dll b/packaging/windows/axidev-osk-resources.dll new file mode 100644 index 0000000..28c6d72 Binary files /dev/null and b/packaging/windows/axidev-osk-resources.dll differ diff --git a/packaging/windows/axidev-osk-resources.rc b/packaging/windows/axidev-osk-resources.rc new file mode 100644 index 0000000..ff087dc --- /dev/null +++ b/packaging/windows/axidev-osk-resources.rc @@ -0,0 +1,7 @@ +LANGUAGE 9, 1 + +STRINGTABLE +BEGIN + 101 "Axidev OSK Development" + 102 "Axidev OSK development on-screen keyboard." +END diff --git a/packaging/windows/axidev-osk.spec b/packaging/windows/axidev-osk.spec index 4d1b9f1..d9314cd 100644 --- a/packaging/windows/axidev-osk.spec +++ b/packaging/windows/axidev-osk.spec @@ -9,6 +9,7 @@ manifest = Path(SPECPATH) / "axidev-osk.manifest" icon_directory = repo_root / "src" / "axidev_osk" / "assets" icon_svg = icon_directory / "axidev-osk.svg" icon_ico = icon_directory / "axidev-osk.ico" +resources_dll = Path(SPECPATH) / "axidev-osk-resources.dll" analysis = Analysis( [str(entrypoint)], @@ -20,6 +21,7 @@ analysis = Analysis( datas=[ (str(icon_svg), "axidev_osk/assets"), (str(icon_ico), "axidev_osk/assets"), + (str(resources_dll), "."), ], hiddenimports=collect_submodules("axidev_osk.components"), hookspath=[], diff --git a/packaging/windows/build-resources.ps1 b/packaging/windows/build-resources.ps1 new file mode 100644 index 0000000..e3895b3 --- /dev/null +++ b/packaging/windows/build-resources.ps1 @@ -0,0 +1,174 @@ +[CmdletBinding()] +param( + [string]$OutputPath, + + [string]$VerifyAgainst +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ResourceSource = Join-Path $PSScriptRoot "axidev-osk-resources.rc" +if (-not $OutputPath) { + $OutputPath = Join-Path $PSScriptRoot "axidev-osk-resources.dll" +} +$VsWhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path -LiteralPath $VsWhere -PathType Leaf)) { + throw "Visual Studio Installer could not be found. Install Visual Studio Build Tools with the C++ build tools." +} + +$VisualStudioPath = & $VsWhere ` + -latest ` + -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath +if ($LASTEXITCODE -ne 0 -or -not $VisualStudioPath) { + throw "Visual Studio C++ build tools could not be found." +} + +$ToolsetRoot = Join-Path $VisualStudioPath "VC\Tools\MSVC" +$Toolset = Get-ChildItem -LiteralPath $ToolsetRoot -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "bin\Hostx64\x64\link.exe") } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 +if ($null -eq $Toolset) { + throw "The 64-bit Microsoft linker could not be found." +} + +$Link = Join-Path $Toolset.FullName "bin\Hostx64\x64\link.exe" +$Dumpbin = Join-Path $Toolset.FullName "bin\Hostx64\x64\dumpbin.exe" +$WindowsSdkBin = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin" +$WindowsSdk = Get-ChildItem -LiteralPath $WindowsSdkBin -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "x64\rc.exe") } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 +if ($null -eq $WindowsSdk) { + throw "A Windows 10 or Windows 11 SDK resource compiler could not be found." +} +$ResourceCompiler = Join-Path $WindowsSdk.FullName "x64\rc.exe" + +function Assert-ResourceOnlyDll([string]$Path) { + $headers = & $Dumpbin /headers $Path | Out-String + if ($LASTEXITCODE -ne 0) { + throw "dumpbin could not inspect $Path." + } + if ($headers -notmatch "(?im)^\s*0+\s+entry point") { + throw "$Path has a nonzero entry point." + } + if ($headers -match "(?im)^\s*\.text\s+name") { + throw "$Path contains executable code." + } +} + +if (-not ([System.Management.Automation.PSTypeName]"AxidevResourceStrings").Type) { + Add-Type -TypeDefinition @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +public static class AxidevResourceStrings +{ + private const uint LOAD_LIBRARY_AS_DATAFILE = 0x00000002; + private const uint LOAD_LIBRARY_AS_IMAGE_RESOURCE = 0x00000020; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int LoadString(IntPtr module, uint id, StringBuilder value, int capacity); + + [DllImport("kernel32.dll")] + private static extern bool FreeLibrary(IntPtr module); + + public static string Read(string path, uint id) + { + IntPtr module = LoadLibraryEx( + path, + IntPtr.Zero, + LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE); + if (module == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error()); + + try + { + StringBuilder value = new StringBuilder(1024); + int length = LoadString(module, id, value, value.Capacity); + if (length == 0) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return value.ToString(); + } + finally + { + FreeLibrary(module); + } + } +} +"@ +} + +function Read-ExpectedStrings { + $strings = @{} + foreach ($line in Get-Content -LiteralPath $ResourceSource) { + if ($line -match '^\s*(\d+)\s+"([^"]*)"\s*$') { + $strings[[int]$Matches[1]] = $Matches[2] + } + } + foreach ($id in @(101, 102)) { + if (-not $strings.ContainsKey($id)) { + throw "$ResourceSource does not define string resource $id." + } + } + return $strings +} + +function Assert-ResourceStrings([string]$Path, $ExpectedStrings) { + foreach ($id in @(101, 102)) { + $actual = [AxidevResourceStrings]::Read((Resolve-Path $Path).ProviderPath, $id) + if ($actual -cne $ExpectedStrings[$id]) { + throw "String resource $id in $Path does not match $ResourceSource." + } + } +} + +$OutputPath = [IO.Path]::GetFullPath($OutputPath) +$OutputDirectory = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$TemporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("axidev-osk-resources-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $TemporaryDirectory | Out-Null + +try { + $ResourceObject = Join-Path $TemporaryDirectory "axidev-osk-resources.res" + & $ResourceCompiler /nologo "/fo$ResourceObject" $ResourceSource + if ($LASTEXITCODE -ne 0) { + throw "The Windows resource compiler failed with exit code $LASTEXITCODE." + } + + & $Link ` + /nologo ` + /dll ` + /noentry ` + /machine:x64 ` + /brepro ` + "/out:$OutputPath" ` + $ResourceObject + if ($LASTEXITCODE -ne 0) { + throw "The Microsoft linker failed with exit code $LASTEXITCODE." + } + + $ExpectedStrings = Read-ExpectedStrings + Assert-ResourceOnlyDll $OutputPath + Assert-ResourceStrings $OutputPath $ExpectedStrings + + if ($VerifyAgainst) { + Assert-ResourceOnlyDll $VerifyAgainst + Assert-ResourceStrings $VerifyAgainst $ExpectedStrings + } +} finally { + Remove-Item -LiteralPath $TemporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Built resource-only DLL: $OutputPath" +if ($VerifyAgainst) { + Write-Host "Verified resource strings against: $VerifyAgainst" +} diff --git a/packaging/windows/development-admin.ps1 b/packaging/windows/development-admin.ps1 index 33cee94..a900440 100644 --- a/packaging/windows/development-admin.ps1 +++ b/packaging/windows/development-admin.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet("Install", "Commit", "Rollback", "Uninstall")] + [ValidateSet("Install", "Uninstall")] [string]$Mode, [string]$SourceDirectory, @@ -10,7 +10,9 @@ param( [string]$CertificateThumbprint, - [string]$ShortcutPath + [string]$ShortcutPath, + + [string]$TransactionPath ) Set-StrictMode -Version Latest @@ -23,6 +25,15 @@ $MarkerName = "development-certificate-thumbprint.txt" $PreviousMarkerName = "development-previous-certificate-thumbprint.txt" $PendingMarkerName = "development-install-pending.txt" $ShortcutMarkerName = "development-shortcut-pending.txt" +$RegistrationBackupName = "development-accessibility-registration.reg" +$RegistrationPresenceName = "development-accessibility-registration-presence.txt" +$RegistrationName = "Axidev_AxidevOSK_Development_v1.0" +$ResourceDllName = "axidev-osk-resources.dll" +$RegistrationPath = Join-Path ` + "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Accessibility\ATs" ` + $RegistrationName +$RegistrationNativePath = ` + "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Accessibility\ATs\$RegistrationName" if (-not $ShortcutPath) { throw "The current user's Start Menu shortcut path is missing." @@ -33,9 +44,23 @@ $ShortcutNewPath = Join-Path $ShortcutDirectory "$ShortcutBaseName.new.lnk" $ShortcutOldPath = Join-Path $ShortcutDirectory "$ShortcutBaseName.old.lnk" function Stop-AxidevOsk { - $processes = @(Get-Process -Name "axidev-osk" -ErrorAction SilentlyContinue) - $processes | Stop-Process -Force -ErrorAction Stop - $processes | Wait-Process -Timeout 10 -ErrorAction Stop + $deadline = (Get-Date).AddSeconds(10) + $emptyChecks = 0 + do { + $processes = @(Get-Process -Name "axidev-osk" -ErrorAction SilentlyContinue) + if ($processes.Count -eq 0) { + $emptyChecks += 1 + if ($emptyChecks -ge 4) { + return + } + } else { + $emptyChecks = 0 + $processes | Stop-Process -Force -ErrorAction Stop + $processes | Wait-Process -Timeout 5 -ErrorAction Stop + } + Start-Sleep -Milliseconds 250 + } while ((Get-Date) -lt $deadline) + throw "Axidev OSK continued restarting during the elevated operation." } function Remove-DevelopmentCertificate([string]$Thumbprint) { @@ -62,6 +87,71 @@ function Read-CertificateMarker([string]$Directory, [string]$Name) { return (Get-Content -LiteralPath $marker -Raw).Trim() } +function Backup-AccessibilityRegistration([string]$Directory) { + $presencePath = Join-Path $Directory $RegistrationPresenceName + if (-not (Test-Path -LiteralPath $RegistrationPath)) { + Set-Content -LiteralPath $presencePath -Value "absent" -NoNewline + return + } + + & reg.exe export ` + $RegistrationNativePath ` + (Join-Path $Directory $RegistrationBackupName) ` + /y | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Unable to back up the Axidev accessibility registration." + } + Set-Content -LiteralPath $presencePath -Value "present" -NoNewline +} + +function Restore-AccessibilityRegistration([string]$Directory) { + Remove-Item -LiteralPath $RegistrationPath -Recurse -Force -ErrorAction SilentlyContinue + $presencePath = Join-Path $Directory $RegistrationPresenceName + if (-not (Test-Path -LiteralPath $presencePath -PathType Leaf)) { + return + } + if ((Get-Content -LiteralPath $presencePath -Raw).Trim() -ne "present") { + return + } + + & reg.exe import (Join-Path $Directory $RegistrationBackupName) | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Unable to restore the Axidev accessibility registration." + } +} + +function Remove-RegistrationBackup([string]$Directory) { + Remove-Item ` + -LiteralPath (Join-Path $Directory $RegistrationBackupName) ` + -Force ` + -ErrorAction SilentlyContinue + Remove-Item ` + -LiteralPath (Join-Path $Directory $RegistrationPresenceName) ` + -Force ` + -ErrorAction SilentlyContinue +} + +function Install-AccessibilityRegistration { + $profile = '' + $resourcePath = Join-Path $InstallPath $ResourceDllName + Remove-Item -LiteralPath $RegistrationPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $RegistrationPath -Force | Out-Null + New-ItemProperty -LiteralPath $RegistrationPath -Name "ApplicationName" ` + -Value "@$resourcePath,-101" -PropertyType String -Force | Out-Null + New-ItemProperty -LiteralPath $RegistrationPath -Name "Description" ` + -Value "@$resourcePath,-102" -PropertyType String -Force | Out-Null + New-ItemProperty -LiteralPath $RegistrationPath -Name "ATExe" ` + -Value "axidev-osk.exe" -PropertyType String -Force | Out-Null + New-ItemProperty -LiteralPath $RegistrationPath -Name "Profile" ` + -Value $profile -PropertyType String -Force | Out-Null + New-ItemProperty -LiteralPath $RegistrationPath -Name "SimpleProfile" ` + -Value "On-screen keyboard" -PropertyType String -Force | Out-Null + New-ItemProperty -LiteralPath $RegistrationPath -Name "StartExe" ` + -Value (Join-Path $InstallPath "axidev-osk.exe") -PropertyType String -Force | Out-Null + New-ItemProperty -LiteralPath $RegistrationPath -Name "TerminateOnDesktopSwitch" ` + -Value 1 -PropertyType DWord -Force | Out-Null +} + function New-StartMenuShortcut([string]$Path) { New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null $shell = New-Object -ComObject "WScript.Shell" @@ -72,57 +162,48 @@ function New-StartMenuShortcut([string]$Path) { $shortcut.Save() } -if ($Mode -eq "Uninstall") { - Stop-AxidevOsk - $thumbprints = @() - foreach ($path in @($InstallPath, $NewPath, $OldPath)) { - $thumbprints += Read-CertificateMarker $path $MarkerName - $thumbprints += Read-CertificateMarker $path $PreviousMarkerName - } - foreach ($path in @($InstallPath, $NewPath, $OldPath)) { - if (Test-Path -LiteralPath $path) { - Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop - } - } - foreach ($path in @($ShortcutPath, $ShortcutNewPath, $ShortcutOldPath)) { - Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue - } - $thumbprints | Where-Object { $_ } | Select-Object -Unique | ForEach-Object { - Remove-DevelopmentCertificate $_ - } - return -} - -if ($Mode -eq "Commit") { - $pendingMarker = Join-Path $InstallPath $PendingMarkerName - if (-not (Test-Path -LiteralPath $pendingMarker -PathType Leaf)) { - throw "No verified development install is pending commit." - } +function Commit-Installation { $currentThumbprint = Read-CertificateMarker $InstallPath $MarkerName $previousThumbprint = Read-CertificateMarker $InstallPath $PreviousMarkerName Remove-Item -LiteralPath $OldPath -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $ShortcutOldPath -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path $InstallPath $ShortcutMarkerName) -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path $InstallPath $PreviousMarkerName) -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $pendingMarker -Force + Remove-Item ` + -LiteralPath (Join-Path $InstallPath $ShortcutMarkerName) ` + -Force ` + -ErrorAction SilentlyContinue + Remove-Item ` + -LiteralPath (Join-Path $InstallPath $PreviousMarkerName) ` + -Force ` + -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $InstallPath $PendingMarkerName) -Force + Remove-RegistrationBackup $InstallPath if ($previousThumbprint -and $previousThumbprint -ne $currentThumbprint) { Remove-DevelopmentCertificate $previousThumbprint } - return } -if ($Mode -eq "Rollback") { - $installIsPending = Test-Path -LiteralPath (Join-Path $InstallPath $PendingMarkerName) -PathType Leaf - if ($installIsPending) { - $failedThumbprint = Read-CertificateMarker $InstallPath $MarkerName - $previousThumbprint = Read-CertificateMarker $InstallPath $PreviousMarkerName +function Rollback-Installation { + $installIsPending = Test-Path ` + -LiteralPath (Join-Path $InstallPath $PendingMarkerName) ` + -PathType Leaf + $backupDirectory = if ($installIsPending) { $InstallPath } else { $NewPath } + if (Test-Path -LiteralPath $backupDirectory -PathType Container) { + Restore-AccessibilityRegistration $backupDirectory + } + + $failedThumbprint = if ($installIsPending) { + Read-CertificateMarker $InstallPath $MarkerName + } else { + $CertificateThumbprint + } + $previousThumbprint = if ($installIsPending) { + Read-CertificateMarker $InstallPath $PreviousMarkerName } else { - $failedThumbprint = $CertificateThumbprint - $previousThumbprint = Read-CertificateMarker $InstallPath $MarkerName + Read-CertificateMarker $InstallPath $MarkerName } + Stop-AxidevOsk if ($installIsPending) { - Stop-AxidevOsk $shortcutMarker = Join-Path $InstallPath $ShortcutMarkerName if (Test-Path -LiteralPath $shortcutMarker -PathType Leaf) { $previousShortcut = (Get-Content -LiteralPath $shortcutMarker -Raw).Trim() @@ -143,6 +224,65 @@ if ($Mode -eq "Rollback") { if ($failedThumbprint -and $failedThumbprint -ne $previousThumbprint) { Remove-DevelopmentCertificate $failedThumbprint } +} + +function Assert-ExpectedSignature( + [string]$Path, + [string]$Description, + [switch]$RequireTrusted +) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "The staged $Description is missing." + } + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $CertificateThumbprint) { + throw "The staged $Description was not signed by the expected development certificate." + } + if ($RequireTrusted -and $signature.Status -ne "Valid") { + throw "The staged $Description signature is not trusted: $($signature.Status)." + } +} + +if ($Mode -eq "Uninstall") { + if (-not $TransactionPath -or -not (Test-Path -LiteralPath $TransactionPath -PathType Container)) { + throw "The uninstall transaction directory is missing." + } + + $canRestoreRegistration = $true + try { + Backup-AccessibilityRegistration $TransactionPath + Remove-Item -LiteralPath $RegistrationPath -Recurse -Force -ErrorAction SilentlyContinue + Stop-AxidevOsk + $canRestoreRegistration = $false + $thumbprints = @() + foreach ($path in @($InstallPath, $NewPath, $OldPath)) { + $thumbprints += Read-CertificateMarker $path $MarkerName + $thumbprints += Read-CertificateMarker $path $PreviousMarkerName + } + foreach ($path in @($InstallPath, $NewPath, $OldPath)) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + } + } + foreach ($path in @($ShortcutPath, $ShortcutNewPath, $ShortcutOldPath)) { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + $thumbprints | Where-Object { $_ } | Select-Object -Unique | ForEach-Object { + Remove-DevelopmentCertificate $_ + } + } catch { + if ($canRestoreRegistration) { + $presencePath = Join-Path $TransactionPath $RegistrationPresenceName + if (Test-Path -LiteralPath $presencePath -PathType Leaf) { + Restore-AccessibilityRegistration $TransactionPath + } + Set-Content -LiteralPath (Join-Path $TransactionPath "restore-configuration") ` + -Value "restore" -NoNewline + } + throw + } + Remove-RegistrationBackup $TransactionPath return } @@ -155,58 +295,85 @@ if (-not $CertificatePath -or -not (Test-Path -LiteralPath $CertificatePath -Pat if (-not $CertificateThumbprint) { throw "The development certificate thumbprint is missing." } - -$sourceExecutable = Join-Path $SourceDirectory "axidev-osk.exe" -$sourceSignature = Get-AuthenticodeSignature -LiteralPath $sourceExecutable -if ($null -eq $sourceSignature.SignerCertificate -or - $sourceSignature.SignerCertificate.Thumbprint -ne $CertificateThumbprint) { - throw "The staged executable was not signed by the expected development certificate." +if (-not $TransactionPath -or -not (Test-Path -LiteralPath $TransactionPath -PathType Container)) { + throw "The installation transaction directory is missing." } -Import-Certificate -FilePath $CertificatePath -CertStoreLocation "Cert:\LocalMachine\Root" | Out-Null -Import-Certificate -FilePath $CertificatePath -CertStoreLocation "Cert:\LocalMachine\TrustedPublisher" | Out-Null +$sourceExecutable = Join-Path $SourceDirectory "axidev-osk.exe" +$sourceResourceDll = Join-Path $SourceDirectory $ResourceDllName +Assert-ExpectedSignature $sourceExecutable "executable" +Assert-ExpectedSignature $sourceResourceDll "resource DLL" -if (Test-Path -LiteralPath $OldPath) { - throw "A previous development install transaction is still pending." -} -if (Test-Path -LiteralPath $ShortcutOldPath) { - throw "A previous Start Menu shortcut transaction is still pending." -} -$previousThumbprint = Read-CertificateMarker $InstallPath $MarkerName -Remove-Item -LiteralPath $NewPath -Recurse -Force -ErrorAction SilentlyContinue -Remove-Item -LiteralPath $ShortcutNewPath -Force -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Path $NewPath | Out-Null -Copy-Item -Path (Join-Path $SourceDirectory "*") -Destination $NewPath -Recurse -Force -Set-Content -LiteralPath (Join-Path $NewPath $MarkerName) -Value $CertificateThumbprint -NoNewline -Set-Content -LiteralPath (Join-Path $NewPath $PendingMarkerName) -Value "pending" -NoNewline -if ($previousThumbprint) { - Set-Content -LiteralPath (Join-Path $NewPath $PreviousMarkerName) -Value $previousThumbprint -NoNewline -} -New-StartMenuShortcut $ShortcutNewPath +try { + Import-Certificate -FilePath $CertificatePath -CertStoreLocation "Cert:\LocalMachine\Root" | Out-Null + Import-Certificate -FilePath $CertificatePath -CertStoreLocation "Cert:\LocalMachine\TrustedPublisher" | Out-Null -$installedSignature = Get-AuthenticodeSignature -LiteralPath (Join-Path $NewPath "axidev-osk.exe") -if ($installedSignature.Status -ne "Valid") { - Remove-Item -LiteralPath $NewPath -Recurse -Force - throw "The staged executable signature is not trusted: $($installedSignature.Status)." -} + if (Test-Path -LiteralPath $OldPath) { + throw "A previous development install transaction is still pending." + } + if (Test-Path -LiteralPath $ShortcutOldPath) { + throw "A previous Start Menu shortcut transaction is still pending." + } + $previousThumbprint = Read-CertificateMarker $InstallPath $MarkerName + Remove-Item -LiteralPath $NewPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $ShortcutNewPath -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $NewPath | Out-Null + Copy-Item -Path (Join-Path $SourceDirectory "*") -Destination $NewPath -Recurse -Force + Set-Content -LiteralPath (Join-Path $NewPath $MarkerName) ` + -Value $CertificateThumbprint -NoNewline + Set-Content -LiteralPath (Join-Path $NewPath $PendingMarkerName) ` + -Value "pending" -NoNewline + if ($previousThumbprint) { + Set-Content -LiteralPath (Join-Path $NewPath $PreviousMarkerName) ` + -Value $previousThumbprint -NoNewline + } + New-StartMenuShortcut $ShortcutNewPath -Stop-AxidevOsk -if (Test-Path -LiteralPath $InstallPath) { - Move-Item -LiteralPath $InstallPath -Destination $OldPath -} + Assert-ExpectedSignature ` + (Join-Path $NewPath "axidev-osk.exe") "executable" -RequireTrusted + Assert-ExpectedSignature ` + (Join-Path $NewPath $ResourceDllName) "resource DLL" -RequireTrusted -try { + Backup-AccessibilityRegistration $NewPath + Stop-AxidevOsk + if (Test-Path -LiteralPath $InstallPath) { + Move-Item -LiteralPath $InstallPath -Destination $OldPath + } Move-Item -LiteralPath $NewPath -Destination $InstallPath -} catch { - if (Test-Path -LiteralPath $OldPath) { - Move-Item -LiteralPath $OldPath -Destination $InstallPath + + $shortcutState = if (Test-Path -LiteralPath $ShortcutPath -PathType Leaf) { + "existing" + } else { + "absent" } - throw -} + Set-Content -LiteralPath (Join-Path $InstallPath $ShortcutMarkerName) ` + -Value $shortcutState -NoNewline + if ($shortcutState -eq "existing") { + Move-Item -LiteralPath $ShortcutPath -Destination $ShortcutOldPath + } + Move-Item -LiteralPath $ShortcutNewPath -Destination $ShortcutPath + Install-AccessibilityRegistration -$shortcutState = if (Test-Path -LiteralPath $ShortcutPath -PathType Leaf) { "existing" } else { "absent" } -Set-Content -LiteralPath (Join-Path $InstallPath $ShortcutMarkerName) -Value $shortcutState -NoNewline -if ($shortcutState -eq "existing") { - Move-Item -LiteralPath $ShortcutPath -Destination $ShortcutOldPath + Set-Content -LiteralPath (Join-Path $TransactionPath "ready") ` + -Value "ready" -NoNewline + $deadline = (Get-Date).AddSeconds(90) + do { + if (Test-Path -LiteralPath (Join-Path $TransactionPath "commit") -PathType Leaf) { + Commit-Installation + return + } + if (Test-Path -LiteralPath (Join-Path $TransactionPath "rollback") -PathType Leaf) { + throw "The parent installer requested rollback." + } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + throw "The parent installer did not finish the transaction within 90 seconds." +} catch { + $installationFailure = $_ + try { + Rollback-Installation + } catch { + throw "Installation failed: $installationFailure`nRollback also failed: $_" + } + throw $installationFailure } -Move-Item -LiteralPath $ShortcutNewPath -Destination $ShortcutPath diff --git a/packaging/windows/install-development.ps1 b/packaging/windows/install-development.ps1 index 3a65ef6..d24409c 100644 --- a/packaging/windows/install-development.ps1 +++ b/packaging/windows/install-development.ps1 @@ -6,6 +6,11 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +$AccessibilityPath = "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Accessibility" +$AccessibilityConfigurationName = "Configuration" +$NormalRegistrationName = "Axidev_AxidevOSK_Development_v1.0" +$ResourceDllName = "axidev-osk-resources.dll" + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).ProviderPath if (-not $Python) { $Python = Join-Path $RepoRoot ".venv-windows\Scripts\python.exe" @@ -32,6 +37,14 @@ $ExecutablePath = Join-Path $BundlePath "axidev-osk.exe" if (-not (Test-Path -LiteralPath $ExecutablePath -PathType Leaf)) { throw "PyInstaller did not create $ExecutablePath." } +$BundledResourcePath = Join-Path $BundlePath $ResourceDllName +if (-not (Test-Path -LiteralPath $BundledResourcePath -PathType Leaf)) { + $CollectedResourcePath = Join-Path $BundlePath "_internal\$ResourceDllName" + if (-not (Test-Path -LiteralPath $CollectedResourcePath -PathType Leaf)) { + throw "PyInstaller did not collect $ResourceDllName." + } + Move-Item -LiteralPath $CollectedResourcePath -Destination $BundledResourcePath +} $CertificateSubject = "CN=Axidev OSK Development" $Certificate = Get-ChildItem -Path "Cert:\CurrentUser\My" -CodeSigningCert | @@ -54,14 +67,19 @@ if ($null -eq $Certificate) { -NotAfter (Get-Date).AddYears(2) } -$Signature = Set-AuthenticodeSignature ` - -LiteralPath $ExecutablePath ` - -Certificate $Certificate ` - -HashAlgorithm SHA256 ` - -IncludeChain All -if ($null -eq $Signature.SignerCertificate -or - $Signature.SignerCertificate.Thumbprint -ne $Certificate.Thumbprint) { - throw "PowerShell did not sign the executable with the expected certificate." +foreach ($signingTarget in @( + [PSCustomObject]@{ Path = $ExecutablePath; Description = "executable" }, + [PSCustomObject]@{ Path = $BundledResourcePath; Description = "resource DLL" } +)) { + $Signature = Set-AuthenticodeSignature ` + -LiteralPath $signingTarget.Path ` + -Certificate $Certificate ` + -HashAlgorithm SHA256 ` + -IncludeChain All + if ($null -eq $Signature.SignerCertificate -or + $Signature.SignerCertificate.Thumbprint -ne $Certificate.Thumbprint) { + throw "PowerShell did not sign the $($signingTarget.Description) with the expected certificate." + } } if (-not ([System.Management.Automation.PSTypeName]"AxidevTokenInfo").Type) { @@ -112,21 +130,92 @@ public static class AxidevTokenInfo "@ } -function Invoke-DevelopmentAdmin([string]$Mode) { +function Start-DevelopmentAdmin { $arguments = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$AdminScript`"", - "-Mode", $Mode, + "-Mode", "Install", "-SourceDirectory", "`"$StagedBundle`"", "-CertificatePath", "`"$ExportedCertificate`"", "-CertificateThumbprint", $Certificate.Thumbprint, - "-ShortcutPath", "`"$ShortcutPath`"" + "-ShortcutPath", "`"$ShortcutPath`"", + "-TransactionPath", "`"$TransactionPath`"" ) - $process = Start-Process -FilePath $PowerShell -Verb RunAs -Wait -PassThru -ArgumentList $arguments - if ($process.ExitCode -ne 0) { - throw "The elevated $Mode operation failed with exit code $($process.ExitCode)." + return Start-Process ` + -FilePath $PowerShell ` + -Verb RunAs ` + -PassThru ` + -ArgumentList $arguments +} + +function Get-AccessibilityConfigurationState { + if (-not (Test-Path -LiteralPath $AccessibilityPath)) { + return [PSCustomObject]@{ Exists = $false; Value = "" } } + $property = Get-ItemProperty -LiteralPath $AccessibilityPath -Name $AccessibilityConfigurationName -ErrorAction SilentlyContinue + if ($null -eq $property) { + return [PSCustomObject]@{ Exists = $false; Value = "" } + } + return [PSCustomObject]@{ + Exists = $true + Value = [string]$property.$AccessibilityConfigurationName + } +} + +function Set-AccessibilityConfiguration([string]$Value) { + New-Item -ItemType Directory -Path $AccessibilityPath -Force | Out-Null + New-ItemProperty ` + -LiteralPath $AccessibilityPath ` + -Name $AccessibilityConfigurationName ` + -Value $Value ` + -PropertyType String ` + -Force | Out-Null +} + +function Restore-AccessibilityConfiguration($State) { + if ($State.Exists) { + Set-AccessibilityConfiguration $State.Value + return + } + Remove-ItemProperty ` + -LiteralPath $AccessibilityPath ` + -Name $AccessibilityConfigurationName ` + -Force ` + -ErrorAction SilentlyContinue +} + +function Enable-AxidevAccessibilityAutoStart { + $state = Get-AccessibilityConfigurationState + $entries = @( + $state.Value -split "," | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } + ) + if ($entries -notcontains $NormalRegistrationName) { + $entries += $NormalRegistrationName + } + Set-AccessibilityConfiguration ($entries -join ",") +} + +function Disable-AxidevAccessibilityAutoStart { + $state = Get-AccessibilityConfigurationState + if (-not $state.Exists) { + return + } + $entries = @( + $state.Value -split "," | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and $_ -ne $NormalRegistrationName } + ) + if ($entries.Count -eq 0) { + Remove-ItemProperty ` + -LiteralPath $AccessibilityPath ` + -Name $AccessibilityConfigurationName ` + -Force + return + } + Set-AccessibilityConfiguration ($entries -join ",") } function Start-VerifiedApplication([string]$ExecutablePath) { @@ -171,6 +260,21 @@ function Start-VerifiedApplication([string]$ExecutablePath) { } } +function Wait-ForAdminReady($Process) { + $deadline = (Get-Date).AddSeconds(30) + do { + if (Test-Path -LiteralPath (Join-Path $TransactionPath "ready") -PathType Leaf) { + return + } + $Process.Refresh() + if ($Process.HasExited) { + throw "The elevated installation failed with exit code $($Process.ExitCode)." + } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + throw "The elevated installation did not become ready within 30 seconds." +} + $NativeStage = Join-Path $env:LOCALAPPDATA "Axidev OSK Development\install-stage" Remove-Item -LiteralPath $NativeStage -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $NativeStage | Out-Null @@ -180,6 +284,9 @@ try { New-Item -ItemType Directory -Path $StagedBundle | Out-Null Copy-Item -Path (Join-Path $BundlePath "*") -Destination $StagedBundle -Recurse -Force + $TransactionPath = Join-Path $NativeStage "transaction" + New-Item -ItemType Directory -Path $TransactionPath | Out-Null + $ExportedCertificate = Join-Path $NativeStage "development-certificate.cer" Export-Certificate -Cert $Certificate -FilePath $ExportedCertificate -Force | Out-Null @@ -188,19 +295,47 @@ try { $PowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" $InstalledExecutable = Join-Path $env:ProgramFiles "Axidev OSK\axidev-osk.exe" $ShortcutPath = Join-Path ([Environment]::GetFolderPath("Programs")) "Axidev OSK.lnk" + $PreviousAccessibilityConfiguration = Get-AccessibilityConfigurationState + $AdminProcess = $null + $InstallationCommitted = $false try { - Invoke-DevelopmentAdmin "Install" + Disable-AxidevAccessibilityAutoStart + $AdminProcess = Start-DevelopmentAdmin + Wait-ForAdminReady $AdminProcess $Verification = Start-VerifiedApplication $InstalledExecutable - Invoke-DevelopmentAdmin "Commit" + Enable-AxidevAccessibilityAutoStart + Set-Content -LiteralPath (Join-Path $TransactionPath "commit") -Value "commit" -NoNewline + if (-not $AdminProcess.WaitForExit(30000)) { + throw "The elevated installation did not commit within 30 seconds." + } + if ($AdminProcess.ExitCode -ne 0) { + throw "The elevated installation failed with exit code $($AdminProcess.ExitCode)." + } + $InstallationCommitted = $true } catch { $installationFailure = $_ - try { - Invoke-DevelopmentAdmin "Rollback" - } catch { - throw "Installation failed: $installationFailure`nRollback also failed: $_" + Restore-AccessibilityConfiguration $PreviousAccessibilityConfiguration + if ($null -ne $AdminProcess) { + $AdminProcess.Refresh() + if (-not $AdminProcess.HasExited) { + Set-Content -LiteralPath (Join-Path $TransactionPath "rollback") ` + -Value "rollback" -NoNewline + if (-not $AdminProcess.WaitForExit(30000)) { + throw "Installation failed: $installationFailure`nThe elevated rollback timed out." + } + } } throw $installationFailure + } finally { + if (-not $InstallationCommitted -and $null -ne $AdminProcess) { + $AdminProcess.Refresh() + if (-not $AdminProcess.HasExited) { + Set-Content -LiteralPath (Join-Path $TransactionPath "rollback") ` + -Value "rollback" -NoNewline + $AdminProcess.WaitForExit(30000) | Out-Null + } + } } } finally { Remove-Item -LiteralPath $NativeStage -Recurse -Force -ErrorAction SilentlyContinue diff --git a/packaging/windows/uninstall-development.ps1 b/packaging/windows/uninstall-development.ps1 index c11d70a..3dfcf4d 100644 --- a/packaging/windows/uninstall-development.ps1 +++ b/packaging/windows/uninstall-development.ps1 @@ -4,6 +4,71 @@ param() Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +$AccessibilityPath = "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Accessibility" +$AccessibilityConfigurationName = "Configuration" +$NormalRegistrationName = "Axidev_AxidevOSK_Development_v1.0" + +function Get-AccessibilityConfigurationState { + if (-not (Test-Path -LiteralPath $AccessibilityPath)) { + return [PSCustomObject]@{ Exists = $false; Value = "" } + } + $property = Get-ItemProperty -LiteralPath $AccessibilityPath -Name $AccessibilityConfigurationName -ErrorAction SilentlyContinue + if ($null -eq $property) { + return [PSCustomObject]@{ Exists = $false; Value = "" } + } + return [PSCustomObject]@{ + Exists = $true + Value = [string]$property.$AccessibilityConfigurationName + } +} + +function Set-AccessibilityConfiguration([string]$Value) { + New-Item -ItemType Directory -Path $AccessibilityPath -Force | Out-Null + New-ItemProperty ` + -LiteralPath $AccessibilityPath ` + -Name $AccessibilityConfigurationName ` + -Value $Value ` + -PropertyType String ` + -Force | Out-Null +} + +function Restore-AccessibilityConfiguration($State) { + if ($State.Exists) { + Set-AccessibilityConfiguration $State.Value + return + } + Remove-ItemProperty ` + -LiteralPath $AccessibilityPath ` + -Name $AccessibilityConfigurationName ` + -Force ` + -ErrorAction SilentlyContinue +} + +function Disable-AxidevAccessibilityAutoStart { + $state = Get-AccessibilityConfigurationState + if (-not $state.Exists) { + return + } + $entries = @( + $state.Value -split "," | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and $_ -ne $NormalRegistrationName } + ) + if ($entries.Count -eq 0) { + Remove-ItemProperty ` + -LiteralPath $AccessibilityPath ` + -Name $AccessibilityConfigurationName ` + -Force + return + } + New-ItemProperty ` + -LiteralPath $AccessibilityPath ` + -Name $AccessibilityConfigurationName ` + -Value ($entries -join ",") ` + -PropertyType String ` + -Force | Out-Null +} + $NativeStage = Join-Path $env:LOCALAPPDATA "Axidev OSK Development\uninstall-stage" Remove-Item -LiteralPath $NativeStage -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $NativeStage | Out-Null @@ -19,10 +84,21 @@ try { "-ExecutionPolicy", "Bypass", "-File", "`"$AdminScript`"", "-Mode", "Uninstall", - "-ShortcutPath", "`"$ShortcutPath`"" + "-ShortcutPath", "`"$ShortcutPath`"", + "-TransactionPath", "`"$NativeStage`"" ) - $AdminProcess = Start-Process -FilePath $PowerShell -Verb RunAs -Wait -PassThru -ArgumentList $AdminArguments + $PreviousAccessibilityConfiguration = Get-AccessibilityConfigurationState + Disable-AxidevAccessibilityAutoStart + try { + $AdminProcess = Start-Process -FilePath $PowerShell -Verb RunAs -Wait -PassThru -ArgumentList $AdminArguments + } catch { + Restore-AccessibilityConfiguration $PreviousAccessibilityConfiguration + throw + } if ($AdminProcess.ExitCode -ne 0) { + if (Test-Path -LiteralPath (Join-Path $NativeStage "restore-configuration") -PathType Leaf) { + Restore-AccessibilityConfiguration $PreviousAccessibilityConfiguration + } throw "The elevated uninstall failed with exit code $($AdminProcess.ExitCode)." } } finally { diff --git a/tests/test_windows_packaging.py b/tests/test_windows_packaging.py index 3701580..b097757 100644 --- a/tests/test_windows_packaging.py +++ b/tests/test_windows_packaging.py @@ -43,6 +43,22 @@ def test_application_icon_assets_are_packaged(self) -> None: self.assertTrue((assets / "axidev-osk.svg").is_file()) self.assertTrue((assets / "axidev-osk.ico").is_file()) + def test_accessibility_resource_dll_is_packaged(self) -> None: + resource_source = WINDOWS_PACKAGING / "axidev-osk-resources.rc" + resource_dll = WINDOWS_PACKAGING / "axidev-osk-resources.dll" + build_script = WINDOWS_PACKAGING / "build-resources.ps1" + spec = (WINDOWS_PACKAGING / "axidev-osk.spec").read_text(encoding="utf-8") + + self.assertIn('101 "Axidev OSK Development"', resource_source.read_text(encoding="utf-8")) + self.assertIn( + '102 "Axidev OSK development on-screen keyboard."', + resource_source.read_text(encoding="utf-8"), + ) + self.assertEqual(resource_dll.read_bytes()[:2], b"MZ") + self.assertTrue(build_script.is_file()) + self.assertIn('resources_dll = Path(SPECPATH) / "axidev-osk-resources.dll"', spec) + self.assertIn('(str(resources_dll), ".")', spec) + def test_release_bootstrap_uses_latest_release_source(self) -> None: bootstrap = ( WINDOWS_PACKAGING / "axidev-osk-windows-install.ps1" @@ -75,6 +91,78 @@ def test_development_installer_manages_start_menu_shortcut_transaction(self) -> self.assertIn("$ShortcutOldPath", admin_script) self.assertIn("Remove-Item -LiteralPath $ShortcutPath", admin_script) + def test_development_installer_registers_one_accessibility_application(self) -> None: + admin_script = (WINDOWS_PACKAGING / "development-admin.ps1").read_text(encoding="utf-8") + install_script = (WINDOWS_PACKAGING / "install-development.ps1").read_text(encoding="utf-8") + + self.assertIn('"Axidev_AxidevOSK_Development_v1.0"', admin_script) + self.assertNotIn("SecureDesktopAccommodation", admin_script) + self.assertNotIn("StartParams", admin_script) + self.assertNotIn("--secure-desktop", admin_script) + self.assertIn("Install-AccessibilityRegistration", admin_script) + for property_name in ( + "ApplicationName", + "Description", + "ATExe", + "StartExe", + "Profile", + "SimpleProfile", + "TerminateOnDesktopSwitch", + ): + self.assertIn(f'-Name "{property_name}"', admin_script) + self.assertIn("Backup-AccessibilityRegistration", admin_script) + self.assertIn("Restore-AccessibilityRegistration", admin_script) + registration_function = admin_script.split("function Install-AccessibilityRegistration", 1)[1].split( + "function New-StartMenuShortcut", 1 + )[0] + self.assertLess( + registration_function.index("Remove-Item -LiteralPath $RegistrationPath"), + registration_function.index("New-Item -ItemType Directory -Path $RegistrationPath"), + ) + self.assertIn("Enable-AxidevAccessibilityAutoStart", install_script) + self.assertEqual(install_script.count("-Verb RunAs"), 1) + self.assertIn('Join-Path $TransactionPath "ready"', install_script) + self.assertIn('Join-Path $TransactionPath "commit"', install_script) + self.assertIn('Join-Path $TransactionPath "rollback"', install_script) + + def test_development_installer_signs_and_registers_resource_dll(self) -> None: + admin_script = (WINDOWS_PACKAGING / "development-admin.ps1").read_text(encoding="utf-8") + install_script = (WINDOWS_PACKAGING / "install-development.ps1").read_text(encoding="utf-8") + + self.assertIn('$ResourceDllName = "axidev-osk-resources.dll"', install_script) + self.assertIn("Path = $BundledResourcePath", install_script) + self.assertIn('Description = "resource DLL"', install_script) + self.assertIn('Assert-ExpectedSignature $sourceResourceDll "resource DLL"', admin_script) + self.assertIn( + '(Join-Path $NewPath $ResourceDllName) "resource DLL" -RequireTrusted', + admin_script, + ) + self.assertNotIn("function Assert-TrustedSignature", admin_script) + self.assertIn('-Value "@$resourcePath,-101"', admin_script) + self.assertIn('-Value "@$resourcePath,-102"', admin_script) + + def test_development_uninstaller_removes_only_axidev_auto_start(self) -> None: + admin_script = (WINDOWS_PACKAGING / "development-admin.ps1").read_text(encoding="utf-8") + uninstall_script = (WINDOWS_PACKAGING / "uninstall-development.ps1").read_text(encoding="utf-8") + + self.assertIn("Disable-AxidevAccessibilityAutoStart", uninstall_script) + self.assertIn("$_ -ne $NormalRegistrationName", uninstall_script) + self.assertNotIn('Join-Path $AccessibilityRoot "osk"', uninstall_script) + self.assertLess( + uninstall_script.rindex("Disable-AxidevAccessibilityAutoStart"), + uninstall_script.index("$AdminProcess = Start-Process"), + ) + uninstall_block = admin_script.split('if ($Mode -eq "Uninstall")', 1)[1].split( + "if (-not $SourceDirectory", 1 + )[0] + self.assertLess( + uninstall_block.index("Remove-Item -LiteralPath $RegistrationPath"), + uninstall_block.index("Stop-AxidevOsk"), + ) + self.assertIn("Restore-AccessibilityRegistration $TransactionPath", uninstall_block) + self.assertIn('Join-Path $TransactionPath "restore-configuration"', uninstall_block) + self.assertIn('Join-Path $NativeStage "restore-configuration"', uninstall_script) + if __name__ == "__main__": unittest.main()