Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/packaging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 48 additions & 13 deletions packaging/windows/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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

Expand All @@ -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.
Binary file added packaging/windows/axidev-osk-resources.dll
Binary file not shown.
7 changes: 7 additions & 0 deletions packaging/windows/axidev-osk-resources.rc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
LANGUAGE 9, 1

STRINGTABLE
BEGIN
101 "Axidev OSK Development"
102 "Axidev OSK development on-screen keyboard."
END
2 changes: 2 additions & 0 deletions packaging/windows/axidev-osk.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand All @@ -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=[],
Expand Down
174 changes: 174 additions & 0 deletions packaging/windows/build-resources.ps1
Original file line number Diff line number Diff line change
@@ -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"
}
Loading