6 Commits

Author SHA1 Message Date
enricobuehler 7975a95cd6 chore(release): regenerate api/openapi.json for 0.4.2
audit / cargo-audit (push) Successful in 17s
apple / swift (push) Successful in 1m7s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 59s
ci / rust (push) Successful in 7m24s
ci / bench (push) Successful in 4m47s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 58s
release / apple (push) Successful in 9m2s
windows-host / package (push) Successful in 6m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m7s
apple / screenshots (push) Successful in 5m35s
android-screenshots / screenshots (push) Successful in 2m18s
deb / build-publish (push) Successful in 8m31s
android / android (push) Successful in 9m30s
decky / build-publish (push) Successful in 14s
linux-client-screenshots / screenshots (push) Successful in 1m37s
flatpak / build-publish (push) Successful in 4m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m13s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
web-screenshots / screenshots (push) Successful in 2m24s
docker / deploy-docs (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
The OpenAPI info.version tracks the crate version, so the 0.4.2 bump
(0604c4f) left api/openapi.json stale at 0.4.1 and would redden
mgmt::tests::openapi_document_is_complete_and_checked_in. The API surface is
unchanged since the last regen (ecbbff5 already refreshed it for the new
library endpoints), so this is the version string only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:40:51 +00:00
enricobuehler 0604c4fba9 chore(release): bump workspace version to 0.4.2
The [workspace.package] version (inherited by every crate via version.workspace)
lagged at 0.4.1 — bump it to 0.4.2, the release being cut, and refresh the 8
workspace entries in Cargo.lock to match (CI builds --locked). This is a patch
release (Windows CI fixes + Apple gamepad UI); the canary base fallbacks stay
at 0.5.0, already one minor ahead of the 0.4.x stable line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:40:46 +00:00
enricobuehler ecbbff5544 feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
2026-07-01 15:14:19 +02:00
enricobuehler c8be614d9a fix(windows-ci): add FFmpeg's bin dir to PATH explicitly, don't rely on daemon env
windows / build (aarch64-pc-windows-msvc) (push) Successful in 42s
apple / swift (push) Successful in 1m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 44s
android / android (push) Successful in 4m20s
ci / rust (push) Successful in 4m42s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m20s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m29s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m28s
docker / deploy-docs (push) Successful in 18s
FFMPEG_DIR alone satisfies the linker, but the test binary needs the actual DLLs
on PATH at runtime. The daemon's own env (project-env.ps1, written by the
punktfunk-extras provisioning step) only takes effect on daemon *restart*, so a
freshly cloned/registered runner's first-ever job runs before that file has ever
been written, let alone picked up - confirmed live as STATUS_DLL_NOT_FOUND on the
new home-windows-runner-1's first real CI run. Setting PATH via GITHUB_PATH here
makes the workflow self-sufficient regardless of daemon restart timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 12:25:25 +02:00
enricobuehler 246552b75e feat(windows-ci): self-provisioning toolchain for the shared unom/infra runner
windows-drivers / driver-build (push) Failing after 14s
windows-host / package (push) Failing after 7s
windows-drivers / probe-and-proto (push) Successful in 39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Failing after 7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 7s
android / android (push) Successful in 4m20s
ci / web (push) Successful in 54s
deb / build-publish (push) Successful in 3m30s
decky / build-publish (push) Successful in 25s
apple / swift (push) Successful in 1m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m15s
ci / rust (push) Successful in 4m48s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m36s
ci / bench (push) Successful in 4m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m54s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m18s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m14s
docker / deploy-docs (push) Successful in 21s
The Windows CI runner (home-windows-runner-1, vmid 210) is now provisioned/owned by
unom/infra and can be rebuilt or joined by additional windows-amd64-labeled runners at
any time - a manually-dispatched provisioning workflow has no way to target a specific
runner instance, so it could land on an already-provisioned box instead of the one that
needed it. Replace windows-drivers-provision.yml / windows-punktfunk-provision.yml with
scripts/ci/ensure-windows-toolchain.ps1, a shared idempotent pre-flight (WDK/cargo-wdk,
FFmpeg, Inno Setup, ARM64 rustup target) that every Windows workflow now runs at job
start - a fast no-op once already provisioned, so any runner self-heals on first real use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 11:41:31 +02:00
enricobuehler e78805798d fix(ci/windows): tolerate 409 on the immutable generic-registry upload
apple / swift (push) Successful in 1m11s
apple / screenshots (push) Successful in 5m22s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m51s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m23s
windows-host / package (push) Successful in 6m31s
android / android (push) Successful in 4m16s
ci / web (push) Successful in 58s
ci / rust (push) Successful in 4m57s
ci / docs-site (push) Successful in 1m0s
deb / build-publish (push) Successful in 3m27s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m1s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m21s
Both Windows publish steps threw on any non-zero curl exit, so re-running a vX.Y.Z
tag (e.g. after a force-push) failed at the versioned generic-registry path —
that path is immutable and 409s a re-upload of an already-published version. The
channel alias right below already delete-then-reuploads to dodge this; mirror that
intent for the versioned path by reading the HTTP status and treating 409 as a
no-op. The MSIX/installer still build, sign, and attach to the release fine — this
only unbreaks the redundant re-publish on a tag re-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:39:08 +02:00
35 changed files with 1982 additions and 331 deletions
@@ -1,27 +0,0 @@
# One-shot provisioning of the WDK + cargo-wdk onto the persistent self-hosted windows-amd64 runner, so
# the all-Rust UMDF drivers can build there (design/windows-host-rewrite.md, M0). The runner has the base
# Windows SDK + MSVC + LLVM + Rust but NOT the WDK (no km/wdf/iddcx headers) or cargo-wdk.
#
# Dispatch manually (workflow_dispatch). Idempotent: re-running is a near no-op once provisioned. The
# install persists on the runner (real box, not an ephemeral container), so this runs once, not per build.
name: windows-drivers-provision
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'scripts/ci/provision-windows-wdk.ps1'
- '.gitea/workflows/windows-drivers-provision.yml'
jobs:
provision:
runs-on: windows-amd64
timeout-minutes: 60
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@v4
- name: Install WDK + cargo-wdk on the runner
run: ./scripts/ci/provision-windows-wdk.ps1
+10 -8
View File
@@ -1,5 +1,5 @@
# Windows driver workspace CI — runs on the self-hosted Windows runner (home-windows-1, host mode; # Windows driver workspace CI — runs on a self-hosted Windows runner (home-windows-runner-1, host
# label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0). # mode; label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
# #
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the # Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code, # inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
@@ -26,7 +26,8 @@ on:
- 'crates/pf-driver-proto/**' - 'crates/pf-driver-proto/**'
- 'packaging/windows/drivers/**' - 'packaging/windows/drivers/**'
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml). # Driver builds need the WDK on the runner - the driver-build job below self-provisions it via
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
jobs: jobs:
probe-and-proto: probe-and-proto:
@@ -124,11 +125,12 @@ jobs:
# retired that — see design/windows-build-and-packaging.md. # retired that — see design/windows-build-and-packaging.md.
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Ensure WDK + cargo-wdk (idempotent self-provision) - name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
# Run the provisioning script here too so driver-build is self-sufficient and never races a # Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
# separate provision run on the single runner. Path is relative to the job working-directory # driver-build is self-sufficient on any windows-amd64 runner and never races a manually
# (packaging/windows/drivers). Near-noop once the toolchain is present. # dispatched provisioning workflow landing on a different one. Path is relative to the job
run: ../../../scripts/ci/provision-windows-wdk.ps1 # working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay) - name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) + # Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve # pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
+17 -14
View File
@@ -1,8 +1,9 @@
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic # 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 # package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled # pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner # bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml. # (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
# #
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that # 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 # CreateProcessAsUserW's into the interactive session for secure-desktop capture, and bundles a
@@ -57,6 +58,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Locale-safety gate (installer-run scripts must be ASCII) - name: Locale-safety gate (installer-run scripts must be ASCII)
shell: pwsh shell: pwsh
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END # The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
@@ -82,7 +87,7 @@ jobs:
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "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 "CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned # FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend # by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1 # (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env. # then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
if (-not $env:FFMPEG_DIR) { if (-not $env:FFMPEG_DIR) {
@@ -125,14 +130,6 @@ jobs:
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" } cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
Pop-Location Pop-Location
- 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: Fetch portable bun runtime (build tool + bundled to run the console) - name: Fetch portable bun runtime (build tool + bundled to run the console)
shell: pwsh shell: pwsh
run: | run: |
@@ -203,9 +200,15 @@ jobs:
# Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort. # Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort.
$PSNativeCommandUseErrorActionPreference = $false $PSNativeCommandUseErrorActionPreference = $false
function Publish-File($f, $url) { function Publish-File($f, $url) {
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url" # The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" } # re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
Write-Output "published $url" # alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
# status code instead of aborting on it.
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
Write-Output "published ($code): $url"
} }
$files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } $files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" } if (-not $files) { throw "pack produced no artifacts to publish" }
+17 -6
View File
@@ -1,8 +1,9 @@
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to # Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start # 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 # tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
# (host mode; scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows # runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
# SDK's makeappx/signtool are baked into the runner's daemon env, same as windows.yml. # self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
# #
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC # Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See # toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
@@ -62,6 +63,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + version - name: Configure + version
shell: pwsh shell: pwsh
run: | run: |
@@ -115,9 +120,15 @@ jobs:
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" } if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) { function Put($f, $url) {
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url" # The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" } # re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
Write-Output "published $url" # alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
# status code instead of aborting on it.
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
Write-Output "published ($code): $url"
} }
foreach ($f in $files) { foreach ($f in $files) {
$name = Split-Path $f -Leaf $name = Split-Path $f -Leaf
+16 -3
View File
@@ -1,5 +1,8 @@
# Windows client CI — runs on the self-hosted Windows runner (home-windows-1, host mode; see # Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
# scripts/ci/setup-windows-runner.ps1). Build + clippy + fmt + test the WinUI 3 client # toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg, WDK, Inno
# Setup, the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a
# fast no-op once already present, so any runner with that label works with no manual dispatch
# step first). Build + clippy + fmt + test the WinUI 3 client
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3). # (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
# #
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and # Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
@@ -61,6 +64,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + toolchain versions - name: Configure + toolchain versions
shell: pwsh shell: pwsh
run: | run: |
@@ -68,9 +75,15 @@ jobs:
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\). # Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' } $td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Per-arch FFmpeg import libs (the runner provisions both — setup-windows-runner.ps1). # Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' } $ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }}
rustc --version rustc --version
cargo --version cargo --version
+19 -1
View File
@@ -285,7 +285,25 @@ clients are deliberately NOT containerized); `apple.yml` builds the xcframework
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows: provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml` `deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG + (Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner. TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
land on an already-provisioned box instead of the one that actually needed it.
## Layout ## Layout
Generated
+9 -8
View File
@@ -1995,7 +1995,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.4.1" version = "0.4.2"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2127,7 +2127,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2720,7 +2720,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2734,7 +2734,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2754,7 +2754,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2774,7 +2774,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2804,7 +2804,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -2844,6 +2844,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower", "tower",
@@ -2870,7 +2871,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
+1 -1
View File
@@ -16,7 +16,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.4.1" version = "0.4.2"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+59 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.4.1" "version": "0.4.2"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -229,6 +229,64 @@
} }
} }
}, },
"/api/v1/library/art/{id}/{kind}": {
"get": {
"tags": [
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"operationId": "getLibraryArt",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The store-qualified library id, e.g. `steam:570`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "kind",
"in": "path",
"description": "`portrait` | `hero` | `logo` | `header`",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Image bytes",
"content": {
"image/jpeg": {}
}
},
"401": {
"description": "Missing or invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No art of that kind for that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/custom": { "/api/v1/library/custom": {
"post": { "post": {
"tags": [ "tags": [
@@ -355,7 +355,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements; CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
@@ -364,7 +364,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
@@ -389,7 +389,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements; CODE_SIGN_ENTITLEMENTS = "Config/Punktfunk-macOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
@@ -398,7 +398,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
@@ -425,11 +425,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -464,11 +464,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -502,11 +502,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -532,11 +532,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist; INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk"; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -45,6 +45,16 @@ struct ContentView: View {
#if !os(macOS) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
#if os(iOS)
// A connected controller (+ the Settings toggle) swaps the whole home screen for
// GamepadHomeView instead of retrofitting HomeView's touch UI see `home` below.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
#endif
var body: some View { var body: some View {
Group { Group {
@@ -114,11 +124,23 @@ struct ContentView: View {
.sheet(item: $speedTestTarget) { host in .sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host) SpeedTestSheet(host: host)
} }
// The library is a full-screen presentation, not a sheet: on iPad a sheet is a centered page
// card, but the gamepad coverflow is meant to be an immersive, full-bleed screen (and the
// launcher behind it stops consuming the controller see GamepadHomeView's `isActive`).
// macOS has no `fullScreenCover`, so it keeps the sheet there.
#if os(macOS)
.sheet(item: $libraryTarget) { host in .sheet(item: $libraryTarget) { host in
NavigationStack { NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) }) LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
} }
} }
#else
.fullScreenCover(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
}
#endif
#endif #endif
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an // Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN // alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
@@ -171,6 +193,23 @@ struct ContentView: View {
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered, connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle) onPaired: handlePaired, onLaunchTitle: launchTitle)
#elseif os(iOS)
Group {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered)
} else {
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
}
}
#else #else
HomeView( HomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
@@ -0,0 +1,266 @@
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
// by a controller (iOS/iPadOS only).
//
// The scrolling is pure native SwiftUI `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
// container width, so it's correct in an iPad split view too) lets the first and last item reach the
// middle. The CALLER owns each card's look, including its own `.scrollTransition` this component
// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without
// the generic wrapper here pushing the type-checker onto an overload it can't satisfy.
//
// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the
// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the
// scroll view WRITES intermediate ids into it while a programmatic animation is in flight so
// reading the "current" item back out of it to compute the next one desyncs badly on a fast held
// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move
// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is
// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag).
//
// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a
// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad).
// Both fire on a move, on confirm, and for a non-wrapping list a duller bump plus a short visual
// recoil when a move is refused at either end.
import PunktfunkKit
import SwiftUI
#if os(iOS)
import UIKit
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
let items: [Item]
/// Output only: the carousel WRITES the focused item's id here for the caller's detail panel.
/// It is deliberately not what drives the scroll (see the file header).
@Binding var selection: Item.ID?
/// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side
/// insets center exactly one at a time.
let itemWidth: CGFloat
let spacing: CGFloat
/// A activate the centered item.
let onActivate: (Item) -> Void
/// Y the screen's secondary action (e.g. open a host's library); nil disables it.
var onSecondary: (() -> Void)?
/// B back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
var onBack: (() -> Void)?
/// L1/R1 jump this many items at once (clamped to the ends); 0 disables the shoulders.
var shoulderJump: Int = 0
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
/// otherwise poll the SAME controller at once driving both. The parent sets this false while
/// something is presented on top so only the front-most carousel consumes the gamepad.
var isActive: Bool = true
@ViewBuilder let card: (Item) -> Card
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
/// while the gamepad is driving that's the whole desync fix.
@State private var cursor = 0
/// The id the scroll view is aligned to its own two-way `.scrollPosition` state.
@State private var scrolledID: Item.ID?
/// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't
/// drag the cursor backward during a fast held direction.
@State private var lastNav = Date.distantPast
/// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new
/// write that lands mid-animation the scroll view stays stuck on the old item even though the
/// binding updated so we never issue one until the previous animation reports complete, then
/// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`).
@State private var isScrolling = false
/// A short horizontal recoil when a move is refused at a list end.
@State private var bumpOffset: CGFloat = 0
/// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the
/// confirm and end-stop events (moves trigger on `cursor`).
@State private var activateTick = 0
@State private var boundaryTick = 0
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
/// (longer than a move animation, so overlapping held-stick moves never let it through).
private let navSettle: TimeInterval = 0.4
var body: some View {
GeometryReader { geo in
let inset = max(0, (geo.size.width - itemWidth) / 2)
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(items) { item in
card(item)
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
}
}
.frame(height: geo.size.height) // fill so shorter cards center vertically
.scrollTargetLayout()
}
.scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.hidden)
.scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset)
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
.onAppear {
reconcile()
wire()
if isActive { input.start() }
}
.onDisappear {
input.stop()
haptics.stop()
}
// Hand controller input to/from a screen presented on top (see `isActive`): a covered
// carousel stops polling so it can't navigate behind the front-most one.
.onChange(of: isActive) { _, active in
if active {
wire()
input.start()
} else {
input.stop()
haptics.stop()
}
}
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
// programmatic scroll is animating (its own intermediate id write-backs would regress the
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
// which never sets `isScrolling` moves the cursor here.
.onChange(of: scrolledID) { _, newValue in
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
selection = newValue
}
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
// current `items` value (a plain array unlike an observed object it would otherwise go
// stale in the closures stored on `input`).
.onChange(of: items.map(\.id)) { _, _ in
reconcile()
wire()
}
}
// MARK: - Input wiring
private func wire() {
input.onMove = { move($0) }
input.onConfirm = { activate() }
input.onSecondary = onSecondary
input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
}
private func move(_ direction: GamepadMenuInput.Direction) {
let forward = direction == .right || direction == .down
step(by: forward ? 1 : -1, clampAtEnds: false)
}
private func shoulder(right: Bool) {
step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true)
}
/// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list
/// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if
/// already there. The cursor is the authority the scroll view is pointed at it, never read for it.
private func step(by delta: Int, clampAtEnds: Bool) {
guard !items.isEmpty else { return }
var target = cursor + delta
if target < 0 || target >= items.count {
guard clampAtEnds else { return boundaryBump(forward: delta > 0) }
target = min(max(target, 0), items.count - 1)
}
guard target != cursor else { return boundaryBump(forward: delta > 0) }
cursor = target
lastNav = Date()
haptics.move()
selection = items[target].id // text/detail updates immediately; the scroll chases
commitScroll()
}
private let scrollAnim: TimeInterval = 0.24
/// A hair past `scrollAnim` long enough that the scroll has actually settled before the next
/// write, short enough to stay responsive.
private var scrollSettle: TimeInterval { scrollAnim + 0.05 }
/// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)`
/// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and
/// re-target the LATEST cursor once it settles coalescing a fast burst (hold OR quick flicks) and
/// always converging on the final item, instead of getting stuck on the old card.
///
/// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a
/// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a
/// reliable completion against (it can fire early which is exactly what let quick flicks slip a
/// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch.
private func commitScroll() {
guard !isScrolling, cursor >= 0, cursor < items.count else { return }
let id = items[cursor].id
guard scrolledID != id else { return }
isScrolling = true
withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) {
MainActor.assumeIsolated {
isScrolling = false
commitScroll() // the cursor may have advanced while this scroll ran chase it
}
}
}
private func activate() {
guard cursor >= 0, cursor < items.count else { return }
activateTick &+= 1
haptics.confirm()
onActivate(items[cursor])
}
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
/// any other re-centers on it.
private func tap(_ item: Item) {
if let idx = index(of: item.id), idx == cursor {
activate()
} else if let idx = index(of: item.id) {
cursor = idx
lastNav = Date()
haptics.move()
selection = item.id
commitScroll()
}
}
// MARK: - Selection housekeeping
private func index(of id: Item.ID?) -> Int? {
guard let id else { return nil }
return items.firstIndex { $0.id == id }
}
/// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list
/// change keep the same focused item when it survives, else clamp the cursor into range.
private func reconcile() {
guard !items.isEmpty else {
cursor = 0
if scrolledID != nil { scrolledID = nil }
if selection != nil { selection = nil }
return
}
if let sid = scrolledID, let idx = index(of: sid) {
cursor = idx
if selection != sid { selection = sid }
} else {
let idx = min(max(cursor, 0), items.count - 1)
cursor = idx
let id = items[idx].id
scrolledID = id
selection = id
}
}
private func boundaryBump(forward: Bool) {
boundaryTick &+= 1
haptics.boundary()
let recoil: CGFloat = forward ? -16 : 16
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
}
}
#endif
@@ -0,0 +1,403 @@
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active a separate screen built
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
// required (a tap still works as a fallback). Scope: browse saved + discovered hosts, connect, and
// when the library flag is on jump into a saved host's library (Y).
//
// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's
// chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
// `.background` modifier NOT a ZStack sibling because an `.ignoresSafeArea()` sibling expands the
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
// status bar / home indicator. As a background it draws behind without affecting layout, so the
// GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with
// `.safeAreaInset` (top / bottom-leading) guaranteed inside the safe area and out of the carousel's
// vertical budget and the card is sized off the remaining height. tvOS/macOS never mount this view.
import PunktfunkKit
import SwiftUI
#if os(iOS)
import GameController
/// One navigable tile: a saved host or a discovered-but-unsaved one. Hashable so it can be the
/// carousel's scroll-position identity.
private enum GamepadHomeTarget: Hashable {
case saved(UUID)
case discovered(String)
}
/// A fully-resolved launcher tile display fields + the activate action, built fresh each render
/// from the live stores so nothing goes stale.
private struct HomeTile: Identifiable {
let id: GamepadHomeTarget
let title: String
let subtitle: String
let isOnline: Bool
let isPaired: Bool
let isConnecting: Bool
/// Saved (solid monogram) vs. discovered-but-unsaved (tinted outline).
let filled: Bool
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
let hasLibrary: Bool
let activate: () -> Void
}
struct GamepadHomeView: View {
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
let connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
/// Same experimental gate the touch grid's "Browse Library" context-menu item uses.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
/// `.compact` in a landscape phone window drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
@State private var selection: GamepadHomeTarget?
@State private var breathe = false
private var compact: Bool { vSizeClass == .compact }
var body: some View {
GeometryReader { geo in
hero(for: geo.size)
}
// Pinned inside the safe area, out of the carousel's vertical budget never clipped.
.safeAreaInset(edge: .top, spacing: 0) {
titleView
.padding(.top, compact ? 4 : 10)
.padding(.bottom, compact ? 4 : 8)
.frame(maxWidth: .infinity)
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
if !tiles.isEmpty {
hintBar
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
}
}
.background { background }
.onAppear {
discovery.start()
withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: true)) { breathe = true }
}
.onDisappear { discovery.stop() }
.alert(
"Connection failed",
isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(model.errorMessage ?? "")
}
}
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View {
if tiles.isEmpty {
emptyState.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
let cardWidth = min(340, size.width * 0.84)
// 96 the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
// the strip + detail always fit the region the safe-area insets leave.
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
detailPanel
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Chrome
private var background: some View {
ZStack {
LinearGradient(
colors: [.black, Color.brand.opacity(0.22), .black],
startPoint: .top, endPoint: .bottom)
// A soft brand orb behind the strip gives the flat gradient depth; it breathes slowly.
Circle()
.fill(RadialGradient(
colors: [Color.brand.opacity(0.55), .clear],
center: .center, startRadius: 0, endRadius: 300))
.frame(width: 560, height: 560)
.blur(radius: 70)
.scaleEffect(breathe ? 1.08 : 0.92)
.opacity(breathe ? 0.5 : 0.32)
.offset(y: -20)
}
.ignoresSafeArea()
}
private var titleView: some View {
Text("Select a Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
.foregroundStyle(.white)
}
private var emptyState: some View {
VStack(spacing: 14) {
Image(systemName: "gamecontroller")
.font(.system(size: 46, weight: .light))
.foregroundStyle(Color.brand)
Text("No hosts yet")
.font(.geist(20, .semibold, relativeTo: .title3))
.foregroundStyle(.white)
Text("Add one with touch first — it'll show up here for the controller.")
.font(.geist(15, relativeTo: .body))
.foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
.frame(maxWidth: 320)
}
}
// MARK: - Carousel
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
GamepadCarousel(
items: tiles,
selection: $selection,
itemWidth: cardWidth,
spacing: 30,
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
// Stop consuming the controller while the library is presented on top otherwise the
// launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
isActive: libraryTarget == nil
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
.frame(height: cardHeight + 40)
}
/// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
/// brightness/saturation + a depth blur on the recessed neighbors.
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
GamepadHostTile(tile: tile, size: size)
.scrollTransition { content, phase in
let d = CGFloat(min(abs(phase.value), 1))
let scale = 1 - d * 0.12
let bright = Double(-d * 0.24)
let sat = Double(1 - d * 0.42)
let soft = d * 3
let fade = Double(1 - d * 0.22)
return content
.scaleEffect(scale)
.brightness(bright)
.saturation(sat)
.blur(radius: soft)
.opacity(fade)
}
}
/// The "now focused" host, spelled out below the strip empty (not hidden) so the layout
/// doesn't jump as the selection changes.
@ViewBuilder private var detailPanel: some View {
let tile = tiles.first { $0.id == selection }
VStack(spacing: 6) {
Text(tile?.title ?? " ")
.font(.geist(22, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.lineLimit(1)
HStack(spacing: 10) {
Text(tile?.subtitle ?? " ")
.font(.geist(13, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.6))
if let tile {
statusPill(online: tile.isOnline, paired: tile.isPaired)
}
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.animation(.smooth(duration: 0.25), value: selection)
}
private func statusPill(online: Bool, paired: Bool) -> some View {
HStack(spacing: 6) {
Circle()
.fill(online ? Color.green : Color.white.opacity(0.35))
.frame(width: 6, height: 6)
Text(online ? "ONLINE" : "OFFLINE")
if paired { Text("· PAIRED") }
}
.font(.geist(11, .medium, relativeTo: .caption2))
.tracking(0.8)
.foregroundStyle(.white.opacity(0.55))
}
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
private var hintBar: some View {
HStack(spacing: 18) {
hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Connect")
if showsLibraryHint {
hint(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")
}
}
.font(.geist(14, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85))
}
private func hint(glyph: String, text: String) -> some View {
HStack(spacing: 7) {
Image(systemName: glyph)
.font(.system(size: 19))
.foregroundStyle(.white)
Text(text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
private var showsLibraryHint: Bool {
guard libraryEnabled else { return false }
return tiles.first { $0.id == selection }?.hasLibrary ?? false
}
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
private func buttonGlyph(
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
?? fallback
}
// MARK: - Data + actions
/// Built fresh each render from the live stores (no stale value capture) saved hosts first,
/// then discovered-but-unsaved ones.
private var tiles: [HomeTile] {
let saved = store.hosts.map { host in
HomeTile(
id: .saved(host.id),
title: host.displayName,
subtitle: "\(host.address):\(String(host.port))",
isOnline: isOnline(host),
isPaired: host.pinnedSHA256 != nil,
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true,
hasLibrary: true,
activate: { connect(host) })
}
let discovered = discoveredUnsaved.map { d in
HomeTile(
id: .discovered(d.id),
title: d.name,
subtitle: "\(d.host):\(String(d.port))",
isOnline: true,
isPaired: false,
isConnecting: false,
filled: false,
hasLibrary: false,
activate: { connectDiscovered(d) })
}
return saved + discovered
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
libraryTarget = host
}
private func isOnline(_ host: StoredHost) -> Bool {
discovery.hosts.contains { host.matches($0) }
}
private var discoveredUnsaved: [DiscoveredHost] {
discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } }
}
}
/// One "console tile" in the host carousel a dark-glass landscape card, bigger and bolder than the
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
private struct GamepadHostTile: View {
let tile: HomeTile
let size: CGSize
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
monogramBadge
Spacer(minLength: 0)
if tile.isOnline {
Circle()
.fill(Color.green)
.frame(width: 9, height: 9)
.shadow(color: .green.opacity(0.7), radius: 5)
}
}
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(23, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(tile.subtitle)
.font(.geist(13, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
.padding(.top, 2)
}
.padding(20)
.frame(width: size.width, height: size.height, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
}
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)],
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
}
private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
colors: [Color.brand, Color.brand.opacity(0.68)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(Color.brand.opacity(0.16)))
if tile.isConnecting {
ProgressView().tint(.white)
} else {
Text(monogram(tile.title))
.font(.geistFixed(25, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand)
}
}
.frame(width: 52, height: 52)
.overlay {
if !tile.filled {
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
}
}
}
private func monogram(_ name: String) -> String {
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "" }
return String(first).uppercased()
}
}
#endif
@@ -0,0 +1,171 @@
// The gamepad-driven presentation of the game library (iOS/iPadOS only see LibraryView's
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
// the controller-glyph hints. A steps through covers, A launches the centered title, B closes, and
// the shoulders (L1/R1) jump a handful at a time through a long library.
//
// Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
// `.background` modifier NOT a ZStack sibling because an `.ignoresSafeArea()` sibling expands the
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
// status bar / home indicator. As a background it draws behind without affecting layout, so the
// GeometryReader is sized to the safe area, and the controller-glyph hints are pinned inside it with
// `.safeAreaInset(.bottom, alignment: .leading)`. Cover size is then derived from the height that
// remains, so a tall 2:3 poster + the detail line always fit.
import PunktfunkKit
import SwiftUI
#if os(iOS)
import GameController
import UIKit
struct LibraryCoverflowView: View {
let games: [GameEntry]
let imageSession: URLSession?
var onLaunch: ((String) -> Void)?
/// Button B (back) dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
var onDismiss: (() -> Void)?
/// `.compact` in a landscape phone window drives a tighter poster so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
@State private var selection: String?
private var compact: Bool { vSizeClass == .compact }
var body: some View {
GeometryReader { geo in
content(for: geo.size)
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
hintBar
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
}
.background {
LinearGradient(
colors: [.black, Color.brand.opacity(0.16), .black],
startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
}
}
@ViewBuilder private func content(for size: CGSize) -> some View {
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
// safe-area inset, already out of this budget) capped so it never dwarfs a large iPad and
// clamped by width on a narrow screen.
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
let coverWidth = coverHeight * 2 / 3
VStack(spacing: 0) {
Spacer(minLength: 4)
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
detailPanel
.padding(.top, 12)
Spacer(minLength: 4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func carousel(coverWidth: CGFloat, coverHeight: CGFloat) -> some View {
GamepadCarousel(
items: games,
selection: $selection,
itemWidth: coverWidth,
spacing: 34,
onActivate: { onLaunch?($0.id) },
onBack: { onDismiss?() },
shoulderJump: 5
) { game in
cover(game, width: coverWidth, height: coverHeight)
}
.frame(height: coverHeight + 44)
}
/// One cover + the coverflow recede. Every continuous visual reads the scroll view's own
/// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen
/// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the
/// scale/rotation/opacity ramp already makes the centered cover prominent.
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
}
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
.scrollTransition { content, phase in
let v = phase.value
let d = CGFloat(min(abs(v), 1))
let scale = 1 - d * 0.24
let rot = v * -38
let anchor: UnitPoint = v < 0 ? .trailing : .leading
let bright = Double(-d * 0.22)
let fade = Double(1 - d * 0.38)
return content
.scaleEffect(scale)
.rotation3DEffect(
.degrees(rot), axis: (x: 0, y: 1, z: 0), anchor: anchor, perspective: 0.55)
.brightness(bright)
.opacity(fade)
}
}
/// The centered title + store tag empty (not hidden) so the layout doesn't jump.
@ViewBuilder private var detailPanel: some View {
let game = games.first { $0.id == selection }
VStack(spacing: 6) {
Text(game?.title ?? " ")
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.multilineTextAlignment(.center)
if let game {
Text(game.isCustom ? "CUSTOM" : "STEAM")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.animation(.smooth(duration: 0.25), value: selection)
}
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
private var hintBar: some View {
HStack(spacing: 18) {
if onLaunch != nil {
hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch")
}
hint(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close")
}
.font(.geist(14, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85))
}
private func hint(glyph: String, text: String) -> some View {
HStack(spacing: 7) {
Image(systemName: glyph)
.font(.system(size: 19))
.foregroundStyle(.white)
Text(text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
/// The active controller's real glyph for a button (Xbox "B", DualSense , ) via
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
private func buttonGlyph(
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
?? fallback
}
}
#endif
@@ -5,6 +5,11 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
struct LibraryView: View { struct LibraryView: View {
@ObservedObject var store: HostStore @ObservedObject var store: HostStore
@@ -12,10 +17,25 @@ struct LibraryView: View {
/// Tapping a title starts a session that asks the host to launch it (the library id is passed /// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` browse-only (cards aren't tappable). /// through). `nil` browse-only (cards aren't tappable).
var onLaunch: ((String) -> Void)? = nil var onLaunch: ((String) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var games: [GameEntry] = [] @State private var games: [GameEntry] = []
@State private var loading = false @State private var loading = false
@State private var errorText: String? @State private var errorText: String?
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
@State private var imageSession: URLSession?
#if os(iOS)
// Gamepad-driven browsing is iOS/iPadOS-only see HomeView's identical gate. tvOS keeps its
// existing plain-grid presentation of this same view unchanged.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
#endif
var body: some View { var body: some View {
content content
@@ -29,8 +49,20 @@ struct LibraryView: View {
#else #else
ToolbarItem(placement: .primaryAction) { reloadButton } ToolbarItem(placement: .primaryAction) { reloadButton }
#endif #endif
// A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in
// (ContentView's `.sheet(item: $libraryTarget)`) give it a focusable, dpad-reachable
// Close action. tvOS already has its own pushed-navigation back (Menu button).
#if !os(tvOS)
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
#endif
} }
.task { await load() } .task { await load() }
.onDisappear {
imageSession?.finishTasksAndInvalidate()
imageSession = nil
}
} }
@ViewBuilder private var content: some View { @ViewBuilder private var content: some View {
@@ -41,9 +73,19 @@ struct LibraryView: View {
errorState(errorText) errorState(errorText)
} else if games.isEmpty { } else if games.isEmpty {
emptyState emptyState
} else {
#if os(iOS)
if gamepadUIActive {
LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch,
onDismiss: { dismiss() })
} else { } else {
grid grid
} }
#else
grid
#endif
}
} }
private var grid: some View { private var grid: some View {
@@ -51,10 +93,10 @@ struct LibraryView: View {
LazyVGrid(columns: columns, spacing: 18) { LazyVGrid(columns: columns, spacing: 18) {
ForEach(games) { game in ForEach(games) { game in
if let onLaunch { if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game) } Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
GameCard(game: game) GameCard(game: game, imageSession: imageSession)
} }
} }
} }
@@ -125,6 +167,13 @@ struct LibraryView: View {
certPEM: identity.certPEM, certPEM: identity.certPEM,
keyPEM: identity.keyPEM, keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256) hostFingerprint: current.pinnedSHA256)
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256)
} catch { } catch {
games = [] games = []
errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription
@@ -137,23 +186,30 @@ struct LibraryView: View {
/// (portrait header hero) and finally a text placeholder. /// (portrait header hero) and finally a text placeholder.
private struct GameCard: View { private struct GameCard: View {
let game: GameEntry let game: GameEntry
let imageSession: URLSession?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title) PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.aspectRatio(2.0 / 3.0, contentMode: .fit) .aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) { storeBadge } .overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
Text(game.title) Text(game.title)
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.lineLimit(2) .lineLimit(2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
}
private var storeBadge: some View { /// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster
Text(game.isCustom ? "Custom" : "Steam") /// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
struct StoreBadge: View {
let isCustom: Bool
var body: some View {
Text(isCustom ? "Custom" : "Steam")
.font(.geist(11, .semibold, relativeTo: .caption2)) .font(.geist(11, .semibold, relativeTo: .caption2))
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 3) .padding(.vertical, 3)
@@ -162,32 +218,63 @@ private struct GameCard: View {
} }
} }
/// Sequentially tries cover-art URLs, advancing past any that fail to load, then a placeholder. #if canImport(UIKit)
private struct PosterImage: View { private typealias PlatformImage = UIImage
#elseif canImport(AppKit)
private typealias PlatformImage = NSImage
#endif
private extension Image {
init(platformImage: PlatformImage) {
#if canImport(UIKit)
self.init(uiImage: platformImage)
#elseif canImport(AppKit)
self.init(nsImage: platformImage)
#endif
}
}
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
/// art proxy, not just public CDNs see `LibraryImageLoader`), advancing past any that fail to
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private`
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
struct PosterImage: View {
let candidates: [URL] let candidates: [URL]
let title: String let title: String
let session: URLSession?
@State private var index = 0 @State private var index = 0
@State private var image: PlatformImage?
var body: some View { var body: some View {
if index < candidates.count { Group {
AsyncImage(url: candidates[index]) { phase in if let image {
switch phase { Image(platformImage: image)
case .success(let image): .resizable()
image.resizable().scaledToFill() .scaledToFill()
case .failure: } else if index < candidates.count {
// Advance to the next candidate on the next render pass.
Color.clear.onAppear { index += 1 }
case .empty:
ZStack { placeholder; ProgressView() } ZStack { placeholder; ProgressView() }
@unknown default:
placeholder
}
}
.id(index) // recreate AsyncImage so it loads the newly-selected URL
} else { } else {
placeholder placeholder
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.task(id: index) { await loadCurrent() }
}
private func loadCurrent() async {
guard index < candidates.count else { return }
guard let session, let data = try? await session.data(from: candidates[index]).0,
let loaded = PlatformImage(data: data)
else {
index += 1 // advance to the next candidate (or past the end placeholder)
return
}
image = loaded
}
private var placeholder: some View { private var placeholder: some View {
ZStack { ZStack {
@@ -38,6 +38,7 @@ struct SettingsView: View {
#endif #endif
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true @AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first), // Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail). // General on iPad (a two-column layout should never open with an empty detail).
@@ -738,6 +739,9 @@ struct SettingsView: View {
Text(option.label).tag(option.tag) Text(option.label).tag(option.tag)
} }
} }
#if os(iOS)
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
#endif
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
Button("Test Controller…") { showControllerTest = true } Button("Test Controller…") { showControllerTest = true }
.disabled(gamepads.active == nil) .disabled(gamepads.active == nil)
@@ -746,7 +750,15 @@ struct SettingsView: View {
} header: { } header: {
Text("Controllers") Text("Controllers")
} footer: { } footer: {
// The iOS-only gamepad-UI blurb is appended here, not merged into the shared
// `controllersFooter` constant tvOS's `tvBody` reuses that exact string (line ~348)
// for its own footer and has no such toggle to describe.
VStack(alignment: .leading, spacing: 6) {
Text(Self.controllersFooter) Text(Self.controllersFooter)
#if os(iOS)
Text(Self.gamepadUIFooter)
#endif
}
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -856,6 +868,15 @@ struct SettingsView: View {
+ "from the next session. Two identical controllers may swap a manual selection " + "from the next session. Two identical controllers may swap a manual selection "
+ "after reconnecting." + "after reconnecting."
#if os(iOS)
private static let gamepadUIFooter =
"When a controller is connected, the host list and game library switch to a "
+ "controller-friendly layout — larger focus targets and a swipeable cover browser "
+ "for the library. Turn this off to always use the touch layout. (The system may "
+ "still move basic focus with a controller connected even with this off — that's "
+ "outside the app's control.)"
#endif
/// "Use controller" choices: Automatic, every forwardable controller, and so a stale /// "Use controller" choices: Automatic, every forwardable controller, and so a stale
/// pin stays visible instead of leaving the Picker selection tag-less any pinned id /// pin stays visible instead of leaving the Picker selection tag-less any pinned id
/// that is NOT among the selectable (extended) entries, present-but-unusable included. /// that is NOT among the selectable (extended) entries, present-but-unusable included.
@@ -4,10 +4,16 @@
// //
// To present that identity, URLSession needs a SecIdentity (cert + private key pair). The client // To present that identity, URLSession needs a SecIdentity (cert + private key pair). The client
// stores its identity as PEM (rcgen ECDSA P-256, PKCS#8 key). We rebuild a SecIdentity natively: // stores its identity as PEM (rcgen ECDSA P-256, PKCS#8 key). We rebuild a SecIdentity natively:
// CryptoKit parses the key its X9.63 form a SecKey, the cert PEM a SecCertificate, and // CryptoKit parses the key its X9.63 form a SecKey, the cert PEM a SecCertificate. From
// SecIdentityCreateWithCertificate pairs them via the Keychain. This is macOS-only // there the two platform families diverge because `SecIdentityCreateWithCertificate` the
// (SecIdentityCreateWithCertificate is unavailable on iOS that path will need a PKCS#12); the // straight-line "pair these two" API is macOS-only:
// client library is macOS-first today. // - macOS: SecIdentityCreateWithCertificate does the pairing directly once the key is in the
// Keychain (a plain `SecItemAdd`).
// - iOS/tvOS: that API is unavailable. Instead, add BOTH the key and the certificate to the
// Keychain (under the same application tag) and query `kSecClassIdentity` the system
// correlates a stored cert against a stored key with a matching public key and vends the pair
// as one `SecIdentity`, no PKCS#12 needed. This is the standard non-macOS technique for
// "I already have a raw cert + key, not a .p12".
import CryptoKit import CryptoKit
import Foundation import Foundation
@@ -18,15 +24,12 @@ private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-t
enum ClientTLS { enum ClientTLS {
enum TLSError: LocalizedError { enum TLSError: LocalizedError {
case unsupportedPlatform
case badKey(String) case badKey(String)
case badCert case badCert
case identity(String) case identity(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .unsupportedPlatform:
return "Library mTLS is supported on macOS only right now."
case .badKey(let why): return "Couldn't load the client key: \(why)" case .badKey(let why): return "Couldn't load the client key: \(why)"
case .badCert: return "Couldn't load the client certificate." case .badCert: return "Couldn't load the client certificate."
case .identity(let why): return "Couldn't build the client identity: \(why)" case .identity(let why): return "Couldn't build the client identity: \(why)"
@@ -45,9 +48,8 @@ enum ClientTLS {
} }
/// Build a `SecIdentity` from the client's PEM cert + PKCS#8 P-256 key. Pairs them via the /// Build a `SecIdentity` from the client's PEM cert + PKCS#8 P-256 key. Pairs them via the
/// Keychain (the key is stored once under a stable tag, so repeat calls reuse it). /// Keychain (stored once under a stable tag, so repeat calls reuse it).
static func makeIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity { static func makeIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
#if os(macOS)
// Key: CryptoKit accepts the SEC1 or PKCS#8 PEM; its x963 form is what SecKey wants. // Key: CryptoKit accepts the SEC1 or PKCS#8 PEM; its x963 form is what SecKey wants.
let priv: P256.Signing.PrivateKey let priv: P256.Signing.PrivateKey
do { do {
@@ -71,9 +73,11 @@ enum ClientTLS {
let cert = SecCertificateCreateWithData(nil, certDER as CFData) let cert = SecCertificateCreateWithData(nil, certDER as CFData)
else { throw TLSError.badCert } else { throw TLSError.badCert }
let tag = Data("io.unom.punktfunk.library-client-key".utf8)
#if os(macOS)
// The key must live in a Keychain for SecIdentityCreateWithCertificate to pair it with the // The key must live in a Keychain for SecIdentityCreateWithCertificate to pair it with the
// cert. Add it under a stable tag; a duplicate just means a previous fetch already did. // cert. Add it under a stable tag; a duplicate just means a previous fetch already did.
let tag = Data("io.unom.punktfunk.library-client-key".utf8)
let add: [CFString: Any] = [ let add: [CFString: Any] = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrApplicationTag: tag, kSecAttrApplicationTag: tag,
@@ -81,7 +85,7 @@ enum ClientTLS {
] ]
let status = SecItemAdd(add as CFDictionary, nil) let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else { guard status == errSecSuccess || status == errSecDuplicateItem else {
throw TLSError.identity("keychain add failed (OSStatus \(status))") throw TLSError.identity("keychain add key failed (OSStatus \(status))")
} }
var identity: SecIdentity? var identity: SecIdentity?
@@ -91,20 +95,64 @@ enum ClientTLS {
} }
return identity return identity
#else #else
throw TLSError.unsupportedPlatform // Add the key (tagged) and the certificate (matched to it by public key) separately
// a duplicate of either just means a previous fetch already added it.
let addKey: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrApplicationTag: tag,
kSecValueRef: secKey,
]
let keyStatus = SecItemAdd(addKey as CFDictionary, nil)
guard keyStatus == errSecSuccess || keyStatus == errSecDuplicateItem else {
throw TLSError.identity("keychain add key failed (OSStatus \(keyStatus))")
}
let addCert: [CFString: Any] = [
kSecClass: kSecClassCertificate,
kSecValueRef: cert,
]
let certStatus = SecItemAdd(addCert as CFDictionary, nil)
guard certStatus == errSecSuccess || certStatus == errSecDuplicateItem else {
throw TLSError.identity("keychain add certificate failed (OSStatus \(certStatus))")
}
// The system correlates the just-added cert against the tagged key (matching public key)
// and vends the pair as a kSecClassIdentity the tag filter here matches the KEY half.
var identityRef: CFTypeRef?
let query: [CFString: Any] = [
kSecClass: kSecClassIdentity,
kSecAttrApplicationTag: tag,
kSecReturnRef: true,
]
let idStatus = SecItemCopyMatching(query as CFDictionary, &identityRef)
guard idStatus == errSecSuccess, let identityRef else {
throw TLSError.identity("SecItemCopyMatching(kSecClassIdentity) (OSStatus \(idStatus))")
}
// Safe: a kSecClassIdentity query with kSecReturnRef always vends a SecIdentity.
return (identityRef as! SecIdentity) // swiftlint:disable:this force_cast
#endif #endif
} }
} }
/// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client /// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client
/// already trusts) and presents the client identity for the mTLS client-cert challenge. /// already trusts) and presents the client identity for the mTLS client-cert challenge but ONLY
/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this
/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a
/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust
/// evaluation and no client cert exactly what `URLSession.shared` would have done. Without the
/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches
/// the host's) and the client identity would leak to servers that didn't ask for it.
final class LibraryTLSDelegate: NSObject, URLSessionDelegate { final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
private let identity: SecIdentity private let identity: SecIdentity
private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU) private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU)
private let host: String
private let port: Int
init(identity: SecIdentity, pinnedHostFingerprint: Data?) { init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) {
self.identity = identity self.identity = identity
self.pinnedHostFingerprint = pinnedHostFingerprint self.pinnedHostFingerprint = pinnedHostFingerprint
self.host = host
self.port = Int(port)
} }
func urlSession( func urlSession(
@@ -112,11 +160,16 @@ final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) { ) {
switch challenge.protectionSpace.authenticationMethod { let space = challenge.protectionSpace
guard space.host == host, space.port == port else {
completionHandler(.performDefaultHandling, nil)
return
}
switch space.authenticationMethod {
case NSURLAuthenticationMethodServerTrust: case NSURLAuthenticationMethodServerTrust:
// Pin the host cert by fingerprint the host is self-signed (the client trusts it the // Pin the host cert by fingerprint the host is self-signed (the client trusts it the
// same way the QUIC session does). No pin yet (TOFU) accept the presented leaf. // same way the QUIC session does). No pin yet (TOFU) accept the presented leaf.
guard let trust = challenge.protectionSpace.serverTrust, guard let trust = space.serverTrust,
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
else { else {
completionHandler(.cancelAuthenticationChallenge, nil) completionHandler(.cancelAuthenticationChallenge, nil)
@@ -48,4 +48,8 @@ public enum DefaultsKey {
/// Which corner the statistics overlay sits in a `HUDPlacement` raw value /// Which corner the statistics overlay sits in a `HUDPlacement` raw value
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement" public static let hudPlacement = "punktfunk.hudPlacement"
/// iOS/iPadOS: switch the host list and game library to a controller-friendly layout
/// (larger focus targets, a coverflow-style library) whenever a gamepad is connected. On by
/// default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
} }
@@ -0,0 +1,149 @@
// Explicit left-stick/dpad-driven menu navigation for the gamepad UI's host carousel and library
// coverflow (iOS/iPadOS only see GamepadUIEnvironment).
//
// Polls the active controller at 60 Hz rather than installing `valueChangedHandler`/
// `pressedChangedHandler` callbacks mirroring `ControllerTestView`'s "Input" card (see its own
// comment: "Poll the live controller ... no handlers installed"), the one thing in this codebase
// already confirmed on real hardware to read a controller reliably outside a streaming session. Two
// earlier versions of this class both installed handlers directly (first reading the dpad's combined
// `.xAxis`/`.yAxis`, then its discrete `.isPressed` states, matching `GamepadCapture`'s pattern) and
// neither one's callbacks fired on-device even though the SAME controller's input showed up correctly
// in `ControllerTestView`'s poll-based readout so polling isn't just a style choice here, it's the
// only approach confirmed to actually work outside a stream. Being read-only, it also can't conflict
// with `GamepadCapture` installing its own handlers once a stream starts there's nothing to hand
// off or race over.
//
// The button set mirrors a console launcher: A confirms, B backs out, Y is a screen's secondary
// action, and the shoulders (L1/R1) are optional fast "jump" steps. Directional moves auto-repeat
// on a held stick/dpad after an initial delay; every button is edge-triggered (fires once per press).
import Foundation
import GameController
@MainActor
public final class GamepadMenuInput {
public enum Direction: Equatable, Sendable {
case up, down, left, right
}
private let manager: GamepadManager
private var pollTimer: Timer?
private var isActive = false
private var currentDirection: Direction?
private var repeatTimer: Timer?
private var wasConfirmPressed = false
private var wasSecondaryPressed = false
private var wasBackPressed = false
private var wasLeftShoulderPressed = false
private var wasRightShoulderPressed = false
/// Discrete directional move already debounced (fires once on a fresh press, then repeats
/// on a hold after an initial delay, like a standard menu).
public var onMove: ((Direction) -> Void)?
/// Button A (or equivalent primary action) edge-triggered, fires once per press.
public var onConfirm: (() -> Void)?
/// Button Y (or equivalent secondary action, e.g. "open library") edge-triggered.
public var onSecondary: (() -> Void)?
/// Button B (or equivalent back/dismiss) edge-triggered.
public var onBack: (() -> Void)?
/// Shoulder buttons (L1 `false` / R1 `true`) edge-triggered fast-jump steps, optional per
/// screen. Unset the shoulders do nothing.
public var onShoulder: ((Bool) -> Void)?
/// Stick magnitude below this reads as neutral (dead zone).
private let deadzone: Float = 0.5
private let initialRepeatDelay: TimeInterval = 0.38
private let repeatInterval: TimeInterval = 0.16
private let pollInterval: TimeInterval = 1.0 / 60.0
public init(manager: GamepadManager) {
self.manager = manager
}
public func start() {
guard !isActive else { return }
isActive = true
let timer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.poll() }
}
RunLoop.main.add(timer, forMode: .common)
pollTimer = timer
}
public func stop() {
isActive = false
pollTimer?.invalidate()
pollTimer = nil
repeatTimer?.invalidate()
repeatTimer = nil
currentDirection = nil
wasConfirmPressed = false
wasSecondaryPressed = false
wasBackPressed = false
wasLeftShoulderPressed = false
wasRightShoulderPressed = false
}
/// Reads `manager.active` fresh every tick (no persistent binding to a specific controller
/// needed) a disconnect/reconnect or a controller switch is just picked up on the next poll.
private func poll() {
guard isActive, let gamepad = manager.active?.controller.extendedGamepad else { return }
edge(gamepad.buttonA.isPressed, &wasConfirmPressed) { onConfirm?() }
edge(gamepad.buttonY.isPressed, &wasSecondaryPressed) { onSecondary?() }
edge(gamepad.buttonB.isPressed, &wasBackPressed) { onBack?() }
edge(gamepad.leftShoulder.isPressed, &wasLeftShoulderPressed) { onShoulder?(false) }
edge(gamepad.rightShoulder.isPressed, &wasRightShoulderPressed) { onShoulder?(true) }
updateDirection(directionFrom(gamepad))
}
/// Fire `action` on the rising edge of `pressed`, tracking the last state in `was`.
private func edge(_ pressed: Bool, _ was: inout Bool, _ action: () -> Void) {
if pressed, !was { action() }
was = pressed
}
/// The current requested direction: the left stick is the primary/natural input; the dpad is an
/// alternative. Read via discrete `.isPressed` / analog `.value` (never the dpad's combined axis
/// the first version of this class did that and it silently never registered a press on-device).
private func directionFrom(_ gamepad: GCExtendedGamepad) -> Direction? {
let stick = gamepad.leftThumbstick
let x = stick.xAxis.value
let y = stick.yAxis.value
if abs(x) > abs(y), abs(x) > deadzone {
return x > 0 ? .right : .left
} else if abs(y) > deadzone {
return y > 0 ? .up : .down
}
let dpad = gamepad.dpad
if dpad.left.isPressed { return .left }
if dpad.right.isPressed { return .right }
if dpad.up.isPressed { return .up }
if dpad.down.isPressed { return .down }
return nil
}
private func updateDirection(_ direction: Direction?) {
guard direction != currentDirection else { return }
repeatTimer?.invalidate()
repeatTimer = nil
currentDirection = direction
guard let direction else { return }
onMove?(direction)
// First repeat after a longer delay (so a quick tap doesn't double-move), then steady.
let timer = Timer(timeInterval: initialRepeatDelay, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
self.repeatTimer?.invalidate()
let repeating = Timer(timeInterval: self.repeatInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.onMove?(direction) }
}
RunLoop.main.add(repeating, forMode: .common)
self.repeatTimer = repeating
}
}
RunLoop.main.add(timer, forMode: .common)
repeatTimer = timer
}
}
@@ -0,0 +1,20 @@
// Whether the iOS/iPadOS UI should be in its controller-friendly mode (larger focus targets on
// the host grid, the coverflow library browser instead of the plain grid). A pure function, not a
// singleton: the reactivity comes from callers already observing `GamepadManager.shared` and the
// `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same local-read pattern SettingsView
// already uses for GamepadManager), so this stays the single place the two combine without adding
// a second ObservableObject or an environment key nobody else needs.
import Foundation
public enum GamepadUIEnvironment {
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
/// `gamepadConnected` is `GamepadManager.shared.active != nil` active only once a usable
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
/// without a real `GCController` (which XCTest can't construct).
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
enabledSetting && gamepadConnected
}
}
@@ -92,7 +92,8 @@ public enum LibraryClient {
throw LibraryError.unreachable( throw LibraryError.unreachable(
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription) (error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
} }
let delegate = LibraryTLSDelegate(identity: identity, pinnedHostFingerprint: hostFingerprint) let delegate = LibraryTLSDelegate(
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() } defer { session.finishTasksAndInvalidate() }
@@ -108,7 +109,16 @@ public enum LibraryClient {
} }
switch http.statusCode { switch http.statusCode {
case 200: case 200:
return try JSONDecoder().decode([GameEntry].self, from: data) var games = try JSONDecoder().decode([GameEntry].self, from: data)
// Steam art now comes back as host-relative proxy paths (`/api/v1/library/art/...`,
// see the host's `library::steam_art`) so they work the same regardless of which
// interface/port the client reached the host on. Resolve them against THIS host now,
// so every other consumer just sees ordinary absolute URLs.
let base = url
for i in games.indices {
games[i].art = games[i].art.resolved(against: base)
}
return games
case 401: case 401:
throw LibraryError.unauthorized throw LibraryError.unauthorized
default: default:
@@ -116,3 +126,43 @@ public enum LibraryClient {
} }
} }
} }
extension Artwork {
/// Rewrite any host-relative field (one starting with `/`) into an absolute URL against `base`.
/// External CDN URLs (GOG/Heroic/Xbox) and `data:` URLs (Lutris) already don't start with `/`,
/// so they pass through unchanged. `internal` (not `fileprivate`) so `LibraryClientTests` can
/// exercise it directly without a live host.
func resolved(against base: URL) -> Artwork {
func abs(_ s: String?) -> String? {
guard let s, s.hasPrefix("/") else { return s }
return URL(string: s, relativeTo: base)?.absoluteString ?? s
}
var a = self
a.portrait = abs(a.portrait)
a.hero = abs(a.hero)
a.logo = abs(a.logo)
a.header = abs(a.header)
return a
}
}
/// Builds the authenticated `URLSession` the library UI uses to fetch cover-art images the same
/// paired identity + host pinning as [`LibraryClient.fetch`], reused across a whole grid's worth of
/// poster loads (this session is NOT one-shot: callers own its lifetime and should invalidate it
/// when the view goes away). Safe to use for every candidate URL a `GameEntry`'s `Artwork` carries:
/// `LibraryTLSDelegate` only pins/presents-cert for the host itself, deferring to normal system
/// trust + no client cert for any other origin (an external CDN URL).
public enum LibraryImageLoader {
public static func session(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data?
) throws -> URLSession {
let identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
let delegate = LibraryTLSDelegate(
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
return URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
}
}
@@ -0,0 +1,119 @@
// Controller-side haptic feedback for the gamepad menu UI (the host launcher + the library
// coverflow). The couch case is the whole point: the user is holding a game controller, not the
// iPhone/iPad, so a device-only `.sensoryFeedback` tick never reaches their hands this plays a
// short CoreHaptics transient on the ACTIVE controller instead, so a dpad move / launch / end-stop
// is felt on the pad. (The views pair this with `.sensoryFeedback` so a touch/handheld user still
// gets the device Taptic tick; the two are independent channels, and both firing is intended.)
//
// This is menu-only it never runs during a stream (the session's own GamepadFeedback owns the
// controller then), so there's no contention over the pad's haptic engine. Like GamepadMenuInput,
// it reads `GamepadManager.shared.active` fresh and rebuilds its engine when the controller
// changes, so a hot-swapped pad just starts buzzing on the next tick. Everything is best-effort:
// a pad with no haptics (many Xbox pads on iOS, a Siri Remote) silently no-ops.
import CoreHaptics
import Foundation
import GameController
@MainActor
public final class MenuHaptics {
private let manager: GamepadManager
/// The engine for the controller it was built against dropped and rebuilt when `active`
/// changes (identity compare) or after a stop/reset handler fires.
private var engine: CHHapticEngine?
private weak var boundController: GCController?
public init(manager: GamepadManager) {
self.manager = manager
}
/// A light, crisp detent one per menu step. Deliberately tiny so a held direction repeating
/// at ~5 Hz reads as a smooth ratchet rather than a jackhammer.
public func move() {
play(intensity: 0.45, sharpness: 0.75, duration: 0.02)
}
/// A fuller, rounder pulse on confirm/launch the "you did the thing" thunk.
public func confirm() {
play(intensity: 1.0, sharpness: 0.55, duration: 0.055)
}
/// A soft, dull bump when a move is refused at the end of a non-wrapping list low sharpness so
/// it feels like hitting a wall, distinct from the crisp `move()` detent.
public func boundary() {
play(intensity: 0.7, sharpness: 0.18, duration: 0.06)
}
/// Release the engine and forget the controller call on the menu screen's disappear so the
/// pad's haptic engine isn't held open while streaming or on the touch UI.
public func stop() {
engine?.stop(completionHandler: nil)
engine = nil
boundController = nil
}
/// Fire a single transient. Rebuilds the engine against the current active controller if it
/// changed; swallows every failure (a pad without a haptics engine, a transient XPC hiccup)
/// menu haptics are a nicety, never a correctness path.
private func play(intensity: Float, sharpness: Float, duration: TimeInterval) {
guard let controller = manager.active?.controller else {
// No pad (or a non-forwardable one): nothing to buzz. Drop any stale engine.
if boundController != nil { stop() }
return
}
guard let engine = engine(for: controller) else { return }
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
],
relativeTime: 0,
duration: duration)
do {
let player = try engine.makePlayer(with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: CHHapticTimeImmediate)
} catch {
// The engine went stale between builds (stopped/reset). Drop it; the next tick rebuilds.
self.engine = nil
boundController = nil
}
}
/// The started engine for `controller`, (re)built on first use or after a controller swap.
private func engine(for controller: GCController) -> CHHapticEngine? {
if let engine, boundController === controller { return engine }
engine?.stop(completionHandler: nil)
engine = nil
boundController = nil
guard let built = controller.haptics?.createEngine(withLocality: .default) else { return nil }
// Menu ticks carry no audio keep the engine out of the app's audio session (the same
// discipline the session RumbleRenderer uses).
built.playsHapticsOnly = true
// The haptic server can pull the engine out from under us (backgrounding, an audio
// interruption, a controller drop); drop our reference so the next tick lazily rebuilds
// rather than throwing forever.
built.stoppedHandler = { [weak self] _ in
Task { @MainActor in self?.dropEngine(if: controller) }
}
built.resetHandler = { [weak self] in
Task { @MainActor in self?.dropEngine(if: controller) }
}
do {
try built.start()
} catch {
return nil
}
engine = built
boundController = controller
return built
}
/// Drop the cached engine only if it's still the one for `controller` a handler firing after a
/// swap must not clobber the freshly built engine for the new pad.
private func dropEngine(if controller: GCController) {
guard boundController === controller else { return }
engine = nil
boundController = nil
}
}
@@ -154,13 +154,18 @@ private func wireChannelLayout(channels: Int) -> AVAudioChannelLayout? {
layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions
layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0) layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0)
layout.pointee.mNumberChannelDescriptions = UInt32(labels.count) layout.pointee.mNumberChannelDescriptions = UInt32(labels.count)
let descs = UnsafeMutableBufferPointer( // `mChannelDescriptions` is the C variable-length tail array (declared `[1]`, over-allocated
start: &layout.pointee.mChannelDescriptions, count: labels.count) // above). Scope the pointer with `withUnsafeMutablePointer` taking `&mChannelDescriptions`
// inline yields a pointer valid only for that expression, so building a buffer from it that
// outlives the call is a dangling-pointer bug. Inside the closure it stays valid while we fill it.
withUnsafeMutablePointer(to: &layout.pointee.mChannelDescriptions) { tail in
let descs = UnsafeMutableBufferPointer(start: tail, count: labels.count)
for (i, lbl) in labels.enumerated() { for (i, lbl) in labels.enumerated() {
descs[i] = AudioChannelDescription( descs[i] = AudioChannelDescription(
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0), mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
mCoordinates: (0, 0, 0)) mCoordinates: (0, 0, 0))
} }
}
return AVAudioChannelLayout(layout: layout) return AVAudioChannelLayout(layout: layout)
} }
@@ -0,0 +1,14 @@
// GamepadUIEnvironment.isActive is a pure AND table-tested exhaustively over its 2x2 inputs.
import XCTest
@testable import PunktfunkKit
final class GamepadUIEnvironmentTests: XCTestCase {
func testActiveOnlyWhenEnabledAndConnected() {
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
}
}
@@ -60,4 +60,22 @@ final class LibraryClientTests: XCTestCase {
XCTAssertTrue(Artwork().posterCandidates.isEmpty) XCTAssertTrue(Artwork().posterCandidates.isEmpty)
} }
func testArtworkResolvedRewritesOnlyHostRelativePaths() {
let base = URL(string: "https://192.168.1.70:47990/api/v1/library")!
// Steam art now comes back as host-relative proxy paths; external CDN URLs (GOG/Heroic/Xbox)
// and `data:` URLs (Lutris) are untouched.
let art = Artwork(
portrait: "/api/v1/library/art/steam:3527290/portrait",
hero: "https://cdn.example.com/hero.jpg",
logo: nil,
header: "/api/v1/library/art/steam:3527290/header")
let resolved = art.resolved(against: base)
XCTAssertEqual(
resolved.portrait, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/portrait")
XCTAssertEqual(
resolved.header, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/header")
XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged
XCTAssertNil(resolved.logo)
}
} }
+2
View File
@@ -62,6 +62,8 @@ utoipa-scalar = { version = "0.3", features = ["axum"] }
# Drive the management API router in-process (no socket) in the handler tests. # Drive the management API router in-process (no socket) in the handler tests.
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
tempfile = "3"
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround # Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The # (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
+182 -9
View File
@@ -114,18 +114,129 @@ impl LibraryProvider for SteamProvider {
} }
} }
/// The Steam CDN poster/hero/logo/header for an appid (public, no auth). Not every appid has a /// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve
/// their CDN assets from a per-asset-hash path the client can't predict (e.g.
/// `.../apps/<id>/<hash>/header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them —
/// [`steam_art_bytes`] is the robust resolver: local Steam cache (exact, no guessing) first, the
/// flat CDN URL as a fallback (still correct for the many titles that haven't been re-hashed).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArtKind {
Portrait,
Hero,
Logo,
Header,
}
impl ArtKind {
pub fn parse(s: &str) -> Option<Self> {
match s {
"portrait" => Some(Self::Portrait),
"hero" => Some(Self::Hero),
"logo" => Some(Self::Logo),
"header" => Some(Self::Header),
_ => None,
}
}
/// Filenames Steam itself caches this kind under in `appcache/librarycache/<appid>/<hash>/`,
/// tried in order (the 2x portrait, when present, is the sharper asset).
fn local_filenames(self) -> &'static [&'static str] {
match self {
Self::Portrait => &["library_600x900_2x.jpg", "library_600x900.jpg"],
Self::Hero => &["library_hero.jpg"],
Self::Logo => &["logo.png"],
// Steam's local cache names the header asset differently from the store CDN's
// `header.jpg` (see `cdn_filename`).
Self::Header => &["library_header.jpg"],
}
}
/// The legacy flat-URL filename on the public Steam CDN (works for any title the CDN hasn't
/// migrated to a per-asset hash path).
fn cdn_filename(self) -> &'static str {
match self {
Self::Portrait => "library_600x900.jpg",
Self::Hero => "library_hero.jpg",
Self::Logo => "logo.png",
Self::Header => "header.jpg",
}
}
}
/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves
/// against the host it just talked to (so they work the same whichever interface/port the client
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it. /// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
fn steam_art(appid: u32) -> Artwork { fn steam_art(appid: u32) -> Artwork {
let base = format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}"); let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
Artwork { Artwork {
portrait: Some(format!("{base}/library_600x900.jpg")), portrait: url("portrait"),
hero: Some(format!("{base}/library_hero.jpg")), hero: url("hero"),
logo: Some(format!("{base}/logo.png")), logo: url("logo"),
header: Some(format!("{base}/header.jpg")), header: url("header"),
} }
} }
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
/// literally what the user's Steam client already shows for this title), the legacy flat CDN URL
/// as a fallback. `None` when neither has it (the client then falls through to its next art
/// candidate). Blocking (disk + network) — call off the async runtime.
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_local_art_bytes(appid, kind).or_else(|| {
let url = format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
kind.cdn_filename()
);
fetch_image(&url)
})
}
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
/// (these run well under 2 MiB in practice) while bounding a pathological file.
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
/// version, so there's normally exactly one candidate per kind).
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_roots()
.into_iter()
.find_map(|root| find_local_art_file(&root, appid, kind))
.and_then(|path| {
let bytes = std::fs::read(&path).ok()?;
let ctype = if path.extension().is_some_and(|e| e == "png") {
"image/png"
} else {
"image/jpeg"
};
Some((bytes, ctype.to_string()))
})
}
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
/// directory fixture.
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
let cache_dir = root
.join("appcache")
.join("librarycache")
.join(appid.to_string());
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
for hash_dir in hash_dirs.flatten() {
for name in kind.local_filenames() {
let path = hash_dir.path().join(name);
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
return Some(path);
}
}
}
None
}
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped. /// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn steam_roots() -> Vec<PathBuf> { fn steam_roots() -> Vec<PathBuf> {
@@ -1166,6 +1277,22 @@ fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the /// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`). /// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> { pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
// those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
{
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
let g = all_games().into_iter().find(|g| g.id == id)?; let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo] [g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter() .into_iter()
@@ -1635,13 +1762,59 @@ mod tests {
} }
#[test] #[test]
fn steam_art_uses_cdn_by_appid() { fn steam_art_points_at_the_host_art_proxy() {
let art = steam_art(570); let art = steam_art(570);
assert_eq!( assert_eq!(
art.portrait.as_deref(), art.portrait.as_deref(),
Some("https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg") Some("/api/v1/library/art/steam:570/portrait")
); );
assert!(art.header.unwrap().ends_with("/570/header.jpg")); assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
}
#[test]
fn art_kind_parses_known_names_only() {
assert_eq!(ArtKind::parse("portrait"), Some(ArtKind::Portrait));
assert_eq!(ArtKind::parse("hero"), Some(ArtKind::Hero));
assert_eq!(ArtKind::parse("logo"), Some(ArtKind::Logo));
assert_eq!(ArtKind::parse("header"), Some(ArtKind::Header));
assert_eq!(ArtKind::parse("background"), None);
}
#[test]
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
let dir = tempfile::tempdir().unwrap();
let cache = dir
.path()
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900.jpg"));
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
assert_eq!(
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
None
);
assert_eq!(
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
None
);
}
#[test]
fn find_local_art_file_prefers_the_2x_portrait() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("appcache/librarycache/570/somehash");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
} }
#[cfg(not(windows))] #[cfg(not(windows))]
+57 -2
View File
@@ -171,6 +171,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(get_library)) .routes(routes!(get_library))
.routes(routes!(create_custom_game)) .routes(routes!(create_custom_game))
.routes(routes!(update_custom_game, delete_custom_game)) .routes(routes!(update_custom_game, delete_custom_game))
.routes(routes!(get_library_art))
.routes(routes!(stats_capture_start)) .routes(routes!(stats_capture_start))
.routes(routes!(stats_capture_stop)) .routes(routes!(stats_capture_stop))
.routes(routes!(stats_capture_status)) .routes(routes!(stats_capture_status))
@@ -544,7 +545,7 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
/// edit the library). `/health` is handled separately (always open). /// edit the library). `/health` is handled separately (always open).
fn cert_may_access(method: &Method, path: &str) -> bool { fn cert_may_access(method: &Method, path: &str) -> bool {
method == Method::GET method == Method::GET
&& matches!( && (matches!(
path, path,
"/api/v1/host" "/api/v1/host"
| "/api/v1/compositors" | "/api/v1/compositors"
@@ -555,7 +556,7 @@ fn cert_may_access(method: &Method, path: &str) -> bool {
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact // library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
// GET-path match above. // GET-path match above.
| "/api/v1/library" | "/api/v1/library"
) ) || path.starts_with("/api/v1/library/art/"))
} }
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the /// Compare SHA-256 digests instead of the strings — constant-time with respect to the
@@ -1276,6 +1277,45 @@ async fn delete_custom_game(Path(id): Path<String>) -> Response {
} }
} }
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
/// Only Steam ids are backed today; any other store 404s.
#[utoipa::path(
get,
path = "/library/art/{id}/{kind}",
tag = "library",
operation_id = "getLibraryArt",
params(
("id" = String, Path, description = "The store-qualified library id, e.g. `steam:570`"),
("kind" = String, Path, description = "`portrait` | `hero` | `logo` | `header`"),
),
responses(
(status = OK, description = "Image bytes", content_type = "image/jpeg"),
(status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError),
(status = NOT_FOUND, description = "No art of that kind for that id", body = ApiError),
)
)]
async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> Response {
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
};
let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
else {
return api_error(StatusCode::NOT_FOUND, "no art proxy for this store");
};
match tokio::task::spawn_blocking(move || crate::library::steam_art_bytes(appid, kind)).await {
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
}
}
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
// Streaming stats capture (design/stats-capture-plan.md §2) // Streaming stats capture (design/stats-capture-plan.md §2)
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
@@ -1694,6 +1734,21 @@ mod tests {
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"a paired cert must reach the library from a LAN peer" "a paired cert must reach the library from a LAN peer"
); );
// The per-image art proxy (`/api/v1/library/art/{id}/{kind}`) is a prefix match in
// `cert_may_access`, not an exact one (dynamic id/kind segments) — exercise it directly. An
// unknown `kind` 404s before any disk/network I/O, so this stays a fast, deterministic check
// of the auth gate (not of art resolution, which `library::tests` covers).
let mut req = get_req("/api/v1/library/art/steam:570/not-a-real-kind");
req.extensions_mut().insert(PeerAddr(lan));
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
assert_eq!(
app.clone().oneshot(req).await.expect("infallible").status(),
StatusCode::NOT_FOUND,
"a paired cert must reach the per-image library art proxy from a LAN peer \
(and an unknown kind 404s, rather than ever being rejected as unauthorized)"
);
} }
#[tokio::test] #[tokio::test]
+8 -3
View File
@@ -178,16 +178,21 @@ forest. (`build-web.ps1` is the dev-box rebuild-and-restart helper.)
## 8. CI workflows (`.gitea/workflows/`) ## 8. CI workflows (`.gitea/workflows/`)
All run on the single self-hosted `windows-amd64` runner (`home-windows-1`), which **serializes** the All run on a self-hosted `windows-amd64` runner (provisioned by unom/infra's `windows-runner/`
whole Windows fleet - a `Cargo.lock`/`packaging/windows/**` touch queues several builds back-to-back. Packer template + Terraform clone, `home-windows-runner-1`), which **serializes** the whole
Windows fleet - a `Cargo.lock`/`packaging/windows/**` touch queues several builds back-to-back.
| Workflow | Trigger | Does | | Workflow | Trigger | Does |
|----------|---------|------| |----------|---------|------|
| `windows-host.yml` | `crates/punktfunk-host`, `packaging/windows`, `scripts/windows`, `web`, tags `v*` | build host + clippy + HDR layer + web smoke-boot -> pack + sign installer -> publish (canary/latest) | | `windows-host.yml` | `crates/punktfunk-host`, `packaging/windows`, `scripts/windows`, `web`, tags `v*` | build host + clippy + HDR layer + web smoke-boot -> pack + sign installer -> publish (canary/latest) |
| `windows-drivers.yml` | `packaging/windows/drivers`, `crates/pf-driver-proto` | probe the driver toolchain + build/test/clippy `pf-driver-proto` + `cargo build` the driver workspace + inspect FORCE_INTEGRITY (the fast driver-only gate; coverage the pack lacks) | | `windows-drivers.yml` | `packaging/windows/drivers`, `crates/pf-driver-proto` | probe the driver toolchain + build/test/clippy `pf-driver-proto` + `cargo build` the driver workspace + inspect FORCE_INTEGRITY (the fast driver-only gate; coverage the pack lacks) |
| `windows-drivers-provision.yml` | `provision-windows-wdk.ps1` | one-shot WDK + cargo-wdk provisioning onto the persistent runner |
| `windows.yml` / `windows-msix.yml` | client | build the Windows *client* + its signed MSIX (x64 + ARM64) | | `windows.yml` / `windows-msix.yml` | client | build the Windows *client* + its signed MSIX (x64 + ARM64) |
Every workflow above self-provisions its own toolchain at job start via `scripts/ci/
ensure-windows-toolchain.ps1` (WDK/cargo-wdk, FFmpeg, Inno Setup, the ARM64 rustup target) - a
fast no-op once already present, so no separate one-shot provisioning workflow/dispatch step is
needed, and it works the same on any runner sharing the `windows-amd64` label.
`windows-host.yml` also builds the drivers from source (in pack), so it overlaps `windows-drivers.yml` on `windows-host.yml` also builds the drivers from source (in pack), so it overlaps `windows-drivers.yml` on
a `drivers/**` edit (two driver builds on the serialized runner). They're kept separate on purpose - a `drivers/**` edit (two driver builds on the serialized runner). They're kept separate on purpose -
`windows-drivers.yml` is the fast pre-pack gate. **CI builds, never launches the exe** (no GPU on the `windows-drivers.yml` is the fast pre-pack gate. **CI builds, never launches the exe** (no GPU on the
+4 -3
View File
@@ -250,9 +250,10 @@ then restore + `git worktree remove`. Drive over ssh via `powershell -EncodedCom
The persistent build validator is the **windows-amd64 CI runner** (no GPU — fine for builds / `iddcx` The persistent build validator is the **windows-amd64 CI runner** (no GPU — fine for builds / `iddcx`
link / `/INTEGRITYCHECK` self-sign / the surface-asserts; live NVENC encode + on-glass defers to the RTX link / `/INTEGRITYCHECK` self-sign / the surface-asserts; live NVENC encode + on-glass defers to the RTX
box). Workflows: `windows-host.yml` (the host installer), `windows-drivers.yml` (the driver workspace box). Workflows: `windows-host.yml` (the host installer), `windows-drivers.yml` (the driver workspace
build + FORCE_INTEGRITY clear), `windows-drivers-provision.yml` (WDK/LLVM toolchain), `windows-msix.yml` build + FORCE_INTEGRITY clear; self-provisions the WDK/LLVM toolchain via `scripts/ci/
(the client). A single Windows runner serializes the whole fleet; a `Cargo.toml` touch costs ~25 min of ensure-windows-toolchain.ps1`), `windows-msix.yml` (the client). A single Windows runner serializes
queue, so driver pushes that avoid `Cargo.toml` skip the fleet serialization. the whole fleet; a `Cargo.toml` touch costs ~25 min of queue, so driver pushes that avoid
`Cargo.toml` skip the fleet serialization.
Local pre-push checks (this Linux box can't compile the Windows paths): Local pre-push checks (this Linux box can't compile the Windows paths):
```sh ```sh
Regular → Executable
View File
+21
View File
@@ -0,0 +1,21 @@
# Idempotent pre-flight for punktfunk's Windows CI dependencies: WDK + cargo-wdk (driver builds),
# FFmpeg x64/ARM64 trees, Inno Setup, and the aarch64-pc-windows-msvc rustup target. Run at the
# start of every Windows CI job so ANY runner - freshly built from unom/infra's windows-runner/
# template, rebuilt, or a new one added later - self-provisions on first real use, instead of
# needing a human to remember to dispatch a separate provisioning workflow first (and instead of
# racing which runner a manually-dispatched provisioning workflow happens to land on, when more
# than one Windows runner shares the windows-amd64 label).
#
# Each underlying script already does its own existence checks (Test-Path/Get-Command) before
# installing anything, so this is a fast no-op on a runner that's already fully provisioned - the
# only cost is on a genuinely fresh box, where it's the first job's problem to pay once.
$ErrorActionPreference = "Stop"
trap {
Write-Host "FATAL: $_"
Write-Host $_.ScriptStackTrace
exit 1
}
$ciDir = $PSScriptRoot
& "$ciDir\provision-windows-wdk.ps1"
& "$ciDir\provision-windows-punktfunk-extras.ps1"
@@ -0,0 +1,73 @@
# Layers punktfunk-specific tooling onto the shared unom Windows CI runner: per-arch FFmpeg
# (host + client native builds), Inno Setup (the host installer), and the aarch64-pc-windows-msvc
# rustup target (windows-msix.yml's ARM64 leg). The runner itself - act_runner, Node, rustup,
# VS Build Tools/NASM/CMake/LLVM - is provisioned generically by unom/infra
# (windows-runner/windows-runner.pkr.hcl + proxmox/windows-runner's Terraform clone); this script
# is what punktfunk adds on top, since FFmpeg/Inno Setup/the ARM64 target aren't every project's
# concern. See also provision-windows-wdk.ps1 for the driver-build toolchain (also punktfunk-only).
#
# Idempotent - safe to re-run. Run ELEVATED (admin) on the runner.
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
function info($m) { Write-Host "[provision-punktfunk-extras] $m" }
$env:RUSTUP_HOME = "C:\Users\Public\.rustup"
$env:CARGO_HOME = "C:\Users\Public\.cargo"
# --- ARM64 cross-compile target (windows.yml / windows-msix.yml build aarch64-pc-windows-msvc off
# this x64 box; the ARM64 MSVC cross compiler itself comes from unom/infra's generic VS Build
# Tools provisioning, which already includes the ARM64 component). ---
$rustup = "C:\Users\Public\.cargo\bin\rustup.exe"
if (Test-Path $rustup) {
info "rustup target add aarch64-pc-windows-msvc"
& $rustup target add aarch64-pc-windows-msvc
} else {
Write-Warning "rustup not found at $rustup - has unom/infra's setup-gitea-runner-base.ps1 run on this box yet?"
}
# --- FFmpeg shared trees for the host (amf-qsv encode) + clients (decode). BtbN **lgpl-shared**
# builds: the AMD/Intel AMF + Intel QSV encoders, swscale, and the HEVC decoder are all present in
# the LGPL build, and punktfunk never calls the GPL-only encoders (x264/x265 - software encode is
# the separate BSD-2 openh264 crate; NVENC is the direct NVIDIA SDK). lgpl-shared keeps the
# bundled DLLs LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the
# shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture.
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be
# re-provisioned - delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run.
function Get-BtbnFfmpeg {
param([string]$Dir, [string]$ZipTag) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { info "FFmpeg ($ZipTag) already present at $Dir"; return }
info "fetching FFmpeg ($ZipTag, BtbN lgpl-shared)"
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
$zip = "$Dir.zip"; $tmp = "$Dir-extract"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir }
Move-Item -Path $inner.FullName -Destination $Dir
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64'
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64'
# --- 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) {
info "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." }
}
# --- Drop punktfunk's env vars into the generic runner's daemon wrapper extension point (see
# unom/infra's scripts/setup-gitea-runner-base.ps1) so the act_runner daemon - and therefore every
# job it runs - sees FFMPEG_DIR without unom/infra needing to know punktfunk exists. ---
$projectEnv = "C:\Users\Public\act-runner\project-env.ps1"
@'
$env:FFMPEG_DIR = "C:\Users\Public\ffmpeg"
$env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH
'@ | Set-Content -Encoding UTF8 $projectEnv
info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up"
info "punktfunk extras provisioned OK."
+5 -2
View File
@@ -7,8 +7,11 @@
# Idempotent: skips the WDK install if the km/wdf headers are already present, and cargo-wdk if already # Idempotent: skips the WDK install if the km/wdf headers are already present, and cargo-wdk if already
# installed. Safe to run repeatedly. Runs non-interactively (/q /norestart) — never auto-reboots. # installed. Safe to run repeatedly. Runs non-interactively (/q /norestart) — never auto-reboots.
# #
# Invoked by .gitea/workflows/windows-drivers-provision.yml (workflow_dispatch) and referenced by # Invoked by scripts/ci/ensure-windows-toolchain.ps1, the shared self-provision step every Windows
# scripts/ci/setup-windows-runner.ps1. Run as the runner's account (SYSTEM) with admin rights. # CI workflow runs at job start (windows-drivers.yml, windows.yml, windows-msix.yml,
# windows-host.yml), on top of the generic runner unom/infra provisions (windows-runner/) and
# provision-windows-punktfunk-extras.ps1's FFmpeg/Inno Setup/ARM64-target layer. Run as the
# runner's account (SYSTEM) with admin rights.
[CmdletBinding()] [CmdletBinding()]
param( param(
# WDK 26100 standalone bootstrapper. Source: https://learn.microsoft.com/windows-hardware/drivers/download-the-wdk # WDK 26100 standalone bootstrapper. Source: https://learn.microsoft.com/windows-hardware/drivers/download-the-wdk
-180
View File
@@ -1,180 +0,0 @@
# 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:
#
# ssh "<box>" 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\path\setup-windows-runner.ps1 -Token <registration token>'
#
# Installs: the act_runner (gitea-runner) binary in **host mode** (jobs run directly on Windows,
# no containers — MSVC/WinUI builds need the host toolchain), Node 20 via the box's nvm4w (JS
# actions like actions/checkout run via node on PATH), and a SYSTEM scheduled task that keeps the
# daemon alive across reboots with nobody logged in. Registration happens once (.runner file); the
# token is NOT persisted.
#
# Get a **GLOBAL** registration token: Gitea **Site Administration -> Actions -> Runners** (the
# registration token shown there). The runner MUST be global/instance-scoped to pick up org-repo
# jobs like unom/punktfunk — an org- or repo-scoped token leaves it registered but unmatchable
# ("no fitting runner for windows-amd64", even though the runner shows idle). Mirrors the Linux
# runner's scope.
#
# Env/param knobs: -Instance (default https://git.unom.io), -Token (GITEA_RUNNER_TOKEN; required
# for first registration), -RunnerName (default COMPUTERNAME), -Labels (default windows-amd64:host
# — match the Windows job's runs-on), -Version (act_runner, default 1.0.8).
#
# The daemon's env wrapper hard-codes this box's MSVC build paths (cargo/rustup, NASM, CMake, LLVM,
# FFmpeg, the ASCII CARGO_HOME that SDL3's PCH needs) so the Windows workflow inherits a working
# toolchain without re-deriving dev-box specifics. Per-checkout vars (CARGO_WORKSPACE_DIR for the
# windows-reactor build.rs) are set by the workflow, not here.
param(
[string]$Instance = $(if ($env:GITEA_INSTANCE) { $env:GITEA_INSTANCE } else { "https://git.unom.io" }),
[string]$Version = $(if ($env:ACT_RUNNER_VERSION) { $env:ACT_RUNNER_VERSION } else { "1.0.8" }),
[string]$RunnerName = $(if ($env:RUNNER_NAME) { $env:RUNNER_NAME } else { $env:COMPUTERNAME }),
[string]$Labels = $(if ($env:RUNNER_LABELS) { $env:RUNNER_LABELS } else { "windows-amd64:host" }),
[string]$Token = $env:GITEA_RUNNER_TOKEN
)
$ErrorActionPreference = "Stop"
$RunnerHome = "C:\Users\Public\act-runner"
$Exe = "$RunnerHome\act_runner.exe"
New-Item -ItemType Directory -Force -Path $RunnerHome | Out-Null
# --- act_runner binary (gitea-runner; CLI surface unchanged from act_runner) ---
$need = $true
if (Test-Path $Exe) { try { $need = -not ((& $Exe --version 2>$null) -match [regex]::Escape($Version)) } catch { } }
if ($need) {
$url = "https://dl.gitea.com/gitea-runner/$Version/gitea-runner-$Version-windows-amd64.exe"
Write-Host "==> downloading act_runner $Version"
Invoke-WebRequest -Uri $url -OutFile "$Exe.tmp" -UseBasicParsing
Move-Item -Force "$Exe.tmp" $Exe
}
& $Exe --version
# --- Node 20 (actions/checkout@v4 demands node20) via the box's nvm4w ---
if (Get-Command nvm -ErrorAction SilentlyContinue) {
if (-not ((node --version 2>$null) -match "^v20")) {
nvm install 20.18.0 | Out-Null
nvm use 20.18.0 | Out-Null
}
}
Write-Host "node $(node --version)"
# --- config + host-mode labels (empty the docker defaults so .runner's labels rule) ---
Push-Location $RunnerHome
if (-not (Test-Path config.yaml)) { & $Exe generate-config | Set-Content -Encoding ASCII config.yaml }
(Get-Content config.yaml) |
Where-Object { $_ -notmatch "docker.gitea.com/runner-images" } |
ForEach-Object { $_ -replace '^(\s*)labels:\s*$', '${1}labels: []' } |
Set-Content -Encoding ASCII config.yaml
Pop-Location
# --- one-time registration (from $RunnerHome: register writes .runner to the CWD) ---
if (-not (Test-Path "$RunnerHome\.runner")) {
if (-not $Token) {
Write-Warning "Not registered yet. Re-run with -Token <GLOBAL registration token>."
Write-Host " (Gitea: Site Administration -> Actions -> Runners -> registration token; must be GLOBAL scope)"
exit 1
}
Push-Location $RunnerHome
& $Exe register --no-interactive --instance $Instance --token $Token --name $RunnerName --labels $Labels
Pop-Location
}
# rustup toolchains under an ASCII path so nothing in the daemon env carries the non-ASCII
# username (the same hazard that breaks SDL3's PCH; here it also keeps this script ASCII-clean).
if (-not (Test-Path "C:\Users\Public\.rustup\settings.toml")) {
Write-Host "==> copying rustup toolchains to an ASCII path"
robocopy "$env:USERPROFILE\.rustup" "C:\Users\Public\.rustup" /E /NFL /NDL /NJH /NJS /MT:16 | Out-Null
}
# --- ARM64 cross-compile support (windows.yml / windows-msix.yml build aarch64-pc-windows-msvc off
# this x64 box; no ARM64 runner needed). Two pieces beyond the x64 toolchain:
# 1. the rustup std for the target;
# 2. an ARM64 FFmpeg import-lib/DLL tree at C:\Users\Public\ffmpeg-arm64 (the workflow matrix
# points FFMPEG_DIR here for the aarch64 leg; the x64 tree stays at C:\Users\Public\ffmpeg).
# The x64 MSVC toolset already ships the ARM64 cross compiler — if
# VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe is missing, add the VS "MSVC v143+ ARM64/ARM64EC
# build tools" + "C++ ARM64 build tools" workload components (the cc/cmake crates need it to
# cross-build SDL3 + libopus).
$env:RUSTUP_HOME = "C:\Users\Public\.rustup"
$env:CARGO_HOME = "C:\Users\Public\.cargo"
$rustup = (Get-Command rustup -ErrorAction SilentlyContinue).Source
if (-not $rustup) { $rustup = "C:\Users\Public\.cargo\bin\rustup.exe" }
if (Test-Path $rustup) {
Write-Host "==> rustup target add aarch64-pc-windows-msvc"
& $rustup target add aarch64-pc-windows-msvc
} else { Write-Warning "rustup not found - install rustup then re-run (needed for the aarch64 target)." }
# FFmpeg shared trees for the host (amf-qsv encode) + clients (decode). We use BtbN **lgpl-shared**
# builds: the AMD/Intel AMF + Intel QSV encoders, swscale, and the HEVC decoder are all present in the
# LGPL build, and punktfunk never calls the GPL-only encoders (x264/x265 — software encode is the
# separate BSD-2 openh264 crate; NVENC is the direct NVIDIA SDK). lgpl-shared keeps the bundled DLLs
# LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the shipped installer/MSIX
# stay consistent with punktfunk's MIT OR Apache-2.0 posture.
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be re-provisioned —
# delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run this script.
function Get-BtbnFfmpeg {
param([string]$Dir, [string]$ZipTag) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { return }
# FFmpeg 7.x (avcodec-61); MSVC-linkable .lib import libs + headers + bin\*.dll — exactly what
# ffmpeg-sys-next + pack-host-installer.ps1 + pack-msix.ps1 consume. The extracted top-level folder
# also carries FFmpeg's own LICENSE/COPYING text, preserved in $Dir for the packagers to bundle.
Write-Host "==> fetching FFmpeg ($ZipTag, BtbN lgpl-shared)"
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
$zip = "$Dir.zip"; $tmp = "$Dir-extract"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir }
Move-Item -Path $inner.FullName -Destination $Dir
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
# x64 host+client tree (the workflow's default FFMPEG_DIR = C:\Users\Public\ffmpeg) and the ARM64 cross
# tree (the aarch64 leg points FFMPEG_DIR at C:\Users\Public\ffmpeg-arm64).
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64'
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64'
# 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"
@'
$env:NO_COLOR = "1"
$env:CARGO_HOME = "C:\Users\Public\.cargo"
$env:RUSTUP_HOME = "C:\Users\Public\.rustup"
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
$env:LIBCLANG_PATH = "C:\Program Files\LLVM\bin"
$env:FFMPEG_DIR = "C:\Users\Public\ffmpeg"
$env:PATH = "C:\Program Files\PowerShell\7;C:\Users\Public\.cargo\bin;C:\nvm4w\nodejs;C:\Program Files\NASM;C:\Program Files\CMake\bin;C:\Program Files\LLVM\bin;C:\Users\Public\ffmpeg\bin;" + $env:PATH
Set-Location "C:\Users\Public\act-runner"
# cmd-level redirect (>>, 2>&1) keeps the daemon's native stderr out of PowerShell's error stream.
& cmd /c "act_runner.exe daemon --config config.yaml >> runner.log 2>&1"
'@ | Set-Content -Encoding UTF8 $wrapper
# --- SYSTEM scheduled task: keep the daemon alive across reboots, no login needed ---
$taskName = "gitea-act-runner"
if (schtasks /Query /TN $taskName 2>$null) {
schtasks /End /TN $taskName 2>$null | Out-Null
schtasks /Delete /TN $taskName /F | Out-Null
}
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$wrapper`""
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 4
Write-Host "==> runner '$RunnerName' labels=$Labels instance=$Instance"
$p = Get-Process act_runner -ErrorAction SilentlyContinue
if ($p) { Write-Host "daemon running (pid $($p.Id), session $($p.SessionId))" }
else { Write-Warning "daemon not running yet - check the gitea-act-runner task" }