Files
punktfunk/clients/windows/packaging/pack-msix.ps1
T
enricobuehler d6647b9183 feat(clients/windows): port the Vulkan session client to Windows — session-always
The punktfunk-session Vulkan client (clients/linux-session, now clients/session)
builds and runs on Windows; the WinUI shell spawns it for every stream. Verified
live: 10-bit HEVC via Vulkan Video on both AMD (iGPU) and NVIDIA, 5120x1440 at
130 fps / 8 ms end-to-end on the RTX 4090.

- pf-ffvk: Windows bindgen branch (FFMPEG_DIR + PF_FFVK_VULKAN_INCLUDE, no
  pkg-config); provisioning fetches Vulkan-Headers (pinned v1.4.309).
- pf-client-core: builds on Windows — WASAPI audio (audio_wasapi.rs, cfg-swapped
  via #[path], same surface as the PipeWire twin), VAAPI/dmabuf gated inline
  (chain = vulkan -> software), trust reads the WinUI shell's %APPDATA% stores
  (parity tests pin both serialized shapes), Settings gains adapter/hdr_enabled
  (serde-defaulted; Linux stores unaffected).
- pf-presenter: builds on Windows — dmabuf module Linux-gated; SDL keyboard grab
  while captured (Alt+Tab/Win reach the host); pick_device ranks discrete over
  integrated (device 0 was the iGPU on hybrid boxes — the silent footgun) and
  honors PUNKTFUNK_VK_ADAPTER (the Settings GPU pick, exported by the session).
- run loop: block in one SDL wait woken by input AND decoded frames (a per-
  session forwarder pushes a FrameWake user event) instead of a 1 ms poll —
  measured 111%% -> 5%% of a core (NVIDIA), 86%% -> 3.5%% (AMD), stats unchanged.
  The pump's decode-fence wait became once-per-window sampling (no per-frame
  pipeline stall; the stat now shows true backlog).
- pf-console-ui: builds on Windows (skia-safe msvc prebuilts); font lookup falls
  through fontconfig aliases to concrete DirectWrite families (Consolas/Segoe UI)
  — browse/coverflow works, verified against a live host.
- WinUI shell: session-always via new src/spawn.rs (GTK spawn.rs port —
  CREATE_NO_WINDOW, stdout contract, kill handle); the Stream screen is a status
  card (chips + stage lines from the child's stats). The legacy in-process
  D3D11VA path stays behind Settings "Streaming engine" / PUNKTFUNK_BUILTIN_
  STREAM=1 as the A/B baseline until Phase 8 deletes it. SessionParams.video_caps
  makes the HDR toggle real.
- clients/linux-session renamed to clients/session (builds for both OSes).
- CI/MSIX: both workflows build/test both bins with widened path filters; the
  MSIX ships punktfunk-session.exe. ARM64 session builds --no-default-features
  (rust-skia has no aarch64-pc-windows-msvc prebuilts; flip when it does).

A/B on this box (5120x1440 HEVC vs home-worker-5): NVIDIA Vulkan 130 fps / 8 ms
e2e / 1.6 ms decode — clearly better than the built-in path. The AMD iGPU VCN
saturates at ~52 fps where its own D3D11VA does ~70 — Adrenalin Vulkan decode is
slower on APU silicon; discrete RDNA validation gates Phase 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:21:36 +02:00

190 lines
11 KiB
PowerShell

<#
.SYNOPSIS
Assemble, pack and sign the punktfunk Windows client as a signed MSIX.
.DESCRIPTION
Builds a packaging layout from a release `cargo build` output (exe + the reactor/SDL3 auto-staged
DLLs + resources.pri + FFmpeg DLLs + the checked-in Assets + the manifest), runs makeappx, and
signs with signtool. Idempotent; safe to re-run.
Signing cert precedence:
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated
in-process. The package installs only where that cert is trusted, so the matching public
.cer is exported next to the .msix for the user to import (Trusted People) before install.
Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher.
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
.EXAMPLE
# x64 (default arch):
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
# arm64 (point -TargetDir + FFMPEG_DIR at the ARM64 build/tree):
$env:FFMPEG_DIR='C:\Users\Public\ffmpeg-arm64'
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -Arch arm64 -TargetDir C:\t-a64\aarch64-pc-windows-msvc\release -OutDir C:\t-a64\msix
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, e.g. 0.2.137.0
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
[string]$FfmpegBin = $(if ($env:FFMPEG_DIR) { Join-Path $env:FFMPEG_DIR 'bin' } else { 'C:\Users\Public\ffmpeg\bin' }),
[string]$OutDir = (Join-Path $TargetDir 'msix'),
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
}
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$assets = Join-Path $here 'assets'
$manifestTemplate = Join-Path $here 'AppxManifest.xml'
# --- locate the Windows SDK tools (newest makeappx/signtool under the x64 kit bin) ---
function Find-SdkTool([string]$name) {
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
# match only versioned x64 kit bins (…\10\bin\10.0.NNNNN.N\x64\tool.exe) and pick the newest
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
Select-Object -Last 1
if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." }
$hit.FullName
}
$makeappx = Find-SdkTool 'makeappx.exe'
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "makeappx: $makeappx"
Write-Host "signtool: $signtool"
# --- assemble the package layout ---
$layout = Join-Path $OutDir 'layout'
if (Test-Path $layout) { Remove-Item -Recurse -Force $layout }
New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Null
# binaries + auto-staged runtime bits (reactor stages the App SDK bootstrap DLL + resources.pri,
# the sdl3 crate stages SDL3.dll — see crate build output). punktfunk-session.exe is the Vulkan
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
# adds no DLLs of its own.
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
foreach ($f in $required) {
$src = Join-Path $TargetDir $f
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
Copy-Item $src (Join-Path $layout $f) -Force
}
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct).
# These are unmodified BtbN *lgpl-shared* builds, linked dynamically (replaceable DLLs) — FFmpeg is
# used under the LGPL v2.1+; the license text + notice ship in licenses\ below.
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): FFmpeg's LGPL
# notice + license text, the project's own MIT/Apache texts, and the generated third-party notices.
$licDir = Join-Path $layout 'licenses'
New-Item -ItemType Directory -Force -Path $licDir | Out-Null
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
Copy-Item (Join-Path $repoRoot 'packaging\windows\licenses\FFmpeg-LGPL-NOTICE.txt') $licDir -Force -ErrorAction SilentlyContinue
foreach ($n in @('THIRD-PARTY-NOTICES.txt', 'LICENSE-MIT', 'LICENSE-APACHE')) {
$p = Join-Path $repoRoot $n
if (Test-Path $p) { Copy-Item $p $licDir -Force }
}
$ffRoot = Split-Path $FfmpegBin -Parent
foreach ($lic in @('LICENSE.txt', 'LICENSE', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.txt')) {
$p = Join-Path $ffRoot $lic
if (Test-Path $p) { Copy-Item $p $licDir -Force }
}
# tile/store assets
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
# manifest with version + publisher + architecture substituted
$manifest = (Get-Content -Raw $manifestTemplate).Replace('{VERSION}', $Version).Replace('{PUBLISHER}', $Publisher).Replace('{ARCH}', $Arch)
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
# --- resource index (resources.pri) ---
# The shell resolves the manifest's logo assets through MRT, so the qualified variants
# (Square44x44Logo.targetsize-*_altform-unplated.png — the alpha-transparent taskbar icons) only
# take effect if a pri indexes them; without one the taskbar falls back to plating the base
# 44x44 onto a solid square (the white-cornered icon). makepri's default config indexes the
# layout's asset files AND merges any existing .pri it finds (reactor's staged WinUI resources)
# via its PRI indexer, yielding one combined resources.pri. Output lands outside the layout
# first — the reactor pri is an input while indexing — then replaces it.
$makepri = Find-SdkTool 'makepri.exe'
$priconfig = Join-Path $OutDir 'priconfig.xml'
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
& $makepri createconfig /cf $priconfig /dq en-US /o
if ($LASTEXITCODE -ne 0) { throw "makepri createconfig failed ($LASTEXITCODE)" }
$priOut = Join-Path $OutDir 'resources.pri'
if (Test-Path $priOut) { Remove-Item $priOut -Force }
& $makepri new /pr $layout /cf $priconfig /mn (Join-Path $layout 'AppxManifest.xml') /of $priOut /o
if ($LASTEXITCODE -ne 0) { throw "makepri new failed ($LASTEXITCODE)" }
Move-Item $priOut (Join-Path $layout 'resources.pri') -Force
Write-Host "layout assembled at $layout :"
Get-ChildItem $layout -Recurse -File | ForEach-Object { " $($_.FullName.Substring($layout.Length + 1))" }
# --- pack ---
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$msix = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.msix"
& $makeappx pack /o /d $layout /p $msix
if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed ($LASTEXITCODE)" }
# --- signing cert (supplied stable pfx OR ephemeral self-signed) ---
$pfxPath = Join-Path $OutDir 'signing.pfx'
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
if ($PfxBase64) {
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
} else {
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
-KeyUsage DigitalSignature -FriendlyName 'punktfunk MSIX (self-signed)' `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
}
# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file
# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert
# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN.
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
if ($pubCert.Subject -ne $Publisher) {
Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'."
}
# --- sign (timestamp best-effort) ---
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix))
if ($LASTEXITCODE -ne 0) {
Write-Warning "timestamped sign failed — retrying without a timestamp"
& $signtool ($signArgs + @($msix))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" }
}
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "==> MSIX: $msix"
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
# emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set)
if ($env:GITHUB_ENV) {
"MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}