Merge pull request 'Windows client: per-user installer + portable zip as the default download — a stable exe path Steam can launch (overlay, Big Picture); MSIX stays for the Store' (#349) from worktree-win-client-installer into main
ci / rust-arm64 (push) Successful in 2m26s
ci / web (push) Successful in 1m53s
ci / bun-nix (push) Successful in 35s
ci / docs-drift (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
ci / docs-site (push) Successful in 2m11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust (push) Successful in 7m57s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
installer-smoke / smoke (arch) (push) Successful in 45s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
installer-smoke / smoke (fedora-44) (push) Successful in 1m18s
installer-smoke / smoke (debian-13) (push) Successful in 2m26s
docker / builders-arm64cross (push) Successful in 31s
docker / deploy-docs (push) Successful in 32s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m48s

This commit was merged in pull request #349.
This commit is contained in:
2026-08-19 23:05:32 +00:00
13 changed files with 659 additions and 66 deletions
+38 -8
View File
@@ -56,8 +56,12 @@
#
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
#
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
# from one assembled layout:
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
# (stable path Steam can launch: overlay + Big Picture work)
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
@@ -283,6 +287,28 @@ jobs:
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
- name: Pack + sign installer + portable zip
if: github.event_name != 'pull_request'
shell: pwsh
env:
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-client-installer.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
- name: Publish to Gitea generic registry
if: github.event_name != 'pull_request'
shell: pwsh
@@ -301,7 +327,10 @@ jobs:
# on that accident, so removing the quotes can't silently reintroduce it.
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
# The installer + portable zip (the default download; docs point at these alias URLs).
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
@@ -324,10 +353,11 @@ jobs:
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
# don't collide.
- name: Attach client artifacts to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
@@ -335,6 +365,6 @@ jobs:
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+7 -4
View File
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
compatibility).
## Get it
Install the signed MSIX from the package registry — see
Install the signed installer from the package registry — see
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
```
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
## Layout
@@ -79,7 +82,7 @@ src/
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
probe.rs · wol.rs speed probe · Wake-on-LAN
logfile.rs log tee to %LOCALAPPDATA%
packaging/ MSIX manifest, signing, pack script
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
```
## Manual smoke checklist
+24 -5
View File
@@ -1,11 +1,30 @@
# punktfunk Windows client — MSIX packaging
# punktfunk Windows client — packaging
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
The Windows client ships **three ways, packed from one assembled layout** by CI
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
[Release Channels](https://punktfunk.unom.io/docs/channels).
[Release Channels](https://punktfunk.unom.io/docs/channels):
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
install shape breaks the top user-reported flows: the exe lands under the ACL'd
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
nothing registered.
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
compatibility. Everything below the fold documents this path.
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
signs its container).
# MSIX packaging
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
@@ -0,0 +1,246 @@
<#
.SYNOPSIS
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
portable .zip, from the layout pack-msix.ps1 already assembled.
.DESCRIPTION
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim — one assembly,
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
Steps:
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
2. sign the four exes individually (the MSIX only signs its container),
3. zip the stage -> the portable build,
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust — an untrusted
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
.EXAMPLE
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
[switch]$NoSign # skip signing (local debug)
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
$PSNativeCommandUseErrorActionPreference = $false
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
$iss = Join-Path $here 'punktfunk-client.iss'
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
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
}
function Find-AzureDlib([string]$Explicit) {
if ($Explicit) {
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
return (Resolve-Path $Explicit).Path
}
$roots = @(
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
'C:\trusted-signing\microsoft.trusted.signing.client'
) | Where-Object { $_ -and (Test-Path $_) }
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
Sort-Object LastWriteTime | Select-Object -Last 1
if (-not $hit) {
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
}
$hit.FullName
}
$iscc = Find-Iscc
Write-Host "ISCC: $iscc"
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
$stage = Join-Path $OutDir 'portable'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $stage | Out-Null
foreach ($f in $required) {
$src = Join-Path $LayoutDir $f
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
Copy-Item $src (Join-Path $stage $f) -Force
}
$licSrc = Join-Path $LayoutDir 'licenses'
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
else { [Convert]::ToBoolean($RequireSignedCert) }
if ($NoSign -and $requireCert) {
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
}
$pfxPath = Join-Path $OutDir 'signing.pfx'
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
$signMode = 'none'
$signtool = $null
if (-not $NoSign) {
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "signtool: $signtool"
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
$signMode = 'azure'
$AzureDlib = Find-AzureDlib $AzureDlib
@{
Endpoint = $AzureEndpoint
CodeSigningAccountName = $AzureAccount
CertificateProfileName = $AzureProfile
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
if (-not [Environment]::GetEnvironmentVariable($v)) {
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
"DefaultAzureCredential; without the service-principal trio it falls through to " +
"an interactive login that cannot complete on a runner and hangs the build.")
}
}
}
elseif ($PfxBase64) {
$signMode = 'pfx'
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
}
elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
}
else {
$signMode = 'selfsigned'
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 client installer (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
}
}
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
function Sign-File([string]$Path) {
if ($NoSign) { return }
if ($signMode -eq 'azure') {
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
$ts = 'http://timestamp.acs.microsoft.com'
}
else {
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
$ts = 'http://timestamp.digicert.com'
}
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
if ($LASTEXITCODE -eq 0) { return }
if ($signMode -eq 'azure') {
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
"untrusted within days of release.")
}
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 exes, zip the stage (portable build), then build + sign the installer ------
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
Sign-File (Join-Path $stage $f)
}
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
Write-Host "==> portable zip: $zip"
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
$brandStage = Join-Path $OutDir 'branding'
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
$defines = @(
"/DMyAppVersion=$Version",
"/DArch=$Arch",
"/DLayoutDir=$stage",
"/DBrandingDir=$brandStage",
"/DOutputDir=$OutDir"
)
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
& $iscc @defines $issLocal
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
Sign-File $setup
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "==> installer: $setup"
if ($signMode -eq 'azure') {
Write-Host "==> signed by a publicly trusted CA."
}
elseif ($signMode -ne 'none') {
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
}
if ($env:GITHUB_ENV) {
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
@@ -0,0 +1,244 @@
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
;
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
;
; Built by pack-client-installer.ps1, e.g.:
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
;
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
; elevation and uninstall leaves nothing behind):
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
; Microsoft.WindowsAppRuntime.2 PackageDependency
; -> download + run the runtime installer when missing ([Code])
#ifndef MyAppVersion
#define MyAppVersion "0.0.0.0"
#endif
#ifndef Arch
#define Arch "x64"
#endif
#ifndef LayoutDir
#define LayoutDir "."
#endif
#ifndef BrandingDir
#define BrandingDir "..\..\..\packaging\windows\branding"
#endif
#ifndef OutputDir
#define OutputDir "."
#endif
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
[Setup]
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
AppName=Punktfunk
AppVersion={#MyAppVersion}
AppPublisher=unom
AppPublisherURL=https://git.unom.io/unom/punktfunk
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
; see the header (Steam overlay / Big Picture).
DefaultDirName={userpf}\Punktfunk
PrivilegesRequired=lowest
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
MinVersion=10.0.17763
#if Arch == "arm64"
ArchitecturesAllowed=arm64
ArchitecturesInstallIn64BitMode=arm64
#else
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
#endif
OutputDir={#OutputDir}
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
Compression=lzma2/max
SolidCompression=yes
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
#if VER >= EncodeVer(6,6,0)
WizardStyle=modern dynamic windows11
#else
WizardStyle=modern
#endif
SetupIconFile={#BrandingDir}\punktfunk.ico
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
UninstallDisplayName=Punktfunk {#MyAppVersion}
UninstallDisplayIcon={app}\punktfunk-client.exe
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
ChangesEnvironment=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
[Files]
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
; skipped by "the MSIX build already signed them".
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
[Icons]
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
Comment: "Controller-driven couch interface for TVs and HTPCs"
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
[Registry]
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
; command line, so this lands in the same positional URL parse in main() that the packaged
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
ValueData: "{app}\punktfunk-client.exe,0"
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
[Code]
const
EnvKey = 'Environment'; { the HKCU per-user environment key }
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
retargeted from the HKLM machine key to HKCU.) }
function PathNeedsAdd(Param: String): Boolean;
var
OrigPath: String;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
begin
Result := True; { no Path value at all - the append creates it }
exit;
end;
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
end;
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
procedure RemoveAppFromPath;
var
OrigPath, NewPath, Entry: String;
Target: String;
P: Integer;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
exit;
Target := Uppercase(ExpandConstant('{app}'));
NewPath := '';
OrigPath := OrigPath + ';';
repeat
P := Pos(';', OrigPath);
Entry := Trim(Copy(OrigPath, 1, P - 1));
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
if (Entry <> '') and (Uppercase(Entry) <> Target) then
begin
if NewPath <> '' then NewPath := NewPath + ';';
NewPath := NewPath + Entry;
end;
until OrigPath = '';
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
RemoveAppFromPath;
end;
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
runtime installer and run it quietly — it registers Store-signed framework packages, which
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
carry, because the app itself reports the missing runtime on first launch too. }
function AppRuntimeMissing(): Boolean;
var
ResultCode: Integer;
begin
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
download below is idempotent and the runtime installer no-ops when it is present. }
if not Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
Result := True;
exit;
end;
Result := ResultCode <> 0;
end;
procedure EnsureAppRuntime;
var
ResultCode: Integer;
Installer: String;
begin
if not AppRuntimeMissing() then
exit;
Installer := 'windowsappruntimeinstall.exe';
try
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
except
SuppressibleMsgBox(
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
'Punktfunk needs it to start. Install it from ' + #13#10 +
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
'and then launch Punktfunk normally.',
mbInformation, MB_OK, IDOK);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
begin
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
if CurStep = ssInstall then
Exec(ExpandConstant('{sys}\taskkill.exe'),
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
if CurStep = ssPostInstall then
EnsureAppRuntime;
end;
+32 -7
View File
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
INBOX.lock().unwrap().push(url);
}
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
/// (`set_app_user_model_id` in main.rs).
pub(crate) fn has_package_identity() -> bool {
use windows::Win32::appmodel::GetCurrentPackageFullName;
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
// PROBE — it writes nothing and only reports whether this process is packaged.
unsafe {
let mut len: u32 = 0;
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
}
}
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
///
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
/// still works if it isn't, because it invokes the client directly — which is the whole point
/// of a shortcut being a container for a URL rather than a second launch mechanism
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
/// it valid across updates, since the install path changes and the alias doesn't.
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
/// internet shortcut. Both would work while the scheme is registered; only this one still works
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
/// being a container for a URL rather than a second launch mechanism
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
/// installed: under MSIX the install path changes on every update but the app execution alias
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
/// but a stable install dir, so unpackaged runs target the absolute exe path.
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
use windows::core::{Interface, HSTRING};
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
.map_err(|_| "USERPROFILE isn't set".to_string())?;
let path = desktop.join(format!("{}.lnk", file_name(label)));
// Alias when packaged, absolute path when not — see the doc comment above.
let target = if has_package_identity() {
"punktfunk-client.exe".to_string()
} else {
std::env::current_exe()
.map_err(|e| format!("current exe: {e}"))?
.to_string_lossy()
.into_owned()
};
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
// synchronous call; nothing here dereferences a pointer the caller supplied.
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
.map_err(|e| format!("shell link: {e}"))?;
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
link.SetPath(&HSTRING::from(target.as_str()))
.ok()
.map_err(|e| format!("shortcut target: {e}"))?;
link.SetArguments(&HSTRING::from(url))
+4 -10
View File
@@ -173,18 +173,12 @@ fn main() {
/// processes are left alone. Must run before any window exists.
#[cfg(windows)]
fn set_app_user_model_id() {
use windows::Win32::appmodel::GetCurrentPackageFullName;
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
// documented identity PROBE — it writes nothing and only reports whether this process is
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
if deeplink::has_package_identity() {
return; // packaged (or indeterminate) — leave the identity alone
}
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
unsafe {
let mut len: u32 = 0;
// No buffer: just probe whether the process has package identity.
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
return; // packaged (or indeterminate) — leave the identity alone
}
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
}
+3 -3
View File
@@ -185,11 +185,11 @@
"id": "windows-client",
"name": "Windows client",
"installs": "client",
"packageManager": "msix",
"packageManager": "installer",
"docs": "/docs/install-client#windows",
"install": [
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
".\\punktfunk-client-setup_x64.exe"
]
},
{
+2 -1
View File
@@ -32,7 +32,8 @@ track per machine; switching is a one-line change.
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
| **Windows client** (installer) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-setup_x64.exe` | `…/latest/…` + the release page |
| **Windows client** (MSIX / portable zip) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` (or `…_x64-portable.zip`) | `…/latest/…` + the release page |
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
+3 -3
View File
@@ -113,7 +113,7 @@ per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and
on Intel and other GPUs (Intel's driver advertises Vulkan Video, but DXVA is the proven path there).
It has [10-bit/HDR present](/docs/hdr#per-client), WASAPI audio + mic, SDL3 controllers (rumble,
lightbar, DualSense), network discovery, the host's **game library** with cover art, and the full
PIN-pairing trust surface. It builds for `x86_64` and `aarch64` and ships as a **signed MSIX**.
PIN-pairing trust surface. It builds for `x86_64` and `aarch64` and ships as a **signed installer** (plus a portable zip, and an MSIX for Microsoft Store compatibility).
The package installs **two** Start-menu entries — **Punktfunk**, the desktop window, and
**Punktfunk Console**, a controller-driven fullscreen interface for a TV or HTPC (host list, pairing,
@@ -148,7 +148,7 @@ It ships as a sideloadable `.ipk` (homebrew package) rather than through the LG
## Scripting: the `punktfunk` CLI
`punktfunk` is the headless client — the same core the graphical apps use, with no window. It ships
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows MSIX**, so
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows installer**, so
if you have a desktop client you already have it:
```sh
@@ -213,7 +213,7 @@ has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
| A Linux desktop or laptop | **[`punktfunk-client`](#linux-desktop-client-gtk4)** (GTK4) |
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the [GTK4 client](#linux-desktop-client-gtk4) in Desktop Mode |
| An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed MSIX) or **[Moonlight](/docs/moonlight)** |
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed installer) or **[Moonlight](/docs/moonlight)** |
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **[Moonlight](/docs/moonlight)** |
| A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
| Scripts, plugins, home automation | The headless **[`punktfunk`](#scripting-the-punktfunk-cli)** CLI |
+41 -22
View File
@@ -22,7 +22,7 @@ Already installed? Skip to [Keeping a client up to date](#keeping-a-client-up-to
|--------|---------|
| **Linux** desktop / laptop | [Flatpak](#linux-desktop-flatpak) (any distro) or native apt/rpm/Arch packages |
| **Steam Deck** | [Decky plugin](/docs/steam-deck) for Gaming Mode, or [Flatpak in Desktop Mode](#steam-deck) |
| **Windows** | [Signed MSIX](#windows) from the package registry |
| **Windows** | [Signed installer](#windows) from the package registry (portable zip and MSIX too) |
| **macOS** | [Notarized `.dmg`](#macos) from the releases page |
| **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) |
| **Android / Android TV** | [Google Play](#android), or sideload the APK |
@@ -100,38 +100,51 @@ See [packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packa
## Windows
The Windows client ships as a **signed MSIX** in the package registry, signed with a publicly
trusted certificate — nothing to import or trust by hand.
The Windows client ships as a **signed installer** in the package registry, signed with a publicly
trusted certificate — nothing to import or trust by hand. It installs per-user (no admin prompt) to
`%LOCALAPPDATA%\Programs\Punktfunk`.
1. Download the package. Each channel keeps one fixed URL, so this line always fetches the current
build — in PowerShell:
1. Download the installer. Each channel keeps one fixed URL, so this line always fetches the
current build — in PowerShell:
```powershell
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe
```
Swap `_x64` for `_arm64` on an Arm device, and `latest` for `canary` to track `main`. The same
file is attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
build is kept under its own version on the
[packages page](https://git.unom.io/unom/-/packages) (generic group, `punktfunk-client-windows`).
2. Install it:
```powershell
# use the _arm64 file instead on an Arm device
Add-AppxPackage .\punktfunk-client-windows_x64.msix
```
If Windows reports a missing dependency, install the
2. Run it. The installer registers `punktfunk://` links, puts the headless `punktfunk` command on
your PATH, and fetches the
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
(the MSIX depends on `Microsoft.WindowsAppRuntime.2`), then re-run `Add-AppxPackage`.
automatically if this PC doesn't have it yet.
3. Launch **Punktfunk** from the Start menu and pick your host. A second entry, **Punktfunk
Console**, is the same client as a controller-driven fullscreen interface for a TV or HTPC.
Install from a signed-in desktop session. Over a remote, non-interactive session (SSH, an RMM
tool) `Add-AppxPackage` can fail with `0x80070005` when the Windows App Runtime is in use and
Windows can't restart the apps holding it.
### Launching through Steam (overlay, Big Picture)
3. Launch **Punktfunk** from the Start menu and pick your host. The package also adds a second
entry, **Punktfunk Console** — the same client as a controller-driven fullscreen interface for a
TV or HTPC — and the headless `punktfunk` command on your PATH.
Because the client is a normal exe at a stable path, you can hand it to Steam: **Add a Non-Steam
Game** → browse to `%LOCALAPPDATA%\Programs\Punktfunk\punktfunk-client.exe` (or
`punktfunk-console.exe` for the couch interface). Launched that way, the **Steam overlay** and
controller configs work in the stream, and it's launchable from **Big Picture**. This is exactly
what the older MSIX package couldn't do — Steam can neither browse nor inject into an app under
`WindowsApps` — so if you set that up before, reinstall with the installer above and re-add it.
### Portable zip and MSIX
Two alternates, same signed binaries, published next to the installer on every build:
- **Portable** — `…/latest/punktfunk-client-windows_x64-portable.zip`: unzip anywhere and run
`punktfunk-client.exe`. Nothing is registered, so `punktfunk://` links and the `punktfunk`
command on PATH stay with the installer. Needs the
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
installed once.
- **MSIX** — `…/latest/punktfunk-client-windows_x64.msix`, kept for Microsoft Store
compatibility: `Add-AppxPackage .\punktfunk-client-windows_x64.msix`. If Windows reports a
missing dependency, install the Windows App Runtime 2.x above and re-run it. Install from a
signed-in desktop session — over SSH/RMM, `Add-AppxPackage` can fail with `0x80070005`. Note the
Steam integration above does **not** work from the MSIX.
> The Windows client's hardware decode and HDR10 present are validated on glass on NVIDIA and Intel
> (including HDR pass-through on the Intel D3D11VA path). If anything misbehaves,
@@ -218,7 +231,8 @@ but keeping them close is the least surprising. (Updating the **host** is its ow
| **Linux Flatpak** | `flatpak update --user io.unom.Punktfunk` — **without `sudo`** (see the [Flatpak section](#linux-desktop-flatpak)) |
| **Linux apt / dnf / pacman** | your normal `sudo apt upgrade` / `sudo dnf upgrade` / `sudo pacman -Syu`, or the app's own updater below |
| **Fedora Atomic (layered)** | `rpm-ostree upgrade` on its own is not enough — see the note below the table |
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. Coming from **0.28.1 or earlier**, see the note below the table |
| **Windows installer** | download the newer `punktfunk-client-setup_<arch>.exe` as in [Windows](#windows) and run it — it upgrades in place, keeping your saved hosts and pairing. Switching **from the MSIX**, see the note below the table |
| **Windows MSIX / portable** | no self-update — download the newer `.msix` and re-run `Add-AppxPackage` (from **0.28.1 or earlier**, see the note below the table), or unzip the newer portable build over the old one |
| **macOS `.dmg`** | download the newer `Punktfunk-<version>.dmg` and drag it over the copy in Applications |
| **iOS / iPadOS / tvOS** | TestFlight updates it |
| **Android** | Google Play updates it; if you sideloaded, download the APK again and install over it |
@@ -253,6 +267,11 @@ This is one-time; releases after that upgrade in place. A packaged app's setting
package, so removing the old one also removes this client's identity and its paired hosts — expect
to [pair](/docs/pairing) again once. Nothing on the host side is affected.
**Switching from the MSIX to the installer** (for the [Steam integration](#launching-through-steam-overlay-big-picture),
or just to follow the new default): remove the MSIX first — `Get-AppxPackage unom.Punktfunk |
Remove-AppxPackage` — then run the installer. Same caveat as above: the packaged app's saved hosts
and pairing identity go with the package, so expect to pair again once.
### The Linux client can update itself
The native Linux client checks its own channel and can apply the update in place, whichever package
+12
View File
@@ -293,6 +293,18 @@ Then remove the repository as described under the host sections above, if this b
it. To clear the client's own state without uninstalling — saved hosts and stream settings, keeping
the paired identity — run `punktfunk-client --reset` instead.
### Windows client (installer)
Uninstall **Punktfunk** from **Settings → Apps → Installed apps** (it's a per-user install, so no
admin prompt), or silently:
```powershell
& "$env:LOCALAPPDATA\Programs\Punktfunk\unins000.exe" /VERYSILENT
```
The uninstaller removes the Start-menu entries, the `punktfunk://` registration, and its own PATH
entry. A **portable** unzip has nothing registered — just delete the folder.
### Windows client (MSIX)
```powershell
+3 -3
View File
@@ -185,11 +185,11 @@
"id": "windows-client",
"name": "Windows client",
"installs": "client",
"packageManager": "msix",
"packageManager": "installer",
"docs": "/docs/install-client#windows",
"install": [
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
".\\punktfunk-client-setup_x64.exe"
]
},
{