diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml new file mode 100644 index 0000000..ce2fdef --- /dev/null +++ b/.gitea/workflows/windows-host.yml @@ -0,0 +1,128 @@ +# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic +# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled +# SudoVDA virtual-display driver) from one signed setup.exe. Runs on the self-hosted Windows runner +# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml. +# +# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that +# CreateProcessAsUserW's into the interactive session for secure-desktop capture, and bundles a +# kernel/IDD driver — neither is expressible in MSIX's sandbox. The real install logic already lives +# in `punktfunk-host service install` (crates/punktfunk-host/src/service.rs); the installer just lays +# the exe down and calls it elevated. Packaging internals: packaging/windows/README.md. +# +# Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group) +# +# Versioning (free-form; not MSIX's 4-part rule): +# host-win-vX.Y.Z tag -> X.Y.Z (a real host release; own tag namespace, off host-v*/win-v*/v* +# to avoid the version-shadow bug class — see deb.yml). +# main push / dispatch -> 0.2. (rolling; climbs monotonically by run number). +# +# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them +# an ephemeral self-signed cert is generated and its public .cer published next to the installer +# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. +# +# NVENC: the host builds with --features nvenc; the only link need is nvencodeapi.lib, synthesised +# from a 2-export .def with llvm-dlltool (no GPU/SDK at build time). The resulting exe is NVIDIA-only +# by design — CI never launches it, so no GPU is needed here. +name: windows-host + +on: + push: + branches: [main] + paths: + - 'crates/punktfunk-host/**' + - 'crates/punktfunk-core/**' + - 'packaging/windows/**' + - 'scripts/windows/host.env.example' + - 'Cargo.lock' + - 'Cargo.toml' + - '.gitea/workflows/windows-host.yml' + tags: ['host-win-v*'] + workflow_dispatch: + +env: + REGISTRY: git.unom.io + OWNER: unom + PKG: punktfunk-host-windows + +jobs: + package: + runs-on: windows-amd64 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + - name: Configure + version + shell: pwsh + run: | + # CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc, + # opus) the host pulls; CARGO_WORKSPACE_DIR mirrors the client workflows. Both via GITHUB_ENV + # (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean). + "CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + $v = if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') { + $env:GITHUB_REF_NAME -replace '^host-win-v', '' + } else { + "0.2.$($env:GITHUB_RUN_NUMBER)" + } + "HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Output "host version $v" + + - name: Generate NVENC import lib + shell: pwsh + run: | + & packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc + "PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Build (release, nvenc) + shell: pwsh + run: cargo build --release -p punktfunk-host --features nvenc + + - name: Clippy (host, Windows) + shell: pwsh + # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). + run: cargo clippy -p punktfunk-host --features nvenc -- -D warnings + + - name: Ensure Inno Setup + shell: pwsh + run: | + if (-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and -not (Get-Command iscc -ErrorAction SilentlyContinue)) { + Write-Output "installing Inno Setup via choco" + choco install innosetup -y --no-progress + } + + - name: Pack + sign installer + shell: pwsh + env: + MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} + MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} + run: | + & packaging/windows/pack-host-installer.ps1 ` + -Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out + + - name: Publish to Gitea generic registry + shell: pwsh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + # Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort. + $PSNativeCommandUseErrorActionPreference = $false + function Publish-File($f, $url) { + curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url" + if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" } + Write-Output "published $url" + } + $files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } + if (-not $files) { throw "pack produced no artifacts to publish" } + $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" + foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" } + # On a tagged release, also refresh the stable `latest/` alias (delete-then-reupload, like + # flatpak.yml/decky.yml) so there's a predictable download URL. + if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') { + $aliases = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' } + foreach ($f in $files) { + $alias = $aliases[$f]; if (-not $alias) { continue } + curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/latest/$alias" 2>$null + Publish-File $f "$base/latest/$alias" + } + } diff --git a/docs-site/content/docs/running-as-a-service.md b/docs-site/content/docs/running-as-a-service.md index de31238..0578cd4 100644 --- a/docs-site/content/docs/running-as-a-service.md +++ b/docs-site/content/docs/running-as-a-service.md @@ -77,6 +77,22 @@ to be up by the time a client connects, so the ordering is soft.) On Bazzite, the host launches its own gamescope/Steam session per client, so you don't need a separate session unit — see [Bazzite](/docs/bazzite). +## Windows + +On Windows the host runs as a `LocalSystem` service that launches into the interactive session, so it +captures the secure desktop (UAC / lock screen) and survives reboots with nobody logged in — the same +model Sunshine/Apollo use. + +The easy path is the **signed installer**: download `punktfunk-host-setup-.exe` from the package +registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host +into `C:\Program Files\punktfunk`, optionally installs the bundled **SudoVDA** virtual-display driver, +and registers + starts the service for you (`/VERYSILENT` for unattended). Upgrades and uninstall are +handled through Add/Remove Programs. + +Prefer the CLI? Run `punktfunk-host service install` from an elevated prompt — see +[Windows service](https://git.unom.io/unom/punktfunk/src/branch/main/docs/windows-service.md). Either +way you need an NVIDIA GPU + driver (the host is NVENC-only on Windows). + ## Verifying After a reboot, from another machine on the network: diff --git a/docs/windows-service.md b/docs/windows-service.md index ab94eb5..cbcdbad 100644 --- a/docs/windows-service.md +++ b/docs/windows-service.md @@ -4,7 +4,24 @@ The `PunktfunkHost` Windows service is the end-user way to run the host on Windo manual bring-up chain (a scheduled task → `PsExec64 -s -i 1` → `wscript launch.vbs` → `host-run.cmd`) with one command, auto-start on boot, and supervision. -## Install +## Install (installer — recommended) + +Download the signed installer from the package registry +(`punktfunk-host-windows`, ) and run it (it elevates itself): + +``` +punktfunk-host-setup-.exe # wizard +punktfunk-host-setup-.exe /VERYSILENT # unattended +``` + +It lays the host into `C:\Program Files\punktfunk`, optionally installs the bundled **SudoVDA** +virtual-display driver, then runs `service install` + `service start` for you. Upgrades stop the +service first and re-point it; uninstall (Add/Remove Programs) runs `service uninstall`. Packaging +details: [`packaging/windows/README.md`](../packaging/windows/README.md). A self-signed CI build also +publishes a `.cer` — import it once (`Import-Certificate -FilePath punktfunk-host-windows.cer +-CertStoreLocation Cert:\LocalMachine\TrustedPublisher`) so Windows trusts the signed setup. + +## Install (manual / CLI) From an **elevated** (Administrator) prompt: diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 0000000..69b48f8 --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,73 @@ +# Windows host packaging — signed Inno Setup installer + +A one-file, signed `setup.exe` for the punktfunk streaming **host** on Windows, published to Gitea's +generic package registry (`punktfunk-host-windows`) by `.gitea/workflows/windows-host.yml`. + +## Why not MSIX (like the client) + +The host installs a **`LocalSystem` SCM service** that `CreateProcessAsUserW`'s from Session 0 into the +interactive session for secure-desktop (UAC / lock screen) capture, adds firewall rules, and depends +on the **SudoVDA** kernel/IDD virtual-display driver. MSIX's sandbox can install **neither** a SYSTEM +service of this kind **nor** a driver. So the host ships as a classic elevated installer. + +The installer is deliberately thin: the real install logic — SCM registration, firewall rules, the +default `host.env`, and the SYSTEM→interactive-session supervisor — already lives in +`punktfunk-host service install` (`crates/punktfunk-host/src/service.rs`). The installer just lays the +exe into `C:\Program Files\punktfunk\` and calls that subcommand, elevated. + +## What the installer does + +- Installs `punktfunk-host.exe` (+ `host.env.example`, this README) to `{app}` (`C:\Program Files\punktfunk`). +- **Optional task** *Install the SudoVDA virtual display driver* — imports the driver's self-signed + cert (machine `Root` + `TrustedPublisher`), creates the `root\sudomaker\sudovda` device node (only + if absent — `install-sudovda.ps1`), and stages the driver with `pnputil /add-driver /install`. + Best-effort: a driver failure warns but never aborts the install (the host degrades to a physical + display without it). +- Runs `punktfunk-host service install` (idempotent; writes a default `host.env` only if absent, so + user config survives upgrades) and, by the *Start service now* task, `service start`. +- **Upgrade:** stops a running `PunktfunkHost` service and waits for `STOPPED` before replacing files + (otherwise the locked exe / respawning supervisor would block the copy), then re-points the service. +- **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove + firewall rules). The SudoVDA driver is intentionally left installed. + +Silent install: `punktfunk-host-setup-.exe /VERYSILENT` (omit the driver with `/MERGETASKS="!installdriver"`). + +## Prerequisites on the target box + +- An **NVIDIA GPU + driver** — the installer's exe is built `--features nvenc` and load-depends on the + driver's `nvEncodeAPI64.dll`. +- **ViGEmBus** (optional) for virtual gamepads — still a manual prerequisite (not bundled yet): + . + +## Files here + +| File | Role | +|------|------| +| `punktfunk-host.iss` | Inno Setup script (the installer definition). | +| `pack-host-installer.ps1` | Orchestrator: cert + sign, fetch/stage SudoVDA, run ISCC, sign setup.exe, emit registry paths. | +| `fetch-sudovda.ps1` | Download + SHA-256-verify the **pinned** SudoVDA + nefcon releases; stage the driver payload. | +| `install-sudovda.ps1` | Runs at install time (elevated): trust cert → gated device-node create → `pnputil` install. | +| `nvenc/nvenc.def`, `nvenc/gen-nvenc-importlib.ps1` | Synthesise `nvencodeapi.lib` for the `--features nvenc` link (llvm-dlltool / lib.exe). | + +> **Pinning:** the SudoVDA / nefcon release URLs + SHA-256s in `fetch-sudovda.ps1` are the source of +> truth for what ships. Confirm the latest asset URLs and fill the SHA-256s to lock a release. + +## Build locally (Windows, MSVC + Windows SDK + Inno Setup) + +```powershell +# 1. import lib for the nvenc link +pwsh -File packaging\windows\nvenc\gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc +$env:PUNKTFUNK_NVENC_LIB_DIR = 'C:\t\nvenc' + +# 2. build the host +cargo build --release -p punktfunk-host --features nvenc + +# 3. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip SudoVDA) +pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out +``` + +## Release + +Push a `host-win-vX.Y.Z` tag — the workflow builds, signs, and publishes +`punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, and refreshes the `latest/` alias. Main pushes +publish rolling `0.2.` builds (no `latest/` update). diff --git a/packaging/windows/fetch-sudovda.ps1 b/packaging/windows/fetch-sudovda.ps1 new file mode 100644 index 0000000..d4f77ea --- /dev/null +++ b/packaging/windows/fetch-sudovda.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + Download + verify the SudoVDA virtual-display driver and the nefcon device tool, and stage the + files the installer bundles into -OutDir. + +.DESCRIPTION + The host uses SudoVDA (the SudoMaker Indirect Display Driver the Apollo Sunshine-fork ships) for + client-native-resolution virtual outputs (crates/punktfunk-host/src/vdisplay/sudovda.rs). The + driver isn't in this repo; CI fetches a PINNED release at pack time so the installer can carry it. + nefcon (nefarius/nefcon) provides `nefconc.exe`, used to create the root-enumerated device node + (pnputil cannot create a software/root device node from nothing). + + Output (consumed by punktfunk-host.iss): -OutDir gets the driver payload (*.inf/*.cat/*.dll/*.sys + + the signing *.cer) and nefconc.exe, flattened. install-sudovda.ps1 is copied in by the packer. + + PINNING: the URLs + SHA-256s below are the single source of truth for what ships. If a Sha256 is + left empty, the script downloads, prints the computed hash, and continues with a loud warning — + fill it in to lock the release. Override any field with the params for a one-off build. + +.EXAMPLE + pwsh -File fetch-sudovda.ps1 -OutDir C:\t\out\stage +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$OutDir, + + # --- PINNED SudoVDA release (https://github.com/SudoMaker/SudoVDA/releases) --------------- + # Baseline validated by the host backend: SudoVDA 0.2.1 (sudovda.rs header). Confirm the latest + # asset name/URL at impl time; fill Sha256 to lock it. + [string]$SudoVdaTag = 'v0.2.1', + [string]$SudoVdaUrl = 'https://github.com/SudoMaker/SudoVDA/releases/download/v0.2.1/SudoVDA-v0.2.1.zip', + [string]$SudoVdaSha256 = '', + + # --- PINNED nefcon release (https://github.com/nefarius/nefcon/releases) ------------------ + [string]$NefconUrl = 'https://github.com/nefarius/nefcon/releases/download/v1.0.2.0/nefconw.zip', + [string]$NefconSha256 = '' +) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +function Get-Verified([string]$Url, [string]$ExpectedSha256, [string]$Dest) { + Write-Host "==> downloading $Url" + Invoke-WebRequest -Uri $Url -OutFile $Dest -UseBasicParsing + $got = (Get-FileHash -Algorithm SHA256 -Path $Dest).Hash.ToLowerInvariant() + if ($ExpectedSha256) { + if ($got -ne $ExpectedSha256.ToLowerInvariant()) { + throw "SHA-256 mismatch for $Url`n expected $ExpectedSha256`n got $got" + } + Write-Host " sha256 ok ($got)" + } + else { + Write-Warning "no pinned SHA-256 for $Url — computed $got (PIN THIS in fetch-sudovda.ps1)" + } +} + +# fresh staging dir +if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir } +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$work = Join-Path $OutDir '_work' +New-Item -ItemType Directory -Force -Path $work | Out-Null + +# --- SudoVDA driver payload ------------------------------------------------------------------- +$sudoZip = Join-Path $work 'sudovda.zip' +Get-Verified -Url $SudoVdaUrl -ExpectedSha256 $SudoVdaSha256 -Dest $sudoZip +$sudoEx = Join-Path $work 'sudovda' +Expand-Archive -Path $sudoZip -DestinationPath $sudoEx -Force + +# Flatten the driver files we need into -OutDir. SudoVDA is a user-mode IDD (a .dll registered by an +# .inf); pull the .inf/.cat/.dll plus a .sys if present and the signing .cer. +$driverFiles = Get-ChildItem -Path $sudoEx -Recurse -Include *.inf, *.cat, *.dll, *.sys, *.cer +if (-not ($driverFiles | Where-Object Extension -eq '.inf')) { + throw "no .inf found in the SudoVDA archive ($SudoVdaUrl) — wrong asset?" +} +$driverFiles | ForEach-Object { Copy-Item $_.FullName (Join-Path $OutDir $_.Name) -Force } + +# --- nefcon (nefconc.exe) --------------------------------------------------------------------- +$nefZip = Join-Path $work 'nefcon.zip' +Get-Verified -Url $NefconUrl -ExpectedSha256 $NefconSha256 -Dest $nefZip +$nefEx = Join-Path $work 'nefcon' +Expand-Archive -Path $nefZip -DestinationPath $nefEx -Force +# Prefer the x64 console build. +$nefc = Get-ChildItem -Path $nefEx -Recurse -Filter 'nefconc.exe' | + Where-Object { $_.FullName -match '(?i)(x64|amd64)' } | + Select-Object -First 1 +if (-not $nefc) { $nefc = Get-ChildItem -Path $nefEx -Recurse -Filter 'nefconc.exe' | Select-Object -First 1 } +if (-not $nefc) { throw "nefconc.exe not found in $NefconUrl" } +Copy-Item $nefc.FullName (Join-Path $OutDir 'nefconc.exe') -Force + +Remove-Item -Recurse -Force $work +Write-Host "==> staged SudoVDA + nefcon in $OutDir :" +Get-ChildItem $OutDir -File | ForEach-Object { " $($_.Name)" } diff --git a/packaging/windows/install-sudovda.ps1 b/packaging/windows/install-sudovda.ps1 new file mode 100644 index 0000000..d8c0a2c --- /dev/null +++ b/packaging/windows/install-sudovda.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS + Install the bundled SudoVDA virtual-display driver. Runs ELEVATED at setup time (invoked from the + installer's [Run] section). Best-effort: warns and exits 0 on any failure — the host degrades to a + physical display without SudoVDA, so a driver hiccup must never abort the whole install. + +.DESCRIPTION + -Dir holds the staged payload from fetch-sudovda.ps1 (the .inf/.cat/.dll + signing .cer + nefconc.exe). + Steps: + 1. Trust the self-signed driver cert (machine Root + TrustedPublisher) so PnP installs it silently. + 2. Create the root device node IF ABSENT (gated — a blind re-create spawns a phantom duplicate, and + the host's open_device() binds interface index 0; crates/punktfunk-host/src/vdisplay/sudovda.rs). + 3. Stage + bind the driver (pnputil /add-driver /install — modern, in-box, idempotent). + + Class/ClassGuid are read from the .inf so they always match the shipped driver. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File install-sudovda.ps1 -Dir C:\path\to\sudovda +#> +[CmdletBinding()] +param( + [string]$Dir = $PSScriptRoot, + [string]$HardwareId = 'root\sudomaker\sudovda' # verified live (docs/windows-host.md) +) +# Never abort the installer on a driver failure. +$ErrorActionPreference = 'Continue' +trap { Write-Warning "SudoVDA install error: $_"; exit 0 } + +function Test-SudoVdaPresent { + $devs = Get-PnpDevice -Class Display -PresentOnly -ErrorAction SilentlyContinue + foreach ($d in $devs) { + $hw = (Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName 'DEVPKEY_Device_HardwareIds' ` + -ErrorAction SilentlyContinue).Data + if ($hw -and ($hw | Where-Object { $_ -ieq $HardwareId })) { return $true } + } + return $false +} + +$inf = Get-ChildItem -Path $Dir -Filter *.inf -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 +$cer = Get-ChildItem -Path $Dir -Filter *.cer -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 +$nef = Get-ChildItem -Path $Dir -Filter 'nefconc.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $inf) { Write-Warning "no SudoVDA .inf in $Dir; skipping driver install."; exit 0 } +Write-Host "SudoVDA inf: $($inf.FullName)" + +# 1) Trust the self-signed driver cert (a self-signed driver needs the cert in BOTH the machine +# Root store, so the chain validates, and TrustedPublisher, so PnP installs without a prompt). +if ($cer) { + Write-Host "==> importing $($cer.Name) to Root + TrustedPublisher" + certutil.exe -addstore -f Root "$($cer.FullName)" | Out-Null + certutil.exe -addstore -f TrustedPublisher "$($cer.FullName)" | Out-Null +} +else { Write-Warning "no .cer in $Dir — driver may not install silently (untrusted publisher)" } + +# 2) Create the root device node only if it isn't already there. +if (Test-SudoVdaPresent) { + Write-Host "SudoVDA device node already present — leaving it as-is." +} +elseif ($nef) { + $infText = Get-Content -Raw $inf.FullName + $classGuid = ([regex]::Match($infText, '(?im)^\s*ClassGuid\s*=\s*(\{[0-9A-Fa-f-]+\})')).Groups[1].Value + $className = ([regex]::Match($infText, '(?im)^\s*Class\s*=\s*([^\s;]+)')).Groups[1].Value + if (-not $classGuid) { $classGuid = '{4d36e968-e325-11ce-bfc1-08002be10318}'; $className = 'Display' } # Display class + Write-Host "==> nefconc --create-device-node hwid=$HardwareId class=$className $classGuid" + & $nef.FullName --create-device-node --hardware-id $HardwareId --class-name $className --class-guid $classGuid + if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { + Write-Warning "nefconc --create-device-node returned $LASTEXITCODE" + } +} +else { Write-Warning "nefconc.exe not found in $Dir — cannot create the SudoVDA device node." } + +# 3) Stage + bind the driver (idempotent; re-staging the same .inf is harmless). +Write-Host "==> pnputil /add-driver $($inf.Name) /install" +& pnputil.exe /add-driver "$($inf.FullName)" /install +$rc = $LASTEXITCODE +if ($rc -eq 3010) { Write-Host " driver installed; a reboot is recommended." } +elseif ($rc -ne 0) { Write-Warning "pnputil /add-driver returned $rc" } + +exit 0 diff --git a/packaging/windows/nvenc/gen-nvenc-importlib.ps1 b/packaging/windows/nvenc/gen-nvenc-importlib.ps1 new file mode 100644 index 0000000..54e21a5 --- /dev/null +++ b/packaging/windows/nvenc/gen-nvenc-importlib.ps1 @@ -0,0 +1,57 @@ +<# +.SYNOPSIS + Generate the NVENC import library (nvencodeapi.lib) into -OutDir, so the host links with + `--features nvenc` on a box that has no NVIDIA Video Codec SDK and no GPU. + +.DESCRIPTION + The host links against nvencodeapi.lib (crates/punktfunk-host/build.rs). That import lib is just + a link-time stub for two exports of nvEncodeAPI64.dll (the real DLL ships with the NVIDIA driver + and resolves at runtime). We synthesise it from nvenc.def: + + 1. llvm-dlltool — preferred; LLVM is on the CI runner PATH (C:\Program Files\LLVM\bin) and this + works without a Visual Studio developer shell. + 2. MSVC lib.exe — fallback; located via vswhere (no vcvars needed). + + Point PUNKTFUNK_NVENC_LIB_DIR at -OutDir before `cargo build --features nvenc`. + +.EXAMPLE + pwsh -File gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$OutDir, + [string]$DefPath = (Join-Path $PSScriptRoot 'nvenc.def') +) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$PSNativeCommandUseErrorActionPreference = $false # check $LASTEXITCODE ourselves (pwsh 7.4 safe) + +if (-not (Test-Path $DefPath)) { throw "module-definition file not found: $DefPath" } +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$out = Join-Path $OutDir 'nvencodeapi.lib' + +# 1) llvm-dlltool (preferred) ------------------------------------------------------------------ +$dlltool = Get-Command llvm-dlltool -ErrorAction SilentlyContinue +if ($dlltool) { + Write-Host "==> llvm-dlltool -> $out" + & $dlltool.Source -m i386:x86-64 -d $DefPath -D nvEncodeAPI64.dll -l $out + if ($LASTEXITCODE -ne 0) { throw "llvm-dlltool failed ($LASTEXITCODE)" } + Write-Host " ok ($((Get-Item $out).Length) bytes)" + return +} + +# 2) MSVC lib.exe via vswhere (fallback) ------------------------------------------------------- +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (Test-Path $vswhere) { + $lib = & $vswhere -latest -prerelease -products * -find 'VC\Tools\MSVC\**\bin\Hostx64\x64\lib.exe' | + Select-Object -First 1 + if ($lib -and (Test-Path $lib)) { + Write-Host "==> lib.exe -> $out" + & $lib "/def:$DefPath" /machine:x64 "/out:$out" + if ($LASTEXITCODE -ne 0) { throw "lib.exe failed ($LASTEXITCODE)" } + Write-Host " ok ($((Get-Item $out).Length) bytes)" + return + } +} + +throw "neither llvm-dlltool (LLVM bin on PATH) nor MSVC lib.exe (via vswhere) was found to build $out" diff --git a/packaging/windows/nvenc/nvenc.def b/packaging/windows/nvenc/nvenc.def new file mode 100644 index 0000000..9f30005 --- /dev/null +++ b/packaging/windows/nvenc/nvenc.def @@ -0,0 +1,14 @@ +; Module-definition file for the NVENC import library the host links against with `--features nvenc`. +; +; The real entry points live in nvEncodeAPI64.dll, which ships with the NVIDIA driver. At LINK time +; the host only needs an import library exporting these two symbols (see crates/punktfunk-host/build.rs: +; it emits `cargo:rustc-link-lib=dylib=nvencodeapi` and searches PUNKTFUNK_NVENC_LIB_DIR). No GPU, +; driver, or NVIDIA Video Codec SDK is required to BUILD — only to run, where the DLL resolves from +; the installed driver. Generate nvencodeapi.lib from this file with gen-nvenc-importlib.ps1. +; +; The LIBRARY line names the DLL the import records point at — required for MSVC `lib.exe /def` +; (without it the import name would default to "nvenc.dll"). llvm-dlltool takes the name from `-D`. +LIBRARY nvEncodeAPI64.dll +EXPORTS + NvEncodeAPICreateInstance + NvEncodeAPIGetMaxSupportedVersion diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 new file mode 100644 index 0000000..fc3c451 --- /dev/null +++ b/packaging/windows/pack-host-installer.ps1 @@ -0,0 +1,143 @@ +<# +.SYNOPSIS + Build + sign the punktfunk Windows host installer (Inno Setup setup.exe). + +.DESCRIPTION + From a release `cargo build -p punktfunk-host --features nvenc` output (the exe), this: + 1. resolves a code-signing cert (supplied stable .pfx from CI secrets OR an ephemeral self-signed + CN=unom — same scheme as the client's pack-msix.ps1) and exports the public .cer, + 2. signs the inner punktfunk-host.exe, + 3. fetches + stages the SudoVDA driver bundle (unless -NoDriver), + 4. runs ISCC to build punktfunk-host-setup-.exe, + 5. signs the setup.exe (timestamp best-effort), + 6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step. + + Idempotent; safe to re-run. Run on the Windows runner / dev box (MSVC + Windows SDK + Inno Setup). + +.EXAMPLE + pwsh -File pack-host-installer.ps1 -Version 0.2.137 -TargetDir C:\t\release -OutDir C:\t\out +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Version, # e.g. 0.2.137 or 1.4.0 (free-form) + [Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release dir (has punktfunk-host.exe) + [string]$OutDir = (Join-Path $TargetDir 'installer'), + [string]$Publisher = 'CN=unom', + [string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret + [string]$PfxPassword = $env:MSIX_CERT_PASSWORD, + [switch]$NoDriver, # build without the bundled SudoVDA driver + [switch]$NoSign # skip signing (local debug) +) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +# Keep the traditional "check $LASTEXITCODE myself" model: don't let pwsh 7.4 turn a non-zero native +# exit into a terminating error (it would bypass Sign-File's timestamp-then-retry fallback below). +$PSNativeCommandUseErrorActionPreference = $false + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$iss = Join-Path $here 'punktfunk-host.iss' +$exe = Join-Path $TargetDir 'punktfunk-host.exe' +if (-not (Test-Path $exe)) { throw "missing build artifact 'punktfunk-host.exe' in $TargetDir (did 'cargo build --release -p punktfunk-host --features nvenc' run?)" } +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# --- locate ISCC (Inno Setup) + signtool (Windows SDK) --------------------------------------- +function Find-Iscc { + foreach ($p in @( + 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe', + 'C:\Program Files\Inno Setup 6\ISCC.exe')) { + if (Test-Path $p) { return $p } + } + $c = Get-Command iscc -ErrorAction SilentlyContinue + if ($c) { return $c.Source } + throw "ISCC.exe (Inno Setup 6, any 6.x) not found — install it (choco install innosetup -y)." +} +function Find-SdkTool([string]$name) { + $root = 'C:\Program Files (x86)\Windows Kits\10\bin' + $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 +} +$iscc = Find-Iscc +Write-Host "ISCC: $iscc" + +# --- signing cert (supplied stable pfx OR ephemeral self-signed) ----------------------------- +$pfxPath = Join-Path $OutDir 'signing.pfx' +$cerPath = Join-Path $OutDir "punktfunk-host-windows_${Version}.cer" +$signtool = $null +if (-not $NoSign) { + $signtool = Find-SdkTool 'signtool.exe' + Write-Host "signtool: $signtool" + 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 host (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. For a self-signed cert it's the file users import once + # (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe; for a real CA + # cert it's a harmless extra. + $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)" +} + +function Sign-File([string]$Path) { + if ($NoSign) { return } + $signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath) + if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) } + & $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $Path)) + if ($LASTEXITCODE -ne 0) { + Write-Warning "timestamped sign failed for $Path — retrying without a timestamp" + & $signtool ($signArgs + @($Path)) + if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" } + } +} + +# --- sign the inner exe before it's packed ---------------------------------------------------- +Sign-File $exe + +# --- stage the SudoVDA driver bundle ---------------------------------------------------------- +$defines = @("/DMyAppVersion=$Version", "/DBinDir=$TargetDir", "/DOutputDir=$OutDir") +if (-not $NoDriver) { + $stage = Join-Path $OutDir 'stage' + & (Join-Path $here 'fetch-sudovda.ps1') -OutDir $stage + Copy-Item (Join-Path $here 'install-sudovda.ps1') (Join-Path $stage 'install-sudovda.ps1') -Force + $defines += "/DStageDir=$stage" +} +else { Write-Host "-NoDriver: building installer WITHOUT the bundled SudoVDA driver" } + +# --- build the installer ---------------------------------------------------------------------- +Write-Host "==> ISCC $($defines -join ' ') $iss" +& $iscc @defines $iss +if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" } + +$setup = Join-Path $OutDir "punktfunk-host-setup-$Version.exe" +if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" } + +# --- sign the setup.exe + clean up ------------------------------------------------------------ +Sign-File $setup +Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "==> installer: $setup" +if (-not $NoSign) { + Write-Host "==> trust the cert once per machine (self-signed builds), then the signed setup.exe is trusted:" + Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPublisher" +} +if ($env:GITHUB_ENV) { + "HOST_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + if (-not $NoSign) { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } +} diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss new file mode 100644 index 0000000..4de4341 --- /dev/null +++ b/packaging/windows/punktfunk-host.iss @@ -0,0 +1,104 @@ +; punktfunk host installer (Inno Setup 6). +; +; Produces a signed setup.exe that lays the host into Program Files, optionally installs the bundled +; SudoVDA virtual-display driver, and DELEGATES service registration to `punktfunk-host service +; install`. The real, idempotent install logic (SCM registration, firewall rules, default host.env, +; the SYSTEM→interactive-session CreateProcessAsUserW supervisor for secure-desktop capture) lives in +; crates/punktfunk-host/src/service.rs — this script does NOT duplicate it. That SYSTEM service model +; is exactly why MSIX is unusable here and we ship a classic elevated installer instead. +; +; Built by pack-host-installer.ps1, e.g.: +; ISCC.exe /DMyAppVersion=0.2.123 /DBinDir=C:\t\release /DStageDir=C:\t\out\stage \ +; /DOutputDir=C:\t\out packaging\windows\punktfunk-host.iss +; Omit /DStageDir to build an installer WITHOUT the bundled driver (driver becomes a prerequisite). + +#ifndef MyAppVersion + #define MyAppVersion "0.0.0" +#endif +#ifndef BinDir + #define BinDir "." +#endif +#ifndef OutputDir + #define OutputDir "." +#endif +; StageDir (the staged SudoVDA payload + nefconc.exe + install-sudovda.ps1) is optional. +#ifdef StageDir + #define WithDriver +#endif + +[Setup] +AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93} +AppName=punktfunk host +AppVersion={#MyAppVersion} +AppPublisher=unom +AppPublisherURL=https://git.unom.io/unom/punktfunk +DefaultDirName={autopf}\punktfunk +DefaultGroupName=punktfunk +DisableProgramGroupPage=yes +UsePreviousAppDir=yes +PrivilegesRequired=admin +MinVersion=10.0 +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +OutputDir={#OutputDir} +OutputBaseFilename=punktfunk-host-setup-{#MyAppVersion} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +UninstallDisplayName=punktfunk host {#MyAppVersion} +UninstallDisplayIcon={app}\punktfunk-host.exe + +[Tasks] +#ifdef WithDriver +Name: "installdriver"; Description: "Install the SudoVDA virtual display driver (required for native-resolution streaming)" +#endif +Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)" + +[Files] +Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#SourcePath}..\..\scripts\windows\host.env.example"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#SourcePath}README.md"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignoreversion +#ifdef WithDriver +; The driver payload + nefconc.exe + install-sudovda.ps1, extracted to {tmp} and removed after install. +Source: "{#StageDir}\*"; DestDir: "{tmp}\sudovda"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver +#endif + +[Run] +#ifdef WithDriver +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\sudovda\install-sudovda.ps1"" -Dir ""{tmp}\sudovda"""; \ + StatusMsg: "Installing the SudoVDA virtual display driver..."; \ + Flags: runhidden waituntilterminated; Tasks: installdriver +#endif +; Register (or re-point, on upgrade — idempotent) the SYSTEM service from its FINAL {app} location: +; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}. +Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \ + StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated +Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \ + StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice + +[UninstallRun] +Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkHostServiceUninstall" + +[Code] +{ On upgrade the running service locks punktfunk-host.exe (and the supervisor would respawn it from + the OLD binary), so stop it and WAIT for STOPPED before files are copied. Best-effort; a fresh + install is a no-op (the service doesn't exist yet). } +procedure StopHostServiceAndWait; +var + ResultCode: Integer; +begin + Exec('powershell.exe', + '-NoProfile -ExecutionPolicy Bypass -Command "' + + '$ErrorActionPreference=''SilentlyContinue''; ' + + '$s=Get-Service -Name ''PunktfunkHost''; ' + + 'if($s -and $s.Status -ne ''Stopped''){Stop-Service -Name ''PunktfunkHost'' -Force; ' + + 'try{$s.WaitForStatus(''Stopped'',[TimeSpan]::FromSeconds(30))}catch{}}"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssInstall then + StopHostServiceAndWait; +end; diff --git a/scripts/ci/setup-windows-runner.ps1 b/scripts/ci/setup-windows-runner.ps1 index f8aceec..2898428 100644 --- a/scripts/ci/setup-windows-runner.ps1 +++ b/scripts/ci/setup-windows-runner.ps1 @@ -1,4 +1,4 @@ -# Provision this Windows box as the Gitea Actions runner for the Windows client CI/packaging. +# Provision this Windows box as the Gitea Actions runner for the Windows client + host CI/packaging. # The Windows analogue of scripts/ci/setup-macos-runner.sh. Idempotent — safe to re-run. Run # ELEVATED (admin) on the box, e.g. over SSH: # @@ -84,6 +84,16 @@ if (-not (Test-Path "C:\Users\Public\.rustup\settings.toml")) { robocopy "$env:USERPROFILE\.rustup" "C:\Users\Public\.rustup" /E /NFL /NDL /NJH /NJS /MT:16 | Out-Null } +# Inno Setup (ISCC.exe) for the host installer build (windows-host.yml). pack-host-installer.ps1 +# locates it at its fixed Program Files path, so it need not be on PATH — just present. +if (-not (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe")) { + if (Get-Command choco -ErrorAction SilentlyContinue) { + Write-Host "==> installing Inno Setup (ISCC)" + choco install innosetup -y --no-progress + } + else { Write-Warning "Inno Setup not found and choco unavailable - install it for windows-host.yml." } +} + # --- daemon env wrapper (the box's MSVC/WinUI/FFmpeg toolchain) --- $wrapper = "$RunnerHome\run-runner.ps1" @'