diff --git a/.gitea/workflows/windows-msix.yml b/.gitea/workflows/windows-msix.yml new file mode 100644 index 0000000..c8875ea --- /dev/null +++ b/.gitea/workflows/windows-msix.yml @@ -0,0 +1,90 @@ +# Build the punktfunk Windows client as a signed MSIX and publish it to Gitea's generic package +# registry, so Windows boxes can download + install a real package (Start tile, clean +# install/uninstall) instead of a loose exe. Runs on the self-hosted Windows runner (host mode; +# scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows SDK's +# makeappx/signtool are baked into the runner's daemon env, same as windows.yml. +# +# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group) +# Packaging internals: crates/punktfunk-client-windows/packaging/README.md. BOM/MAX_PATH runner +# gotchas baked into the daemon env + windows.yml: see that workflow. +# +# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so: +# win-vX.Y.Z tag -> X.Y.Z.0 (a real Windows-client release; `win-v*` is its own tag namespace, +# kept off the host's `host-v*` and the Apple `v*` to avoid the +# version-shadow class of bug — see deb.yml). +# main push / dispatch -> 0.2..0 (rolling; climbs monotonically by run number). +# +# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets +# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed +# with them. Otherwise an ephemeral self-signed cert is generated and its public .cer is published +# next to the .msix (users import it to Trusted People before install). Drop in a real cert later +# with no workflow change — just add the secrets (+ pass -Publisher if its subject differs). +name: windows-msix + +on: + push: + branches: [main] + paths: + - 'crates/punktfunk-client-windows/**' + - 'crates/punktfunk-core/**' + - 'Cargo.lock' + - 'Cargo.toml' + - '.gitea/workflows/windows-msix.yml' + tags: ['win-v*'] + workflow_dispatch: + +env: + REGISTRY: git.unom.io + OWNER: unom + PKG: punktfunk-client-windows + +jobs: + package: + runs-on: windows-amd64 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Configure + version + shell: pwsh + run: | + # windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR=C:\t dodges the + # MAX_PATH wall in the CMake-from-source crates (see windows.yml). Both via GITHUB_ENV. + "CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + $parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') { + ($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.') + } else { + @('0', '2', $env:GITHUB_RUN_NUMBER) + } + while ($parts.Count -lt 4) { $parts += '0' } + $v = ($parts[0..3] -join '.') + "MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Output "MSIX version $v" + + - name: Build (release) + shell: pwsh + run: cargo build --release -p punktfunk-client-windows + + - name: Pack + sign MSIX + shell: pwsh + env: + MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} + MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} + run: | + & crates/punktfunk-client-windows/packaging/pack-msix.ps1 ` + -Version $env:MSIX_VERSION -TargetDir C:\t\release -OutDir C:\t\msix + + - name: Publish to Gitea generic registry + shell: pwsh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } + if (-not $files) { throw "pack produced no artifacts to publish" } + foreach ($f in $files) { + $name = Split-Path $f -Leaf + $url = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)/$($env:MSIX_VERSION)/$name" + curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url" + Write-Output "published $name -> $url" + } diff --git a/crates/punktfunk-client-windows/packaging/AppxManifest.xml b/crates/punktfunk-client-windows/packaging/AppxManifest.xml new file mode 100644 index 0000000..222fa49 --- /dev/null +++ b/crates/punktfunk-client-windows/packaging/AppxManifest.xml @@ -0,0 +1,65 @@ + + + + + + + + Punktfunk + unom + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/punktfunk-client-windows/packaging/README.md b/crates/punktfunk-client-windows/packaging/README.md new file mode 100644 index 0000000..f42b598 --- /dev/null +++ b/crates/punktfunk-client-windows/packaging/README.md @@ -0,0 +1,71 @@ +# punktfunk Windows client — MSIX packaging + +The Windows client ships as a **signed MSIX** so Windows boxes get a real package (Start tile, +clean install/uninstall) instead of a loose exe. CI builds + publishes it from +[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's +**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that +touches the client and on `win-v*` release tags. + +## What's in the package + +`pack-msix.ps1` assembles a layout from a `cargo build --release` and runs `makeappx` + `signtool`: + +| File | Source | +|---|---| +| `punktfunk-client.exe` | the release build | +| `Microsoft.WindowsAppRuntime.Bootstrap.dll`, `resources.pri` | auto-staged by windows-reactor's `build.rs` | +| `SDL3.dll` | auto-staged by the `sdl3` crate | +| `avcodec/avformat/avutil/swscale/swresample/...-*.dll` | `FFMPEG_DIR\bin` | +| `Assets\*.png` | checked-in tile/store logos (rasterized from `packaging/flatpak/io.unom.Punktfunk.svg`) | +| `AppxManifest.xml` | the template here, with `{VERSION}`/`{PUBLISHER}` substituted | + +### Why an "unpackaged" WinUI app packages cleanly + +windows-reactor calls `MddBootstrapInitialize2` with `OnPackageIdentity_NOOP` +(`crates/libs/reactor/src/app.rs`), so under MSIX **package identity** the App SDK bootstrapper is +a no-op and the runtime is resolved from the manifest's `` on +`Microsoft.WindowsAppRuntime.2` instead (reactor pins `WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000` += 2.0). It's a full-trust Win32 app (`EntryPoint="Windows.FullTrustApplication"` + `runFullTrust`) +because it owns raw D3D11, Win32 low-level input hooks, WASAPI and SDL3. + +## Versioning + +MSIX requires a strictly 4-part numeric version. The workflow computes: +- `win-vX.Y.Z` tag → `X.Y.Z.0` (a real client release; `win-v*` is its own tag namespace, kept off + the host's `host-v*` and Apple's `v*` to avoid the version-shadow bug). +- `main` push / `workflow_dispatch` → `0.2..0` (rolling, climbs by run number). + +## Signing & install + +Signing precedence in `pack-msix.ps1`: +1. **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** Actions secrets — a real (or shared) code-signing + `.pfx` whose **subject DN must equal `-Publisher`** (default `CN=unom`). Drop these in later to + move off self-signed with **no manifest change**; if the cert's subject differs, pass a matching + `-Publisher` (it's stamped into the manifest `Identity`). +2. otherwise an **ephemeral self-signed** cert (subject = `-Publisher`) is generated and its public + `.cer` is published next to the `.msix`. + +To install a self-signed build, trust the cert once, then add the package: + +```powershell +Import-Certificate -FilePath .\punktfunk-client-windows__x64.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople +Add-AppxPackage -Path .\punktfunk-client-windows__x64.msix +``` + +The MSIX declares a dependency on the Windows App SDK 2.x runtime; install +[the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing +`Microsoft.WindowsAppRuntime.2` framework. + +## Building locally + +On the Windows runner / dev VM (MSVC + Windows SDK present), after a release build: + +```powershell +cargo build --release -p punktfunk-client-windows +pwsh -File crates/punktfunk-client-windows/packaging/pack-msix.ps1 ` + -Version 0.2.0.0 -TargetDir C:\t\release -OutDir C:\t\msix +``` + +Validated end-to-end on the build VM (pack → sign → `Add-AppxPackage` → framework-dependency +resolution). The only step that needs a real display is *launching* the WinUI window (same +on-glass constraint as the rest of the client). diff --git a/crates/punktfunk-client-windows/packaging/assets/Square150x150Logo.png b/crates/punktfunk-client-windows/packaging/assets/Square150x150Logo.png new file mode 100644 index 0000000..6113c5e Binary files /dev/null and b/crates/punktfunk-client-windows/packaging/assets/Square150x150Logo.png differ diff --git a/crates/punktfunk-client-windows/packaging/assets/Square44x44Logo.png b/crates/punktfunk-client-windows/packaging/assets/Square44x44Logo.png new file mode 100644 index 0000000..3b3f2d8 Binary files /dev/null and b/crates/punktfunk-client-windows/packaging/assets/Square44x44Logo.png differ diff --git a/crates/punktfunk-client-windows/packaging/assets/Square71x71Logo.png b/crates/punktfunk-client-windows/packaging/assets/Square71x71Logo.png new file mode 100644 index 0000000..60f9372 Binary files /dev/null and b/crates/punktfunk-client-windows/packaging/assets/Square71x71Logo.png differ diff --git a/crates/punktfunk-client-windows/packaging/assets/StoreLogo.png b/crates/punktfunk-client-windows/packaging/assets/StoreLogo.png new file mode 100644 index 0000000..2c06f60 Binary files /dev/null and b/crates/punktfunk-client-windows/packaging/assets/StoreLogo.png differ diff --git a/crates/punktfunk-client-windows/packaging/pack-msix.ps1 b/crates/punktfunk-client-windows/packaging/pack-msix.ps1 new file mode 100644 index 0000000..dce9ad7 --- /dev/null +++ b/crates/punktfunk-client-windows/packaging/pack-msix.ps1 @@ -0,0 +1,139 @@ +<# +.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 + pwsh -File pack-msix.ps1 -Version 0.2.137.0 -TargetDir C:\t\release -FfmpegBin C:\Users\Public\ffmpeg\bin -OutDir C:\t\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) + [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 + +# binary + auto-staged runtime bits (reactor stages the App SDK bootstrap DLL + resources.pri, +# the sdl3 crate stages SDL3.dll — see crate build output). +$required = @('punktfunk-client.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) +$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 } + +# tile/store assets +Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force + +# manifest with version + publisher substituted +$manifest = (Get-Content -Raw $manifestTemplate).Replace('{VERSION}', $Version).Replace('{PUBLISHER}', $Publisher) +Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 + +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}_x64.msix" +& $makeappx pack /o /d $layout /p $msix +if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed ($LASTEXITCODE)" } + +# --- signing cert --- +$pfxPath = Join-Path $OutDir 'signing.pfx' +$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_x64.cer" +$selfSigned = $false +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)" + $selfSigned = $true + if (-not $PfxPassword) { $PfxPassword = 'punktfunk' } + $cert = 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\$($cert.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null + Export-Certificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $cerPath | Out-Null + Remove-Item "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force +} + +# --- sign (timestamp best-effort; a self-signed cert needs no TSA) --- +$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 + +# if self-signed, the public .cer must travel with the package; otherwise drop it (chain is public) +if (-not $selfSigned) { Remove-Item $cerPath -Force -ErrorAction SilentlyContinue } + +Write-Host "" +Write-Host "==> MSIX: $msix" +if ($selfSigned) { + Write-Host "==> self-signed; trust before install: 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 + if ($selfSigned) { "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } +}