Compare commits
@@ -31,6 +31,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Publish the SIGNED stable update manifest — the moment every host's update check learns
|
||||
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
|
||||
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
|
||||
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
|
||||
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
|
||||
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
|
||||
- name: Publish the stable update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
VER="${TAG#v}"
|
||||
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
|
||||
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
grep -qi "$SHA" /tmp/installer.sha256 || {
|
||||
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
|
||||
exit 1
|
||||
}
|
||||
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
- name: Post release announcement to Discord
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -348,6 +348,21 @@ jobs:
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "web console smoke (bun): /login -> $code"
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
|
||||
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
|
||||
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
|
||||
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
|
||||
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
|
||||
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
|
||||
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
|
||||
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
|
||||
# The throw is the point: never silently ship a console-less installer again.
|
||||
- name: Export the console output dir (cache hit or fresh build)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path 'web\.output\server\index.mjs')) {
|
||||
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
|
||||
}
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build plugin/script runner bundle (bun)
|
||||
@@ -368,16 +383,40 @@ jobs:
|
||||
}
|
||||
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
# The UMDF drivers build IN-TREE inside the pack step (a relocated CARGO_TARGET_DIR
|
||||
# breaks wdk-build's manifest walk), and checkout's clean wipes that tree every run —
|
||||
# ~1 min of rebuild for crates that rarely change. Cache the in-tree target; cargo's
|
||||
# own fingerprints decide what's still fresh after a restore.
|
||||
- name: Cache drivers workspace target (built in-tree by the pack step)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: packaging/windows/drivers/target
|
||||
key: drivers-target-${{ hashFiles('packaging/windows/drivers/**', 'crates/pf-driver-proto/**') }}
|
||||
restore-keys: drivers-target-
|
||||
# NOT cached, and it must stay that way: the UMDF drivers build IN-TREE inside the pack
|
||||
# step (a relocated CARGO_TARGET_DIR breaks wdk-build's manifest walk), and act rotates
|
||||
# the job workspace path (~/.cache/act/<hash>/hostexecutor) between runs. A cargo target
|
||||
# dir restored under a DIFFERENT absolute path brings state that points at the old one:
|
||||
# measured 2026-07-30, `pf-umdf-util` died with 14 × "unable to create file lock (os
|
||||
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
|
||||
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
|
||||
# workspace-relative target.
|
||||
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
|
||||
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
|
||||
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
|
||||
# all of these, so here a missing input is a build failure rather than a quietly smaller
|
||||
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
|
||||
- name: Verify every installer payload is present
|
||||
shell: pwsh
|
||||
run: |
|
||||
$need = @(
|
||||
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
|
||||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||||
)
|
||||
$missing = @()
|
||||
foreach ($x in $need) {
|
||||
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
|
||||
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
|
||||
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
|
||||
else { Write-Output "payload OK - $($x.n) -> $full" }
|
||||
}
|
||||
if ($missing.Count) {
|
||||
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
|
||||
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
|
||||
}
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
@@ -463,6 +502,36 @@ jobs:
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
|
||||
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
|
||||
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
|
||||
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
|
||||
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
|
||||
canary-manifest:
|
||||
needs: package
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish the canary update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
|
||||
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
|
||||
@@ -139,16 +139,20 @@ jobs:
|
||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||
# for the shell, which has no features).
|
||||
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
|
||||
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
|
||||
# RUN the binary (help contract), as the session's contract_smoke runs the session —
|
||||
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
|
||||
- name: Build
|
||||
shell: pwsh
|
||||
run: |
|
||||
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
@@ -156,9 +160,9 @@ jobs:
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
|
||||
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2896,7 +2896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2914,7 +2914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2935,7 +2935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2959,7 +2959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2968,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2980,7 +2980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2994,11 +2994,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3027,14 +3027,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3049,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3082,7 +3082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3094,7 +3094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3302,7 +3302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3313,7 +3313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3329,7 +3329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3346,18 +3346,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"glib-build-tools",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"relm4",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3366,7 +3361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3386,7 +3381,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3418,7 +3413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3502,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3516,7 +3511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3539,7 +3534,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
@@ -51,7 +51,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.22.0"
|
||||
version = "0.22.3"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.21.0"
|
||||
"version": "0.22.2"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -3432,6 +3432,90 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/check": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Check for updates now",
|
||||
"description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.",
|
||||
"operationId": "forceUpdateCheck",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Refreshed update-check state (`last_error` carries a failed check)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Update checks are disabled on this host",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "A forced check ran less than 30 s ago",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Update-check status",
|
||||
"description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.",
|
||||
"operationId": "getUpdateStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current update-check state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -4799,6 +4883,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).",
|
||||
"required": [
|
||||
"version",
|
||||
"channel",
|
||||
"install_kind",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "The channel it was announced on (`stable` | `canary`)."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.available"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The newer release's version string."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7040,6 +7154,111 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateManifestInfo": {
|
||||
"type": "object",
|
||||
"description": "One channel's manifest facts, as much as the console renders.",
|
||||
"required": [
|
||||
"version",
|
||||
"serial",
|
||||
"published_at",
|
||||
"notes_url",
|
||||
"stale"
|
||||
],
|
||||
"properties": {
|
||||
"notes_url": {
|
||||
"type": "string",
|
||||
"description": "Release-notes link (pinned to our forge by the manifest validator)."
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"description": "RFC-3339 publish time (display only)."
|
||||
},
|
||||
"serial": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Publish serial (unix seconds) — monotonic per channel.",
|
||||
"minimum": 0
|
||||
},
|
||||
"stale": {
|
||||
"type": "boolean",
|
||||
"description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The released version this manifest announces."
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateStatus": {
|
||||
"type": "object",
|
||||
"description": "The full update-check state for this host.",
|
||||
"required": [
|
||||
"install_kind",
|
||||
"channel",
|
||||
"current_version",
|
||||
"apply",
|
||||
"channel_hint",
|
||||
"check_disabled",
|
||||
"available"
|
||||
],
|
||||
"properties": {
|
||||
"apply": {
|
||||
"type": "string",
|
||||
"description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)."
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)."
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Release channel this install follows: `stable` | `canary`."
|
||||
},
|
||||
"channel_hint": {
|
||||
"type": "string",
|
||||
"description": "The copy-pastable update command for this install kind."
|
||||
},
|
||||
"check_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)."
|
||||
},
|
||||
"current_version": {
|
||||
"type": "string",
|
||||
"description": "The running host version."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
|
||||
},
|
||||
"last_checked_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When the last successful check happened (unix seconds).",
|
||||
"minimum": 0
|
||||
},
|
||||
"last_error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the last check failed, verbatim, if it did."
|
||||
},
|
||||
"manifest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateManifestInfo",
|
||||
"description": "The last verified manifest, if any check has succeeded."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -7110,6 +7329,10 @@
|
||||
{
|
||||
"name": "store",
|
||||
"description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"
|
||||
},
|
||||
{
|
||||
"name": "update",
|
||||
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,7 +59,131 @@ punktfunk — the Punktfunk client, headless
|
||||
|
||||
A <host-ref> is a saved host's id, its name, or an address — the same reference a
|
||||
punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found,
|
||||
6 needs a person.";
|
||||
6 needs a person.
|
||||
|
||||
\"punktfunk help <command>\" (or any command with --help) explains that command.";
|
||||
|
||||
/// The long help for one verb — `punktfunk help <verb>`, or `--help` after the verb.
|
||||
/// Each entry documents its flags and the behaviour a script would need to know
|
||||
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||
Some(match verb {
|
||||
"pair" => {
|
||||
"\
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
|
||||
--pin N the PIN the host is showing; without it the command asks, and
|
||||
refuses (exit 6) when there is no terminal to ask on
|
||||
--name LABEL the label the host files this device under
|
||||
(default: this machine's name)
|
||||
|
||||
Pairing verifies the host end-to-end and pins its fingerprint in the saved-hosts
|
||||
store, so every later connect — here, in the desktop client or the console — is
|
||||
silent. The port defaults to 9777. Prints `paired <addr>:<port> fp=<hex>` on
|
||||
success; exit 3 if the host refuses or the PIN is wrong."
|
||||
}
|
||||
"hosts" => {
|
||||
"\
|
||||
punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
Every saved host, name TAB addr:port TAB paired/trusted TAB state.
|
||||
--probe asks each host directly (no mDNS, so routed/VPN hosts answer
|
||||
too); --json emits one object with per-host detail, profiles included.
|
||||
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
Save a host by address — the door for a box mDNS never sees (Tailscale,
|
||||
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||
|
||||
punktfunk hosts forget <host-ref>
|
||||
Remove a saved host, its pinned fingerprint included. A later connect
|
||||
must pair or trust it again."
|
||||
}
|
||||
"wake" => {
|
||||
"\
|
||||
punktfunk wake <host-ref> [--wait] — Wake-on-LAN
|
||||
|
||||
Sends a magic packet to a saved host's MAC (learned from its advert while it
|
||||
was awake; exit 5 if none is known yet). With --wait, keeps sending every 6 s
|
||||
and polls presence every second for up to 90 s, exiting 0 the moment the host
|
||||
answers — the same cadence every graphical shell uses."
|
||||
}
|
||||
"library" => {
|
||||
"\
|
||||
punktfunk library <host-ref> [--json] — the host's game library
|
||||
|
||||
TSV on stdout by default (id TAB store TAB title), one game per line; --json
|
||||
emits {\"games\":[…]} for tools — the Playnite importer shells to exactly
|
||||
this. Needs a paired host (exit 6 otherwise)."
|
||||
}
|
||||
"launch" => {
|
||||
"\
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
|
||||
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||
and relays its lifecycle to stderr.
|
||||
|
||||
--game ID ask the host to launch this library title into the stream
|
||||
--profile REF use a settings profile (id or name) for this connect only;
|
||||
without it the host's own binding applies
|
||||
--fullscreen start the stream window fullscreen
|
||||
--exec become the session process instead of supervising it — the
|
||||
gamescope-wrapper mode, where the launched process must BE
|
||||
the streaming one for focus and lifecycle to work
|
||||
|
||||
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||
trusts this device (re-pair), 4 the renderer could not start."
|
||||
}
|
||||
"open" => {
|
||||
"\
|
||||
punktfunk open <punktfunk://…> — follow a punktfunk:// link, headless
|
||||
|
||||
Same parser and same refusal rules as clicking the link in a shell: a
|
||||
contradicted fingerprint refuses and says so, an ambiguous name refuses
|
||||
rather than guessing, and an unknown host is never trusted from a URL —
|
||||
that is a decision for a person, at a surface that can show the fingerprint
|
||||
(exit 6 points at `punktfunk pair`). --exec as in launch."
|
||||
}
|
||||
"reachable" => {
|
||||
"\
|
||||
punktfunk reachable <host-ref> — one bounded reachability probe
|
||||
|
||||
Asks the host directly (no mDNS), so routed/VPN hosts answer too. The
|
||||
reference may be an unsaved address — this verb answers \"can I reach it\",
|
||||
not \"do I know it\". Exit 0 reachable, 2 not; one line either way."
|
||||
}
|
||||
"speed-test" => {
|
||||
"\
|
||||
punktfunk speed-test <host-ref> [--json] — measure the real data plane
|
||||
|
||||
Runs the host's bandwidth probe over an actual session connect and prints the
|
||||
measured throughput, loss, and the bitrate it recommends. Deliberately does
|
||||
NOT apply the result: which layer a bitrate belongs in (a bound profile, the
|
||||
global default) is a decision the GUI makes with the user, and a CLI silently
|
||||
rewriting settings would be exactly the surprise that rule exists to prevent."
|
||||
}
|
||||
"profiles" => {
|
||||
"\
|
||||
punktfunk profiles list [--json] — the settings profiles on this device
|
||||
|
||||
One line per profile: id TAB name TAB how many settings it overrides.
|
||||
Profiles are created and edited in the desktop client; a connect uses one via
|
||||
`punktfunk launch --profile` or a punktfunk:// link that names it."
|
||||
}
|
||||
"reset" => {
|
||||
"\
|
||||
punktfunk reset — forget every saved host and reset stream settings
|
||||
|
||||
Asks for confirmation, and refuses (exit 6) when there is no terminal to ask
|
||||
on. This device's identity keypair is deliberately kept, so hosts that knew
|
||||
this machine still recognise it after re-pairing; delete the identity files
|
||||
from the config directory for a true factory reset."
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The value after `--flag`, if any.
|
||||
fn value(args: &[String], flag: &str) -> Option<String> {
|
||||
@@ -138,6 +262,12 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
return OK;
|
||||
};
|
||||
let rest: Vec<String> = args[1..].to_vec();
|
||||
// `--help`/`-h` after any verb prints that verb's help — before dispatch, so a verb
|
||||
// never mistakes the flag for its subject (`punktfunk pair -h` must not dial "-h").
|
||||
if rest.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!("{}", verb_help(&verb).unwrap_or(USAGE));
|
||||
return OK;
|
||||
}
|
||||
match verb.as_str() {
|
||||
"pair" => pair(&rest),
|
||||
"hosts" => hosts(&rest),
|
||||
@@ -149,10 +279,22 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
"speed-test" => speed_test(&rest),
|
||||
"profiles" => profiles(&rest),
|
||||
"reset" => reset(),
|
||||
"-h" | "--help" | "help" => {
|
||||
println!("{USAGE}");
|
||||
OK
|
||||
}
|
||||
"-h" | "--help" | "help" => match positional(&rest, 0) {
|
||||
None => {
|
||||
println!("{USAGE}");
|
||||
OK
|
||||
}
|
||||
Some(topic) => match verb_help(&topic) {
|
||||
Some(h) => {
|
||||
println!("{h}");
|
||||
OK
|
||||
}
|
||||
None => {
|
||||
eprintln!("no command called \"{topic}\"\n\n{USAGE}");
|
||||
UNRESOLVED
|
||||
}
|
||||
},
|
||||
},
|
||||
"--version" | "version" => {
|
||||
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
|
||||
OK
|
||||
@@ -600,10 +742,17 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
return UNRESOLVED;
|
||||
};
|
||||
// An address that isn't saved is still a legitimate thing to probe — this verb answers
|
||||
// "can I reach this?", not "do I know this?".
|
||||
let (addr, port) = match resolve(&reference) {
|
||||
Ok((known, i)) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
Err(_) => split_host_port(&reference),
|
||||
// "can I reach this?", not "do I know this?". Resolved QUIETLY for the same reason:
|
||||
// `resolve`'s "pair it first" advice is for verbs that need a saved host, and printing
|
||||
// it here would scold the exact usage this verb documents.
|
||||
let known = KnownHosts::load();
|
||||
let link = DeepLink {
|
||||
host_ref: reference.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let (addr, port) = match deeplink::resolve_host(&link, &known) {
|
||||
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
_ => split_host_port(&reference),
|
||||
};
|
||||
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||
println!("reachable {addr}:{port}");
|
||||
@@ -769,15 +918,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
/// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with
|
||||
/// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX.
|
||||
fn is_tty() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY-free check: `isatty` via the std file descriptor, without libc.
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -820,6 +961,32 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777));
|
||||
}
|
||||
|
||||
/// Every advertised verb documents itself — a USAGE line without a help entry is a
|
||||
/// promise `help <verb>` breaks. The overview and each entry must also name the verb.
|
||||
#[test]
|
||||
fn every_usage_verb_has_help() {
|
||||
for verb in [
|
||||
"pair",
|
||||
"hosts",
|
||||
"wake",
|
||||
"library",
|
||||
"launch",
|
||||
"open",
|
||||
"reachable",
|
||||
"speed-test",
|
||||
"profiles",
|
||||
"reset",
|
||||
] {
|
||||
let h = verb_help(verb).unwrap_or_else(|| panic!("no help for {verb}"));
|
||||
assert!(
|
||||
h.starts_with(&format!("punktfunk {verb}")),
|
||||
"help for {verb} must lead with its own invocation"
|
||||
);
|
||||
assert!(USAGE.contains(verb), "USAGE must advertise {verb}");
|
||||
}
|
||||
assert!(verb_help("bogus").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_the_argument_after_its_flag() {
|
||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Runs the REAL `punktfunk` binary and pins its self-documentation contract: help goes
|
||||
//! to stdout and exits 0, an unknown verb refuses on stderr with the not-found code.
|
||||
//!
|
||||
//! This exists so CI *executes* the shipped binary at least once per platform. A binary
|
||||
//! that compiles but is the wrong program passes every build/clippy/fmt gate — that is
|
||||
//! exactly how 0.22.0 shipped a stub as `punktfunk-session` — and only a gate that runs
|
||||
//! the thing catches the class. The help paths are the right probe: they touch no config
|
||||
//! stores and no network, so they are safe on any runner.
|
||||
#![cfg(any(target_os = "linux", windows))]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn punktfunk(args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_punktfunk"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run punktfunk")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_and_help_print_the_overview_on_stdout() {
|
||||
for args in [&[][..], &["help"][..], &["--help"][..], &["-h"][..]] {
|
||||
let out = punktfunk(args);
|
||||
assert!(out.status.success(), "{args:?} must exit 0");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("punktfunk pair"),
|
||||
"{args:?} overview lists the verbs"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("help <command>"),
|
||||
"{args:?} overview points at per-verb help"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_verb_help_answers_both_spellings() {
|
||||
for args in [
|
||||
&["help", "launch"][..],
|
||||
&["launch", "--help"][..],
|
||||
&["launch", "-h"][..],
|
||||
] {
|
||||
let out = punktfunk(args);
|
||||
assert!(out.status.success(), "{args:?} must exit 0");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("--exec"),
|
||||
"{args:?} documents launch's flags"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_prints_the_crate_version() {
|
||||
let out = punktfunk(&["--version"]);
|
||||
assert!(out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains(env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||
let out = punktfunk(&["frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown verb exits 5");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command"));
|
||||
|
||||
let out = punktfunk(&["help", "frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||
}
|
||||
@@ -41,37 +41,16 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# The GTK4/libadwaita session shell (app.rs, cli.rs, ui_*.rs, shortcuts.rs, spawn.rs). Those
|
||||
# modules are `#[cfg(target_os = "linux")]` in main.rs, so their dependencies belong in a
|
||||
# linux-only block — the Windows leg of this crate is a stub binary and must not pull GTK.
|
||||
#
|
||||
# Versions and features track clients/linux verbatim (gtk4 0.11 "v4_16", libadwaita 0.9 "v1_5"):
|
||||
# the two crates compile the same widget vocabulary, and a version skew between them would show up
|
||||
# as type mismatches through relm4 rather than as anything legible.
|
||||
#
|
||||
# glib / gio / gdk / pango are NOT listed on purpose — the sources reach them as `gtk::glib`,
|
||||
# `gtk::gio`, `gtk::gdk` and `gtk::pango`, gtk4's own re-exports. Declaring them separately would
|
||||
# risk resolving a DIFFERENT version of the same sys crate than the one gtk4 links against.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
|
||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
relm4 = { version = "0.11", features = ["libadwaita"] }
|
||||
async-channel = "2"
|
||||
# NOT optional here, unlike the shared block above where it hangs off `ui`. cli.rs's `--json` host
|
||||
# listing is a CLI feature, not a console-UI one, so on Linux it is needed whether or not `ui` is
|
||||
# on — and `--no-default-features` is a documented Linux build ("same streaming, stats on stdout
|
||||
# only"), which without this simply did not compile.
|
||||
serde_json = "1"
|
||||
# This crate carries NO toolkit, deliberately: it is the renderer the shells spawn, and the
|
||||
# `--no-default-features` build is what a minimal/embedded image installs. GTK4/libadwaita/relm4
|
||||
# belong to `clients/linux` (the shell) and must never appear here — a stray copy of the shell's
|
||||
# modules landed in src/ once (b0ea1e6b) and dragging its dependencies in behind it is what let
|
||||
# the wrong `main.rs` build and ship as `punktfunk-session`.
|
||||
|
||||
# Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from
|
||||
# the SDK); same pattern as clients/windows.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
# Compiles data/ (the OS-mark symbolic icons) into the embedded gresource (build.rs) —
|
||||
# needs `glib-compile-resources`, which ships with the GTK dev stack this crate needs anyway.
|
||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||
glib-build-tools = "0.22"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -5,10 +5,15 @@ no widgets, terminal stats. The power-user / gamescope stream client, and the st
|
||||
presenter of the Linux client re-architecture (punktfunk-planning:
|
||||
`linux-client-rearchitecture.md`).
|
||||
|
||||
This binary is deliberately dumb: a renderer the front-ends call INTO — the GTK shell
|
||||
(`punktfunk-client`), the WinUI shell, and the `punktfunk` CLI all spawn it through the
|
||||
same brain (`pf_client_core::orchestrate`), which resolves policy (profiles, settings,
|
||||
wake) and hands the result down, normally as a `--resolved-spec` file. It reads the
|
||||
shared stores only as the compat fallback for a bare hand-launched invocation.
|
||||
|
||||
```
|
||||
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
||||
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
||||
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
|
||||
```
|
||||
|
||||
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
||||
@@ -21,12 +26,10 @@ Reads the same identity / known-hosts / settings stores as the desktop client
|
||||
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||
|
||||
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
|
||||
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
|
||||
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
|
||||
label the host files this client under, defaulting to the hostname. It is in the
|
||||
`--no-default-features` build too: enrolling must never be the reason a minimal image has
|
||||
to pull in Skia.
|
||||
Pairing is `punktfunk pair <host>` — the CLI, which ships alongside this binary in every
|
||||
package and needs no window and no toolkit either. `punktfunk-session --pair` still works
|
||||
for one release (someone's provisioning script calls it today) but prints a deprecation
|
||||
notice: pairing is a trust ceremony and belongs to the brain, not a renderer.
|
||||
|
||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||
|
||||
@@ -4,17 +4,8 @@
|
||||
//! icon is the generic default).
|
||||
|
||||
fn main() {
|
||||
// Linux: compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic
|
||||
// icons) into a gresource bundle, registered at startup via
|
||||
// `gio::resources_register_include!`. Host-gated like the Windows leg below.
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
|
||||
glib_build_tools::compile_resources(
|
||||
&["data"],
|
||||
"data/resources.gresource.xml",
|
||||
"punktfunk-client.gresource",
|
||||
);
|
||||
}
|
||||
// No Linux leg: this binary carries no toolkit, so it has no gresource to compile. The
|
||||
// OS-mark icons belong to the GTK shell (`clients/linux/data`), which builds its own.
|
||||
|
||||
// cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary);
|
||||
// CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass).
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="#000000"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 635 B |
@@ -1,2 +0,0 @@
|
||||
<!-- arch — from Simple Icons (CC0 1.0), via react-icons SiArchlinux. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"/></svg>
|
||||
|
Before Width: | Height: | Size: 836 B |
@@ -1,2 +0,0 @@
|
||||
<!-- debian — from Simple Icons (CC0 1.0), via react-icons SiDebian. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- fedora — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaFedora. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- linux — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaLinux. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- nixos — from Simple Icons (CC0 1.0), via react-icons SiNixos. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 875 B |
@@ -1,2 +0,0 @@
|
||||
<!-- opensuse — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSuse. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="#000000"><path d="M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSteam. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 932 B |
@@ -1,2 +0,0 @@
|
||||
<!-- ubuntu — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaUbuntu. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
||||
|
Before Width: | Height: | Size: 339 B |
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the
|
||||
assets/os-icons masters; see that directory's README for provenance/licensing).
|
||||
Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
|
||||
<gresources>
|
||||
<gresource prefix="/io/unom/Punktfunk">
|
||||
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-steam-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-ubuntu-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-fedora-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-arch-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-debian-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-nixos-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-opensuse-symbolic.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -1,699 +0,0 @@
|
||||
//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the
|
||||
//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot
|
||||
//! scenes.
|
||||
|
||||
use crate::app::AppModel;
|
||||
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use relm4::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the
|
||||
/// component parts, so the scene can be dispatched from the window's `map` callback.
|
||||
pub struct ShotCtx {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub nav: adw::NavigationView,
|
||||
pub hosts: relm4::Sender<HostsMsg>,
|
||||
pub settings: Rc<RefCell<crate::trust::Settings>>,
|
||||
pub gamepad: crate::gamepad::GamepadService,
|
||||
pub identity: (String, String),
|
||||
pub sender: ComponentSender<AppModel>,
|
||||
}
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
pub fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// True if argv contains `flag` (a valueless switch).
|
||||
pub fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link
|
||||
/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what
|
||||
/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's
|
||||
/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser —
|
||||
/// this only decides whether argv contains something addressed to us.
|
||||
pub fn deep_link_arg() -> Option<String> {
|
||||
std::env::args().skip(1).find(|a| {
|
||||
let lower = a.to_ascii_lowercase();
|
||||
lower.starts_with("punktfunk://") || lower.starts_with("pf://")
|
||||
})
|
||||
}
|
||||
|
||||
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
|
||||
/// console library exec the session binary, which handles its own fullscreen).
|
||||
pub fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable
|
||||
/// port yields `None` for it (callers decide whether to default or bail).
|
||||
fn parse_host_port(target: &str) -> (String, Option<u16>) {
|
||||
match target.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().ok()),
|
||||
None => (target.to_string(), Some(9777)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect` / `--browse`: streams and the console library live in the
|
||||
/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the
|
||||
/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with
|
||||
/// these flags) working unchanged until it's repointed at the session binary.
|
||||
pub fn exec_session() -> glib::ExitCode {
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
let forward = [
|
||||
"--connect",
|
||||
"--browse",
|
||||
"--fp",
|
||||
"--launch",
|
||||
"--mgmt",
|
||||
"--connect-timeout",
|
||||
];
|
||||
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a == "--fullscreen" || a == "--stats" {
|
||||
cmd.arg(a);
|
||||
} else if forward.contains(&a.as_str()) {
|
||||
cmd.arg(&a);
|
||||
if let Some(v) = args.peek() {
|
||||
if !v.starts_with("--") {
|
||||
cmd.arg(args.next().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let err = cmd.exec(); // only returns on failure
|
||||
eprintln!("exec punktfunk-session: {err}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
||||
/// store the streaming path uses, so pairing here makes the stream work.
|
||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
// The label the HOST stores this client under (its paired-devices list).
|
||||
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
||||
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = crate::trust::hex(&fp);
|
||||
crate::trust::persist_host(
|
||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
&addr,
|
||||
port,
|
||||
&fp_hex,
|
||||
true,
|
||||
);
|
||||
// A host manually added via `--add-host` (no fingerprint yet) is stored as an
|
||||
// addr-keyed placeholder; now that the ceremony yielded the real fingerprint,
|
||||
// `persist_host` created the fp-keyed entry — drop the placeholder so the list
|
||||
// shows this host once, not twice.
|
||||
forget_placeholder(&addr, port);
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"pairing failed: {} ({e:?})",
|
||||
crate::trust::pair_error_message(&e)
|
||||
);
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit,
|
||||
/// without opening a window. The MAC comes from the known-hosts store (learned from the
|
||||
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
||||
pub fn cli_wake() -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--wake") else {
|
||||
eprintln!("--wake requires host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let mac = crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.map(|h| h.mac.clone())
|
||||
.unwrap_or_default();
|
||||
if mac.is_empty() {
|
||||
eprintln!(
|
||||
"--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \
|
||||
advertised MAC is learned"
|
||||
);
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
crate::wol::wake(&mac, addr.parse().ok());
|
||||
println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
||||
/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when
|
||||
/// given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
||||
pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
||||
_ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT),
|
||||
};
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let pin = arg_value("--fp")
|
||||
.as_deref()
|
||||
.and_then(crate::trust::parse_hex32)
|
||||
.or_else(|| {
|
||||
crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr)
|
||||
.and_then(|h| crate::trust::parse_hex32(&h.fp_hex))
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
Ok(games) => {
|
||||
for g in &games {
|
||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
||||
}
|
||||
println!("{} game(s)", games.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("library: {e}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`)
|
||||
// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan
|
||||
// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/
|
||||
// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client
|
||||
// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts
|
||||
// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached
|
||||
// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/// The per-probe budget: a cold host on a routed link answers in well under this, and every
|
||||
/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP
|
||||
/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint).
|
||||
enum Selector {
|
||||
Fp(String),
|
||||
Addr(String, u16),
|
||||
}
|
||||
|
||||
fn parse_selector(s: &str) -> Selector {
|
||||
if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Selector::Fp(s.to_lowercase())
|
||||
} else {
|
||||
let (addr, port) = parse_host_port(s);
|
||||
Selector::Addr(addr, port.unwrap_or(9777))
|
||||
}
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
fn matches(&self, h: &KnownHost) -> bool {
|
||||
match self {
|
||||
Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp),
|
||||
Selector::Addr(addr, port) => h.addr == *addr && h.port == *port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips).
|
||||
fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
|
||||
crate::trust::probe_reachable_many(
|
||||
hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(),
|
||||
PROBE_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
||||
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
||||
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
||||
/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`.
|
||||
pub fn headless_list_hosts() -> glib::ExitCode {
|
||||
let known = KnownHosts::load();
|
||||
let online: Option<Vec<bool>> = arg_flag("--probe").then(|| probe_all(&known.hosts));
|
||||
let hosts: Vec<serde_json::Value> = known
|
||||
.hosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| {
|
||||
serde_json::json!({
|
||||
"name": h.name,
|
||||
"addr": h.addr,
|
||||
"port": h.port,
|
||||
"fp_hex": h.fp_hex,
|
||||
"paired": h.paired,
|
||||
"mac": h.mac,
|
||||
"os": h.os,
|
||||
"last_used": h.last_used,
|
||||
"online": online.as_ref().map(|v| serde_json::Value::Bool(v[i]))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) {
|
||||
Ok(s) => {
|
||||
println!("{s}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("list-hosts: {e}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap
|
||||
/// "is it online / test this address" check that never touches mDNS.
|
||||
pub fn headless_reachable(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = parse_host_port(target);
|
||||
let port = port.unwrap_or(9777);
|
||||
if NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||
println!("reachable {addr}:{port}");
|
||||
glib::ExitCode::SUCCESS
|
||||
} else {
|
||||
eprintln!("unreachable {addr}:{port}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can
|
||||
/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the
|
||||
/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair`
|
||||
/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert)
|
||||
/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added <addr>:<port>`.
|
||||
pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = parse_host_port(target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let name = arg_value("--host-label")
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| addr.clone());
|
||||
if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) {
|
||||
// Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN
|
||||
// ceremony happened; a later `--pair` upgrades it).
|
||||
crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false);
|
||||
forget_placeholder(&addr, port);
|
||||
println!("added {addr}:{port} fp={}", fp_hex.to_lowercase());
|
||||
return glib::ExitCode::SUCCESS;
|
||||
}
|
||||
// No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists.
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
{
|
||||
h.name = name;
|
||||
} else {
|
||||
known.hosts.push(KnownHost {
|
||||
name,
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
match known.save() {
|
||||
Ok(()) => {
|
||||
println!("added {addr}:{port}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("add-host: {e:#}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--set-host <fp|host[:port]> [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved
|
||||
/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or
|
||||
/// current address. Prints `updated <name>`; fails if nothing matched.
|
||||
pub fn headless_set_host(selector: &str) -> glib::ExitCode {
|
||||
let sel = parse_selector(selector);
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else {
|
||||
eprintln!("set-host: no saved host matches {selector:?}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) {
|
||||
if !name.is_empty() {
|
||||
h.name = name;
|
||||
}
|
||||
}
|
||||
if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) {
|
||||
if !addr.is_empty() {
|
||||
h.addr = addr;
|
||||
}
|
||||
}
|
||||
if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::<u16>().ok()) {
|
||||
h.port = port;
|
||||
}
|
||||
let label = h.name.clone();
|
||||
match known.save() {
|
||||
Ok(()) => {
|
||||
println!("updated {label}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("set-host: {e:#}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--forget-host <fp|host[:port]>` — remove a saved host (drops the pinned fingerprint; a later
|
||||
/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent).
|
||||
pub fn headless_forget_host(selector: &str) -> glib::ExitCode {
|
||||
let sel = parse_selector(selector);
|
||||
let mut known = KnownHosts::load();
|
||||
let before = known.hosts.len();
|
||||
known.hosts.retain(|h| !sel.matches(h));
|
||||
let removed = before - known.hosts.len();
|
||||
if removed > 0 {
|
||||
if let Err(e) = known.save() {
|
||||
eprintln!("forget-host: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
println!("forgot {removed}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `--reset` — clear this device's client state: the saved known-hosts and the stream
|
||||
/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately
|
||||
/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts);
|
||||
/// a caller wanting a true factory reset removes those separately. Missing files are fine.
|
||||
pub fn headless_reset() -> glib::ExitCode {
|
||||
let Ok(dir) = crate::trust::config_dir() else {
|
||||
eprintln!("reset: could not resolve config dir (HOME unset?)");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let mut ok = true;
|
||||
for name in ["client-known-hosts.json", "client-gtk-settings.json"] {
|
||||
match std::fs::remove_file(dir.join(name)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
eprintln!("reset: {name}: {e}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
println!("reset");
|
||||
glib::ExitCode::SUCCESS
|
||||
} else {
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
|
||||
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
|
||||
/// dispatch in `app.rs` is a single line.
|
||||
pub fn headless_host_command() -> Option<glib::ExitCode> {
|
||||
if arg_flag("--list-hosts") {
|
||||
return Some(headless_list_hosts());
|
||||
}
|
||||
if let Some(t) = arg_value("--reachable") {
|
||||
return Some(headless_reachable(&t));
|
||||
}
|
||||
if let Some(t) = arg_value("--add-host") {
|
||||
return Some(headless_add_host(&t));
|
||||
}
|
||||
if let Some(s) = arg_value("--set-host") {
|
||||
return Some(headless_set_host(&s));
|
||||
}
|
||||
if let Some(s) = arg_value("--forget-host") {
|
||||
return Some(headless_forget_host(&s));
|
||||
}
|
||||
if arg_flag("--reset") {
|
||||
return Some(headless_reset());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
||||
pub fn shot_scene() -> Option<String> {
|
||||
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Render one mock-populated, host-free scene over the already-presented window, then
|
||||
/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set
|
||||
/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick
|
||||
/// needed. The stream and gamepad-library scenes are gone with the pages (both live in
|
||||
/// the session binary now).
|
||||
pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
let sender = &ctx.sender;
|
||||
// A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars).
|
||||
let mock_req = || ConnectRequest {
|
||||
name: "Living Room PC".to_string(),
|
||||
addr: "192.168.1.42".to_string(),
|
||||
port: 9777,
|
||||
fp_hex: Some(
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
||||
),
|
||||
pair_optional: true,
|
||||
launch: None,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
};
|
||||
let mock_advert =
|
||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
||||
key: key.to_string(),
|
||||
fullname: format!("{key}._punktfunk._udp.local."),
|
||||
name: name.to_string(),
|
||||
addr: addr.to_string(),
|
||||
port: 9777,
|
||||
fp_hex: fp.to_string(),
|
||||
pair: "required".to_string(),
|
||||
mgmt_port: None,
|
||||
mac: Vec::new(),
|
||||
os: "linux/arch/steamos".to_string(),
|
||||
};
|
||||
|
||||
// What the self-capture renders: the main window, except for scenes that open their
|
||||
// own toplevel (the shortcuts window).
|
||||
let mut target: gtk::Widget = ctx.window.clone().upcast();
|
||||
let hosts = &ctx.hosts;
|
||||
match scene {
|
||||
// Saved hosts come from the seeded known-hosts store; on top, inject synthetic
|
||||
// adverts through the same path the mDNS stream feeds.
|
||||
"hosts" | "02-hosts" => {
|
||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||
"mock-online",
|
||||
"Living Room PC",
|
||||
"192.168.1.42",
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
)));
|
||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||
"mock-new",
|
||||
"steamdeck",
|
||||
"192.168.1.77",
|
||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||
)));
|
||||
}
|
||||
"settings" | "03-settings" => {
|
||||
// Mock devices so the shot shows the probe-dependent pickers populated.
|
||||
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
};
|
||||
let probes = crate::ui_settings::DeviceProbes {
|
||||
adapters: vec![
|
||||
"NVIDIA GeForce RTX 4070".to_string(),
|
||||
"AMD Radeon 780M".to_string(),
|
||||
],
|
||||
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
|
||||
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
|
||||
};
|
||||
// `PUNKTFUNK_SHOT_SETTINGS_SCOPE=<profile id|name>` captures the dialog in
|
||||
// PROFILE scope — the second half of the settings surface (design
|
||||
// client-settings-profiles.md §5.1), where only profileable rows render.
|
||||
let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.and_then(|reference| {
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.resolve(&reference)
|
||||
.0
|
||||
.map(|p| crate::ui_settings::Scope::Profile(p.id.clone()))
|
||||
})
|
||||
.unwrap_or(crate::ui_settings::Scope::Defaults);
|
||||
let dialog = crate::ui_settings::show_scoped(
|
||||
&ctx.window,
|
||||
ctx.settings.clone(),
|
||||
&ctx.gamepad,
|
||||
&probes,
|
||||
scope,
|
||||
|_| {},
|
||||
|| {},
|
||||
);
|
||||
// Optional page for the capture (general/display/input/audio/controllers);
|
||||
// the dialog opens on General otherwise.
|
||||
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
|
||||
if !page.is_empty() {
|
||||
use adw::prelude::PreferencesDialogExt as _;
|
||||
dialog.set_visible_page_name(&page);
|
||||
}
|
||||
}
|
||||
}
|
||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||
"pair" | "05-pair" => {
|
||||
crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req())
|
||||
}
|
||||
"addhost" | "06-addhost" => {
|
||||
let _ = hosts.send(HostsMsg::ShowAddHost);
|
||||
}
|
||||
"shortcuts" | "07-shortcuts" => {
|
||||
let w = crate::app::shortcuts_window(&ctx.window);
|
||||
w.present();
|
||||
target = w.upcast();
|
||||
}
|
||||
// The library page with injected entries: mixed stores exercising the badge set,
|
||||
// no-art placeholders, and one solid-color texture standing in for a poster.
|
||||
"library" | "08-library" => {
|
||||
let (games, art) = mock_library();
|
||||
crate::ui_library::open_mock(
|
||||
&ctx.nav,
|
||||
ctx.identity.clone(),
|
||||
sender,
|
||||
mock_req(),
|
||||
games,
|
||||
art,
|
||||
);
|
||||
}
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
|
||||
let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(900);
|
||||
let scene = scene.to_string();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||
use std::io::Write as _;
|
||||
// Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL
|
||||
// renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't
|
||||
// composite the `AdwDialog` overlay layer (the dialog IS presented — the
|
||||
// page-content scenes capture fine either way; CI uses GL or the X11 root-grab).
|
||||
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
||||
.ok()
|
||||
.filter(|p| !p.is_empty());
|
||||
if let Some(out) = &self_capture {
|
||||
if let Err(e) = save_png(&target, out) {
|
||||
eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}");
|
||||
}
|
||||
}
|
||||
println!("PF_SHOT_READY scene={scene}");
|
||||
let _ = std::io::stdout().flush();
|
||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs
|
||||
// don't stack windows on a live desktop.
|
||||
if self_capture.is_some() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The mock game set for the `library` scene: mixed stores exercising the badge set,
|
||||
/// plus one solid-colour poster texture.
|
||||
fn mock_library() -> (
|
||||
Vec<crate::library::GameEntry>,
|
||||
Vec<(String, gtk::gdk::Texture)>,
|
||||
) {
|
||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
||||
id: id.to_string(),
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
||||
game("custom:emu-1", "custom", "RetroArch"),
|
||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
||||
game("lutris:osu", "lutris", "osu!"),
|
||||
];
|
||||
let art = vec![(
|
||||
"steam:570".to_string(),
|
||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
||||
)];
|
||||
(games, art)
|
||||
}
|
||||
|
||||
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
|
||||
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
||||
let px = [r, g, b, 0xff].repeat((w * h) as usize);
|
||||
gtk::gdk::MemoryTexture::new(
|
||||
w,
|
||||
h,
|
||||
gtk::gdk::MemoryFormat::R8g8b8a8,
|
||||
&glib::Bytes::from_owned(px),
|
||||
(w * 4) as usize,
|
||||
)
|
||||
.upcast()
|
||||
}
|
||||
|
||||
/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable →
|
||||
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
||||
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
||||
use anyhow::Context as _;
|
||||
let (w, h) = (widget.width(), widget.height());
|
||||
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
||||
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
||||
let snapshot = gtk::Snapshot::new();
|
||||
paintable.snapshot(&snapshot, f64::from(w), f64::from(h));
|
||||
let node = snapshot.to_node().context("empty snapshot")?;
|
||||
let renderer = widget
|
||||
.native()
|
||||
.context("widget not realized")?
|
||||
.renderer()
|
||||
.context("no gsk renderer")?;
|
||||
let texture = renderer.render_texture(node, None);
|
||||
texture
|
||||
.save_to_png(path)
|
||||
.with_context(|| format!("save {path}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,42 +1,645 @@
|
||||
//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita).
|
||||
//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning
|
||||
//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS
|
||||
//! the power-user CLI build).
|
||||
//!
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
||||
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
||||
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
||||
//! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
|
||||
//! with no window and no toolkit, for machines that have only a shell.
|
||||
//!
|
||||
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pf_client_core::{discovery, gamepad, library, os, trust, video, wol};
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod app;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cli;
|
||||
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod shortcuts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spawn;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_settings;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_trust;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod session_main {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::SessionParams;
|
||||
use pf_client_core::trust;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
app::run()
|
||||
pub const EXIT_CONNECT_FAILED: u8 = 2;
|
||||
pub const EXIT_TRUST_REJECTED: u8 = 3;
|
||||
pub const EXIT_PRESENTER_FAILED: u8 = 4;
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
pub(crate) fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
pub(crate) fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
|
||||
/// gated with `mod browse`, its one caller.)
|
||||
#[cfg(feature = "ui")]
|
||||
pub(crate) fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
|
||||
/// shell passes its own position so the session opens on the same monitor); absent or
|
||||
/// unparsable = centered on the primary display.
|
||||
pub(crate) fn window_pos() -> Option<(i32, i32)> {
|
||||
let v = arg_value("--window-pos")?;
|
||||
let (x, y) = v.split_once(',')?;
|
||||
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
|
||||
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
|
||||
/// client, a headless box, an image being provisioned. Writes the verified host into the
|
||||
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
|
||||
/// later stream connect silently.
|
||||
///
|
||||
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
|
||||
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
|
||||
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
|
||||
/// enrolment must not be the reason an embedded image has to pull in Skia.
|
||||
fn headless_pair(pin: &str) -> u8 {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
// The label the HOST files this client under. A headless box has nobody to ask, so
|
||||
// the hostname is the only name that will mean anything in the paired-devices list.
|
||||
let name = arg_value("--name").unwrap_or_else(trust::device_name);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = trust::hex(&fp);
|
||||
trust::persist_host(
|
||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
&addr,
|
||||
port,
|
||||
&fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&addr, port);
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
|
||||
EXIT_TRUST_REJECTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `host[:port]`, port defaulting to the native 9777.
|
||||
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||
match target.rsplit_once(':') {
|
||||
Some((a, p)) => match p.parse() {
|
||||
Ok(port) => (a.to_string(), port),
|
||||
Err(_) => {
|
||||
eprintln!("unparsable port in '{target}', using default 9777");
|
||||
(a.to_string(), 9777)
|
||||
}
|
||||
},
|
||||
None => (target.to_string(), 9777),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--profile <id|name>` — the settings profile this one session runs with, overriding the
|
||||
/// host's own binding for this launch only (never rebinding it): the shells' "Connect
|
||||
/// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's
|
||||
/// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is
|
||||
/// how "Connect with ▸ Default settings" reaches a bound host.
|
||||
fn profile_arg() -> Option<String> {
|
||||
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
|
||||
/// shell's request-access flow passes ~185 s because the host PARKS the connection
|
||||
/// until the operator clicks Approve.
|
||||
pub(crate) fn connect_timeout() -> Duration {
|
||||
Duration::from_secs(
|
||||
arg_value("--connect-timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(15),
|
||||
)
|
||||
}
|
||||
|
||||
/// One session's pump parameters from the EFFECTIVE settings — shared by `--connect`
|
||||
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
|
||||
/// window's display (the GTK client reads the monitor under its window — same
|
||||
/// contract).
|
||||
///
|
||||
/// `settings` is what [`trust::effective_settings`] returned, never a raw
|
||||
/// `Settings::load()`: both callers resolve the host's profile first, so the two
|
||||
/// construction sites cannot drift (they historically did — touching one and not the
|
||||
/// other is a Windows-only build break). `profile` is that profile's name, for the
|
||||
/// stats overlay's first line.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn session_params(
|
||||
settings: &trust::Settings,
|
||||
profile: Option<String>,
|
||||
clipboard_override: Option<bool>,
|
||||
addr: String,
|
||||
port: u16,
|
||||
pin: [u8; 32],
|
||||
identity: (String, String),
|
||||
launch: Option<String>,
|
||||
gamepad: &GamepadService,
|
||||
native: Mode,
|
||||
force_software: Arc<AtomicBool>,
|
||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
) -> SessionParams {
|
||||
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3). In spec
|
||||
// mode the spawner already resolved it; otherwise this looks it up itself, which is
|
||||
// the last store read the compat path still owes. `addr` is moved into the struct
|
||||
// below, so read it first.
|
||||
let clipboard = clipboard_override.unwrap_or_else(|| {
|
||||
trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync)
|
||||
});
|
||||
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||
// process. Applied per params-build (idempotent; browse re-launches included) so
|
||||
// it lands before the session attaches. Empty = automatic (most recent).
|
||||
if !settings.forward_pad.is_empty() {
|
||||
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||
}
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
} else {
|
||||
settings.width
|
||||
},
|
||||
height: if settings.width == 0 {
|
||||
native.height
|
||||
} else {
|
||||
settings.height
|
||||
},
|
||||
refresh_hz: if settings.refresh_hz == 0 {
|
||||
native.refresh_hz.max(30)
|
||||
} else {
|
||||
settings.refresh_hz
|
||||
},
|
||||
};
|
||||
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
|
||||
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
|
||||
// Native/explicit resolution so it composes uniformly with both.
|
||||
let (sw, sh) = punktfunk_core::render_scale::apply(
|
||||
mode.width,
|
||||
mode.height,
|
||||
settings.render_scale,
|
||||
punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: sw,
|
||||
height: sh,
|
||||
..mode
|
||||
};
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&settings.compositor)
|
||||
.unwrap_or(CompositorPref::Auto),
|
||||
gamepad: {
|
||||
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
|
||||
// builds each virtual pad from that pad's arrival and only falls back to this
|
||||
// session default for a pad that never declares one, so an explicit choice that
|
||||
// stopped here would be undone the moment a controller connected.
|
||||
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
|
||||
gamepad.set_kind_override(chosen);
|
||||
match chosen {
|
||||
GamepadPref::Auto => gamepad.auto_pref(),
|
||||
explicit => explicit,
|
||||
}
|
||||
},
|
||||
bitrate_kbps: settings.bitrate_kbps,
|
||||
audio_channels: settings.audio_channels,
|
||||
preferred_codec: settings.preferred_codec(),
|
||||
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
|
||||
video_caps: if settings.hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||
// pump) pins one manually.
|
||||
display_hdr: None,
|
||||
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
|
||||
// channel); capture-mode sessions keep the composited cursor, so only advertise
|
||||
// when the session STARTS in desktop mode. The host gates further (Linux portal
|
||||
// compositors only).
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||
decoder: settings.decoder.clone(),
|
||||
launch,
|
||||
vulkan,
|
||||
pin: Some(pin),
|
||||
identity,
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's starting size under Match-window: the persisted last size, so the
|
||||
/// first connect's mode already matches the glass; `None` (policy off / never
|
||||
/// stored) = the 1280×720 default.
|
||||
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
|
||||
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
|
||||
.then_some((settings.last_window_w, settings.last_window_h))
|
||||
}
|
||||
|
||||
/// The Match-window policy hook for the presenter loop
|
||||
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
|
||||
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
||||
/// logical window size (load-modify-save, like the console settings screen) so the
|
||||
/// next launch opens at it.
|
||||
/// The Match-window policy hook (design/midstream-resolution-resize.md D1/D2). The
|
||||
/// callback used to load-modify-save the shared settings file from inside the renderer —
|
||||
/// one of that file's five concurrent writers, for a value only the parent needs. It now
|
||||
/// REPORTS the size on stdout and the spawner persists it
|
||||
/// (design/client-architecture-split.md §5).
|
||||
///
|
||||
/// `persist_locally` keeps a hand-run session remembering its own window: nobody is
|
||||
/// listening to stdout there, so the event alone would drop the value. A spawned session
|
||||
/// leaves the write to its parent, which is the whole point.
|
||||
pub(crate) fn match_window(
|
||||
settings: &trust::Settings,
|
||||
persist_locally: bool,
|
||||
) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||
settings.match_window.then(|| {
|
||||
Box::new(move |w: u32, h: u32| {
|
||||
println!("{{\"window\":{{\"w\":{w},\"h\":{h}}}}}");
|
||||
if persist_locally {
|
||||
pf_client_core::orchestrate::persist_window_size(w, h);
|
||||
}
|
||||
}) as Box<dyn FnMut(u32, u32)>
|
||||
})
|
||||
}
|
||||
|
||||
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
||||
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
||||
/// failure through the same contract when spawned with `--json-status`.
|
||||
pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option<bool>) {
|
||||
let escaped: String = msg
|
||||
.chars()
|
||||
.flat_map(|c| match c {
|
||||
'"' => vec!['\\', '"'],
|
||||
'\\' => vec!['\\', '\\'],
|
||||
'\n' => vec!['\\', 'n'],
|
||||
c if (c as u32) < 0x20 => vec![' '],
|
||||
c => vec![c],
|
||||
})
|
||||
.collect();
|
||||
match trust_rejected {
|
||||
Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"),
|
||||
None => println!("{{\"{key}\":\"{escaped}\"}}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*`
|
||||
/// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`.
|
||||
/// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s
|
||||
/// `auto` path can't build the Vulkan decoder and the session silently falls back to
|
||||
/// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks
|
||||
/// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the
|
||||
/// RADV driver loads (the Vulkan instance is created later, inside `run_session`).
|
||||
///
|
||||
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
|
||||
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||
}
|
||||
tracing::info!(
|
||||
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||
"opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
||||
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
||||
if arg_flag("--list-adapters") {
|
||||
return match pf_presenter::vk::list_adapters() {
|
||||
Ok(names) => {
|
||||
for n in names {
|
||||
println!("{n}");
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("list-adapters: {e:#}");
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
|
||||
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
|
||||
// same enumeration the GTK shell probes.
|
||||
#[cfg(target_os = "linux")]
|
||||
if arg_flag("--list-audio") {
|
||||
return match pf_client_core::audio::devices() {
|
||||
Ok((sinks, sources)) => {
|
||||
for d in sinks {
|
||||
println!("sink\t{}\t{}", d.name, d.description);
|
||||
}
|
||||
for d in sources {
|
||||
println!("source\t{}\t{}", d.name, d.description);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("list-audio: {e:#}");
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `--pair <PIN>`: enrol this machine against a host and exit. DEPRECATED — pairing is
|
||||
// a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell
|
||||
// (design/client-architecture-split.md §5). It still works, with a notice, for the one
|
||||
// release this needs; a renderer owning a trust ceremony is exactly the mixing of
|
||||
// concerns the split exists to undo.
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
eprintln!(
|
||||
"note: punktfunk-session --pair is deprecated \u{2014} use `punktfunk pair \
|
||||
<host[:port]>` instead (same store, same result)."
|
||||
);
|
||||
return headless_pair(&pin);
|
||||
}
|
||||
|
||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_radv_video_decode();
|
||||
|
||||
// The Settings device picks → env, unless the user already forced one by hand:
|
||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
||||
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
||||
// the RADV knob (covers --connect and --browse).
|
||||
{
|
||||
let s = trust::Settings::load();
|
||||
for (var, value) in [
|
||||
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
|
||||
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
|
||||
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
||||
] {
|
||||
if std::env::var_os(var).is_none() && !value.is_empty() {
|
||||
std::env::set_var(var, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
|
||||
// every pad Steam Input has virtualized; capturing the Deck's real built-in
|
||||
// controller needs it cleared (same rationale as the GTK client's `app::run`).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
|
||||
if arg_flag("--browse") {
|
||||
// Bare `--browse` opens the console home (hosts, pairing, settings);
|
||||
// `--browse host[:port]` opens straight into that host's library.
|
||||
let target = arg_value("--browse");
|
||||
#[cfg(feature = "ui")]
|
||||
return crate::console::run(target.as_deref());
|
||||
#[cfg(not(feature = "ui"))]
|
||||
{
|
||||
let _ = target;
|
||||
eprintln!(
|
||||
"--browse needs the console UI — this is the minimal build \
|
||||
(rebuild without --no-default-features)"
|
||||
);
|
||||
return EXIT_PRESENTER_FAILED;
|
||||
}
|
||||
}
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!(
|
||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\
|
||||
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
||||
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
|
||||
\n\
|
||||
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
|
||||
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
|
||||
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
|
||||
library. --profile picks a settings profile by id or name for this session\n\
|
||||
only (\"\" = the global defaults); without it the host's own profile applies.\n\
|
||||
--connect never dials a host it has no pinned fingerprint for —\n\
|
||||
enrol with --pair (no display needed), in the console, or from the desktop\n\
|
||||
client."
|
||||
);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
json_line("error", &format!("client identity: {e:#}"), None);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
// `--resolved-spec <path>`: the spawner already did the resolving, so this process
|
||||
// performs ZERO store reads (design/client-architecture-split.md §5) — no Settings
|
||||
// load, no known-hosts lookup, no profile resolution. Without it (a hand-run
|
||||
// `--connect`, an old Decky script) the session resolves for itself through the SAME
|
||||
// helper, so the two modes cannot drift.
|
||||
let spec = arg_value("--resolved-spec").map(std::path::PathBuf::from);
|
||||
let (settings, profile_name, clipboard_override) = match &spec {
|
||||
Some(path) => match pf_client_core::orchestrate::ResolvedSpec::read(path) {
|
||||
Ok(s) => {
|
||||
tracing::info!(path = %path.display(), "running from a resolved spec");
|
||||
(s.settings, s.profile, Some(s.clipboard))
|
||||
}
|
||||
Err(e) => {
|
||||
json_line("error", &format!("resolved spec: {e}"), None);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let (settings, profile) =
|
||||
trust::effective_settings(&addr, port, profile_arg().as_deref());
|
||||
(settings, profile.map(|p| p.name), None)
|
||||
}
|
||||
};
|
||||
if let Some(name) = &profile_name {
|
||||
tracing::info!(profile = %name, "streaming with a settings profile");
|
||||
}
|
||||
|
||||
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
|
||||
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
|
||||
// silent TOFU would defeat the pinning model. Pair via the desktop client.
|
||||
let known = trust::KnownHosts::load();
|
||||
let known_host = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let pin = arg_value("--fp")
|
||||
.as_deref()
|
||||
.and_then(trust::parse_hex32)
|
||||
.or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex)));
|
||||
let Some(pin) = pin else {
|
||||
json_line(
|
||||
"error",
|
||||
&format!(
|
||||
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
),
|
||||
Some(true),
|
||||
);
|
||||
return EXIT_TRUST_REJECTED;
|
||||
};
|
||||
|
||||
let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone());
|
||||
let launch = arg_value("--launch");
|
||||
let title = launch
|
||||
.clone()
|
||||
.map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}"));
|
||||
|
||||
let fullscreen = arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some();
|
||||
|
||||
let opts = pf_presenter::SessionOpts {
|
||||
window_title: format!("Punktfunk · {title}"),
|
||||
fullscreen,
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||
// demoting an explicitly chosen richer tier.
|
||||
stats_verbosity: match settings.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
})),
|
||||
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
|
||||
// power-user build (`--no-default-features` drops the `ui` feature).
|
||||
#[cfg(feature = "ui")]
|
||||
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
||||
#[cfg(not(feature = "ui"))]
|
||||
overlay: None,
|
||||
window_size: window_size(&settings),
|
||||
// A spawned session (spec mode) reports its window; a hand-run one persists it.
|
||||
match_window: match_window(&settings, spec.is_none()),
|
||||
render_scale: settings.render_scale,
|
||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
};
|
||||
|
||||
let outcome =
|
||||
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
||||
session_params(
|
||||
&settings,
|
||||
profile_name,
|
||||
clipboard_override,
|
||||
addr,
|
||||
port,
|
||||
pin,
|
||||
identity,
|
||||
launch,
|
||||
gamepad,
|
||||
native,
|
||||
force_software,
|
||||
vulkan,
|
||||
)
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(pf_presenter::Outcome::Ended(None)) => 0,
|
||||
Ok(pf_presenter::Outcome::Ended(Some(reason))) => {
|
||||
// The host ending the session (game quit, host shutdown) is a normal end
|
||||
// for a one-shot stream binary — report the reason, exit clean.
|
||||
json_line("ended", &reason, None);
|
||||
0
|
||||
}
|
||||
Ok(pf_presenter::Outcome::ConnectFailed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
json_line("error", &msg, Some(trust_rejected));
|
||||
if trust_rejected {
|
||||
EXIT_TRUST_REJECTED
|
||||
} else {
|
||||
EXIT_CONNECT_FAILED
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
json_line("error", &format!("presenter: {e:#}"), None);
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
||||
/// (the Mac client lives in clients/apple).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn main() -> std::process::ExitCode {
|
||||
std::process::ExitCode::from(session_main::run())
|
||||
}
|
||||
|
||||
/// This stub keeps `cargo build --workspace` green elsewhere (the Mac client lives in
|
||||
/// clients/apple).
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn main() {
|
||||
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
||||
eprintln!(
|
||||
"punktfunk-session runs on Linux and Windows — the macOS client lives in clients/apple"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a
|
||||
//! profile or a game), design/client-deep-links.md §5.
|
||||
//!
|
||||
//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the
|
||||
//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser
|
||||
//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the
|
||||
//! host store changes, because the URL itself carries the stable id, the address and the pin.
|
||||
//!
|
||||
//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the
|
||||
//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for
|
||||
//! exactly this, with its own confirmation) is the intended upgrade there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and
|
||||
/// nowhere else — the standard check, and the one the portal docs use.
|
||||
pub fn sandboxed() -> bool {
|
||||
std::path::Path::new("/.flatpak-info").exists()
|
||||
}
|
||||
|
||||
/// Write `~/.local/share/applications/punktfunk-<slug>.desktop` for this URL and return the
|
||||
/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still
|
||||
/// works from a file manager, it just won't show up in search straight away.
|
||||
pub fn write_desktop_entry(label: &str, url: &str) -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?;
|
||||
let dir = PathBuf::from(home).join(".local/share/applications");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label)));
|
||||
// Desktop-entry values are line-oriented: a newline in a host name would end the Name
|
||||
// key and turn the rest into an unparsable line (or, worse, another key).
|
||||
let name = one_line(label);
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\n\
|
||||
Type=Application\n\
|
||||
Name={name}\n\
|
||||
Comment=Stream from this Punktfunk host\n\
|
||||
Exec=punktfunk-client \"{url}\"\n\
|
||||
Icon=io.unom.Punktfunk\n\
|
||||
Terminal=false\n\
|
||||
Categories=Game;Network;\n\
|
||||
StartupNotify=true\n"
|
||||
);
|
||||
std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
// Some desktops only offer a `.desktop` as a launchable icon when it is executable.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let _ = std::process::Command::new("update-desktop-database")
|
||||
.arg(&dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`,
|
||||
/// capped so a long host+profile pair can't produce a name the filesystem rejects.
|
||||
fn file_slug(label: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in label.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-');
|
||||
let capped: String = trimmed.chars().take(48).collect();
|
||||
if capped.is_empty() {
|
||||
"host".to_string()
|
||||
} else {
|
||||
capped
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic.
|
||||
fn one_line(s: &str) -> String {
|
||||
s.replace(['\n', '\r'], " ").trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Names become safe filenames, and a name that survives to `Name=` can't break the file.
|
||||
#[test]
|
||||
fn labels_are_sanitised_both_ways() {
|
||||
assert_eq!(file_slug("Living Room PC"), "living-room-pc");
|
||||
assert_eq!(file_slug("Büro · Mac"), "b-ro-mac");
|
||||
assert_eq!(file_slug("Desk · Work"), "desk-work");
|
||||
assert_eq!(file_slug("////"), "host");
|
||||
assert_eq!(file_slug(""), "host");
|
||||
assert!(file_slug(&"x".repeat(200)).len() <= 48);
|
||||
// The classic injection: a newline would end the Name key and start a new one.
|
||||
assert_eq!(
|
||||
one_line("Desk\nExec=rm -rf ~"),
|
||||
"Desk Exec=rm -rf ~".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session`
|
||||
//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of
|
||||
//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the
|
||||
//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed
|
||||
//! lifecycle events become the [`AppMsg`]s the relm4 app consumes — spinner until
|
||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, exit code 3 +
|
||||
//! `trust_rejected` routed to the re-pair PIN ceremony.
|
||||
//!
|
||||
//! Spawning, the argv, the stdout contract and the child handle live in
|
||||
//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell
|
||||
//! and the coming CLI spawn through the same code, so "what flags does a stream get" and
|
||||
//! "what does ready mean" have exactly one answer.
|
||||
|
||||
use crate::app::AppMsg;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
/// Spawn tunables beyond a plain connect.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SpawnOpts {
|
||||
/// Handshake budget override (`--connect-timeout`) — the request-access flow passes
|
||||
/// ~185 s because the host PARKS the connection until the operator approves.
|
||||
pub connect_timeout_secs: Option<u64>,
|
||||
/// Persist the host as *paired* once the child reports ready (request-access: the
|
||||
/// operator's approval IS the pairing). Plain TOFU persists unpaired.
|
||||
pub persist_paired: bool,
|
||||
/// A cancel handle to arm (request-access's waiting dialog): killing the child is
|
||||
/// the only abort a parked connect has.
|
||||
pub cancel: Option<CancelHandle>,
|
||||
}
|
||||
|
||||
pub use orchestrate::{session_binary, CancelHandle};
|
||||
|
||||
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
|
||||
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
|
||||
/// rather than the store — the app persists it once the child reports ready (the child
|
||||
/// connects pinned to it, so ready proves the host really holds that identity).
|
||||
///
|
||||
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
||||
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
||||
pub fn spawn_session(
|
||||
sender: relm4::Sender<AppMsg>,
|
||||
req: ConnectRequest,
|
||||
fp_hex: String,
|
||||
tofu: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
opts: SpawnOpts,
|
||||
) -> Result<(), String> {
|
||||
// The plan this connect resolves to. A plain card click carries no `--profile`: it honors
|
||||
// the host's own binding, which the session resolves through the same helper this shell
|
||||
// would have used (design/client-settings-profiles.md §4.6) — passing it here would be a
|
||||
// second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's
|
||||
// `profile=`) sets one, and it applies to this session alone.
|
||||
//
|
||||
// Two fields are deliberately not the shell's state yet, because nothing in the spawn path
|
||||
// reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake`
|
||||
// is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both
|
||||
// become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0).
|
||||
let plan = ConnectPlan {
|
||||
host: HostTarget {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex: Some(fp_hex.clone()),
|
||||
mac: req.mac.clone(),
|
||||
id: None,
|
||||
},
|
||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
profile: None,
|
||||
// A one-off pick rides the flag; without one the session resolves the host's own
|
||||
// binding through the same helper this shell would have used.
|
||||
profile_override: req.profile.clone(),
|
||||
settings: Settings {
|
||||
fullscreen_on_stream,
|
||||
..Default::default()
|
||||
},
|
||||
wake: false,
|
||||
connect_timeout_secs: opts.connect_timeout_secs,
|
||||
tofu,
|
||||
// The per-host clipboard decision, resolved here so the child doesn't look it up
|
||||
// again — matched by address, the way every other per-host lookup matches.
|
||||
clipboard: pf_client_core::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.any(|h| h.addr == req.addr && h.port == req.port && h.clipboard_sync),
|
||||
};
|
||||
|
||||
let persist_paired = opts.persist_paired;
|
||||
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
|
||||
orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev {
|
||||
SessionEvent::Ready => {
|
||||
let _ = sender.send(AppMsg::SessionReady {
|
||||
req: req.clone(),
|
||||
fp_hex: fp_hex.clone(),
|
||||
tofu,
|
||||
persist_paired,
|
||||
});
|
||||
}
|
||||
SessionEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => error = Some((msg, trust_rejected)),
|
||||
SessionEvent::Ended(msg) => ended = Some(msg),
|
||||
// The brain persists the window size; the shell has nothing to do with it.
|
||||
SessionEvent::Window { .. } => {}
|
||||
SessionEvent::Exited(code) => {
|
||||
let _ = sender.send(AppMsg::SessionExited {
|
||||
req: req.clone(),
|
||||
code,
|
||||
error: error.take(),
|
||||
ended: ended.take(),
|
||||
tofu,
|
||||
});
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's
|
||||
//! unified library fetched over the management API (`library.rs`), pushed onto the nav
|
||||
//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously
|
||||
//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping
|
||||
//! a title starts a session that asks the host to launch it (the library id rides the
|
||||
//! Hello via `ConnectRequest::launch`).
|
||||
|
||||
use crate::app::{AppModel, AppMsg};
|
||||
use crate::library::{self, GameEntry};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use relm4::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
|
||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||
/// art consumer (its weak upgrade fails).
|
||||
struct State {
|
||||
sender: ComponentSender<AppModel>,
|
||||
identity: (String, String),
|
||||
/// The advertised mgmt port when the host was live at open time (else the default).
|
||||
mgmt_port: u16,
|
||||
/// The host this library belongs to — cards clone it and add `launch`.
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
flow: gtk::FlowBox,
|
||||
error_page: adw::StatusPage,
|
||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||||
/// The Picture each entry currently renders into (rebuilt per render), so async art
|
||||
/// results land on the right card.
|
||||
pics: RefCell<HashMap<String, gtk::Picture>>,
|
||||
/// Screenshot mode: render injected entries only, never touch the network.
|
||||
mock: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
|
||||
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
|
||||
pub fn open(
|
||||
app: &AppModel,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port);
|
||||
load(&state);
|
||||
}
|
||||
|
||||
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
||||
/// entry id) with no host and no network — the CI `library` scene.
|
||||
pub fn open_mock(
|
||||
nav: &adw::NavigationView,
|
||||
identity: (String, String),
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
games: Vec<GameEntry>,
|
||||
art: Vec<(String, gdk::Texture)>,
|
||||
) {
|
||||
let state = build(nav, identity, sender, req, None);
|
||||
state.mock.set(true);
|
||||
state.art.borrow_mut().extend(art);
|
||||
if games.is_empty() {
|
||||
state.stack.set_visible_child_name("empty");
|
||||
} else {
|
||||
render(&state, &games);
|
||||
state.stack.set_visible_child_name("grid");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the page (loading / error / empty / grid states in a stack) and push it.
|
||||
fn build(
|
||||
nav: &adw::NavigationView,
|
||||
identity: (String, String),
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
mgmt_port: Option<u16>,
|
||||
) -> Rc<State> {
|
||||
let flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.column_spacing(12)
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
|
||||
// `activate` — bridge it so each poster's connect handler (below) runs on click.
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&flow);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(1100)
|
||||
.child(&content)
|
||||
.build();
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let loading = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||
loading.set_valign(gtk::Align::Center);
|
||||
let spinner = gtk::Spinner::new();
|
||||
spinner.set_size_request(32, 32);
|
||||
spinner.start();
|
||||
spinner.set_halign(gtk::Align::Center);
|
||||
loading.append(&spinner);
|
||||
let loading_label = gtk::Label::new(Some("Loading library…"));
|
||||
loading_label.add_css_class("dim-label");
|
||||
loading.append(&loading_label);
|
||||
|
||||
let error_page = adw::StatusPage::builder()
|
||||
.icon_name("dialog-error-symbolic")
|
||||
.title("Couldn't load the library")
|
||||
.build();
|
||||
let retry = gtk::Button::with_label("Retry");
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_halign(gtk::Align::Center);
|
||||
error_page.set_child(Some(&retry));
|
||||
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("applications-games-symbolic")
|
||||
.title("No games found")
|
||||
.description(
|
||||
"No games found on this host. Install Steam titles or add custom \
|
||||
entries in the host's web console.",
|
||||
)
|
||||
.build();
|
||||
|
||||
let stack = gtk::Stack::new();
|
||||
stack.add_named(&loading, Some("loading"));
|
||||
stack.add_named(&error_page, Some("error"));
|
||||
stack.add_named(&empty, Some("empty"));
|
||||
stack.add_named(&scrolled, Some("grid"));
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let reload = gtk::Button::from_icon_name("view-refresh-symbolic");
|
||||
reload.set_tooltip_text(Some("Reload"));
|
||||
header.pack_end(&reload);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&stack));
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title(format!("{} — Library", req.name))
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
|
||||
let state = Rc::new(State {
|
||||
sender: sender.clone(),
|
||||
identity,
|
||||
mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
error_page,
|
||||
art: RefCell::new(HashMap::new()),
|
||||
pics: RefCell::new(HashMap::new()),
|
||||
mock: Cell::new(false),
|
||||
});
|
||||
{
|
||||
let state = state.clone();
|
||||
reload.connect_clicked(move |_| load(&state));
|
||||
}
|
||||
{
|
||||
let state = state.clone();
|
||||
retry.connect_clicked(move |_| load(&state));
|
||||
}
|
||||
nav.push(&page);
|
||||
state
|
||||
}
|
||||
|
||||
/// Fetch the library off the main thread and route the result into the grid or the
|
||||
/// error/empty states.
|
||||
fn load(state: &Rc<State>) {
|
||||
if state.mock.get() {
|
||||
return; // screenshot scene renders injected entries only
|
||||
}
|
||||
state.stack.set_visible_child_name("loading");
|
||||
let port = state.mgmt_port;
|
||||
let addr = state.req.addr.clone();
|
||||
let identity = state.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-library".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin));
|
||||
})
|
||||
.expect("spawn library thread");
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
let Ok(result) = rx.recv().await else { return };
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
match result {
|
||||
Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"),
|
||||
Ok(games) => {
|
||||
render(&state, &games);
|
||||
state.stack.set_visible_child_name("grid");
|
||||
load_art(&state, &games);
|
||||
}
|
||||
Err(e) => {
|
||||
state.error_page.set_description(Some(&e.to_string()));
|
||||
state.stack.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// (Re)build the poster grid from one library snapshot. Cached textures apply
|
||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
state.flow.remove_all();
|
||||
state.pics.borrow_mut().clear();
|
||||
for game in games {
|
||||
state.flow.append(&game_card(state, game));
|
||||
}
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
||||
monogram.add_css_class("pf-poster-monogram");
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
placeholder.append(&monogram);
|
||||
monogram.set_vexpand(true);
|
||||
|
||||
let pic = gtk::Picture::new();
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
if let Some(tex) = state.art.borrow().get(&game.id) {
|
||||
pic.set_paintable(Some(tex));
|
||||
}
|
||||
state.pics.borrow_mut().insert(game.id.clone(), pic.clone());
|
||||
|
||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
||||
badge.add_css_class("pf-pill");
|
||||
badge.add_css_class("pf-store-badge");
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_valign(gtk::Align::Start);
|
||||
badge.set_margin_start(6);
|
||||
badge.set_margin_top(6);
|
||||
|
||||
let poster = gtk::Overlay::new();
|
||||
poster.set_child(Some(&placeholder));
|
||||
poster.add_overlay(&pic);
|
||||
poster.add_overlay(&badge);
|
||||
poster.add_css_class("pf-poster");
|
||||
poster.set_overflow(gtk::Overflow::Hidden);
|
||||
poster.set_size_request(150, 225);
|
||||
poster.set_halign(gtk::Align::Center);
|
||||
|
||||
let title = gtk::Label::new(Some(&game.title));
|
||||
title.add_css_class("caption");
|
||||
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||
title.set_max_width_chars(16);
|
||||
title.set_tooltip_text(Some(&game.title));
|
||||
|
||||
let card = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
card.append(&poster);
|
||||
card.append(&title);
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
child.set_child(Some(&card));
|
||||
let sender = state.sender.clone();
|
||||
let mut req = state.req.clone();
|
||||
req.launch = Some((game.id.clone(), game.title.clone()));
|
||||
child.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone())));
|
||||
child
|
||||
}
|
||||
|
||||
/// Fetch poster art for every uncached entry on a small worker pool, walking each
|
||||
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
||||
/// texturing the first that loads on the main loop.
|
||||
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
||||
let jobs: VecDeque<(String, Vec<String>)> = {
|
||||
let cache = state.art.borrow();
|
||||
games
|
||||
.iter()
|
||||
.filter(|g| !cache.contains_key(&g.id))
|
||||
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
|
||||
.filter(|(_, candidates)| !candidates.is_empty())
|
||||
.collect()
|
||||
};
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let identity = state.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok((id, bytes)) = rx.recv().await {
|
||||
let Some(state) = weak.upgrade() else { break };
|
||||
// Texture decode happens here on the main loop — posters are small (tens of
|
||||
// KB), and `from_bytes` handles jpeg/png alike.
|
||||
match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) {
|
||||
Ok(tex) => {
|
||||
if let Some(pic) = state.pics.borrow().get(&id) {
|
||||
pic.set_paintable(Some(&tex));
|
||||
}
|
||||
state.art.borrow_mut().insert(id, tex);
|
||||
}
|
||||
Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
|
||||
/// stores per the host's provider list), with the id prefix as a fallback spelling.
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn store_label(store: &str) -> &'static str {
|
||||
match store {
|
||||
"steam" => "Steam",
|
||||
"custom" => "Custom",
|
||||
"heroic" => "Heroic",
|
||||
"lutris" => "Lutris",
|
||||
"epic" => "Epic",
|
||||
"gog" => "GOG",
|
||||
"xbox" => "Xbox",
|
||||
_ => "Game",
|
||||
}
|
||||
}
|
||||
|
||||
/// Monogram for the placeholder tile: the first letters of the first two words.
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn initials(title: &str) -> String {
|
||||
title
|
||||
.split_whitespace()
|
||||
.take(2)
|
||||
.filter_map(|w| w.chars().next())
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn initials_take_two_words() {
|
||||
assert_eq!(initials("Dota 2"), "D2");
|
||||
assert_eq!(initials("half-life"), "H");
|
||||
assert_eq!(initials("The Witness III"), "TW");
|
||||
assert_eq!(initials(""), "");
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
||||
//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in
|
||||
//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens,
|
||||
//! each resolving into typed [`AppMsg`]s.
|
||||
|
||||
use crate::app::{AppModel, AppMsg};
|
||||
use crate::spawn::{CancelHandle, SpawnOpts};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
|
||||
use relm4::prelude::*;
|
||||
|
||||
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||||
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
||||
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||||
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||||
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
||||
/// lets the user cancel.
|
||||
///
|
||||
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
|
||||
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
|
||||
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
|
||||
pub fn wake_and_connect(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waking Host"),
|
||||
Some(&format!(
|
||||
"Sent a wake signal to “{}”. Waiting for it to come online…",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
||||
}
|
||||
waiting.present(Some(window));
|
||||
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::Duration;
|
||||
let events = crate::discovery::browse();
|
||||
let mut wait = WakeWait::new();
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
return;
|
||||
}
|
||||
// Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
|
||||
// and carries the address it came back on.
|
||||
let mut seen: Option<(String, u16)> = None;
|
||||
while let Ok(ev) = events.try_recv() {
|
||||
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
||||
continue;
|
||||
};
|
||||
let matched = match &req.fp_hex {
|
||||
Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp,
|
||||
None => h.addr == req.addr && h.port == req.port,
|
||||
};
|
||||
if matched {
|
||||
seen = Some((h.addr, h.port));
|
||||
}
|
||||
}
|
||||
let tick = wait.tick(seen.is_some());
|
||||
if tick.send_packet {
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
}
|
||||
match tick.outcome {
|
||||
Some(WakeOutcome::Online) => {
|
||||
waiting.close();
|
||||
let mut req = req.clone();
|
||||
// Re-key on a new DHCP lease so this + future connects dial the
|
||||
// live address.
|
||||
if let Some((addr, port)) =
|
||||
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
|
||||
{
|
||||
if let Some(fp) = &req.fp_hex {
|
||||
trust::rekey_addr(fp, &addr, port);
|
||||
}
|
||||
req.addr = addr;
|
||||
req.port = port;
|
||||
}
|
||||
sender.input(AppMsg::Connect(req));
|
||||
return;
|
||||
}
|
||||
Some(WakeOutcome::TimedOut) => {
|
||||
waiting.close();
|
||||
sender.input(AppMsg::Toast(format!(
|
||||
"Couldn't reach “{}” — is it powered and on the network?",
|
||||
req.name
|
||||
)));
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
glib::timeout_future(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines,
|
||||
/// far easier to compare against the host's log than one 64-char run.
|
||||
fn grouped_fingerprint(fp: &str) -> String {
|
||||
let groups: Vec<&str> = fp
|
||||
.as_bytes()
|
||||
.chunks(4)
|
||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||
.collect();
|
||||
groups
|
||||
.chunks(8)
|
||||
.map(|line| line.join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// First contact with a discovered host that opted into TOFU: show the advertised
|
||||
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
|
||||
/// Trusting starts a session pinned to the advertised fp; it persists once the child
|
||||
/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`).
|
||||
pub fn tofu_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let fp = req.fp_hex.clone().unwrap_or_default();
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("New Host"),
|
||||
Some(&format!(
|
||||
"{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \
|
||||
trusting accepts it as-is.",
|
||||
req.name, req.addr, req.port
|
||||
)),
|
||||
);
|
||||
let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp)));
|
||||
fp_label.add_css_class("monospace");
|
||||
fp_label.set_selectable(true);
|
||||
fp_label.set_justify(gtk::Justification::Center);
|
||||
fp_label.set_halign(gtk::Align::Center);
|
||||
dialog.set_extra_child(Some(&fp_label));
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pair", "Pair with PIN…"),
|
||||
("trust", "Trust & Connect"),
|
||||
]);
|
||||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("trust"));
|
||||
dialog.set_close_response("cancel");
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"trust" => sender.input(AppMsg::StartSession {
|
||||
req: req.clone(),
|
||||
fp_hex: fp.clone(),
|
||||
tofu: true,
|
||||
opts: SpawnOpts::default(),
|
||||
}),
|
||||
"pair" => sender.input(AppMsg::Pair(req.clone())),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
||||
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||
/// transcript. Success persists the host as paired and connects.
|
||||
pub fn pin_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
identity: (String, String),
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.input_purpose(gtk::InputPurpose::Digits)
|
||||
.placeholder_text("4-digit PIN shown by the host")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
// The label the HOST stores this client under — prefilled with the hostname.
|
||||
let name_entry = gtk::Entry::builder()
|
||||
.text(glib::host_name().as_str())
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let name_caption = gtk::Label::new(Some("This device"));
|
||||
name_caption.add_css_class("caption");
|
||||
name_caption.add_css_class("dim-label");
|
||||
name_caption.set_halign(gtk::Align::Start);
|
||||
let fields = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
fields.append(&name_caption);
|
||||
fields.append(&name_entry);
|
||||
let pin_caption = gtk::Label::new(Some("PIN"));
|
||||
pin_caption.add_css_class("caption");
|
||||
pin_caption.add_css_class("dim-label");
|
||||
pin_caption.set_halign(gtk::Align::Start);
|
||||
fields.append(&pin_caption);
|
||||
fields.append(&entry);
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pair with PIN"),
|
||||
Some(&format!(
|
||||
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.set_extra_child(Some(&fields));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
||||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("pair"));
|
||||
dialog.set_close_response("cancel");
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(Some("pair"), move |_, _| {
|
||||
let pin = entry.text().to_string();
|
||||
let req = req.clone();
|
||||
let identity = identity.clone();
|
||||
let sender = sender.clone();
|
||||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||
let device = name_entry.text().trim().to_string();
|
||||
let name = if device.is_empty() {
|
||||
glib::host_name().to_string()
|
||||
} else {
|
||||
device
|
||||
};
|
||||
let (host, port) = (req.addr.clone(), req.port);
|
||||
std::thread::spawn(move || {
|
||||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host
|
||||
// rejection) — never blame the PIN for a dead network path.
|
||||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
||||
.map_err(|e| trust::pair_error_message(&e));
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(fp)) => {
|
||||
let fp_hex = trust::hex(&fp);
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||||
sender.input(AppMsg::Toast("Paired — connecting…".into()));
|
||||
sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts::default(),
|
||||
});
|
||||
}
|
||||
Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)),
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// A fresh host that requires pairing: "Request access" (connect and wait for the
|
||||
/// operator to click Approve in the host's console — delegated approval) or the PIN
|
||||
/// ceremony.
|
||||
pub fn approval_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pairing Required"),
|
||||
Some(&format!(
|
||||
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pin", "Use a PIN instead…"),
|
||||
("request", "Request Access"),
|
||||
]);
|
||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("request"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = window.clone();
|
||||
let window = window.clone();
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
|
||||
"pin" => sender.input(AppMsg::Pair(req.clone())),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: the session child opens an identified connect the
|
||||
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
|
||||
/// dialog covers the wait. On approval the same connection is admitted and the host is
|
||||
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
|
||||
///
|
||||
/// The pinned fingerprint is the advertised one for a discovered host (defence against
|
||||
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
|
||||
/// the session binary refuses pinless connects, so this path requires the advert; a
|
||||
/// manual entry's Request Access rides the same flow only when a fingerprint exists.
|
||||
fn request_access(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let Some(fp_hex) = req.fp_hex.clone() else {
|
||||
// No fingerprint to pin (manual entry): the strict child can't do a
|
||||
// trust-on-approval connect — route to the PIN ceremony instead.
|
||||
sender.input(AppMsg::Toast(
|
||||
"No advertised identity for this host — pair with a PIN instead.".into(),
|
||||
));
|
||||
sender.input(AppMsg::Pair(req));
|
||||
return;
|
||||
};
|
||||
let cancel = CancelHandle::default();
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waiting for Approval"),
|
||||
Some(&format!(
|
||||
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||
connects automatically once you approve it.",
|
||||
glib::host_name(),
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let sender = sender.clone();
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||
cancel.kill();
|
||||
sender.input(AppMsg::CancelPending);
|
||||
});
|
||||
}
|
||||
waiting.present(Some(window));
|
||||
*waiting_slot.borrow_mut() = Some(waiting);
|
||||
|
||||
sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
|
||||
// operator approval still lands on this connection.
|
||||
connect_timeout_secs: Some(185),
|
||||
persist_paired: true,
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Runs the REAL `punktfunk-session` binary and asserts it speaks the stdout contract.
|
||||
//!
|
||||
//! 0.22.0 shipped a stub as `punktfunk-session` — a stray copy of the GTK shell replaced
|
||||
//! this crate's `main.rs`, and every build gate stayed green because the wrong program
|
||||
//! compiled perfectly. Nothing in CI ever *ran* the binary; the failure only existed at
|
||||
//! runtime, as a connect that silently bounced back to the host list. This test is the
|
||||
//! gate that would have caught it: whatever the binary does, it must SAY so on stdout.
|
||||
#![cfg(any(target_os = "linux", windows))]
|
||||
|
||||
use std::io::Read as _;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A failing connect must still produce a contract line. Port 1 on loopback refuses
|
||||
/// instantly and the all-zero pin keeps trust logic out of the way; whatever fails first
|
||||
/// on this machine — presenter init on a headless runner, the dial on a box with a
|
||||
/// display — the contract requires one `{"error"…}` JSON line on stdout saying so. (On a
|
||||
/// desktop this may flash a window for under a second; the connect refuses immediately.)
|
||||
#[test]
|
||||
fn a_failing_connect_still_speaks_the_contract() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_punktfunk-session"))
|
||||
.args([
|
||||
"--connect",
|
||||
"127.0.0.1:1",
|
||||
"--fp",
|
||||
&"0".repeat(64),
|
||||
"--connect-timeout",
|
||||
"5",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn punktfunk-session");
|
||||
|
||||
let mut stdout = child.stdout.take().expect("piped stdout");
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
let _ = stdout.read_to_string(&mut buf);
|
||||
buf
|
||||
});
|
||||
|
||||
// Generous bound: presenter init on a cold CI runner can be slow, but a healthy
|
||||
// binary answers in seconds. Only a hang (or a stub that inherited our stdout and
|
||||
// blocked) gets anywhere near it.
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
match child.try_wait().expect("wait on punktfunk-session") {
|
||||
Some(_) => break,
|
||||
None if Instant::now() > deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("punktfunk-session neither exited nor spoke within 120 s");
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(200)),
|
||||
}
|
||||
}
|
||||
|
||||
let out = reader.join().expect("stdout reader");
|
||||
let spoke = out.lines().map(str::trim).any(|l| {
|
||||
l.starts_with('{')
|
||||
&& (l.contains("\"error\"") || l.contains("\"ready\"") || l.contains("\"ended\""))
|
||||
});
|
||||
assert!(
|
||||
spoke,
|
||||
"no stdout-contract line — the wrong program may be wearing this binary's name. \
|
||||
stdout was:\n{out}"
|
||||
);
|
||||
}
|
||||
@@ -287,7 +287,7 @@ fn connect_spawn(
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||
SpawnEvent::Exited { error, ended } => {
|
||||
SpawnEvent::Exited { error, ended, code } => {
|
||||
match error {
|
||||
Some((msg, true)) => {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via
|
||||
@@ -311,8 +311,14 @@ fn connect_spawn(
|
||||
}
|
||||
// `ended` = the host ended the session (banner); a clean exit
|
||||
// (user closed the stream window / Disconnect) returns silently.
|
||||
// A child that said nothing AND failed gets the exit code, so the
|
||||
// return to the host list is never unexplained.
|
||||
None => {
|
||||
st.call(ended.unwrap_or_default());
|
||||
st.call(
|
||||
ended
|
||||
.or_else(|| crate::spawn::silent_exit_banner(code))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
@@ -367,11 +373,18 @@ pub(crate) fn open_console(
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||
SpawnEvent::Exited { error, ended } => {
|
||||
SpawnEvent::Exited { error, ended, code } => {
|
||||
crate::shell_window::restore();
|
||||
// Quit from the library (B / closing the window) returns silently;
|
||||
// a failed start surfaces its error line.
|
||||
st.call(error.map(|(msg, _)| msg).or(ended).unwrap_or_default());
|
||||
// a failed start surfaces its error line, or the exit code when it
|
||||
// died without producing one.
|
||||
st.call(
|
||||
error
|
||||
.map(|(msg, _)| msg)
|
||||
.or(ended)
|
||||
.or_else(|| crate::spawn::silent_exit_banner(code))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,16 @@ pub(crate) enum SpawnEvent {
|
||||
/// One `stats:` line, already human-formatted by the session (per 1 s window).
|
||||
Stats(String),
|
||||
/// The child exited (stdout EOF + reap; a kill lands here too). `error`/`ended`
|
||||
/// carry the contract lines seen on the way out, when any (the exit code is logged
|
||||
/// by the reader; routing keys off the lines, which say strictly more).
|
||||
/// carry the contract lines seen on the way out, when any — routing keys off those,
|
||||
/// which say strictly more than a number. `code` is the process exit status (-1 = no
|
||||
/// code, i.e. killed) and exists for the case where there were NO lines at all: a
|
||||
/// child that dies before it can speak the contract would otherwise be indistinguishable
|
||||
/// from a clean user-initiated quit, and the shell would bounce to the host list with a
|
||||
/// blank banner. That is exactly how the 0.22.0 session-binary regression presented.
|
||||
Exited {
|
||||
error: Option<(String, bool)>,
|
||||
ended: Option<String>,
|
||||
code: i32,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -75,6 +80,19 @@ fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The banner for a child that exited having said NOTHING on stdout — no `ready`, no
|
||||
/// `error`, no `ended`. `None` keeps the silent return the UI has always given a clean
|
||||
/// quit: code 0 is the user closing the stream window, and -1 is our own Disconnect/Cancel
|
||||
/// kill (no exit code). Anything else is the session dying before it could speak its
|
||||
/// contract — a missing runtime DLL, a crash, or the wrong binary sitting next to the
|
||||
/// shell — and reporting the code is the difference between a diagnosable failure and a
|
||||
/// connect that silently drops back to the host list.
|
||||
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
|
||||
(code != 0 && code != -1).then(|| {
|
||||
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
|
||||
})
|
||||
}
|
||||
|
||||
/// The session binary: installed next to the shell (the MSIX layout and dev
|
||||
/// `target\…` runs both land on the sibling), else `PATH`.
|
||||
pub(crate) fn session_binary() -> std::path::PathBuf {
|
||||
@@ -220,7 +238,7 @@ fn spawn_with(
|
||||
.and_then(|s| s.code())
|
||||
.unwrap_or(-1);
|
||||
tracing::info!(code, "session binary exited");
|
||||
on_event(SpawnEvent::Exited { error, ended });
|
||||
on_event(SpawnEvent::Exited { error, ended, code });
|
||||
})
|
||||
.map_err(|e| format!("session reader thread: {e}"))?;
|
||||
Ok(())
|
||||
@@ -262,4 +280,17 @@ mod tests {
|
||||
assert!(parse_line("").is_none());
|
||||
assert!(parse_line("{\"other\":1}").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_failing_exit_is_never_blank() {
|
||||
// Clean quit (stream window closed) and our own kill stay silent.
|
||||
assert!(silent_exit_banner(0).is_none());
|
||||
assert!(silent_exit_banner(-1).is_none());
|
||||
// A child that died without speaking the contract names its code — the 0.22.0
|
||||
// regression (a stub session binary exiting 2) showed as a blank bounce to the
|
||||
// host list precisely because nothing filled this in.
|
||||
let banner = silent_exit_banner(2).expect("failing exit must say something");
|
||||
assert!(banner.contains('2'), "{banner}");
|
||||
assert!(silent_exit_banner(101).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,9 +385,15 @@ impl WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: 0,
|
||||
instance_prefix: "pf_pad",
|
||||
hwid: "pf_gamepad",
|
||||
// ⚠️ A HARDWARE ID, not the package name. `pf-dualsense` became `pf-gamepad` in
|
||||
// 560e663a and this line was renamed with it — but the INF deliberately kept the four
|
||||
// OLD hardware ids, so `pf_gamepad` matched no INF of ours. PnP then fell through to
|
||||
// the devnode's synthesized USB ids, bound Microsoft's inbox `input.inf`/`HidUsb`, and
|
||||
// that cannot start on a software-enumerated devnode: CM_PROB_FAILED_START, no HID
|
||||
// child, no channel proof, and a pad no game ever saw. `hwid_matches_inf` pins it now.
|
||||
hwid: "pf_dualsense",
|
||||
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||
description: "punktfunk Virtual DualSense",
|
||||
description: "Punktfunk Virtual DualSense",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +403,7 @@ impl WinDsIdentity {
|
||||
instance_prefix: "pf_edge",
|
||||
hwid: "pf_dualsenseedge",
|
||||
usb_vid_pid: "VID_054C&PID_0DF2",
|
||||
description: "punktfunk Virtual DualSense Edge",
|
||||
description: "Punktfunk Virtual DualSense Edge",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,12 +639,12 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
hwid: super::steam_deck_windows::DECK_HWID,
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The Deck's controller interface — the promotion gate the first spike run hit
|
||||
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck (spike)",
|
||||
description: "Punktfunk Virtual Steam Deck (spike)",
|
||||
})?;
|
||||
// The spike drives a real pad channel, so it takes the same devnode-proved delivery a session
|
||||
// pad does — no special case, and no reason for a bring-up tool to run on the old trust.
|
||||
@@ -802,6 +808,58 @@ mod drain_tests {
|
||||
assert_eq!(got, vec![vec![0x02, 99]]);
|
||||
}
|
||||
|
||||
/// Every hardware id the host puts on a pad devnode must be one the shipped INF actually
|
||||
/// declares — otherwise PnP matches none of our models, falls through to the synthesized USB
|
||||
/// ids on the same devnode, and binds Microsoft's inbox `input.inf`/`HidUsb`, which cannot
|
||||
/// start on a software-enumerated devnode. The result is a pad that exists, never starts, and
|
||||
/// never answers a channel proof; that is exactly what shipped in 0.22.0/0.22.1 for the plain
|
||||
/// DualSense, because the `pf-dualsense` -> `pf-gamepad` PACKAGE rename also rewrote this
|
||||
/// HARDWARE id (560e663a). The ids are a binding contract with every installed system, so they
|
||||
/// must outlive any future package rename — this test is what makes that structural.
|
||||
#[test]
|
||||
fn hwid_matches_inf() {
|
||||
let inx = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx"
|
||||
);
|
||||
let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx");
|
||||
// The [Models] lines: `%DeviceDesc…%=pfGamepad, <hwid>[, <hwid>…]`.
|
||||
let declared: Vec<String> = inf
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.starts_with(';'))
|
||||
.filter_map(|l| l.split_once("=pfGamepad,"))
|
||||
.flat_map(|(_, ids)| {
|
||||
ids.split(',')
|
||||
.map(|id| id.trim().to_ascii_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
declared.len() >= 4,
|
||||
"parsed {} hardware ids out of {inx} — the [Models] shape changed and this test went \
|
||||
vacuous; fix the parse rather than deleting the assert",
|
||||
declared.len()
|
||||
);
|
||||
for hwid in [
|
||||
WinDsIdentity::dualsense().hwid,
|
||||
WinDsIdentity::dualsense_edge().hwid,
|
||||
super::super::dualshock4_windows::DS4_HWID,
|
||||
super::super::steam_deck_windows::DECK_HWID,
|
||||
] {
|
||||
let want = hwid.to_ascii_lowercase();
|
||||
let rooted = format!("root\\{want}");
|
||||
assert!(
|
||||
declared
|
||||
.iter()
|
||||
.any(|d| d.as_str() == want || d.as_str() == rooted),
|
||||
"the host creates pad devnodes with hardware id {hwid:?}, which pf_gamepad.inx \
|
||||
does not declare (it has {declared:?}) — PnP would bind inbox input.inf/HidUsb \
|
||||
instead and the pad would never start"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_driver_still_drains_the_latest_slot() {
|
||||
let mut buf = section();
|
||||
|
||||
@@ -21,6 +21,10 @@ use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package
|
||||
/// rename must never touch it (`dualsense_windows::tests::hwid_matches_inf` enforces that).
|
||||
pub(super) const DS4_HWID: &str = "pf_dualshock4";
|
||||
|
||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
|
||||
@@ -66,10 +70,10 @@ impl Ds4WinPad {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_dualshock4",
|
||||
hwid: DS4_HWID,
|
||||
usb_vid_pid: "VID_054C&PID_09CC",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual DualShock 4",
|
||||
description: "Punktfunk Virtual DualShock 4",
|
||||
})?; // Propagate, do NOT swallow — see below.
|
||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
||||
// Swallowing a create failure here (the previous behaviour) latched the pad slot to
|
||||
|
||||
@@ -56,7 +56,7 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let desc: Vec<u16> = "punktfunk Virtual Xbox 360 (XUSB)"
|
||||
let desc: Vec<u16> = "Punktfunk Virtual Xbox 360 (XUSB)"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
@@ -66,7 +66,7 @@ impl VirtualMouse {
|
||||
// a mouse (nothing fingerprints them); reusing the shared profile keeps one code path.
|
||||
usb_vid_pid: "VID_5046&PID_4D4F",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual Mouse",
|
||||
description: "Punktfunk Virtual Mouse",
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
@@ -386,7 +386,7 @@ pub fn channel_proof_probe() -> Result<()> {
|
||||
hwid: "pf_mouse",
|
||||
usb_vid_pid: "VID_5046&PID_4D4F",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual Mouse (channel-proof probe)",
|
||||
description: "Punktfunk Virtual Mouse (channel-proof probe)",
|
||||
})?;
|
||||
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
||||
let Some(instance_id) = instance_id else {
|
||||
|
||||
@@ -30,6 +30,10 @@ use anyhow::Result;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package
|
||||
/// rename must never touch it (`dualsense_windows::tests::hwid_matches_inf` enforces that).
|
||||
pub(super) const DECK_HWID: &str = "pf_steamdeck";
|
||||
|
||||
/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
|
||||
@@ -70,13 +74,13 @@ impl DeckWinPad {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
hwid: DECK_HWID,
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The wired Deck controller interface — WITHOUT this the HID child carries no MI_
|
||||
// token, hidapi reports interface 0, and Steam never claims the pad (the N4
|
||||
// spike's run-1 failure).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck",
|
||||
description: "Punktfunk Virtual Steam Deck",
|
||||
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
|
||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
||||
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
|
||||
|
||||
@@ -92,7 +92,7 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
|
||||
}
|
||||
|
||||
/// Reap the ghost (NOT-present) "punktfunk" virtual-monitor device nodes that `IddCxMonitorDeparture`
|
||||
/// leaves behind. Each departed monitor leaves a not-present "Generic Monitor (punktfunk)" PDO that keeps
|
||||
/// leaves behind. Each departed monitor leaves a not-present "Generic Monitor (Punktfunk)" PDO that keeps
|
||||
/// pinning an OS VidPN target against the IddCx adapter's fixed monitor-slot budget; once ~16 accumulate,
|
||||
/// `IOCTL_ADD` wedges at 0x80070490 (`ERROR_NOT_FOUND`) and every session black-screens until a manual
|
||||
/// reset/reboot. Removing the not-present PDOs frees the slots — the in-process equivalent of
|
||||
@@ -532,7 +532,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
|
||||
}
|
||||
// CLEAR_ALL only departs the driver's own (in-process) monitor list; it can NOT remove the
|
||||
// OS-side not-present "Generic Monitor (punktfunk)" PDOs that a previous host-run's monitor
|
||||
// OS-side not-present "Generic Monitor (Punktfunk)" PDOs that a previous host-run's monitor
|
||||
// departures left behind. Reap those here so a fresh host start begins with a clean IddCx
|
||||
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
|
||||
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
|
||||
|
||||
@@ -103,7 +103,18 @@ impl DataPump {
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
|
||||
// default): the target is deliberately far above any plausible link so the burst measures
|
||||
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
|
||||
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
|
||||
// timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
|
||||
// embedder that caps its own speed test wants this capped to match.
|
||||
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(2_000_000);
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
@@ -245,14 +256,14 @@ impl DataPump {
|
||||
};
|
||||
if ctrl_tx
|
||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||
target_kbps: CAPACITY_PROBE_KBPS,
|
||||
target_kbps: capacity_probe_kbps,
|
||||
duration_ms: CAPACITY_PROBE_MS,
|
||||
}))
|
||||
.is_ok()
|
||||
{
|
||||
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
|
||||
tracing::info!(
|
||||
target_kbps = CAPACITY_PROBE_KBPS,
|
||||
target_kbps = capacity_probe_kbps,
|
||||
duration_ms = CAPACITY_PROBE_MS,
|
||||
"adaptive bitrate: startup link-capacity probe"
|
||||
);
|
||||
|
||||
@@ -202,6 +202,19 @@ pub enum EventKind {
|
||||
/// API (RFC §8) lands.
|
||||
source: String,
|
||||
},
|
||||
/// A verified update manifest announced a release newer than the running host. Emitted
|
||||
/// once per discovered version (a steady-state "newer exists" doesn't re-fire on every
|
||||
/// refresh).
|
||||
#[serde(rename = "update.available")]
|
||||
UpdateAvailable {
|
||||
/// The newer release's version string.
|
||||
version: String,
|
||||
/// The channel it was announced on (`stable` | `canary`).
|
||||
channel: String,
|
||||
/// This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the
|
||||
/// tray render the right "how to update" hint without a second call.
|
||||
install_kind: String,
|
||||
},
|
||||
#[serde(rename = "plugins.changed")]
|
||||
PluginsChanged {
|
||||
/// The plugin whose registration changed (registered, restarted, deregistered, or
|
||||
@@ -242,6 +255,7 @@ impl EventKind {
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::UpdateAvailable { .. } => "update.available",
|
||||
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||
EventKind::StoreChanged => "store.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
|
||||
@@ -99,6 +99,7 @@ mod stats_recorder;
|
||||
// same runner CLI the `plugins` subcommand uses (design/plugin-store.md).
|
||||
mod store;
|
||||
mod stream_marker;
|
||||
mod update;
|
||||
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
|
||||
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -45,6 +45,7 @@ mod stats;
|
||||
mod store;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod update;
|
||||
|
||||
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
||||
/// number Sunshine users already associate with "the config UI".
|
||||
@@ -257,7 +258,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(store::get_job))
|
||||
.routes(routes!(store::list_sources))
|
||||
.routes(routes!(store::put_source, store::delete_source))
|
||||
.routes(routes!(store::get_runtime, store::set_runtime)),
|
||||
.routes(routes!(store::get_runtime, store::set_runtime))
|
||||
.routes(routes!(update::get_update_status))
|
||||
.routes(routes!(update::force_update_check)),
|
||||
)
|
||||
.split_for_parts()
|
||||
}
|
||||
@@ -297,6 +300,7 @@ pub fn openapi_json() -> String {
|
||||
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
||||
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
|
||||
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
|
||||
(name = "update", description = "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
@@ -149,7 +149,12 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
|| (method == Method::DELETE
|
||||
&& (path.starts_with("/api/v1/clients/")
|
||||
|| path.starts_with("/api/v1/native/clients/")))
|
||||
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"));
|
||||
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
|
||||
// The update surface is operator business end to end: today it is only a check, but
|
||||
// the same prefix will carry `apply` (running an installer / the root helper), and a
|
||||
// whole-prefix deny can't be defeated by a route added later.
|
||||
|| path == "/api/v1/update"
|
||||
|| path.starts_with("/api/v1/update/");
|
||||
!denied
|
||||
}
|
||||
|
||||
|
||||
@@ -544,6 +544,31 @@ fn plugin_allowlist_excludes_escalation_routes() {
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
|
||||
// The update surface, wholesale: today a check, tomorrow `apply` (an installer / the root
|
||||
// helper) — operator business end to end, denied by whole-prefix so the apply route added in
|
||||
// U1/U2 is denied by default rather than by remembering to list it. And it is deliberately
|
||||
// NOT on the paired-cert allowlist either: a streaming client has no business knowing or
|
||||
// steering the host's update state.
|
||||
for path in [
|
||||
"/api/v1/update",
|
||||
"/api/v1/update/status",
|
||||
"/api/v1/update/check",
|
||||
"/api/v1/update/apply-does-not-exist-yet",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::GET, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::POST, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
assert!(
|
||||
!auth::cert_may_access(&Method::GET, path),
|
||||
"a paired streaming cert must not reach {path}"
|
||||
);
|
||||
}
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::PUT,
|
||||
"/api/v1/store/sources/evil"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `/api/v1/update/*` — the host update-check surface (design
|
||||
//! `host-update-from-web-console.md` §4.2, phase U0).
|
||||
//!
|
||||
//! Admin lane ONLY: denied to the plugin token (`auth::plugin_may_access`) and absent from
|
||||
//! the paired-cert allowlist — an update trigger is operator business. U0 exposes `status` +
|
||||
//! `check`; the `apply` route arrives with the first apply leg (U1) so the API never
|
||||
//! advertises a capability no code backs.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::update::{self, detect};
|
||||
|
||||
/// One channel's manifest facts, as much as the console renders.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateManifestInfo {
|
||||
/// The released version this manifest announces.
|
||||
pub version: String,
|
||||
/// Publish serial (unix seconds) — monotonic per channel.
|
||||
pub serial: u64,
|
||||
/// RFC-3339 publish time (display only).
|
||||
pub published_at: String,
|
||||
/// Release-notes link (pinned to our forge by the manifest validator).
|
||||
pub notes_url: String,
|
||||
/// The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// The full update-check state for this host.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateStatus {
|
||||
/// How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |
|
||||
/// `dnf` | `pacman` | `steamos-source` | `nix` | `source`.
|
||||
pub install_kind: String,
|
||||
/// Release channel this install follows: `stable` | `canary`.
|
||||
pub channel: String,
|
||||
/// The running host version.
|
||||
pub current_version: String,
|
||||
/// What the console may offer for this install: `notify` (show the command) — later
|
||||
/// phases add `full` (one-click apply) and `staged` (apply + reboot to finish).
|
||||
pub apply: String,
|
||||
/// The copy-pastable update command for this install kind.
|
||||
pub channel_hint: String,
|
||||
/// Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`).
|
||||
pub check_disabled: bool,
|
||||
/// A newer release than `current_version` exists for this channel (definitive
|
||||
/// comparisons only — an unparseable version pair never flags).
|
||||
pub available: bool,
|
||||
/// The last verified manifest, if any check has succeeded.
|
||||
pub manifest: Option<UpdateManifestInfo>,
|
||||
/// When the last successful check happened (unix seconds).
|
||||
pub last_checked_unix: Option<u64>,
|
||||
/// Why the last check failed, verbatim, if it did.
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
fn status_from(snap: update::Snapshot) -> UpdateStatus {
|
||||
let (kind, channel) = detect::detect();
|
||||
let current = env!("PUNKTFUNK_VERSION");
|
||||
let stale = snap.stale();
|
||||
let available = snap
|
||||
.checked
|
||||
.as_ref()
|
||||
.map(|c| detect::is_newer(&c.manifest.version, c.manifest.ci_run, current, channel))
|
||||
.unwrap_or(false);
|
||||
UpdateStatus {
|
||||
install_kind: kind.as_str().into(),
|
||||
channel: channel.as_str().into(),
|
||||
current_version: current.into(),
|
||||
apply: "notify".into(),
|
||||
channel_hint: detect::channel_hint(kind).into(),
|
||||
check_disabled: update::check_disabled(),
|
||||
available,
|
||||
manifest: snap.checked.as_ref().map(|c| UpdateManifestInfo {
|
||||
version: c.manifest.version.clone(),
|
||||
serial: c.manifest.serial,
|
||||
published_at: c.manifest.published_at.clone(),
|
||||
notes_url: c.manifest.notes_url.clone(),
|
||||
stale,
|
||||
}),
|
||||
last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix),
|
||||
last_error: snap.last_error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update-check status
|
||||
///
|
||||
/// How this host was installed, which channel it follows, whether a newer release is known,
|
||||
/// and how to update. Reading this may kick a background refresh when the cached check is
|
||||
/// older than 6 h; the response never blocks on the network.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/update/status",
|
||||
tag = "update",
|
||||
operation_id = "getUpdateStatus",
|
||||
responses(
|
||||
(status = OK, description = "Current update-check state", body = UpdateStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_update_status() -> Json<UpdateStatus> {
|
||||
Json(status_from(update::snapshot_and_maybe_refresh()))
|
||||
}
|
||||
|
||||
/// Check for updates now
|
||||
///
|
||||
/// Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to
|
||||
/// one forced check per 30 s.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/update/check",
|
||||
tag = "update",
|
||||
operation_id = "forceUpdateCheck",
|
||||
responses(
|
||||
(status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus),
|
||||
(status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError),
|
||||
(status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn force_update_check() -> Response {
|
||||
match update::force_check().await {
|
||||
Ok(snap) => Json(status_from(snap)).into_response(),
|
||||
Err(update::ForceError::Disabled) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0)",
|
||||
),
|
||||
Err(update::ForceError::TooSoon) => api_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"a forced update check ran less than 30 s ago — try again shortly",
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Host **update check** (design `host-update-from-web-console.md`, phase U0).
|
||||
//!
|
||||
//! This module answers one question for the console: *does a newer host release exist for
|
||||
//! this box's channel* — by fetching the per-channel signed manifest and verifying it against
|
||||
//! the Ed25519 keys pinned below. It deliberately contains **no apply code**: U0 ships check
|
||||
//! everywhere; apply legs land per-channel (U1 Windows, U2 Linux helper) behind the same
|
||||
//! status surface.
|
||||
//!
|
||||
//! Shape: a process-wide cache + a lazy refresh. `GET /update/status` returns the cache and,
|
||||
//! when it is older than [`AUTO_REFRESH_AFTER`], kicks a background refresh — the console
|
||||
//! polls status anyway, so freshness needs no timer of its own. `POST /update/check` forces a
|
||||
//! refresh, rate-limited to one per [`FORCE_MIN_INTERVAL`].
|
||||
//!
|
||||
//! Trust and failure rules live in [`manifest`]; the serial floor persisted here
|
||||
//! (`update-state.json`) is what makes a replayed older manifest an *error*, not a silent
|
||||
//! downgrade of our knowledge. `PUNKTFUNK_UPDATE_CHECK=0` disables all network activity —
|
||||
//! status then reports `check_disabled` and carries whatever identity facts need no network.
|
||||
|
||||
pub(crate) mod detect;
|
||||
pub(crate) mod manifest;
|
||||
|
||||
use crate::store::index::PublicKey;
|
||||
use manifest::Manifest;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The Ed25519 public keys this binary trusts for update manifests — two slots so a key
|
||||
/// rotation is "sign with the new one, ship a host trusting both, retire the old" (the
|
||||
/// plugin-store `OFFICIAL_KEYS` drill). The private half is the `UPDATE_MANIFEST_KEY` CI
|
||||
/// secret; it also lives in the operator's offline backup (plan U0.1 DoD).
|
||||
pub(crate) const UPDATE_KEYS: [&str; 2] = [
|
||||
"ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=",
|
||||
"", // rotation slot
|
||||
];
|
||||
|
||||
/// Feed base — `<base>/<channel>/manifest.json` + `.sig`. Override for tests/dev feeds via
|
||||
/// `PUNKTFUNK_UPDATE_FEED` (a base URL, not request-time input: env is operator config).
|
||||
const DEFAULT_FEED_BASE: &str = "https://git.unom.io/api/packages/unom/generic/punktfunk-update";
|
||||
|
||||
/// A cache older than this is refreshed in the background on the next status read.
|
||||
const AUTO_REFRESH_AFTER: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
|
||||
/// Forced checks (`POST /update/check`) are rate-limited to one per this interval.
|
||||
pub(crate) const FORCE_MIN_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A manifest whose publish serial is older than this is flagged stale in status — the
|
||||
/// freeze-detection hint (design §3.2), not an error.
|
||||
const STALE_AFTER: Duration = Duration::from_secs(45 * 24 * 60 * 60);
|
||||
|
||||
/// One fetch's wall-clock budget (mirrors the store catalog fetch).
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Update checks disabled by operator config (env or `host.env`).
|
||||
pub(crate) fn check_disabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_UPDATE_CHECK").as_deref(),
|
||||
Ok("0") | Ok("false") | Ok("off")
|
||||
)
|
||||
}
|
||||
|
||||
fn feed_base() -> String {
|
||||
std::env::var("PUNKTFUNK_UPDATE_FEED")
|
||||
.ok()
|
||||
.filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1"))
|
||||
.unwrap_or_else(|| DEFAULT_FEED_BASE.to_string())
|
||||
}
|
||||
|
||||
fn pinned_keys() -> Vec<PublicKey> {
|
||||
UPDATE_KEYS
|
||||
.iter()
|
||||
.filter(|k| !k.is_empty())
|
||||
.filter_map(|k| PublicKey::parse(k).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- runtime state
|
||||
|
||||
/// What the last successful refresh produced.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Checked {
|
||||
pub manifest: Manifest,
|
||||
pub fetched_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Runtime {
|
||||
checked: Option<Checked>,
|
||||
last_error: Option<String>,
|
||||
/// Refresh in flight (status kicks at most one).
|
||||
refreshing: bool,
|
||||
/// Wall-clock guard for the forced-check rate limit.
|
||||
last_forced: Option<Instant>,
|
||||
/// Last attempt of any kind — drives the auto-refresh cadence.
|
||||
last_attempt: Option<Instant>,
|
||||
/// The manifest version an `update.available` event was already emitted for, so a
|
||||
/// steady-state "newer exists" doesn't re-announce every 6 h.
|
||||
announced: Option<String>,
|
||||
}
|
||||
|
||||
fn runtime() -> &'static Mutex<Runtime> {
|
||||
static RT: OnceLock<Mutex<Runtime>> = OnceLock::new();
|
||||
RT.get_or_init(|| Mutex::new(Runtime::default()))
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- serial floor
|
||||
|
||||
/// Persisted anti-rollback state: the highest manifest serial ever accepted per channel.
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
struct FloorFile {
|
||||
#[serde(default)]
|
||||
serial_floor: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("update-state.json")
|
||||
}
|
||||
|
||||
fn load_floor(path: &Path, channel: &str) -> u64 {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<FloorFile>(&b).ok())
|
||||
.and_then(|f| f.serial_floor.get(channel).copied())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
|
||||
fn store_floor(path: &Path, channel: &str, serial: u64) {
|
||||
let mut file: FloorFile = std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let slot = file.serial_floor.entry(channel.to_string()).or_insert(0);
|
||||
if serial <= *slot {
|
||||
return;
|
||||
}
|
||||
*slot = serial;
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&file) else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refresh
|
||||
|
||||
/// Fetch + verify the channel manifest. Blocking (`ureq`) — call from a blocking thread.
|
||||
fn fetch_manifest_blocking(channel: &str) -> Result<Manifest, String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
// Follow the registry's 303-to-object-storage redirect; the signature is verified
|
||||
// over the FINAL bytes (the sysext-feed lesson).
|
||||
.redirects(3)
|
||||
.user_agent(&format!(
|
||||
"punktfunk-host/{} (update-check)",
|
||||
env!("PUNKTFUNK_VERSION")
|
||||
))
|
||||
.build();
|
||||
let base = feed_base();
|
||||
let url = format!("{base}/{channel}/manifest.json");
|
||||
let sig_url = format!("{url}.sig");
|
||||
|
||||
let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?;
|
||||
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
|
||||
let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?;
|
||||
|
||||
let keys = pinned_keys();
|
||||
if keys.is_empty() {
|
||||
// Both slots empty would mean a build with the feature disarmed; refuse rather than
|
||||
// silently skipping verification.
|
||||
return Err("no update key is pinned in this build".into());
|
||||
}
|
||||
manifest::verify_and_parse(&body, &sig_text, &keys, channel).map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
fn fetch_err(e: ureq::Error) -> String {
|
||||
match e {
|
||||
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
|
||||
other => format!("feed fetch failed: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read as _;
|
||||
let mut buf = Vec::new();
|
||||
let mut reader = resp
|
||||
.into_reader()
|
||||
.take(manifest::MAX_MANIFEST_BYTES as u64 + 1);
|
||||
reader
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("read failed: {e}"))?;
|
||||
if buf.len() > manifest::MAX_MANIFEST_BYTES {
|
||||
return Err("response exceeds the manifest size cap".into());
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache,
|
||||
/// announce a newly available release on the event bus. Returns the user-facing error string
|
||||
/// on failure (also cached for status).
|
||||
pub(crate) fn refresh_blocking() -> Result<Checked, String> {
|
||||
let (kind, channel) = detect::detect();
|
||||
let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| {
|
||||
let path = state_path();
|
||||
let floor = load_floor(&path, channel.as_str());
|
||||
if m.serial < floor {
|
||||
return Err(format!(
|
||||
"manifest serial {} is older than the last accepted {} — refusing rollback",
|
||||
m.serial, floor
|
||||
));
|
||||
}
|
||||
store_floor(&path, channel.as_str(), m.serial);
|
||||
Ok(m)
|
||||
});
|
||||
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
rt.last_attempt = Some(Instant::now());
|
||||
rt.refreshing = false;
|
||||
match result {
|
||||
Ok(m) => {
|
||||
let checked = Checked {
|
||||
manifest: m,
|
||||
fetched_unix: now_unix(),
|
||||
};
|
||||
let newer = detect::is_newer(
|
||||
&checked.manifest.version,
|
||||
checked.manifest.ci_run,
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
channel,
|
||||
);
|
||||
if newer && rt.announced.as_deref() != Some(checked.manifest.version.as_str()) {
|
||||
rt.announced = Some(checked.manifest.version.clone());
|
||||
crate::events::emit(crate::events::EventKind::UpdateAvailable {
|
||||
version: checked.manifest.version.clone(),
|
||||
channel: channel.as_str().to_string(),
|
||||
install_kind: kind.as_str().to_string(),
|
||||
});
|
||||
}
|
||||
rt.last_error = None;
|
||||
rt.checked = Some(checked.clone());
|
||||
Ok(checked)
|
||||
}
|
||||
Err(e) => {
|
||||
rt.last_error = Some(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The status handler's read: current cache + errors, kicking a background refresh when the
|
||||
/// cache is cold and checks are enabled.
|
||||
pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot {
|
||||
let mut kick = false;
|
||||
let snap = {
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
let cold = rt
|
||||
.last_attempt
|
||||
.map(|t| t.elapsed() >= AUTO_REFRESH_AFTER)
|
||||
.unwrap_or(true);
|
||||
if cold && !rt.refreshing && !check_disabled() {
|
||||
rt.refreshing = true;
|
||||
rt.last_attempt = Some(Instant::now());
|
||||
kick = true;
|
||||
}
|
||||
Snapshot {
|
||||
checked: rt.checked.clone(),
|
||||
last_error: rt.last_error.clone(),
|
||||
}
|
||||
};
|
||||
if kick {
|
||||
// Fire-and-forget; the console's next poll reads the outcome.
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let _ = refresh_blocking();
|
||||
});
|
||||
}
|
||||
snap
|
||||
}
|
||||
|
||||
/// A forced check (`POST /update/check`): rate-limited, blocking until the refresh finishes.
|
||||
pub(crate) async fn force_check() -> Result<Snapshot, ForceError> {
|
||||
if check_disabled() {
|
||||
return Err(ForceError::Disabled);
|
||||
}
|
||||
{
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
if let Some(t) = rt.last_forced {
|
||||
if t.elapsed() < FORCE_MIN_INTERVAL {
|
||||
return Err(ForceError::TooSoon);
|
||||
}
|
||||
}
|
||||
rt.last_forced = Some(Instant::now());
|
||||
rt.refreshing = true;
|
||||
}
|
||||
let _ = tokio::task::spawn_blocking(refresh_blocking).await;
|
||||
let rt = runtime().lock().unwrap();
|
||||
Ok(Snapshot {
|
||||
checked: rt.checked.clone(),
|
||||
last_error: rt.last_error.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) enum ForceError {
|
||||
Disabled,
|
||||
TooSoon,
|
||||
}
|
||||
|
||||
/// What status hands to the API layer.
|
||||
pub(crate) struct Snapshot {
|
||||
pub checked: Option<Checked>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// The stale-feed hint: last successful check is fine but the manifest itself was
|
||||
/// published suspiciously long ago (freeze detection, design §3.2).
|
||||
pub(crate) fn stale(&self) -> bool {
|
||||
self.checked
|
||||
.as_ref()
|
||||
.map(|c| now_unix().saturating_sub(c.manifest.serial) > STALE_AFTER.as_secs())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn floor_roundtrip_and_monotonicity() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-update-floor-{}", std::process::id()));
|
||||
let path = dir.join("update-state.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(load_floor(&path, "stable"), 0);
|
||||
store_floor(&path, "stable", 100);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
// Lowering is a no-op.
|
||||
store_floor(&path, "stable", 50);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
// Channels are independent.
|
||||
store_floor(&path, "canary", 7);
|
||||
assert_eq!(load_floor(&path, "canary"), 7);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_floor_file_reads_as_zero() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-update-floor2-{}", std::process::id()));
|
||||
let path = dir.join("update-state.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"not json").unwrap();
|
||||
assert_eq!(load_floor(&path, "stable"), 0);
|
||||
// And writing over it recovers.
|
||||
store_floor(&path, "stable", 5);
|
||||
assert_eq!(load_floor(&path, "stable"), 5);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_keys_skip_empty_rotation_slot() {
|
||||
let keys = pinned_keys();
|
||||
assert_eq!(keys.len(), 1, "one live key, one empty rotation slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_math() {
|
||||
let mk = |serial| Snapshot {
|
||||
checked: Some(Checked {
|
||||
manifest: manifest::parse_verified(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"schema": 1, "channel": "stable", "serial": serial,
|
||||
"version": "0.23.0",
|
||||
"notes_url": "https://git.unom.io/unom/punktfunk/releases",
|
||||
}))
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
"stable",
|
||||
)
|
||||
.unwrap(),
|
||||
fetched_unix: now_unix(),
|
||||
}),
|
||||
last_error: None,
|
||||
};
|
||||
assert!(!mk(now_unix()).stale());
|
||||
assert!(mk(now_unix() - STALE_AFTER.as_secs() - 10).stale());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! **How was this host installed, and on which channel?** (design §4.1)
|
||||
//!
|
||||
//! The apply strategy — and, until apply lands, the command hint the console shows — hangs
|
||||
//! off the install kind. Detection is a ladder over root-owned facts: packaging writes a
|
||||
//! marker (`/usr/share/punktfunk/install-kind`, e.g. `apt stable`), the sysext self-identifies
|
||||
//! via its merged extension-release, Nix by store path, and so on. The API only ever *reads*
|
||||
//! this; nothing request-side can influence it.
|
||||
//!
|
||||
//! The ladder itself is a pure function over a [`Probe`] so every branch is unit-testable;
|
||||
//! [`detect`] gathers the real probe once per process.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Where the Linux packages stamp how they were installed. First word = kind
|
||||
/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`).
|
||||
const MARKER_PATH: &str = "/usr/share/punktfunk/install-kind";
|
||||
|
||||
/// The merged sysext names itself here (written by `build-sysext.sh`); its presence means the
|
||||
/// running `/usr` overlay came from the sysext image, regardless of any leftover marker.
|
||||
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
|
||||
|
||||
/// The sysext updater's own config (`CHANNEL=stable|canary`).
|
||||
const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InstallKind {
|
||||
WindowsInstaller,
|
||||
Sysext,
|
||||
RpmOstree,
|
||||
Apt,
|
||||
Dnf,
|
||||
Pacman,
|
||||
SteamosSource,
|
||||
Nix,
|
||||
Source,
|
||||
}
|
||||
|
||||
impl InstallKind {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
InstallKind::WindowsInstaller => "windows-installer",
|
||||
InstallKind::Sysext => "sysext",
|
||||
InstallKind::RpmOstree => "rpm-ostree",
|
||||
InstallKind::Apt => "apt",
|
||||
InstallKind::Dnf => "dnf",
|
||||
InstallKind::Pacman => "pacman",
|
||||
InstallKind::SteamosSource => "steamos-source",
|
||||
InstallKind::Nix => "nix",
|
||||
InstallKind::Source => "source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Channel {
|
||||
Stable,
|
||||
Canary,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Stable => "stable",
|
||||
Channel::Canary => "canary",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The root-owned facts the ladder reads, gathered once by [`gather`] (tests build these
|
||||
/// directly).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Probe {
|
||||
/// Running on Windows (cfg, not a file).
|
||||
pub windows: bool,
|
||||
/// The running exe's path.
|
||||
pub exe: PathBuf,
|
||||
/// `$HOME`, if any.
|
||||
pub home: Option<PathBuf>,
|
||||
/// Contents of [`MARKER_PATH`], if present.
|
||||
pub marker: Option<String>,
|
||||
/// [`SYSEXT_MARKER`] exists (merged sysext overlay).
|
||||
pub sysext: bool,
|
||||
/// Contents of [`SYSEXT_CONF`], if present.
|
||||
pub sysext_conf: Option<String>,
|
||||
/// `/run/ostree-booted` exists (rpm-ostree / bootc family).
|
||||
pub ostree_booted: bool,
|
||||
}
|
||||
|
||||
fn gather() -> Probe {
|
||||
Probe {
|
||||
windows: cfg!(target_os = "windows"),
|
||||
exe: std::env::current_exe().unwrap_or_default(),
|
||||
home: std::env::var_os("HOME").map(PathBuf::from),
|
||||
marker: std::fs::read_to_string(MARKER_PATH).ok(),
|
||||
sysext: Path::new(SYSEXT_MARKER).exists(),
|
||||
sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(),
|
||||
ostree_booted: Path::new("/run/ostree-booted").exists(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder (design §4.1). Order matters and each rung is a root-owned fact:
|
||||
/// sysext overlay > Nix store path > dev/source tree > user-owned Deck build > package
|
||||
/// marker (flipped to rpm-ostree when the box is ostree-booted) > `source` fallback.
|
||||
pub(crate) fn classify(p: &Probe) -> (InstallKind, Channel) {
|
||||
if p.windows {
|
||||
// The installer is the only supported Windows delivery; a loose cargo build shows
|
||||
// itself by not living under Program Files. Channel: canary installers carry the CI
|
||||
// run as the third component (`M.m.<run>`), see `windows_channel_of`.
|
||||
let installed = p
|
||||
.exe
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("\\program files\\punktfunk");
|
||||
return if installed {
|
||||
(
|
||||
InstallKind::WindowsInstaller,
|
||||
windows_channel_of(env!("PUNKTFUNK_VERSION")),
|
||||
)
|
||||
} else {
|
||||
(InstallKind::Source, Channel::Stable)
|
||||
};
|
||||
}
|
||||
|
||||
if p.sysext {
|
||||
let channel = p
|
||||
.sysext_conf
|
||||
.as_deref()
|
||||
.and_then(conf_channel)
|
||||
.unwrap_or(Channel::Stable);
|
||||
return (InstallKind::Sysext, channel);
|
||||
}
|
||||
|
||||
if p.exe.starts_with("/nix/store") {
|
||||
return (InstallKind::Nix, Channel::Stable);
|
||||
}
|
||||
|
||||
// A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the
|
||||
// Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is
|
||||
// user-owned but NOT a plain `target/` dir — that distinction is the marker here.
|
||||
let exe_str = p.exe.to_string_lossy().to_string();
|
||||
if exe_str.contains("/target/") {
|
||||
return (InstallKind::Source, Channel::Stable);
|
||||
}
|
||||
if let Some(home) = &p.home {
|
||||
if p.exe.starts_with(home) {
|
||||
return (InstallKind::SteamosSource, Channel::Canary);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(marker) = &p.marker {
|
||||
let mut words = marker.split_whitespace();
|
||||
let kind = words.next().unwrap_or("");
|
||||
let channel = match words.next() {
|
||||
Some("canary") => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
};
|
||||
let kind = match kind {
|
||||
"apt" => Some(InstallKind::Apt),
|
||||
// An ostree-booted box consumed the RPM by layering (or an image build); either
|
||||
// way `dnf upgrade` is not how it updates. bootc-vs-layered is refined in U2 via
|
||||
// `rpm-ostree status` — until then both report `rpm-ostree` (notify text is
|
||||
// identical in U0).
|
||||
"dnf" if p.ostree_booted => Some(InstallKind::RpmOstree),
|
||||
"dnf" => Some(InstallKind::Dnf),
|
||||
"pacman" => Some(InstallKind::Pacman),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = kind {
|
||||
return (kind, channel);
|
||||
}
|
||||
}
|
||||
|
||||
(InstallKind::Source, Channel::Stable)
|
||||
}
|
||||
|
||||
/// `CHANNEL=canary` in `/etc/punktfunk-sysext.conf` (the sysext updater's own format).
|
||||
fn conf_channel(conf: &str) -> Option<Channel> {
|
||||
for line in conf.lines() {
|
||||
if let Some(v) = line.trim().strip_prefix("CHANNEL=") {
|
||||
return Some(match v.trim() {
|
||||
"canary" => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows canary installers are versioned `M.m.<run>` where `<run>` is a 4+ digit CI run
|
||||
/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10).
|
||||
fn windows_channel_of(version: &str) -> Channel {
|
||||
match triple(version) {
|
||||
Some((_, _, patch)) if patch >= 1000 => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide answer, computed once.
|
||||
pub(crate) fn detect() -> (InstallKind, Channel) {
|
||||
static DETECTED: OnceLock<(InstallKind, Channel)> = OnceLock::new();
|
||||
*DETECTED.get_or_init(|| classify(&gather()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- version comparison
|
||||
|
||||
/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`).
|
||||
pub(crate) fn triple(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = v
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|s| !s.is_empty());
|
||||
// Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three
|
||||
// ONLY if the string actually starts with digits (else it's not a version at all).
|
||||
if !v.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// The CI run number embedded in a canary version string, wherever the channel's format hid
|
||||
/// it: `0.23.0~ci10250.g<sha>` (deb), `0.23.0-0.ci10250.g<sha>` (rpm), `0.23.10250`
|
||||
/// (Windows/decky style, run-as-patch). A stable string yields `None`.
|
||||
pub(crate) fn canary_run(version: &str) -> Option<u64> {
|
||||
// `ci` immediately followed by digits, anywhere.
|
||||
let mut rest = version;
|
||||
while let Some(pos) = rest.find("ci") {
|
||||
let digits: String = rest[pos + 2..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if !digits.is_empty() {
|
||||
return digits.parse().ok();
|
||||
}
|
||||
rest = &rest[pos + 2..];
|
||||
}
|
||||
match triple(version) {
|
||||
Some((_, _, patch)) if patch >= 1000 => Some(patch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the manifest's release newer than what this process runs? Definitive-or-false: an
|
||||
/// unparseable pair never flags (the console still shows both version strings — the badge
|
||||
/// just doesn't light up on guesswork). Canary compares `(major, minor)` then the CI run,
|
||||
/// because canary patch fields mean different things per channel (R10).
|
||||
pub(crate) fn is_newer(
|
||||
manifest_version: &str,
|
||||
manifest_ci_run: Option<u64>,
|
||||
current: &str,
|
||||
channel: Channel,
|
||||
) -> bool {
|
||||
let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else {
|
||||
return false;
|
||||
};
|
||||
match channel {
|
||||
Channel::Stable => m > c,
|
||||
Channel::Canary => {
|
||||
if (m.0, m.1) != (c.0, c.1) {
|
||||
return (m.0, m.1) > (c.0, c.1);
|
||||
}
|
||||
let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version));
|
||||
match (manifest_run, canary_run(current)) {
|
||||
(Some(mr), Some(cr)) => mr > cr,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-kind "how to update" command the console shows while (or instead of) an apply
|
||||
/// path existing (design §5). One line, copy-pastable, no placeholders.
|
||||
pub(crate) fn channel_hint(kind: InstallKind) -> &'static str {
|
||||
match kind {
|
||||
InstallKind::WindowsInstaller => {
|
||||
"winget upgrade unom.PunktfunkHost (or re-run the newer installer)"
|
||||
}
|
||||
InstallKind::Sysext => "sudo punktfunk-sysext update",
|
||||
InstallKind::RpmOstree => {
|
||||
"sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)"
|
||||
}
|
||||
InstallKind::Apt => "sudo apt update && sudo apt install --only-upgrade punktfunk-host",
|
||||
InstallKind::Dnf => "sudo dnf upgrade punktfunk",
|
||||
InstallKind::Pacman => "sudo pacman -Syu",
|
||||
InstallKind::SteamosSource => "bash ~/punktfunk/scripts/steamdeck/update.sh --pull",
|
||||
InstallKind::Nix => "nix flake update punktfunk (then rebuild your system)",
|
||||
InstallKind::Source => "git pull && cargo build --release -p punktfunk-host",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn probe() -> Probe {
|
||||
Probe {
|
||||
windows: false,
|
||||
exe: PathBuf::from("/usr/bin/punktfunk-host"),
|
||||
home: Some(PathBuf::from("/home/deck")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_sysext_beats_marker() {
|
||||
let mut p = probe();
|
||||
p.sysext = true;
|
||||
p.marker = Some("dnf canary".into());
|
||||
p.sysext_conf = Some("CHANNEL=canary\n".into());
|
||||
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Canary));
|
||||
p.sysext_conf = None;
|
||||
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_nix_store_path() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::Nix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_cargo_target_is_source_even_under_home() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::Source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_deck_build_is_steamos_source() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::SteamosSource);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_markers() {
|
||||
for (marker, ostree, kind, channel) in [
|
||||
("apt stable", false, InstallKind::Apt, Channel::Stable),
|
||||
("apt canary", false, InstallKind::Apt, Channel::Canary),
|
||||
("dnf stable", false, InstallKind::Dnf, Channel::Stable),
|
||||
("dnf stable", true, InstallKind::RpmOstree, Channel::Stable),
|
||||
("pacman canary", false, InstallKind::Pacman, Channel::Canary),
|
||||
] {
|
||||
let mut p = probe();
|
||||
p.marker = Some(marker.into());
|
||||
p.ostree_booted = ostree;
|
||||
assert_eq!(classify(&p), (kind, channel), "marker `{marker}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_unknown_marker_falls_through_to_source() {
|
||||
let mut p = probe();
|
||||
p.marker = Some("snap stable".into());
|
||||
assert_eq!(classify(&p).0, InstallKind::Source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triples() {
|
||||
assert_eq!(triple("0.23.0"), Some((0, 23, 0)));
|
||||
assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0)));
|
||||
assert_eq!(triple("0.23.10250"), Some((0, 23, 10250)));
|
||||
assert_eq!(triple("garbage"), None);
|
||||
assert_eq!(triple("1.2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canary_runs() {
|
||||
assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250));
|
||||
assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777));
|
||||
assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky)
|
||||
assert_eq!(canary_run("0.23.0"), None); // stable string
|
||||
assert_eq!(canary_run("0.23.0-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_stable() {
|
||||
assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable));
|
||||
assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable));
|
||||
assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags
|
||||
assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_canary_compares_runs_not_patch() {
|
||||
// deb canary current vs Windows-style manifest version, same run ⇒ NOT newer,
|
||||
// even though a naive triple compare says 10250 > 0.
|
||||
assert!(!is_newer(
|
||||
"0.23.10250",
|
||||
Some(10250),
|
||||
"0.23.0~ci10250.gab12cd34",
|
||||
Channel::Canary
|
||||
));
|
||||
assert!(is_newer(
|
||||
"0.23.10251",
|
||||
Some(10251),
|
||||
"0.23.0~ci10250.gab12cd34",
|
||||
Channel::Canary
|
||||
));
|
||||
// Minor bump wins outright.
|
||||
assert!(is_newer(
|
||||
"0.24.100",
|
||||
Some(100),
|
||||
"0.23.0~ci10250.g12",
|
||||
Channel::Canary
|
||||
));
|
||||
// No run extractable on either side ⇒ conservative false.
|
||||
assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_channel_heuristic() {
|
||||
assert_eq!(windows_channel_of("0.22.2"), Channel::Stable);
|
||||
assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The signed **update manifest**: the check truth for "a newer host exists"
|
||||
//! (design `host-update-from-web-console.md` §3).
|
||||
//!
|
||||
//! One small JSON document per channel, Ed25519-signed with keys pinned in this binary
|
||||
//! ([`super::UPDATE_KEYS`]) and verified by the exact code path the plugin store already
|
||||
//! trusts ([`crate::store::index::verify_signature`]). TLS and the registry that serves the
|
||||
//! document are transport, never trust.
|
||||
//!
|
||||
//! Rules, all fail-closed (the sysext 303 lesson encoded):
|
||||
//! 1. **Signature before parse** — over the exact fetched bytes, then strict JSON. An HTML
|
||||
//! error page, a redirect stub, or a truncated body dies before any field is read.
|
||||
//! 2. **Channel binding** — the document names its channel and it must match the one we
|
||||
//! asked for, so a validly-signed canary manifest replayed onto the stable URL is refused.
|
||||
//! 3. **Monotonic serial** — the publish-time serial can never go backwards for a channel
|
||||
//! (the anti-downgrade/anti-replay floor, persisted by [`super`]).
|
||||
//! 4. **Pinned notes origin** — the release-notes link the console renders must live on our
|
||||
//! forge, so a signed-but-wrong document can't send the operator to a lookalike page.
|
||||
|
||||
use crate::store::index::{verify_signature, PublicKey};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The only manifest schema this host understands. A breaking change bumps this; old hosts
|
||||
/// report "unsupported schema" instead of guessing.
|
||||
pub(crate) const SCHEMA: u32 = 1;
|
||||
|
||||
/// Hard cap on a fetched manifest (and its signature). The real document is <1 KB.
|
||||
pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// The only origin a manifest may point the operator at for release notes.
|
||||
const NOTES_ORIGIN: &str = "https://git.unom.io/";
|
||||
|
||||
/// The signed update manifest, as served (and signed) per channel.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct Manifest {
|
||||
/// Document schema — must equal [`SCHEMA`].
|
||||
pub schema: u32,
|
||||
/// The channel this document was published for (`stable` | `canary`). Bound-checked
|
||||
/// against the channel we fetched, see module docs rule 2.
|
||||
pub channel: String,
|
||||
/// Unix seconds at publish. Strictly increasing per channel; also drives the stale-feed
|
||||
/// hint (freshness needs no date parsing and no trust in `published_at`).
|
||||
pub serial: u64,
|
||||
/// RFC-3339 publish time. Display only.
|
||||
#[serde(default)]
|
||||
pub published_at: String,
|
||||
/// The released host version this manifest announces.
|
||||
pub version: String,
|
||||
/// Release-notes link the console renders. Must be on [`NOTES_ORIGIN`].
|
||||
#[serde(default)]
|
||||
pub notes_url: String,
|
||||
/// Canary only: the CI run number, the definitive "newer" axis where per-channel version
|
||||
/// strings differ (`~ciN`, `0.ciN`, a padded pkgrel, `M.m.run`).
|
||||
#[serde(default)]
|
||||
pub ci_run: Option<u64>,
|
||||
/// The Windows installer leg (design §6) — parsed and carried now so a U0 host is already
|
||||
/// schema-complete, consumed by the U1 apply path.
|
||||
#[serde(default)]
|
||||
pub windows_host: Option<WindowsHostAsset>,
|
||||
}
|
||||
|
||||
/// Where the Windows host installer for [`Manifest::version`] lives and how to verify it.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct WindowsHostAsset {
|
||||
/// Immutable per-version download URL — never a mutable `latest/` alias, so the hash
|
||||
/// below can't race an alias re-upload.
|
||||
pub url: String,
|
||||
/// SHA-256 (hex) of the installer bytes.
|
||||
pub sha256: String,
|
||||
/// Accepted Authenticode signing-leaf SHA-256 fingerprints. Living in the signed manifest
|
||||
/// (not the binary) is what makes the self-signed → Trusted Signing migration a manifest
|
||||
/// edit instead of a lockstep host release.
|
||||
#[serde(default)]
|
||||
pub authenticode_sha256: Vec<String>,
|
||||
/// Minimum Windows build (display/preflight only).
|
||||
#[serde(default)]
|
||||
pub min_os: String,
|
||||
}
|
||||
|
||||
/// Verify `sig_text` over the exact `bytes` against `keys`, then strictly parse and validate
|
||||
/// the document for `expected_channel`. The only constructor — there is no unsigned path.
|
||||
pub(crate) fn verify_and_parse(
|
||||
bytes: &[u8],
|
||||
sig_text: &str,
|
||||
keys: &[PublicKey],
|
||||
expected_channel: &str,
|
||||
) -> Result<Manifest> {
|
||||
verify_signature(bytes, sig_text, keys).context("update manifest signature")?;
|
||||
parse_verified(bytes, expected_channel)
|
||||
}
|
||||
|
||||
/// Parse + validate a document whose signature has already been checked. Split out so tests
|
||||
/// can exercise validation without minting signatures for every case.
|
||||
pub(crate) fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result<Manifest> {
|
||||
if bytes.len() > MAX_MANIFEST_BYTES {
|
||||
bail!("manifest is larger than the {MAX_MANIFEST_BYTES}-byte cap");
|
||||
}
|
||||
let m: Manifest = serde_json::from_slice(bytes).context("update manifest is not valid JSON")?;
|
||||
if m.schema != SCHEMA {
|
||||
bail!(
|
||||
"unsupported manifest schema {} (this host understands {SCHEMA})",
|
||||
m.schema
|
||||
);
|
||||
}
|
||||
if m.channel != expected_channel {
|
||||
bail!(
|
||||
"manifest is for channel `{}` but this host asked for `{expected_channel}`",
|
||||
m.channel
|
||||
);
|
||||
}
|
||||
if m.version.is_empty() || m.version.len() > 64 {
|
||||
bail!("manifest version is empty or implausibly long");
|
||||
}
|
||||
if m.serial == 0 {
|
||||
bail!("manifest serial is zero");
|
||||
}
|
||||
if !m.notes_url.is_empty() && !m.notes_url.starts_with(NOTES_ORIGIN) {
|
||||
bail!("manifest notes_url is not on {NOTES_ORIGIN}");
|
||||
}
|
||||
if let Some(w) = &m.windows_host {
|
||||
if !w.url.starts_with("https://") {
|
||||
bail!("windows_host.url must be https");
|
||||
}
|
||||
if w.sha256.len() != 64 || !w.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
bail!("windows_host.sha256 is not a hex SHA-256");
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"schema": 1,
|
||||
"channel": "stable",
|
||||
"serial": 1785400000u64,
|
||||
"published_at": "2026-07-30T12:00:00Z",
|
||||
"version": "0.23.0",
|
||||
"notes_url": "https://git.unom.io/unom/punktfunk/releases/tag/v0.23.0",
|
||||
"windows_host": {
|
||||
"url": "https://git.unom.io/unom/punktfunk/releases/download/v0.23.0/punktfunk-host-setup-0.23.0.exe",
|
||||
"sha256": "aa".repeat(32),
|
||||
"authenticode_sha256": ["bb".repeat(32)],
|
||||
"min_os": "10.0.22621"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn bytes(v: &serde_json::Value) -> Vec<u8> {
|
||||
serde_json::to_vec(v).unwrap()
|
||||
}
|
||||
|
||||
/// End-to-end: sign with a fresh ring keypair, verify with the matching pinned-key
|
||||
/// string — the format contract with the CI signer (raw 64-byte sig, base64; raw
|
||||
/// 32-byte key, `ed25519:<base64>`).
|
||||
#[test]
|
||||
fn signed_roundtrip_and_tamper() {
|
||||
use base64::Engine as _;
|
||||
use ring::signature::KeyPair as _;
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
|
||||
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
|
||||
let key_str = format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
|
||||
);
|
||||
let keys = vec![PublicKey::parse(&key_str).unwrap()];
|
||||
|
||||
let body = bytes(&doc());
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(kp.sign(&body));
|
||||
|
||||
let m = verify_and_parse(&body, &sig, &keys, "stable").unwrap();
|
||||
assert_eq!(m.version, "0.23.0");
|
||||
assert_eq!(
|
||||
m.windows_host.as_ref().unwrap().authenticode_sha256.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// One flipped byte ⇒ refused before parse.
|
||||
let mut tampered = body.clone();
|
||||
tampered[10] ^= 1;
|
||||
assert!(verify_and_parse(&tampered, &sig, &keys, "stable").is_err());
|
||||
|
||||
// Signed for stable, replayed as canary ⇒ refused (channel binding).
|
||||
assert!(verify_and_parse(&body, &sig, &keys, "canary").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_error_page_is_not_a_manifest() {
|
||||
// The Gitea 303 stub that poisoned the sysext feed — must die in strict parse.
|
||||
let html = b"<a href=\"https://objects.example/x\">See Other</a>.";
|
||||
assert!(parse_verified(html, "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_json_refused() {
|
||||
let body = bytes(&doc());
|
||||
assert!(parse_verified(&body[..body.len() - 5], "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_schema_refused() {
|
||||
let mut v = doc();
|
||||
v["schema"] = serde_json::json!(2);
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_serial_refused() {
|
||||
let mut v = doc();
|
||||
v["serial"] = serde_json::json!(0);
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsite_notes_url_refused() {
|
||||
let mut v = doc();
|
||||
v["notes_url"] = serde_json::json!("https://evil.example/notes");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_asset_validation() {
|
||||
let mut v = doc();
|
||||
v["windows_host"]["sha256"] = serde_json::json!("nothex");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
let mut v = doc();
|
||||
v["windows_host"]["url"] = serde_json::json!("http://git.unom.io/x.exe");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
// No windows leg at all is fine (PM channels don't need it).
|
||||
let mut v = doc();
|
||||
v.as_object_mut().unwrap().remove("windows_host");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_refused() {
|
||||
let mut v = doc();
|
||||
v["published_at"] = serde_json::json!("x".repeat(MAX_MANIFEST_BYTES));
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
}
|
||||
@@ -471,7 +471,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
"firewall",
|
||||
"delete",
|
||||
"rule",
|
||||
"name=punktfunk web console (TCP 47992)",
|
||||
"name=Punktfunk web console (TCP 47992)",
|
||||
],
|
||||
);
|
||||
if !run_quiet(
|
||||
@@ -481,7 +481,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
"firewall",
|
||||
"add",
|
||||
"rule",
|
||||
"name=punktfunk web console (TCP 47992)",
|
||||
"name=Punktfunk web console (TCP 47992)",
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
"protocol=TCP",
|
||||
|
||||
@@ -995,7 +995,7 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
("UDP", "UDP", "47998-48010,9777,5353"),
|
||||
];
|
||||
for (suffix, proto, ports) in rules {
|
||||
let name = format!("punktfunk {suffix}");
|
||||
let name = format!("Punktfunk {suffix}");
|
||||
let ok = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
@@ -1028,7 +1028,9 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
|
||||
fn remove_firewall_rules() {
|
||||
for suffix in ["TCP", "UDP"] {
|
||||
let name = format!("punktfunk {suffix}");
|
||||
// Capital P is the brand; netsh matches a rule name case-INSENSITIVELY, so this still
|
||||
// reaps the lowercase rules every release up to 0.22.1 created — no orphans on upgrade.
|
||||
let name = format!("Punktfunk {suffix}");
|
||||
let _ = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"steamos-host",
|
||||
"windows-host",
|
||||
"web-console",
|
||||
"updating",
|
||||
"---Configure your desktop---",
|
||||
"configuration",
|
||||
"kde",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Updating the Host
|
||||
description: How to see when a newer punktfunk host is available — the web console's update card — and the update command for every install method.
|
||||
---
|
||||
|
||||
The web console tells you when a newer host is out. The **Host** page has an **Updates** card
|
||||
showing the version you run, the channel you follow (stable or canary), how this host was
|
||||
installed, and — once a newer release exists — the exact command that updates it. The
|
||||
"update available" state also fires an `update.available` event on the host
|
||||
[event stream](/docs/automation), so hooks and scripts can react to it too.
|
||||
|
||||
The check is a small signed manifest the host fetches from the punktfunk release feed and
|
||||
verifies against keys built into the host itself — a tampered or replayed feed is rejected, and
|
||||
the console will tell you when a check failed rather than silently showing stale facts.
|
||||
|
||||
## Updating, per install method
|
||||
|
||||
The console shows the right one of these automatically; for reference:
|
||||
|
||||
| How you installed | How to update |
|
||||
|---|---|
|
||||
| Windows installer / winget | `winget upgrade unom.PunktfunkHost`, or run the newer `punktfunk-host-setup.exe` |
|
||||
| Ubuntu / Debian (apt) | `sudo apt update && sudo apt install --only-upgrade punktfunk-host` |
|
||||
| Fedora (dnf) | `sudo dnf upgrade punktfunk` |
|
||||
| Bazzite sysext (recommended) | `sudo punktfunk-sysext update` |
|
||||
| Bazzite rpm-ostree layer | `sudo /usr/share/punktfunk/update-punktfunk.sh` (staged — reboot to finish) |
|
||||
| Arch / CachyOS (pacman) | `sudo pacman -Syu` (a normal full system upgrade) |
|
||||
| Steam Deck (on-device build) | `bash ~/punktfunk/scripts/steamdeck/update.sh --pull` |
|
||||
| NixOS | update the flake input and rebuild |
|
||||
|
||||
After a Linux package update, restart the host to pick up the new binary:
|
||||
|
||||
```bash
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
(The Windows installer restarts the service itself; `punktfunk-sysext update` prints the same
|
||||
restart hint when it's needed.)
|
||||
|
||||
One-click updating from the console is on the way, per install method — the card will grow an
|
||||
**Apply** button where the platform supports it.
|
||||
|
||||
## Turning the check off
|
||||
|
||||
The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a
|
||||
normal download request. If you'd rather the host never checks, set:
|
||||
|
||||
```bash
|
||||
PUNKTFUNK_UPDATE_CHECK=0
|
||||
```
|
||||
|
||||
in the host's environment (`host.env` on Windows, the systemd user unit environment on Linux).
|
||||
The card then shows checks as disabled; everything else keeps working.
|
||||
|
||||
## If the card says the feed is stale
|
||||
|
||||
"Feed hasn't changed in over 45 days" means checks *succeed* but nothing new arrives. Usually
|
||||
that just means no release happened for a while; if the [releases page](https://git.unom.io/unom/punktfunk/releases)
|
||||
shows something newer than the card does, something between this host and the feed is pinning old
|
||||
data — worth a look at proxies or DNS on the way to `git.unom.io`.
|
||||
@@ -0,0 +1,20 @@
|
||||
Wire-compatible with 0.21.x and 0.22.0 — hosts are untouched by this release, and everything already paired keeps working.
|
||||
|
||||
**If you installed 0.22.0 on Windows or Linux, update now:** this fixes connecting, which 0.22.0 broke on exactly those clients. Coming from 0.21.0, you get everything 0.22.0 added — settings profiles, `punktfunk://` links, the `punktfunk` command, Gaming-Mode HDR — plus these fixes.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Connecting works again on Windows and Linux.** 0.22.0 accidentally shipped the wrong program as the piece that renders your stream. On Windows, every connect immediately bounced you back to the host list with no message; on Linux, the stream window simply never appeared. Mac, iPhone, iPad, Apple TV and Android were not affected. Nothing about how streams work has changed — 0.22.1 delivers what 0.22.0's notes promised.
|
||||
- **A stream that can't start now tells you why.** If the stream window exits without reporting anything, the Windows client now shows its exit code instead of silently returning to your hosts — a broken install is a message, not a mystery.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Every `punktfunk` command now explains itself.** `punktfunk help launch` — or `--help` after any command — prints that command's flags, what lands where, and what each exit code means, so a script author never has to read the source. `punktfunk help` still lists everything.
|
||||
- **`punktfunk reachable` stopped scolding.** Probing an address you haven't saved is exactly what the command is for; it no longer suggests pairing first before answering.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- The 0.22.0 breakage: a stray copy of the GTK shell's sources landed in the session crate and overwrote its `main.rs` — the `[[bin]]` entry point — so `punktfunk-session` was built from the wrong file. On Windows that file's non-Linux arm is a three-line stub (exit 2, no stdout contract); on Linux it was the shell itself, whose `--connect` handling execs `punktfunk-session` — that is, itself. Every compile gate stayed green, because the wrong program compiled perfectly.
|
||||
- CI now *runs* what it ships: an integration test spawns the built `punktfunk-session` against a refusing port and fails unless a stdout-contract line answers (proven against the 0.22.0 stub, which fails it); another runs `punktfunk` over its help surface. `punktfunk-cli` also joined the Windows build/clippy/fmt/test gates — it was packaged in the MSIX but only the release workflow ever compiled it.
|
||||
- The Windows shell's `SpawnEvent::Exited` now carries the child's exit code; a nonzero exit with no contract line gets a banner naming it. Codes 0 (window closed) and −1 (our own kill) stay silent as before.
|
||||
- No wire, ABI or driver changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0.
|
||||
@@ -0,0 +1,22 @@
|
||||
Wire-compatible with 0.21.x and 0.22.x — nothing about streaming changed, and everything already paired keeps working. This release only touches Windows PCs you stream *to*; the apps on your phone, tablet, Mac and TV are unchanged, as are Linux hosts.
|
||||
|
||||
**If you stream to a Windows PC and use a controller, update that PC.** On 0.22.0 and 0.22.1 your controller worked everywhere in the app but no game on the PC ever saw it. Update using the Windows installer rather than replacing the program by hand — the repair includes the controller drivers themselves.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Controllers work again on Windows.** Your controller paired, the app responded to it, and a DualSense's touchpad could even still move the mouse pointer on the PC — but games saw no controller at all. Windows had been attaching one of its own built-in drivers to Punktfunk's virtual controller instead of Punktfunk's, and that driver cannot run on a controller that isn't physically plugged in, so the controller was created and then never started. Nothing you could change in the app worked around it.
|
||||
|
||||
This hit almost everybody, because it hit the controller type Punktfunk emulates by default. If you had gone into settings and explicitly chosen DualShock 4, Xbox 360, DualSense Edge or Steam Deck, yours kept working the whole time — only the default was broken. Both 0.22.0 and 0.22.1 are affected; 0.21.0 and earlier are not.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Punktfunk is spelled Punktfunk on Windows.** The virtual display, controllers, mouse and firewall entries Punktfunk creates all announced themselves in lower case. They now carry the proper name — in Device Manager, in your monitor list, and in Windows Firewall. Purely cosmetic, and it appears once the updated drivers install.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- The controller regression was a one-line hardware-id slip. `560e663a` renamed the driver *package* `pf-dualsense` → `pf-gamepad` and its message asserted the four hardware ids were untouched — but `WinDsIdentity::dualsense()` was renamed along with it, so the host began advertising `pf_gamepad`, which `pf_gamepad.inx` does not declare (it still binds `root\pf_dualsense` / `pf_dualsense` / `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck` — deliberately, as the contract with already-installed systems). PnP matched none of our models, fell through to the USB ids the same devnode synthesizes for the DualSense identity (`USB\VID_054C&PID_0CE6`, `USB\Class_03`), and Microsoft's inbox `input.inf` won on signature. `HidUsb` then bound a software-enumerated devnode with no USB port behind it and could not start — `CM_PROB_FAILED_START`. Without a start, hidclass never enumerates the collection PDO, so there is no device interface, so the devnode cannot answer a channel proof, and the gamepad channel's v3 delivery gate correctly refused to hand over the shared input section. Every layer downstream behaved exactly as designed.
|
||||
- Measured against a clean install, all four identities served by the one `pf_gamepad.inf`: DualSense (`pf_gamepad`) → `input.inf`/`HidUsb`, failed start; DualShock 4 (`pf_dualshock4`), Edge (`pf_dualsenseedge`) and the virtual mouse (`pf_mouse`) → our package, attached. `devgen /add /hardwareid "root\pf_dualsense"` binds our INF with no problem code.
|
||||
- A new `hwid_matches_inf` test parses `pf_gamepad.inx`'s `[Models]` section and asserts every hardware id the host puts on a pad devnode is declared there, with a vacuity check on the parse so a shape change fails loudly rather than passing empty. `DS4_HWID` / `DECK_HWID` exist so the test pins the same constants the create paths use.
|
||||
- Why the installer and not a binary swap: re-creating a software device with a known instance id revives the existing devnode with its previously-bound driver and never re-ranks against the driver store. Instance ids did not change, so a PC still holding a stale virtual pad bound to `input.inf` would revive `input.inf` even with the correct hardware id. `driver install --gamepad` sweeps those devnodes; replacing the host executable alone does not.
|
||||
- The rename covers the four driver INFs' `[Strings]`, every `SwDeviceProfile` description, `pf-mouse`'s HID manufacturer and product strings, pf-vdisplay's IddCx endpoint names, the EDID `0xFC` display-name descriptor (one byte — `Edid::generate_with` recomputes both block checksums), and the netsh rule names. Left in lower case on purpose, because they are identities rather than display names: the `SwDeviceCreate` enumerator (it *is* the `SWD\PUNKTFUNK\…` instance-id path), `%ProgramData%\punktfunk`, the `CN=punktfunk-driver` cert subject, and `install.rs`' already-lowercased device probes. netsh, `Get-NetFirewallRule -DisplayName` and PowerShell's `-match` are case-insensitive, so the delete paths still reap what earlier releases created.
|
||||
- No wire, ABI or driver-protocol changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0 and 0.22.1.
|
||||
@@ -0,0 +1,27 @@
|
||||
Wire-compatible with 0.21.x and 0.22.x — nothing about streaming changed, and everything already paired keeps working. This release is almost entirely about the Windows installer; the apps on your phone, tablet, Mac and TV are unchanged, as are Linux hosts.
|
||||
|
||||
**If you stream to a Windows PC, update that PC.** The 0.22.1 and 0.22.2 installers were built without the web console in them at all — not broken, absent. If the tray menu on your PC says "Open web console (not responding)" and the page never loads no matter what you try, that is this, and reinstalling those versions could never have fixed it.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **The web console is in the installer again.** On 0.22.1 and 0.22.2 the Windows installer shipped with the console missing entirely: the files it runs from were never included, so the PC had nothing to start and nothing to open. The host itself was fine the whole time — streaming, pairing and controllers all worked — but the settings page you reach in a browser simply was not there. The tray reported it as "not responding", which was true and also the only clue you got, and because every reinstall produced the same installer, uninstalling and reinstalling made no difference. Updating to 0.22.3 restores it; you don't need to touch your settings, your password, or your paired devices.
|
||||
|
||||
- **Updating no longer stops on a "DeleteFile failed; code 5" error.** Partway through an update, the installer could stop on a dialog complaining that it could not replace `bun\bun.exe`, offering only Try again, Skip, or Cancel — and Try again usually failed the same way. Windows refuses to replace a program while it is running, and the pieces of Punktfunk that were still running weren't the ones the installer knew how to stop. It now shuts all of them down, waits for them to actually exit rather than assuming, and if something still holds the file it finishes the install and puts the new copy in place on your next restart instead of leaving you stuck at a dialog.
|
||||
|
||||
- **Updating no longer switches your plugins off.** If you had enabled the script and plugin runner, every update quietly disabled it again, so plugins stopped running until you noticed and re-enabled them by hand. Updates now leave it exactly as you had it — on if it was on, and still off by default for everyone who has never turned it on.
|
||||
|
||||
## Improved
|
||||
|
||||
- **You can save or share the host's log from the console.** The Logs page has two new buttons: one downloads what you're looking at as a timestamped `.log` file, the other hands it to your phone or tablet's share sheet, or copies it to the clipboard on a desktop. Handy when someone asks you for a log — the file carries full dates and times, so it still makes sense once it leaves your browser. Whatever level filter and search you have applied is what you get, and it saves everything that matches rather than just the part on screen.
|
||||
|
||||
- **Televisions that struggle with the startup speed test can be told to ease off.** Two seconds into a session Punktfunk briefly bursts traffic to measure how much room your network really has. On some TVs that burst is enough to disturb the very link it is measuring — one LG set showed no video for fourteen seconds when it went badly. The burst's size can now be capped, so a device that already limits its own speed test can keep ours in line with it. Nothing changes unless a device asks for it.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- The console's absence was one CI variable in the wrong place. `windows-host.yml` exported `WEB_OUTPUT_DIR` on the last line of "Build + smoke-boot web console (bun)", and `be2fabcf` had just put that step behind `if: steps.webconsole.outputs.cache-hit != 'true'`. So the first build after the cache was populated — and every build after it with `web/**` and `sdk/**` unchanged — skipped the step, left the variable unset, and `pack-host-installer.ps1` reads an unset `WEB_OUTPUT_DIR` as "omit the console", announcing it in a single `Write-Host` before packing happily. Confirmed from the job logs rather than inferred: on both the v0.22.1 (run 14235) and v0.22.2 (run 14272) tag builds, step 12 is `skipped` and the job is green. `BUN_EXE` and `SCRIPTING_BUNDLE` are exported from unconditional steps, which is why those installers still carried bun and the plugin runner but no `{app}\web`.
|
||||
- Downstream, `web setup` bailed at `web launcher missing: {app}\web\web-run.cmd` before registering anything, so there was no `PunktfunkWeb` task, no listener on 47992, and no firewall rule — and Inno ignores `[Run]` exit codes, so the failure left no trace in the install at all. `WEB_OUTPUT_DIR` now comes from its own unconditional step that throws when `web\.output\server\index.mjs` is absent, and a new pre-pack step asserts all five payloads (console, bun, plugin runner, FFmpeg DLLs, VB-CABLE) so a missing input fails the build rather than silently redefining the installer. The shape is borrowed from the packer's existing VB-CABLE check. FFmpeg was the most dangerous of the silent ones: an `amf-qsv` host link-imports avcodec, so an installer without those DLLs ships a host that cannot start at all.
|
||||
- The same cache-hit build is why `bun.exe` locked. It ships under `WithWeb` **or** `WithScripting`, but the installer's pre-copy stop was `#ifdef WithWeb` — so a console-less installer shipped bun while the only code that stopped bun was compiled out. `StopBunRuntimes` replaces `StopWebConsole` behind the same `WithWeb || WithScripting` gate the payload uses, and covers what the old routine structurally could not: neither bun runs under the host service (both are Task Scheduler tasks — `PunktfunkWeb` as SYSTEM, `PunktfunkScripting` as LocalService), and the runner listens on no port, so a task-name-plus-port sweep could never see it. It now disables both tasks before stopping them — they carry restart-on-failure at 10× and 999× a minute, and the web task also has a logon trigger, so a force-kill alone invited a respawn into the middle of a copy that takes over a minute at `lzma2/max` — kills any bun whose image lives under the install dir (by path, so an unrelated bun survives), and polls until they are gone, since `Stop-ScheduledTask` returns on request and `Stop-Process` is `TerminateProcess`. `bun.exe` also gained `restartreplace`, so a survivor defers to reboot instead of dead-ending the install.
|
||||
- Disabling a task is not free: unlike a stopped one it does not return at the next boot, so an aborted install would have taken the console down permanently. Two restores cover it — a last-in-`[Run]` entry for the normal flow and `DeinitializeSetup`, which Inno calls even on user cancel — both re-enabling only what was enabled before the copy. That is also the plugin-runner fix: the scripting `[Run]` entry re-registers its task and then unconditionally disables it, correct on a first install and wrong on every upgrade since.
|
||||
- `PUNKTFUNK_ABR_PROBE_KBPS` sets the startup capacity probe's burst target for embedders. Unset, zero or unparseable keeps the 2 Gbps default, so existing sessions are unchanged; `PUNKTFUNK_ABR_PROBE=0` remains a poor substitute because it pins the climb ceiling at the negotiated ~20 Mbps.
|
||||
- The console's log export serializes the filters' full result rather than the rendered tail — the 1000-row cap is a DOM budget and has no business truncating a file destined for a bug report — and stamps each line with a local ISO 8601 timestamp plus numeric offset. Web Share level 2 is probed at runtime after mount (SSR has no `navigator`, and guessing there would mismatch on hydration), falling back to the clipboard, and the button is omitted only where neither exists.
|
||||
- No wire, ABI or driver-protocol changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0, 0.22.1 and 0.22.2.
|
||||
@@ -173,6 +173,12 @@ package_punktfunk-host() {
|
||||
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
|
||||
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
|
||||
# Install-kind + channel marker, read by the host's update-check surface (planning:
|
||||
# host-update-from-web-console.md §4.1). A canary build's pkgrel is `0.<zero-padded run>`.
|
||||
local _pf_update_channel=stable
|
||||
[[ $pkgrel == 0.* ]] && _pf_update_channel=canary
|
||||
printf 'pacman %s\n' "$_pf_update_channel" | \
|
||||
install -Dm0644 /dev/stdin "$pkgdir/usr/share/punktfunk/install-kind"
|
||||
install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \
|
||||
"$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
@@ -79,6 +79,16 @@ sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
|
||||
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 scripts/punktfunk-host-desktop-session.conf \
|
||||
"$STAGE/usr/share/punktfunk-host/punktfunk-host-desktop-session.conf"
|
||||
# Install-kind + channel marker, read by the host's update-check surface (planning:
|
||||
# host-update-from-web-console.md §4.1). ONE canonical path across all package formats —
|
||||
# /usr/share/punktfunk/, not this package's punktfunk-host/ data dir. A canary version
|
||||
# carries `~ciN`; anything else is stable.
|
||||
case "$VERSION" in
|
||||
*~ci*) _pf_update_channel=canary ;;
|
||||
*) _pf_update_channel=stable ;;
|
||||
esac
|
||||
printf 'apt %s\n' "$_pf_update_channel" | \
|
||||
install -Dm0644 /dev/stdin "$STAGE/usr/share/punktfunk/install-kind"
|
||||
# Optional headless KWin session unit (the kwin --virtual appliance), as the RPM/Arch ship.
|
||||
# Repoint its ExecStart from the dev source tree to the packaged script. NOT enabled by default.
|
||||
install -Dm0644 scripts/punktfunk-kde-session.service "$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
@@ -64,6 +64,7 @@ rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" "${HOST_OPT[@]}" \
|
||||
--define "_topdir $TOP" \
|
||||
--define "pf_version ${PF_VERSION}" \
|
||||
--define "pf_release ${PF_RELEASE}" \
|
||||
--define "pf_channel $(case "${PF_RELEASE:-1}" in 0.ci*) echo canary ;; *) echo stable ;; esac)" \
|
||||
packaging/rpm/punktfunk.spec
|
||||
|
||||
mkdir -p dist
|
||||
|
||||
@@ -295,6 +295,11 @@ sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#'
|
||||
# the operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 scripts/punktfunk-host-desktop-session.conf %{buildroot}%{_datadir}/%{name}/punktfunk-host-desktop-session.conf
|
||||
|
||||
# Install-kind + channel marker, read by the host's update-check surface (planning:
|
||||
# host-update-from-web-console.md §4.1). `pf_channel` is defined by build-rpm.sh (canary
|
||||
# when the release override starts `0.ci`); a plain local rpmbuild is stable.
|
||||
printf 'dnf %{?pf_channel}%{!?pf_channel:stable}\n' > %{buildroot}%{_datadir}/%{name}/install-kind
|
||||
|
||||
# Optional headless KDE session unit (the kwin streaming appliance): brings up `kwin --virtual` on
|
||||
# wayland-kde via the packaged run-headless-kde.sh, so the host's kwin backend has a session whose
|
||||
# privileged screencast protocol it can bind. Repoint its ExecStart from the dev source tree to the
|
||||
|
||||
@@ -90,13 +90,13 @@ ServiceBinary="%13%\pf_gamepad.dll"
|
||||
pf_gamepad.dll
|
||||
|
||||
[Strings]
|
||||
ProviderString ="punktfunk"
|
||||
ManufacturerString ="punktfunk"
|
||||
ProviderString ="Punktfunk"
|
||||
ManufacturerString ="Punktfunk"
|
||||
ClassName ="HID device"
|
||||
Disk_Description ="punktfunk Gamepad Installation Disk"
|
||||
Disk_Description ="Punktfunk Gamepad Installation Disk"
|
||||
; One per hardware id — these are what Device Manager shows. Keep them aligned with the product
|
||||
; strings the driver serves per device_type (src/lib.rs `on_get_string`).
|
||||
DeviceDesc ="punktfunk Virtual DualSense"
|
||||
DeviceDescDS4 ="punktfunk Virtual DualShock 4"
|
||||
DeviceDescEdge ="punktfunk Virtual DualSense Edge"
|
||||
DeviceDescDeck ="punktfunk Virtual Steam Deck Controller"
|
||||
DeviceDesc ="Punktfunk Virtual DualSense"
|
||||
DeviceDescDS4 ="Punktfunk Virtual DualShock 4"
|
||||
DeviceDescEdge ="Punktfunk Virtual DualSense Edge"
|
||||
DeviceDescDeck ="Punktfunk Virtual Steam Deck Controller"
|
||||
|
||||
@@ -72,8 +72,8 @@ ServiceBinary="%13%\pf_mouse.dll"
|
||||
pf_mouse.dll
|
||||
|
||||
[Strings]
|
||||
ProviderString ="punktfunk"
|
||||
ManufacturerString ="punktfunk"
|
||||
ProviderString ="Punktfunk"
|
||||
ManufacturerString ="Punktfunk"
|
||||
ClassName ="HID device"
|
||||
Disk_Description ="punktfunk Mouse Installation Disk"
|
||||
DeviceDesc ="punktfunk Virtual Mouse"
|
||||
Disk_Description ="Punktfunk Mouse Installation Disk"
|
||||
DeviceDesc ="Punktfunk Virtual Mouse"
|
||||
|
||||
@@ -428,14 +428,14 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
||||
};
|
||||
let string_id = id_val & 0xFFFF;
|
||||
let s: String = match string_id {
|
||||
0 | 0x000E => "punktfunk".into(),
|
||||
0 | 0x000E => "Punktfunk".into(),
|
||||
// (2) The SERIAL carries the channel proof — the one transport measured to reach a UMDF HID
|
||||
// minidriver from user mode (`HidD_GetSerialNumberString`, zero-access handle, verified on
|
||||
// .173). Safe HERE and only here: nothing reads the virtual mouse's serial, whereas the pads'
|
||||
// serials are what SDL and Steam dedup controllers on. The old value was the inert
|
||||
// "PFMOUSE00"; the proof text is just as inert and does the security work.
|
||||
2 | 0x0010 => ChannelProof::new(CHANNEL.index(), std::process::id()).to_hid_string(),
|
||||
_ => "punktfunk Virtual Mouse".into(),
|
||||
_ => "Punktfunk Virtual Mouse".into(),
|
||||
};
|
||||
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
||||
for u in s.encode_utf16() {
|
||||
|
||||
@@ -75,10 +75,10 @@ UMDriverCopy=12,UMDF
|
||||
pf_vdisplay.dll
|
||||
|
||||
[Strings]
|
||||
ManufacturerName="punktfunk"
|
||||
DiskName="punktfunk Virtual Display Installation Disk"
|
||||
ManufacturerName="Punktfunk"
|
||||
DiskName="Punktfunk Virtual Display Installation Disk"
|
||||
WudfRdDisplayName="Windows Driver Foundation - User-mode Driver Framework Reflector"
|
||||
DeviceName="punktfunk Virtual Display"
|
||||
DeviceName="Punktfunk Virtual Display"
|
||||
|
||||
REG_MULTI_SZ=0x00010000
|
||||
REG_SZ=0x00000000
|
||||
|
||||
@@ -82,8 +82,8 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
diag.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO>() as u32;
|
||||
diag.GammaSupport = iddcx::IDDCX_FEATURE_IMPLEMENTATION::IDDCX_FEATURE_IMPLEMENTATION_NONE;
|
||||
diag.TransmissionType = iddcx::IDDCX_TRANSMISSION_TYPE::IDDCX_TRANSMISSION_TYPE_WIRED_OTHER;
|
||||
diag.pEndPointFriendlyName = wstr!("punktfunk Virtual Display Adapter");
|
||||
diag.pEndPointManufacturerName = wstr!("punktfunk");
|
||||
diag.pEndPointFriendlyName = wstr!("Punktfunk Virtual Display Adapter");
|
||||
diag.pEndPointManufacturerName = wstr!("Punktfunk");
|
||||
diag.pEndPointModelName = wstr!("Virtual Display");
|
||||
// SAFETY: `version` is a stack local that outlives this `init_adapter` call; IddCxAdapterInitAsync
|
||||
// (below) reads through these pointers SYNCHRONOUSLY, before `version` drops — the pointer never escapes.
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! made "Use HDR" never appear for the virtual display). The base block declares EDID 1.4 + 10-bit
|
||||
//! digital so the panel's bit depth is unambiguous.
|
||||
//!
|
||||
//! Identity: manufacturer "PNK" (bytes 8-9), product name "punktfunk" (the 0xFC display descriptor). The
|
||||
//! Identity: manufacturer "PNK" (bytes 8-9), product name "Punktfunk" (the 0xFC display descriptor —
|
||||
//! this is what Windows shows as `Generic Monitor (Punktfunk)`; byte 127's checksum is recomputed in
|
||||
//! [`Edid::generate_with`], so editing the name here needs no hand-patched checksum). The
|
||||
//! serial-number field (base offset 0x0C, little-endian) encodes the per-monitor index so
|
||||
//! `parse_monitor_description` can map an EDID the OS hands back to its monitor; [`Edid::generate_with`]
|
||||
//! patches that serial and recomputes BOTH block checksums (base byte 127 + extension byte 255). The
|
||||
@@ -34,7 +36,7 @@ const BASE: [u8; 128] = [
|
||||
0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1E,
|
||||
0x00, 0x00, 0x00, 0xFD, 0x00, 0x17, 0xF0, 0x0F, // display range-limits descriptor
|
||||
0xFF, 0x0F, 0x00, 0x0A, 0x20, 0x20, 0x20, 0x20,
|
||||
0x20, 0x20, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x70, // name descriptor "punktfunk"
|
||||
0x20, 0x20, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x50, // name descriptor "Punktfunk"
|
||||
0x75, 0x6E, 0x6B, 0x74, 0x66, 0x75, 0x6E, 0x6B,
|
||||
0x0A, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, // empty 4th descriptor...
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
|
||||
@@ -58,7 +58,7 @@ UmdfLibraryVersion=$UMDFVERSION$
|
||||
ServiceBinary=%13%\pf_xusb.dll
|
||||
|
||||
[Strings]
|
||||
ProviderString = "punktfunk"
|
||||
ProviderString = "Punktfunk"
|
||||
StdMfg = "(Standard system devices)"
|
||||
DiskId1 = "punktfunk XUSB Installation Disk"
|
||||
DeviceDesc = "punktfunk Virtual Xbox 360 (XUSB)"
|
||||
DiskId1 = "Punktfunk XUSB Installation Disk"
|
||||
DeviceDesc = "Punktfunk Virtual Xbox 360 (XUSB)"
|
||||
|
||||
@@ -209,8 +209,12 @@ Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
#endif
|
||||
; The portable bun runtime -> {app}\bun\bun.exe. Shared by the web console AND the plugin/script
|
||||
; runner (both run on bun), so stage it once when EITHER is bundled.
|
||||
; restartreplace is the safety net under StopBunRuntimes: Windows refuses to delete a RUNNING image,
|
||||
; so if any bun survives the pre-copy stop, DeleteFile fails with code 5 and - with no reboot-time
|
||||
; MoveFileEx fallback - Inno can only show the user a dead-end Retry/Skip/Cancel box. With it, the
|
||||
; install completes and the new runtime lands on the next restart instead.
|
||||
#if defined(WithWeb) || defined(WithScripting)
|
||||
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
|
||||
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion restartreplace uninsrestartdelete
|
||||
#endif
|
||||
#ifdef WithWeb
|
||||
; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps
|
||||
@@ -326,6 +330,16 @@ Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
|
||||
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
||||
#endif
|
||||
#if defined(WithWeb) || defined(WithScripting)
|
||||
; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that
|
||||
; touches the tasks: it has to follow `web setup`'s re-register AND the scripting entry above, whose
|
||||
; unconditional Disable-ScheduledTask is correct for a fresh install but would otherwise silently
|
||||
; switch an operator's plugin runner off on every upgrade. Skipped entirely when neither task was
|
||||
; enabled beforehand (a fresh install), so it can't enable anything the user never asked for.
|
||||
Filename: "powershell.exe"; Parameters: "{code:RestoreTasksParams}"; \
|
||||
StatusMsg: "Restoring the console + script runner tasks..."; \
|
||||
Flags: runhidden waituntilterminated; Check: NeedsTaskRestore
|
||||
#endif
|
||||
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
||||
; icon appears without waiting for the next sign-in.
|
||||
Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon
|
||||
@@ -351,7 +365,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --gamepad";
|
||||
; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config,
|
||||
; like the host uninstall does).
|
||||
Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'Punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
||||
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
|
||||
#endif
|
||||
#ifdef WithScripting
|
||||
@@ -593,23 +607,96 @@ begin
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
|
||||
#ifdef WithWeb
|
||||
{ Stop a running web console + free its port BEFORE the file copy, so the old server doesn't lock
|
||||
.output / web-run.cmd / bun.exe and the new task can bind. Killing the listener owner is
|
||||
runtime-agnostic (an early install may have run node on :3000, the current one runs bun on
|
||||
:47992 - sweep both). `web setup` repeats this idempotently after the copy. Best-effort; a
|
||||
fresh install is a no-op. }
|
||||
procedure StopWebConsole;
|
||||
#if defined(WithWeb) || defined(WithScripting)
|
||||
{ Each bun task's enabled state as it was BEFORE StopBunRuntimes disabled it, so the [Run] restore
|
||||
entry can put it back. PunktfunkScripting is OPT-IN, which makes this load-bearing rather than
|
||||
tidy: re-enabling it unconditionally would switch the plugin runner on for everyone, and leaving
|
||||
it disabled would switch it off for everyone who had it on. }
|
||||
var
|
||||
WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean;
|
||||
|
||||
{ Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote).
|
||||
The install dir is user-chosen, so it can legitimately contain an apostrophe. }
|
||||
function PsLiteral(S: String): String;
|
||||
begin
|
||||
Result := S;
|
||||
StringChangeEx(Result, '''', '''''', True);
|
||||
end;
|
||||
|
||||
{ Is the task registered AND not Disabled? Answered through the exit code, so there is no temp file
|
||||
to write, read back, and clean up. A missing task reports False. }
|
||||
function TaskEnabled(TaskName: String): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
if Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' +
|
||||
'if($t -and $t.State -ne ''Disabled''){exit 1}; exit 0"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
Result := ResultCode = 1;
|
||||
end;
|
||||
|
||||
{ Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a
|
||||
running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a
|
||||
user hit updating to 0.22.1.
|
||||
Neither bun runs under the host service, so StopHostServiceAndWait does nothing for either: both
|
||||
are Task Scheduler tasks - PunktfunkWeb (SYSTEM, the console) and PunktfunkScripting (LocalService,
|
||||
the plugin runner) - and the runner listens on NO port, so the port sweep below cannot see it at
|
||||
all. That is why this stops tasks by name and processes by image path, not just by socket.
|
||||
DISABLE before stopping: both carry aggressive restart-on-failure (web 10x at 1min, scripting 999x
|
||||
at 1min) and the web task also has a logon trigger, so a force-kill on its own invites a respawn
|
||||
into the middle of a copy that takes well over a minute at lzma2/max. Then WAIT for the processes
|
||||
to actually go - Stop-ScheduledTask returns when termination is merely requested, and Stop-Process
|
||||
is TerminateProcess, also asynchronous.
|
||||
Best-effort throughout; a fresh install is a no-op. }
|
||||
procedure StopBunRuntimes;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
WebTaskWasEnabled := TaskEnabled('PunktfunkWeb');
|
||||
ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting');
|
||||
Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$ErrorActionPreference=''SilentlyContinue''; ' +
|
||||
'Stop-ScheduledTask -TaskName PunktfunkWeb; ' +
|
||||
'Get-NetTCPConnection -LocalPort 47992,3000 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"',
|
||||
'$app=''' + PsLiteral(ExpandConstant('{app}')) + '''.ToLower(); ' +
|
||||
'foreach($t in ''PunktfunkWeb'',''PunktfunkScripting''){ Disable-ScheduledTask -TaskName $t; Stop-ScheduledTask -TaskName $t }; ' +
|
||||
{ Scoped to OUR bun by image path - a blanket kill would take out an unrelated bun (a dev box
|
||||
runs its own). The port half stays runtime-agnostic: a pre-bun install ran node on :3000. }
|
||||
'for($i=0; $i -lt 40; $i++){ ' +
|
||||
'$b=@(Get-Process -Name bun | Where-Object { $_.Path -and $_.Path.ToLower().StartsWith($app) }); ' +
|
||||
'$l=@(Get-NetTCPConnection -LocalPort 47992,3000 -State Listen); ' +
|
||||
'if($b.Count -eq 0 -and $l.Count -eq 0){ break }; ' +
|
||||
'$b | ForEach-Object { Stop-Process -Id $_.Id -Force }; ' +
|
||||
'$l | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }; ' +
|
||||
'Start-Sleep -Milliseconds 250 }"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
|
||||
{ The [Run] restore, built here so it names only the tasks that were actually enabled before the
|
||||
copy. Runs LAST, after `web setup` has re-registered PunktfunkWeb and after the scripting entry has
|
||||
re-registered-then-disabled PunktfunkScripting - that entry is right for a fresh install and wrong
|
||||
for an upgrade, and this is what puts an operator's enabled runner back.
|
||||
The web task is only re-enabled, never started: `web setup` starts it on the builds that ship it,
|
||||
and starting a console whose files a scripting-only build just removed would be worse than leaving
|
||||
it for the next boot. The runner was running, so it is restarted. }
|
||||
function RestoreTasksParams(Param: String): String;
|
||||
begin
|
||||
Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; ';
|
||||
if WebTaskWasEnabled then
|
||||
Result := Result + 'Enable-ScheduledTask -TaskName PunktfunkWeb | Out-Null; ';
|
||||
if ScriptingTaskWasEnabled then
|
||||
Result := Result +
|
||||
'Enable-ScheduledTask -TaskName PunktfunkScripting | Out-Null; ' +
|
||||
'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null; ';
|
||||
Result := Result + '"';
|
||||
end;
|
||||
|
||||
function NeedsTaskRestore: Boolean;
|
||||
begin
|
||||
Result := WebTaskWasEnabled or ScriptingTaskWasEnabled;
|
||||
end;
|
||||
#endif
|
||||
|
||||
const
|
||||
@@ -672,11 +759,30 @@ begin
|
||||
begin
|
||||
StopHostServiceAndWait;
|
||||
StopTrays; { upgrade-safe: unlock punktfunk-tray.exe before the copy }
|
||||
#if defined(WithWeb) || defined(WithScripting)
|
||||
StopBunRuntimes; { upgrade-safe: unlock the bundled bun.exe + free :47992 before the copy }
|
||||
#endif
|
||||
#ifdef WithWeb
|
||||
StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy }
|
||||
{ Stash the chosen password for `web setup` (fresh install only); the temp copy is auto-cleaned. }
|
||||
if FreshWebInstall then
|
||||
SaveStringToFile(ExpandConstant('{tmp}\webpw.txt'), Trim(WebPwPage.Values[0]), False);
|
||||
#endif
|
||||
end;
|
||||
end;
|
||||
|
||||
#if defined(WithWeb) || defined(WithScripting)
|
||||
{ The safety net under StopBunRuntimes' Disable-ScheduledTask. Inno calls this even when the user
|
||||
cancels or an install fails, which is the case that matters: the [Run] restore would never have run,
|
||||
and a task left DISABLED does not come back at the next boot the way a merely-stopped one does - an
|
||||
aborted update would take the console down for good. Re-runs the same restore the [Run] entry
|
||||
builds, so in the normal flow it is a no-op (enabling an enabled task does nothing).
|
||||
Also called when Setup exits before ssInstall, where both flags are still False and this does
|
||||
nothing at all. }
|
||||
procedure DeinitializeSetup;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
if NeedsTaskRestore then
|
||||
Exec('powershell.exe', RestoreTasksParams(''), '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
#endif
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
.DESCRIPTION
|
||||
Sustained connect/disconnect churn (e.g. a client reconnect loop x the host's 8 pipeline-build
|
||||
retries - ~100 ADD/REMOVE cycles) exhausts the driver's IddCx monitor slots: the per-monitor
|
||||
target_ids climb, ghost "Generic Monitor (punktfunk)" device nodes pile up, and eventually
|
||||
target_ids climb, ghost "Generic Monitor (Punktfunk)" device nodes pile up, and eventually
|
||||
IOCTL_ADD returns 0x80070490 ERROR_NOT_FOUND ("Element nicht gefunden"). Every session then fails
|
||||
to create a virtual output -> the client gets a hard blackscreen. A host-service restart's
|
||||
IOCTL_CLEAR_ALL does NOT recover it; the driver instance itself must be reloaded.
|
||||
@@ -16,16 +16,16 @@
|
||||
1. Stop the host service (it holds the driver's control device).
|
||||
2. pnputil /remove-device the GHOST (Status != OK = not-present) punktfunk virtual-monitor nodes
|
||||
that accumulated - the root of the slot exhaustion.
|
||||
3. Disable + Enable the pf-vdisplay adapter (ROOT\DISPLAY\*, "punktfunk Virtual Display") to
|
||||
3. Disable + Enable the pf-vdisplay adapter (ROOT\DISPLAY\*, "Punktfunk Virtual Display") to
|
||||
reload the IddCx driver instance and reset its monitor list. (Restart-PnpDevice does NOT exist
|
||||
on this box's PowerShell, so we disable+enable explicitly.)
|
||||
4. Restart the host service.
|
||||
Avoids a reboot on purpose (this box boots to Proxmox).
|
||||
|
||||
.PARAMETER Service Host service name. Default PunktfunkHost.
|
||||
.PARAMETER AdapterName FriendlyName substring of the IddCx adapter to cycle. Default "punktfunk
|
||||
.PARAMETER AdapterName FriendlyName substring of the IddCx adapter to cycle. Default "Punktfunk
|
||||
Virtual Display" (NOT SudoVDA's "SudoMaker Virtual Display Adapter").
|
||||
.PARAMETER GhostMatch FriendlyName substring of the virtual monitors to reap. Default "punktfunk".
|
||||
.PARAMETER GhostMatch FriendlyName substring of the virtual monitors to reap. Default "Punktfunk".
|
||||
.PARAMETER KeepGhosts Skip the ghost-node cleanup; only cycle the adapter.
|
||||
.PARAMETER NoHost Don't stop/start the host service (just reset the driver) - used by
|
||||
redeploy-pf-vdisplay.ps1, which manages the service itself.
|
||||
@@ -41,8 +41,8 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Service = 'PunktfunkHost',
|
||||
[string]$AdapterName = 'punktfunk Virtual Display',
|
||||
[string]$GhostMatch = 'punktfunk',
|
||||
[string]$AdapterName = 'Punktfunk Virtual Display',
|
||||
[string]$GhostMatch = 'Punktfunk',
|
||||
[switch]$KeepGhosts,
|
||||
[switch]$NoHost,
|
||||
[switch]$Verify,
|
||||
|
||||
@@ -63,3 +63,35 @@ if (-not (Test-Path $sccacheExe)) {
|
||||
}
|
||||
& $sccacheExe --version
|
||||
if ($env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $sccacheDir }
|
||||
|
||||
# --- zstd: what actions/cache compresses with. Without it the toolkit falls back to gzip,
|
||||
# and Git's GNU tar then shells out to a `gzip` that is NOT on the runner's PATH — every
|
||||
# cache SAVE died with "Child returned status 127" / "Cannot write: Broken pipe" / exit 2,
|
||||
# reported only as a ::warning:: so runs stayed green while nothing was ever cached
|
||||
# (measured 2026-07-30 on windows-host). Own directory on purpose: putting Git's usr\bin on
|
||||
# PATH would shadow Windows' find/sort/echo and break unrelated steps.
|
||||
$zstdDir = 'C:\Users\Public\zstd'
|
||||
$zstdExe = Join-Path $zstdDir 'zstd.exe'
|
||||
if (-not (Test-Path $zstdExe)) {
|
||||
try {
|
||||
$v = '1.5.6'
|
||||
$zip = Join-Path $env:TEMP "zstd-$v.zip"
|
||||
Invoke-WebRequest -Uri "https://github.com/facebook/zstd/releases/download/v$v/zstd-v$v-win64.zip" -OutFile $zip
|
||||
$tmp = Join-Path $env:TEMP 'zstd-extract'
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
Expand-Archive -Path $zip -DestinationPath $tmp -Force
|
||||
New-Item -ItemType Directory -Force -Path $zstdDir | Out-Null
|
||||
Get-ChildItem -Path $tmp -Recurse -Filter 'zstd.exe' | Select-Object -First 1 |
|
||||
ForEach-Object { Copy-Item $_.FullName $zstdExe -Force }
|
||||
$gitGzip = 'C:\Program Files\Git\usr\bin\gzip.exe'
|
||||
if ((Test-Path $gitGzip) -and -not (Test-Path (Join-Path $zstdDir 'gzip.exe'))) {
|
||||
Copy-Item $gitGzip (Join-Path $zstdDir 'gzip.exe') -Force
|
||||
}
|
||||
Remove-Item $zip, $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch { Write-Warning "zstd install failed (cache saves will fall back to gzip): $_" }
|
||||
}
|
||||
if (Test-Path $zstdExe) {
|
||||
& $zstdExe --version
|
||||
if ($env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $zstdDir }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build, sign, upload, and self-verify the host UPDATE MANIFEST for one channel
|
||||
# (planning: host-update-from-web-console.md §3 — the check truth the console trusts).
|
||||
#
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-update/<channel>/manifest.json
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-update/<channel>/manifest.json.sig
|
||||
#
|
||||
# The signature is a raw 64-byte Ed25519 over the EXACT manifest bytes, base64 in the .sig —
|
||||
# the same format the plugin index uses and `store::index::verify_signature` checks. The
|
||||
# public half is pinned in the host binary (UPDATE_KEYS in crates/punktfunk-host/src/update.rs);
|
||||
# before signing, this script cross-checks the signing key against that constant and refuses
|
||||
# on mismatch — the most likely deploy mistake is signing with a key no host trusts (the
|
||||
# sysext publisher's fingerprint-crosscheck drill).
|
||||
#
|
||||
# Upload order is manifest THEN signature: a client caught in the replace window sees a
|
||||
# mismatched pair and refuses (fail-closed), never a stale-signed document.
|
||||
#
|
||||
# Environment:
|
||||
# CHANNEL stable | canary (required)
|
||||
# VERSION the announced host version string (required)
|
||||
# CI_RUN CI run number (required for canary)
|
||||
# WINDOWS_URL immutable per-version installer URL (required for stable)
|
||||
# WINDOWS_SHA256 hex sha256 of that installer (paired with WINDOWS_URL)
|
||||
# AUTHENTICODE_SHA256 comma-separated accepted signing-leaf sha256s (optional)
|
||||
# NOTES_URL release-notes link (git.unom.io only) (optional)
|
||||
# UPDATE_MANIFEST_KEY PKCS#8 PEM, the Ed25519 private key (required to sign)
|
||||
# REQUIRE_KEY=1 missing key is a hard failure (announce/stable) (optional)
|
||||
# REGISTRY_TOKEN Gitea PAT with write:package (required)
|
||||
# REGISTRY / OWNER default git.unom.io / unom
|
||||
set -euo pipefail
|
||||
|
||||
CHANNEL="${CHANNEL:?set CHANNEL=stable|canary}"
|
||||
VERSION="${VERSION:?set VERSION}"
|
||||
REGISTRY="${REGISTRY:-git.unom.io}"
|
||||
OWNER="${OWNER:-unom}"
|
||||
BASE="https://${REGISTRY}/api/packages/${OWNER}/generic/punktfunk-update/${CHANNEL}"
|
||||
|
||||
case "$CHANNEL" in stable|canary) ;; *) echo "CHANNEL must be stable or canary" >&2; exit 1 ;; esac
|
||||
if [ "$CHANNEL" = canary ] && [ -z "${CI_RUN:-}" ]; then
|
||||
echo "canary manifests need CI_RUN (the definitive newer-than axis)" >&2; exit 1
|
||||
fi
|
||||
if [ "$CHANNEL" = stable ] && [ -z "${WINDOWS_URL:-}" ]; then
|
||||
echo "stable manifests need WINDOWS_URL/WINDOWS_SHA256 (the U1 apply leg)" >&2; exit 1
|
||||
fi
|
||||
if [ -n "${WINDOWS_URL:-}" ] && ! printf '%s' "${WINDOWS_SHA256:-}" | grep -Eq '^[0-9a-f]{64}$'; then
|
||||
echo "WINDOWS_SHA256 must be 64 hex chars when WINDOWS_URL is set" >&2; exit 1
|
||||
fi
|
||||
|
||||
# ---- key handling (fail-closed where it matters) --------------------------------------------
|
||||
if [ -z "${UPDATE_MANIFEST_KEY:-}" ]; then
|
||||
if [ "${REQUIRE_KEY:-0}" = 1 ] || case "${GITHUB_REF:-}" in refs/tags/v*) true ;; *) false ;; esac; then
|
||||
echo "ERROR: UPDATE_MANIFEST_KEY is not set — refusing to publish an unsigned manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "WARN: UPDATE_MANIFEST_KEY not set — skipping the ${CHANNEL} update manifest" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
KEY="$WORK/key.pem"
|
||||
# Tolerate secret stores that mangle newlines into literal \n (the plugin-index signer's fix).
|
||||
if printf '%s' "$UPDATE_MANIFEST_KEY" | grep -q '\\n' && ! printf '%s' "$UPDATE_MANIFEST_KEY" | grep -q '^-----BEGIN.*-----$'; then
|
||||
printf '%s' "$UPDATE_MANIFEST_KEY" | sed 's/\\n/\n/g' > "$KEY"
|
||||
else
|
||||
printf '%s\n' "$UPDATE_MANIFEST_KEY" > "$KEY"
|
||||
fi
|
||||
|
||||
# Cross-check: the key we are about to sign with must be one the host binary pins.
|
||||
PUB="ed25519:$(openssl pkey -in "$KEY" -pubout -outform DER | tail -c 32 | base64)"
|
||||
KEYS_FILE="crates/punktfunk-host/src/update.rs"
|
||||
if [ -f "$KEYS_FILE" ]; then
|
||||
if ! grep -qF "\"$PUB\"" "$KEYS_FILE"; then
|
||||
echo "ERROR: signing key $PUB is not pinned in $KEYS_FILE (UPDATE_KEYS) — wrong key?" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARN: $KEYS_FILE not in this checkout — skipping the pinned-key cross-check" >&2
|
||||
fi
|
||||
|
||||
# ---- build the manifest ---------------------------------------------------------------------
|
||||
SERIAL="$(date +%s)"
|
||||
PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
MANIFEST="$WORK/manifest.json"
|
||||
|
||||
AUTH_JSON="$(printf '%s' "${AUTHENTICODE_SHA256:-}" | jq -R 'split(",") | map(select(length > 0))')"
|
||||
jq -n \
|
||||
--arg channel "$CHANNEL" \
|
||||
--arg version "$VERSION" \
|
||||
--arg published_at "$PUBLISHED_AT" \
|
||||
--arg notes_url "${NOTES_URL:-}" \
|
||||
--argjson serial "$SERIAL" \
|
||||
--arg win_url "${WINDOWS_URL:-}" \
|
||||
--arg win_sha "${WINDOWS_SHA256:-}" \
|
||||
--argjson auth "$AUTH_JSON" \
|
||||
--arg ci_run "${CI_RUN:-}" \
|
||||
'
|
||||
{schema: 1, channel: $channel, serial: $serial, published_at: $published_at, version: $version}
|
||||
+ (if $notes_url != "" then {notes_url: $notes_url} else {} end)
|
||||
+ (if $ci_run != "" then {ci_run: ($ci_run | tonumber)} else {} end)
|
||||
+ (if $win_url != "" then {windows_host: {url: $win_url, sha256: $win_sha, authenticode_sha256: $auth}} else {} end)
|
||||
' > "$MANIFEST"
|
||||
echo "manifest:"; cat "$MANIFEST"
|
||||
|
||||
# ---- sign + local verify --------------------------------------------------------------------
|
||||
SIG_BIN="$WORK/sig.bin"
|
||||
openssl pkeyutl -sign -inkey "$KEY" -rawin -in "$MANIFEST" -out "$SIG_BIN"
|
||||
[ "$(wc -c < "$SIG_BIN")" -eq 64 ] || { echo "ERROR: signature is not 64 bytes" >&2; exit 1; }
|
||||
SIG="$WORK/manifest.json.sig"
|
||||
base64 < "$SIG_BIN" | tr -d '\n' > "$SIG"; printf '\n' >> "$SIG"
|
||||
|
||||
PUBPEM="$WORK/pub.pem"
|
||||
openssl pkey -in "$KEY" -pubout -out "$PUBPEM"
|
||||
openssl pkeyutl -verify -pubin -inkey "$PUBPEM" -rawin -in "$MANIFEST" -sigfile "$SIG_BIN" >/dev/null
|
||||
|
||||
# ---- upload (manifest first, then signature) ------------------------------------------------
|
||||
: "${REGISTRY_TOKEN:?set REGISTRY_TOKEN}"
|
||||
upload() { # file url
|
||||
curl -fsS -o /dev/null --user "enricobuehler:${REGISTRY_TOKEN}" -X DELETE "$2" 2>/dev/null || true
|
||||
curl -fsS -o /dev/null --user "enricobuehler:${REGISTRY_TOKEN}" --upload-file "$1" "$2"
|
||||
echo "published: $2"
|
||||
}
|
||||
upload "$MANIFEST" "$BASE/manifest.json"
|
||||
upload "$SIG" "$BASE/manifest.json.sig"
|
||||
|
||||
# ---- self-verify the LIVE feed (bytes must round-trip; -L follows the 303) ------------------
|
||||
curl -fsSL "$BASE/manifest.json" -o "$WORK/live.json"
|
||||
curl -fsSL "$BASE/manifest.json.sig" -o "$WORK/live.sig"
|
||||
cmp -s "$MANIFEST" "$WORK/live.json" || { echo "ERROR: live manifest differs from what was uploaded" >&2; exit 1; }
|
||||
cmp -s "$SIG" "$WORK/live.sig" || { echo "ERROR: live signature differs from what was uploaded" >&2; exit 1; }
|
||||
openssl pkeyutl -verify -pubin -inkey "$PUBPEM" -rawin -in "$WORK/live.json" -sigfile "$SIG_BIN" >/dev/null
|
||||
echo "OK: ${CHANNEL} update manifest ${VERSION} (serial ${SERIAL}) is live and verifies"
|
||||
@@ -1,438 +1,460 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "Verwaltungskonsole",
|
||||
"display_settings_saved": "Display-Konfiguration gespeichert",
|
||||
"nav_dashboard": "Übersicht",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtuelle Anzeigen",
|
||||
"nav_clients": "Gekoppelte Geräte",
|
||||
"nav_pairing": "Kopplung",
|
||||
"nav_library": "Bibliothek",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "Dieses Plugin läuft nicht",
|
||||
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
|
||||
"plugin_retry": "Erneut versuchen",
|
||||
"plugin_open_new_tab": "In neuem Tab öffnen",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_more": "Mehr",
|
||||
"status_title": "Live-Status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Aktiv",
|
||||
"status_idle": "Inaktiv",
|
||||
"status_session": "Sitzung",
|
||||
"status_sessions_active": "{count} aktiv",
|
||||
"status_no_session": "Keine aktive Sitzung",
|
||||
"status_paired_count": "Gekoppelte Geräte",
|
||||
"status_pin_pending": "Kopplungs-PIN ausstehend",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Auflösung",
|
||||
"stream_fps": "Bildrate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Sitzung beenden",
|
||||
"action_request_idr": "Keyframe anfordern",
|
||||
"action_unpair": "Entkoppeln",
|
||||
"host_identity": "Identität",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Betriebssystem",
|
||||
"host_local_ip": "Lokale IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI-Version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Eindeutige ID",
|
||||
"host_compositors": "Compositoren",
|
||||
"host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.",
|
||||
"compositor_available": "Verfügbar",
|
||||
"compositor_unavailable": "Nicht verfügbar",
|
||||
"compositor_default": "Standard",
|
||||
"compositor_none": "Dieser Host hat keine Compositor-Backends – die gibt es nur unter Linux. Virtuelle Displays laufen hier über den mitgelieferten pf-vdisplay-Treiber.",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"gpu_automatic": "Automatisch",
|
||||
"gpu_prefer": "Bevorzugen",
|
||||
"gpu_preferred": "Bevorzugt",
|
||||
"gpu_in_use": "In Benutzung · {backend}",
|
||||
"gpu_next_session": "Nächste Sitzung",
|
||||
"gpu_none": "Keine GPUs erkannt.",
|
||||
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
|
||||
"host_displays": "Virtuelle Displays",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_config_title": "Konfiguration",
|
||||
"display_preset": "Voreinstellung",
|
||||
"display_preset_custom": "Benutzerdefiniert",
|
||||
"display_preset_default": "Standard",
|
||||
"display_preset_gaming_rig": "Headless-Box",
|
||||
"display_preset_shared_desktop": "Geteilter Desktop",
|
||||
"display_preset_hotdesk": "Hot-Desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Nach Trennung aktiv halten",
|
||||
"display_keep_alive_off": "Aus",
|
||||
"display_keep_alive_keep": "Behalten für",
|
||||
"display_keep_alive_forever": "Dauerhaft",
|
||||
"display_keep_alive_seconds": "Sekunden",
|
||||
"display_topology": "Topologie",
|
||||
"display_topology_auto": "Automatisch",
|
||||
"display_topology_extend": "Erweitern",
|
||||
"display_topology_primary": "Primär",
|
||||
"display_topology_exclusive": "Exklusiv",
|
||||
"display_max": "Max. Displays",
|
||||
"display_save": "Speichern",
|
||||
"display_effective": "Aktiv",
|
||||
"display_pending_note": "Änderungen greifen ab der nächsten Verbindung — eine laufende Sitzung behält die Anzeige, mit der sie gestartet ist.",
|
||||
"display_live": "Aktive Displays",
|
||||
"display_none_live": "Derzeit keine virtuellen Displays.",
|
||||
"display_state_active": "Aktiv",
|
||||
"display_state_lingering": "Wird gehalten",
|
||||
"display_state_pinned": "Angeheftet",
|
||||
"display_release_btn": "Freigeben",
|
||||
"display_release_all": "Alle gehaltenen freigeben",
|
||||
"display_expires_in": "Abbau in {sec}s",
|
||||
"display_sessions": "{count} streamend",
|
||||
"display_arrange": "Anzeigen anordnen",
|
||||
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
||||
"display_arrange_save": "Anordnung speichern",
|
||||
"display_custom_desc": "Jede Option selbst festlegen.",
|
||||
"display_preset_current": "Aktiv",
|
||||
"display_preset_soon": "in Kürze",
|
||||
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
|
||||
"display_topology_help": "Wie sich die gestreamte Anzeige in den Desktop des Hosts einfügt. Erweitern: ein zusätzlicher Bildschirm neben deinen Monitoren. Primär: die gestreamte Anzeige wird der primäre Ausgang, deine Monitore bleiben. Exklusiv: die gestreamte Anzeige ist der einzige Ausgang — physische Monitore werden beim Streamen deaktiviert und danach wiederhergestellt. Auf einem Host ohne Monitor legt es nur fest, ob die gestreamte Anzeige primär ist.",
|
||||
"display_conflict": "Wenn ein weiterer Client verbindet",
|
||||
"display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.",
|
||||
"display_conflict_separate": "Eigene Anzeige",
|
||||
"display_conflict_steal": "Übernehmen",
|
||||
"display_conflict_join": "Ansicht teilen",
|
||||
"display_conflict_reject": "Besetzt — ablehnen",
|
||||
"display_identity": "Client-Identität",
|
||||
"display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.",
|
||||
"display_game_session": "Dedizierte Spiel-Sitzungen",
|
||||
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dediziert",
|
||||
"display_experimental": "Experimentell",
|
||||
"display_ddc": "Monitore beim Streamen ausschalten (DDC/CI)",
|
||||
"display_ddc_help": "Nur Windows, wirkt bei Topologie Exklusiv. Bevor die physischen Monitore deaktiviert werden, weist der Host sie zusätzlich über den DDC/CI-Steuerkanal an, ihr Panel auszuschalten — und weckt sie am Ende des Streams wieder. Das kann ein periodisches Ruckeln beheben, das manche Setups zeigen, wenn die gestreamte Anzeige die einzige aktive ist (der dunkle Monitor sucht sonst weiter seine Eingänge ab; ein schlafen gelegtes Panel nicht). Monitore ohne DDC/CI werden übersprungen — falls ein Monitor danach nicht aufwacht, einmal seinen Power-Knopf drücken und die Option ausschalten.",
|
||||
"display_ddc_disabled": "Aus",
|
||||
"display_ddc_enabled": "Ein",
|
||||
"display_ddc_badge": "Monitore aus via DDC/CI",
|
||||
"display_pnp": "Monitor-Geräte beim Streamen deaktivieren (PnP)",
|
||||
"display_pnp_help": "Nur Windows, wirkt bei Topologie Exklusiv. Zusätzlich zum Entfernen der physischen Monitore vom Desktop deaktiviert der Host deren Windows-Geräteeinträge für die Dauer des Streams und aktiviert sie danach wieder. Ein Monitor oder Fernseher im Standby, der seine Verbindung immer wieder aufweckt (automatische Eingangssuche, Instant-On), kann den Stream so nicht mehr stören — Windows ignoriert seine Aufwach-Ereignisse vollständig, solange das Gerät deaktiviert ist. Stürzt der Host mitten im Stream ab, werden die Monitore beim nächsten Start wieder aktiviert; bis dahin lassen sie sich manuell im Geräte-Manager aktivieren.",
|
||||
"display_pnp_disabled": "Aus",
|
||||
"display_pnp_enabled": "Ein",
|
||||
"display_pnp_badge": "Monitor-Geräte deaktiviert (PnP)",
|
||||
"display_identity_shared": "Geteilt",
|
||||
"display_identity_per_client": "Pro Client",
|
||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||
"display_layout_mode": "Multi-Monitor-Anordnung",
|
||||
"display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.",
|
||||
"display_layout_auto_row": "Automatisch (nebeneinander)",
|
||||
"display_layout_manual": "Manuell",
|
||||
"display_preset_custom_label": "Eigene Voreinstellungen",
|
||||
"display_preset_save_as": "Als Voreinstellung speichern…",
|
||||
"display_preset_name": "Name der Voreinstellung",
|
||||
"display_preset_edit": "Umbenennen",
|
||||
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
|
||||
"display_preset_delete": "Löschen",
|
||||
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
|
||||
"display_custom_title": "Eigene Einstellungen",
|
||||
"display_unsaved": "Nicht gespeicherte Änderungen",
|
||||
"display_unsaved_hint": "Diese Optionen greifen erst, wenn du speicherst.",
|
||||
"display_all_saved": "Alle Änderungen gespeichert",
|
||||
"display_revert": "Änderungen verwerfen",
|
||||
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
|
||||
"clients_title": "Gekoppelte Geräte",
|
||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerabdruck",
|
||||
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_title": "Kopplung",
|
||||
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
|
||||
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "PIN bestätigen",
|
||||
"pairing_pin_sent": "PIN gesendet. Wenn sie stimmt, schließt der Client die Kopplung ab und erscheint unten unter „Gekoppelte Geräte“.",
|
||||
"pairing_failed": "PIN konnte nicht übermittelt werden — prüfe, ob sie stimmt und der Client noch auf die Kopplung wartet.",
|
||||
"pairing_native_title": "Gerät koppeln",
|
||||
"pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner Punktfunk-App ein, um dieses Gerät zu koppeln.",
|
||||
"pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um Punktfunk-Geräte zu koppeln.",
|
||||
"pairing_native_arm": "Gerät koppeln",
|
||||
"pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:",
|
||||
"pairing_native_expires": "Läuft ab in",
|
||||
"pairing_native_cancel": "Abbrechen",
|
||||
"pairing_native_devices": "Gekoppelte Geräte",
|
||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
||||
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_protocol": "Protokoll",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Warten auf Freigabe",
|
||||
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||
"pairing_pending_approve": "Freigeben",
|
||||
"pairing_pending_deny": "Ablehnen",
|
||||
"pairing_pending_name_prompt": "Gerät benennen:",
|
||||
"pairing_pending_age_just_now": "gerade eben",
|
||||
"pairing_pending_age_secs": "vor {s}s",
|
||||
"pairing_pending_age_mins": "vor {min} min",
|
||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||
"library_title": "Bibliothek",
|
||||
"library_empty": "Noch keine Spiele gefunden.",
|
||||
"library_sources_title": "Spielequellen",
|
||||
"library_sources_help": "Launcher, die dieser Host nach installierten Spielen durchsucht. Eine Quelle abschalten blendet ihre Spiele auf allen Geräten aus — nichts wird gelöscht, und beim Wiedereinschalten sind sie sofort zurück.",
|
||||
"library_sources_failed": "Die Spielequelle konnte nicht aktualisiert werden.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Eigene",
|
||||
"library_add_title": "Eigenes Spiel hinzufügen",
|
||||
"library_edit_title": "Eigenes Spiel bearbeiten",
|
||||
"library_add_button": "Eigenes Spiel hinzufügen",
|
||||
"library_field_title": "Titel",
|
||||
"library_field_portrait": "Portrait-Bild-URL",
|
||||
"library_field_hero": "Hero-Bild-URL",
|
||||
"library_field_header": "Header-Bild-URL",
|
||||
"library_field_logo": "Logo-Bild-URL",
|
||||
"library_field_command": "Startbefehl",
|
||||
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
|
||||
"library_field_platform": "Plattform",
|
||||
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Beschreibung",
|
||||
"library_field_developer": "Entwickler",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Erscheinungsjahr",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Kommagetrennt, z. B. RPG, Plattformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Kommagetrennte Labels zum Organisieren, z. B. Koop, Kinder.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Spieler",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Speichern",
|
||||
"library_create": "Hinzufügen",
|
||||
"library_cancel": "Abbrechen",
|
||||
"library_edit": "Bearbeiten",
|
||||
"library_delete": "Löschen",
|
||||
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_token_label": "API-Token",
|
||||
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
||||
"settings_language": "Sprache",
|
||||
"settings_save": "Speichern",
|
||||
"settings_saved": "Gespeichert.",
|
||||
"common_loading": "Wird geladen…",
|
||||
"common_error": "Etwas ist schiefgelaufen.",
|
||||
"common_retry": "Erneut versuchen",
|
||||
"common_yes": "Ja",
|
||||
"common_cancel": "Abbrechen",
|
||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||
"login_title": "Anmelden",
|
||||
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
|
||||
"login_docs_link": "Besuche die Dokumentation",
|
||||
"login_password": "Passwort",
|
||||
"login_submit": "Anmelden",
|
||||
"login_error": "Falsches Passwort.",
|
||||
"login_signing_in": "Anmeldung läuft…",
|
||||
"action_logout": "Abmelden",
|
||||
"settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.",
|
||||
"nav_stats": "Leistung",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Leeren",
|
||||
"logs_search": "Logs durchsuchen…",
|
||||
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
|
||||
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
|
||||
"stats_title": "Leistung",
|
||||
"stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.",
|
||||
"stats_capture_title": "Aufzeichnung",
|
||||
"stats_capture_desc": "Aufzeichnung scharfschalten, eine Sitzung fahren, dann stoppen, um eine Aufnahme zu speichern. Die Abtastung erfolgt an der Aggregationsgrenze des Hosts — kein Overhead pro Frame.",
|
||||
"stats_recording": "Zeichnet auf",
|
||||
"stats_idle": "Inaktiv",
|
||||
"stats_start": "Aufzeichnung starten",
|
||||
"stats_stop": "Stoppen & speichern",
|
||||
"stats_elapsed": "Vergangen",
|
||||
"stats_samples": "Proben",
|
||||
"stats_kind": "Pfad",
|
||||
"stats_kind_native": "Nativ",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.",
|
||||
"stats_latency_title": "Latenz nach Stufe",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Pipeline-Zeit pro Stufe, gestapelt — die Ansicht „wohin geht die Zeit“.",
|
||||
"stats_throughput_title": "Durchsatz",
|
||||
"stats_health_title": "Zustand",
|
||||
"stats_fps_new": "Neue fps",
|
||||
"stats_fps_repeat": "Wiederholte fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Ziel-Mb/s",
|
||||
"stats_health_gamestream_note": "Paketverlust, Sende-Drops und FEC-Wiederherstellung sind empfängerseitige Werte, die der GameStream-Pfad nicht erfasst — hier werden nur Frame-Drops angezeigt.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Verworfene Frames",
|
||||
"stats_packets_dropped": "Verworfene Pakete",
|
||||
"stats_send_dropped": "Sende-Verluste",
|
||||
"stats_fec_recovered": "FEC wiederhergestellt",
|
||||
"stats_recordings_title": "Aufnahmen",
|
||||
"stats_recordings_empty": "Noch keine Aufnahmen. Starte eine Aufzeichnung, um eine anzulegen.",
|
||||
"stats_col_time": "Zeit",
|
||||
"stats_col_kind": "Pfad",
|
||||
"stats_col_resolution": "Auflösung",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Dauer",
|
||||
"stats_col_samples": "Proben",
|
||||
"stats_view": "Ansehen",
|
||||
"stats_download": "Herunterladen",
|
||||
"stats_delete": "Löschen",
|
||||
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"stats_detail_title": "Aufnahme-Details",
|
||||
"stats_close": "Schließen",
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
|
||||
"store_tab_browse": "Entdecken",
|
||||
"store_tab_installed": "Installiert",
|
||||
"store_tab_sources": "Quellen",
|
||||
"store_tier_verified": "Geprüft",
|
||||
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
|
||||
"store_tier_external": "Externe Quelle",
|
||||
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
|
||||
"store_tier_unverified": "Ungeprüft",
|
||||
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
|
||||
"store_tier_cli": "Über die CLI installiert",
|
||||
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
|
||||
"store_from_source": "von",
|
||||
"store_search_placeholder": "Plugins durchsuchen…",
|
||||
"store_filter_all": "Alle Quellen",
|
||||
"store_empty": "Noch keine Plugins im Katalog.",
|
||||
"store_no_match": "Kein Plugin passt zur Suche.",
|
||||
"store_by_author": "von {author}",
|
||||
"store_homepage": "Website",
|
||||
"store_install": "Installieren",
|
||||
"store_installed_label": "Installiert",
|
||||
"store_update_to": "Auf {version} aktualisieren",
|
||||
"store_incompatible": "Nicht kompatibel mit diesem Host.",
|
||||
"store_blocked": "Vom Host blockiert: {reason}",
|
||||
"store_spec_open": "Aus Paketangabe installieren…",
|
||||
"store_runner_title": "Plugin-Runner",
|
||||
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
|
||||
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
|
||||
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
|
||||
"store_runner_enable": "Runner aktivieren",
|
||||
"store_runner_disable": "Runner deaktivieren",
|
||||
"store_runner_state_running": "Läuft",
|
||||
"store_runner_state_stopped": "Gestoppt",
|
||||
"store_runner_state_disabled": "Deaktiviert",
|
||||
"store_runner_state_missing": "Nicht installiert",
|
||||
"store_runner_unit": "Dienst",
|
||||
"store_runner_principal": "Läuft als",
|
||||
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
|
||||
"store_installed_title": "Installierte Plugins",
|
||||
"store_installed_empty": "Noch keine Plugins installiert.",
|
||||
"store_running": "Läuft",
|
||||
"store_stopped": "Läuft nicht",
|
||||
"store_uninstall": "Deinstallieren",
|
||||
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
|
||||
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
|
||||
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
|
||||
"store_sources_title": "Katalogquellen",
|
||||
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
|
||||
"store_refresh_all": "Alle aktualisieren",
|
||||
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
|
||||
"store_source_builtin": "Eingebaut",
|
||||
"store_source_signed": "Signiert",
|
||||
"store_source_unsigned": "Unsigniert",
|
||||
"store_source_stale": "Veraltet",
|
||||
"store_source_entries": "{count} Plugins",
|
||||
"store_source_fetched": "zuletzt geladen {when}",
|
||||
"store_source_never": "nie",
|
||||
"store_source_remove": "Quelle entfernen",
|
||||
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
|
||||
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
|
||||
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
|
||||
"store_add_source_title": "Katalogquelle hinzufügen",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index-URL",
|
||||
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
|
||||
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
|
||||
"store_add_source": "Quelle hinzufügen",
|
||||
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
|
||||
"store_source_trust_title": "Dieser Quelle vertrauen?",
|
||||
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
|
||||
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
|
||||
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
|
||||
"store_install_title": "{title} installieren?",
|
||||
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
|
||||
"store_install_confirm": "Installieren",
|
||||
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
|
||||
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
|
||||
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
|
||||
"store_install_external_confirm": "Trotzdem installieren",
|
||||
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
|
||||
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
|
||||
"store_spec_title": "Aus einer Paketangabe installieren",
|
||||
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
|
||||
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
|
||||
"store_spec_field": "Paketangabe",
|
||||
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
|
||||
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
|
||||
"store_spec_confirm": "Ungeprüft installieren",
|
||||
"store_job_install": "{target} wird installiert",
|
||||
"store_job_uninstall": "{target} wird entfernt",
|
||||
"store_job_done_install": "Installiert.",
|
||||
"store_job_done_uninstall": "Entfernt.",
|
||||
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
|
||||
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
|
||||
"store_job_log": "Log anzeigen",
|
||||
"store_job_dismiss": "Ausblenden",
|
||||
"store_phase_queued": "In der Warteschlange",
|
||||
"store_phase_verifying": "Paket wird verifiziert",
|
||||
"store_phase_installing": "Wird installiert",
|
||||
"store_phase_removing": "Wird entfernt",
|
||||
"store_phase_checking": "Installation wird geprüft",
|
||||
"store_phase_rolling_back": "Wird zurückgerollt",
|
||||
"store_phase_recording": "Herkunft wird vermerkt",
|
||||
"store_phase_restarting": "Plugin-Runner startet neu",
|
||||
"store_phase_done": "Fertig",
|
||||
"games_title": "Laufende Spiele",
|
||||
"games_state_launching": "Startet",
|
||||
"games_state_running": "Läuft",
|
||||
"games_state_exited": "Beendet",
|
||||
"games_state_grace": "Wartet auf Client",
|
||||
"games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt",
|
||||
"games_end_now": "Jetzt beenden",
|
||||
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
|
||||
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
|
||||
"session_game_on_exit": "Wenn das Spiel endet",
|
||||
"session_game_on_exit_help": "Wer das Spiel beendet, landet wieder in der eigenen Bibliothek statt auf deinem Desktop. Schalte es aus, wenn du den Desktop streamst und das Spiel nebensächlich ist.",
|
||||
"session_game_on_exit_end": "Sitzung beenden",
|
||||
"session_game_on_exit_keep": "Weiter streamen",
|
||||
"session_game_end_game": "Wenn die Sitzung endet",
|
||||
"session_game_end_game_help": "Ob das Stoppen (oder der Verlust) einer Sitzung auch das gestartete Spiel schließt. Gilt nie für ein Spiel, das du selbst gestartet hast – nur für eines, das dieser Host für die Sitzung gestartet hat.",
|
||||
"session_game_end_keep": "Weiterlaufen lassen",
|
||||
"session_game_end_on_quit": "Beim Stoppen schließen",
|
||||
"session_game_end_always": "Immer schließen",
|
||||
"session_game_always_warning": "Ein Spiel zu schließen kostet alles, was es nicht gespeichert hat. Der Host bittet es zuerst höflich und erzwingt es nur, wenn es sich weigert – aber ein Verbindungsabbruch ist kein bewusstes Stoppen, deshalb bekommt ein abgerissener Client erst das Zeitfenster unten. Eine dauerhaft offen gehaltene Anzeige bleibt davon unberührt: Diese Einstellung regelt das Spiel, nicht den Bildschirm.",
|
||||
"session_game_grace": "Zeitfenster für die Rückkehr",
|
||||
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
|
||||
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
|
||||
"session_game_inert": "Dieser Host kann keine Spiele starten – hier bewirken diese Einstellungen nichts",
|
||||
"session_game_nested_note": "In einer gamescope-Spielsitzung läuft das Spiel *innerhalb* der gestreamten Anzeige und lebt daher genau so lange wie diese – das entscheidet „Offen halten“ oben, nicht diese Einstellung. Ein bewusstes Stoppen reißt die Anzeige sofort ab und nimmt das Spiel mit, egal was du hier wählst.",
|
||||
"display_monitor_title": "Übertragener Bildschirm",
|
||||
"display_monitor_intro": "Standardmäßig erstellt der Host für jeden Client einen eigenen virtuellen Bildschirm in dessen Auflösung. Stattdessen kannst du einen Bildschirm übertragen, den dieser Computer bereits hat — was auf diesem Monitor zu sehen ist, sieht auch der Client.",
|
||||
"display_monitor_virtual": "Virtueller Bildschirm (Standard)",
|
||||
"display_monitor_virtual_hint": "Jeder Client bekommt einen eigenen Bildschirm in eigener Auflösung.",
|
||||
"display_monitor_mirror_hint": "Alle Clients sehen diesen Monitor in dessen Auflösung.",
|
||||
"display_monitor_none": "Dieser Host meldet keine Monitore.",
|
||||
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
|
||||
"display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.",
|
||||
"display_monitor_unsupported": "Das Übertragen eines dieser Bildschirme wird auf diesem Host noch nicht unterstützt — das gibt es bisher nur auf Linux-Hosts. Die Bildschirme sind hier aufgeführt, damit du siehst, was dieser Computer hat; Clients bekommen weiterhin ihren eigenen virtuellen Bildschirm.",
|
||||
"display_monitor_primary": "primär",
|
||||
"display_monitor_disabled": "aus",
|
||||
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "Verwaltungskonsole",
|
||||
"display_settings_saved": "Display-Konfiguration gespeichert",
|
||||
"nav_dashboard": "Übersicht",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtuelle Anzeigen",
|
||||
"nav_clients": "Gekoppelte Geräte",
|
||||
"nav_pairing": "Kopplung",
|
||||
"nav_library": "Bibliothek",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "Dieses Plugin läuft nicht",
|
||||
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
|
||||
"plugin_retry": "Erneut versuchen",
|
||||
"plugin_open_new_tab": "In neuem Tab öffnen",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_more": "Mehr",
|
||||
"status_title": "Live-Status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Aktiv",
|
||||
"status_idle": "Inaktiv",
|
||||
"status_session": "Sitzung",
|
||||
"status_sessions_active": "{count} aktiv",
|
||||
"status_no_session": "Keine aktive Sitzung",
|
||||
"status_paired_count": "Gekoppelte Geräte",
|
||||
"status_pin_pending": "Kopplungs-PIN ausstehend",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Auflösung",
|
||||
"stream_fps": "Bildrate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Sitzung beenden",
|
||||
"action_request_idr": "Keyframe anfordern",
|
||||
"action_unpair": "Entkoppeln",
|
||||
"host_identity": "Identität",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Betriebssystem",
|
||||
"host_local_ip": "Lokale IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI-Version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Eindeutige ID",
|
||||
"host_compositors": "Compositoren",
|
||||
"host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.",
|
||||
"compositor_available": "Verfügbar",
|
||||
"compositor_unavailable": "Nicht verfügbar",
|
||||
"compositor_default": "Standard",
|
||||
"compositor_none": "Dieser Host hat keine Compositor-Backends – die gibt es nur unter Linux. Virtuelle Displays laufen hier über den mitgelieferten pf-vdisplay-Treiber.",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"gpu_automatic": "Automatisch",
|
||||
"gpu_prefer": "Bevorzugen",
|
||||
"gpu_preferred": "Bevorzugt",
|
||||
"gpu_in_use": "In Benutzung · {backend}",
|
||||
"gpu_next_session": "Nächste Sitzung",
|
||||
"gpu_none": "Keine GPUs erkannt.",
|
||||
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
|
||||
"host_displays": "Virtuelle Displays",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_config_title": "Konfiguration",
|
||||
"display_preset": "Voreinstellung",
|
||||
"display_preset_custom": "Benutzerdefiniert",
|
||||
"display_preset_default": "Standard",
|
||||
"display_preset_gaming_rig": "Headless-Box",
|
||||
"display_preset_shared_desktop": "Geteilter Desktop",
|
||||
"display_preset_hotdesk": "Hot-Desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Nach Trennung aktiv halten",
|
||||
"display_keep_alive_off": "Aus",
|
||||
"display_keep_alive_keep": "Behalten für",
|
||||
"display_keep_alive_forever": "Dauerhaft",
|
||||
"display_keep_alive_seconds": "Sekunden",
|
||||
"display_topology": "Topologie",
|
||||
"display_topology_auto": "Automatisch",
|
||||
"display_topology_extend": "Erweitern",
|
||||
"display_topology_primary": "Primär",
|
||||
"display_topology_exclusive": "Exklusiv",
|
||||
"display_max": "Max. Displays",
|
||||
"display_save": "Speichern",
|
||||
"display_effective": "Aktiv",
|
||||
"display_pending_note": "Änderungen greifen ab der nächsten Verbindung — eine laufende Sitzung behält die Anzeige, mit der sie gestartet ist.",
|
||||
"display_live": "Aktive Displays",
|
||||
"display_none_live": "Derzeit keine virtuellen Displays.",
|
||||
"display_state_active": "Aktiv",
|
||||
"display_state_lingering": "Wird gehalten",
|
||||
"display_state_pinned": "Angeheftet",
|
||||
"display_release_btn": "Freigeben",
|
||||
"display_release_all": "Alle gehaltenen freigeben",
|
||||
"display_expires_in": "Abbau in {sec}s",
|
||||
"display_sessions": "{count} streamend",
|
||||
"display_arrange": "Anzeigen anordnen",
|
||||
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
||||
"display_arrange_save": "Anordnung speichern",
|
||||
"display_custom_desc": "Jede Option selbst festlegen.",
|
||||
"display_preset_current": "Aktiv",
|
||||
"display_preset_soon": "in Kürze",
|
||||
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
|
||||
"display_topology_help": "Wie sich die gestreamte Anzeige in den Desktop des Hosts einfügt. Erweitern: ein zusätzlicher Bildschirm neben deinen Monitoren. Primär: die gestreamte Anzeige wird der primäre Ausgang, deine Monitore bleiben. Exklusiv: die gestreamte Anzeige ist der einzige Ausgang — physische Monitore werden beim Streamen deaktiviert und danach wiederhergestellt. Auf einem Host ohne Monitor legt es nur fest, ob die gestreamte Anzeige primär ist.",
|
||||
"display_conflict": "Wenn ein weiterer Client verbindet",
|
||||
"display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.",
|
||||
"display_conflict_separate": "Eigene Anzeige",
|
||||
"display_conflict_steal": "Übernehmen",
|
||||
"display_conflict_join": "Ansicht teilen",
|
||||
"display_conflict_reject": "Besetzt — ablehnen",
|
||||
"display_identity": "Client-Identität",
|
||||
"display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.",
|
||||
"display_game_session": "Dedizierte Spiel-Sitzungen",
|
||||
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dediziert",
|
||||
"display_experimental": "Experimentell",
|
||||
"display_ddc": "Monitore beim Streamen ausschalten (DDC/CI)",
|
||||
"display_ddc_help": "Nur Windows, wirkt bei Topologie Exklusiv. Bevor die physischen Monitore deaktiviert werden, weist der Host sie zusätzlich über den DDC/CI-Steuerkanal an, ihr Panel auszuschalten — und weckt sie am Ende des Streams wieder. Das kann ein periodisches Ruckeln beheben, das manche Setups zeigen, wenn die gestreamte Anzeige die einzige aktive ist (der dunkle Monitor sucht sonst weiter seine Eingänge ab; ein schlafen gelegtes Panel nicht). Monitore ohne DDC/CI werden übersprungen — falls ein Monitor danach nicht aufwacht, einmal seinen Power-Knopf drücken und die Option ausschalten.",
|
||||
"display_ddc_disabled": "Aus",
|
||||
"display_ddc_enabled": "Ein",
|
||||
"display_ddc_badge": "Monitore aus via DDC/CI",
|
||||
"display_pnp": "Monitor-Geräte beim Streamen deaktivieren (PnP)",
|
||||
"display_pnp_help": "Nur Windows, wirkt bei Topologie Exklusiv. Zusätzlich zum Entfernen der physischen Monitore vom Desktop deaktiviert der Host deren Windows-Geräteeinträge für die Dauer des Streams und aktiviert sie danach wieder. Ein Monitor oder Fernseher im Standby, der seine Verbindung immer wieder aufweckt (automatische Eingangssuche, Instant-On), kann den Stream so nicht mehr stören — Windows ignoriert seine Aufwach-Ereignisse vollständig, solange das Gerät deaktiviert ist. Stürzt der Host mitten im Stream ab, werden die Monitore beim nächsten Start wieder aktiviert; bis dahin lassen sie sich manuell im Geräte-Manager aktivieren.",
|
||||
"display_pnp_disabled": "Aus",
|
||||
"display_pnp_enabled": "Ein",
|
||||
"display_pnp_badge": "Monitor-Geräte deaktiviert (PnP)",
|
||||
"display_identity_shared": "Geteilt",
|
||||
"display_identity_per_client": "Pro Client",
|
||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||
"display_layout_mode": "Multi-Monitor-Anordnung",
|
||||
"display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.",
|
||||
"display_layout_auto_row": "Automatisch (nebeneinander)",
|
||||
"display_layout_manual": "Manuell",
|
||||
"display_preset_custom_label": "Eigene Voreinstellungen",
|
||||
"display_preset_save_as": "Als Voreinstellung speichern…",
|
||||
"display_preset_name": "Name der Voreinstellung",
|
||||
"display_preset_edit": "Umbenennen",
|
||||
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
|
||||
"display_preset_delete": "Löschen",
|
||||
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
|
||||
"display_custom_title": "Eigene Einstellungen",
|
||||
"display_unsaved": "Nicht gespeicherte Änderungen",
|
||||
"display_unsaved_hint": "Diese Optionen greifen erst, wenn du speicherst.",
|
||||
"display_all_saved": "Alle Änderungen gespeichert",
|
||||
"display_revert": "Änderungen verwerfen",
|
||||
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
|
||||
"clients_title": "Gekoppelte Geräte",
|
||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerabdruck",
|
||||
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_title": "Kopplung",
|
||||
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
|
||||
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "PIN bestätigen",
|
||||
"pairing_pin_sent": "PIN gesendet. Wenn sie stimmt, schließt der Client die Kopplung ab und erscheint unten unter „Gekoppelte Geräte“.",
|
||||
"pairing_failed": "PIN konnte nicht übermittelt werden — prüfe, ob sie stimmt und der Client noch auf die Kopplung wartet.",
|
||||
"pairing_native_title": "Gerät koppeln",
|
||||
"pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner Punktfunk-App ein, um dieses Gerät zu koppeln.",
|
||||
"pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um Punktfunk-Geräte zu koppeln.",
|
||||
"pairing_native_arm": "Gerät koppeln",
|
||||
"pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:",
|
||||
"pairing_native_expires": "Läuft ab in",
|
||||
"pairing_native_cancel": "Abbrechen",
|
||||
"pairing_native_devices": "Gekoppelte Geräte",
|
||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
||||
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_protocol": "Protokoll",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Warten auf Freigabe",
|
||||
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||
"pairing_pending_approve": "Freigeben",
|
||||
"pairing_pending_deny": "Ablehnen",
|
||||
"pairing_pending_name_prompt": "Gerät benennen:",
|
||||
"pairing_pending_age_just_now": "gerade eben",
|
||||
"pairing_pending_age_secs": "vor {s}s",
|
||||
"pairing_pending_age_mins": "vor {min} min",
|
||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||
"library_title": "Bibliothek",
|
||||
"library_empty": "Noch keine Spiele gefunden.",
|
||||
"library_sources_title": "Spielequellen",
|
||||
"library_sources_help": "Launcher, die dieser Host nach installierten Spielen durchsucht. Eine Quelle abschalten blendet ihre Spiele auf allen Geräten aus — nichts wird gelöscht, und beim Wiedereinschalten sind sie sofort zurück.",
|
||||
"library_sources_failed": "Die Spielequelle konnte nicht aktualisiert werden.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Eigene",
|
||||
"library_add_title": "Eigenes Spiel hinzufügen",
|
||||
"library_edit_title": "Eigenes Spiel bearbeiten",
|
||||
"library_add_button": "Eigenes Spiel hinzufügen",
|
||||
"library_field_title": "Titel",
|
||||
"library_field_portrait": "Portrait-Bild-URL",
|
||||
"library_field_hero": "Hero-Bild-URL",
|
||||
"library_field_header": "Header-Bild-URL",
|
||||
"library_field_logo": "Logo-Bild-URL",
|
||||
"library_field_command": "Startbefehl",
|
||||
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
|
||||
"library_field_platform": "Plattform",
|
||||
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Beschreibung",
|
||||
"library_field_developer": "Entwickler",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Erscheinungsjahr",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Kommagetrennt, z. B. RPG, Plattformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Kommagetrennte Labels zum Organisieren, z. B. Koop, Kinder.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Spieler",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Speichern",
|
||||
"library_create": "Hinzufügen",
|
||||
"library_cancel": "Abbrechen",
|
||||
"library_edit": "Bearbeiten",
|
||||
"library_delete": "Löschen",
|
||||
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_token_label": "API-Token",
|
||||
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
||||
"settings_language": "Sprache",
|
||||
"settings_save": "Speichern",
|
||||
"settings_saved": "Gespeichert.",
|
||||
"common_loading": "Wird geladen…",
|
||||
"common_error": "Etwas ist schiefgelaufen.",
|
||||
"common_retry": "Erneut versuchen",
|
||||
"common_yes": "Ja",
|
||||
"common_cancel": "Abbrechen",
|
||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||
"login_title": "Anmelden",
|
||||
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
|
||||
"login_docs_link": "Besuche die Dokumentation",
|
||||
"login_password": "Passwort",
|
||||
"login_submit": "Anmelden",
|
||||
"login_error": "Falsches Passwort.",
|
||||
"login_signing_in": "Anmeldung läuft…",
|
||||
"action_logout": "Abmelden",
|
||||
"settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.",
|
||||
"nav_stats": "Leistung",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Leeren",
|
||||
"logs_search": "Logs durchsuchen…",
|
||||
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
|
||||
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
|
||||
"logs_download": "Logs herunterladen",
|
||||
"logs_share": "Logs teilen",
|
||||
"logs_copy": "Logs in die Zwischenablage kopieren",
|
||||
"logs_copied": "Logs in die Zwischenablage kopiert",
|
||||
"logs_share_failed": "Logs konnten nicht geteilt werden",
|
||||
"stats_title": "Leistung",
|
||||
"stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.",
|
||||
"stats_capture_title": "Aufzeichnung",
|
||||
"stats_capture_desc": "Aufzeichnung scharfschalten, eine Sitzung fahren, dann stoppen, um eine Aufnahme zu speichern. Die Abtastung erfolgt an der Aggregationsgrenze des Hosts — kein Overhead pro Frame.",
|
||||
"stats_recording": "Zeichnet auf",
|
||||
"stats_idle": "Inaktiv",
|
||||
"stats_start": "Aufzeichnung starten",
|
||||
"stats_stop": "Stoppen & speichern",
|
||||
"stats_elapsed": "Vergangen",
|
||||
"stats_samples": "Proben",
|
||||
"stats_kind": "Pfad",
|
||||
"stats_kind_native": "Nativ",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.",
|
||||
"stats_latency_title": "Latenz nach Stufe",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Pipeline-Zeit pro Stufe, gestapelt — die Ansicht „wohin geht die Zeit“.",
|
||||
"stats_throughput_title": "Durchsatz",
|
||||
"stats_health_title": "Zustand",
|
||||
"stats_fps_new": "Neue fps",
|
||||
"stats_fps_repeat": "Wiederholte fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Ziel-Mb/s",
|
||||
"stats_health_gamestream_note": "Paketverlust, Sende-Drops und FEC-Wiederherstellung sind empfängerseitige Werte, die der GameStream-Pfad nicht erfasst — hier werden nur Frame-Drops angezeigt.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Verworfene Frames",
|
||||
"stats_packets_dropped": "Verworfene Pakete",
|
||||
"stats_send_dropped": "Sende-Verluste",
|
||||
"stats_fec_recovered": "FEC wiederhergestellt",
|
||||
"stats_recordings_title": "Aufnahmen",
|
||||
"stats_recordings_empty": "Noch keine Aufnahmen. Starte eine Aufzeichnung, um eine anzulegen.",
|
||||
"stats_col_time": "Zeit",
|
||||
"stats_col_kind": "Pfad",
|
||||
"stats_col_resolution": "Auflösung",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Dauer",
|
||||
"stats_col_samples": "Proben",
|
||||
"stats_view": "Ansehen",
|
||||
"stats_download": "Herunterladen",
|
||||
"stats_delete": "Löschen",
|
||||
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"stats_detail_title": "Aufnahme-Details",
|
||||
"stats_close": "Schließen",
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
|
||||
"store_tab_browse": "Entdecken",
|
||||
"store_tab_installed": "Installiert",
|
||||
"store_tab_sources": "Quellen",
|
||||
"store_tier_verified": "Geprüft",
|
||||
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
|
||||
"store_tier_external": "Externe Quelle",
|
||||
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
|
||||
"store_tier_unverified": "Ungeprüft",
|
||||
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
|
||||
"store_tier_cli": "Über die CLI installiert",
|
||||
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
|
||||
"store_from_source": "von",
|
||||
"store_search_placeholder": "Plugins durchsuchen…",
|
||||
"store_filter_all": "Alle Quellen",
|
||||
"store_empty": "Noch keine Plugins im Katalog.",
|
||||
"store_no_match": "Kein Plugin passt zur Suche.",
|
||||
"store_by_author": "von {author}",
|
||||
"store_homepage": "Website",
|
||||
"store_install": "Installieren",
|
||||
"store_installed_label": "Installiert",
|
||||
"store_update_to": "Auf {version} aktualisieren",
|
||||
"store_incompatible": "Nicht kompatibel mit diesem Host.",
|
||||
"store_blocked": "Vom Host blockiert: {reason}",
|
||||
"store_spec_open": "Aus Paketangabe installieren…",
|
||||
"store_runner_title": "Plugin-Runner",
|
||||
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
|
||||
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
|
||||
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
|
||||
"store_runner_enable": "Runner aktivieren",
|
||||
"store_runner_disable": "Runner deaktivieren",
|
||||
"store_runner_state_running": "Läuft",
|
||||
"store_runner_state_stopped": "Gestoppt",
|
||||
"store_runner_state_disabled": "Deaktiviert",
|
||||
"store_runner_state_missing": "Nicht installiert",
|
||||
"store_runner_unit": "Dienst",
|
||||
"store_runner_principal": "Läuft als",
|
||||
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
|
||||
"store_installed_title": "Installierte Plugins",
|
||||
"store_installed_empty": "Noch keine Plugins installiert.",
|
||||
"store_running": "Läuft",
|
||||
"store_stopped": "Läuft nicht",
|
||||
"store_uninstall": "Deinstallieren",
|
||||
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
|
||||
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
|
||||
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
|
||||
"store_sources_title": "Katalogquellen",
|
||||
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
|
||||
"store_refresh_all": "Alle aktualisieren",
|
||||
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
|
||||
"store_source_builtin": "Eingebaut",
|
||||
"store_source_signed": "Signiert",
|
||||
"store_source_unsigned": "Unsigniert",
|
||||
"store_source_stale": "Veraltet",
|
||||
"store_source_entries": "{count} Plugins",
|
||||
"store_source_fetched": "zuletzt geladen {when}",
|
||||
"store_source_never": "nie",
|
||||
"store_source_remove": "Quelle entfernen",
|
||||
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
|
||||
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
|
||||
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
|
||||
"store_add_source_title": "Katalogquelle hinzufügen",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index-URL",
|
||||
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
|
||||
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
|
||||
"store_add_source": "Quelle hinzufügen",
|
||||
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
|
||||
"store_source_trust_title": "Dieser Quelle vertrauen?",
|
||||
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
|
||||
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
|
||||
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
|
||||
"store_install_title": "{title} installieren?",
|
||||
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
|
||||
"store_install_confirm": "Installieren",
|
||||
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
|
||||
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
|
||||
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
|
||||
"store_install_external_confirm": "Trotzdem installieren",
|
||||
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
|
||||
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
|
||||
"store_spec_title": "Aus einer Paketangabe installieren",
|
||||
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
|
||||
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
|
||||
"store_spec_field": "Paketangabe",
|
||||
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
|
||||
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
|
||||
"store_spec_confirm": "Ungeprüft installieren",
|
||||
"store_job_install": "{target} wird installiert",
|
||||
"store_job_uninstall": "{target} wird entfernt",
|
||||
"store_job_done_install": "Installiert.",
|
||||
"store_job_done_uninstall": "Entfernt.",
|
||||
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
|
||||
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
|
||||
"store_job_log": "Log anzeigen",
|
||||
"store_job_dismiss": "Ausblenden",
|
||||
"store_phase_queued": "In der Warteschlange",
|
||||
"store_phase_verifying": "Paket wird verifiziert",
|
||||
"store_phase_installing": "Wird installiert",
|
||||
"store_phase_removing": "Wird entfernt",
|
||||
"store_phase_checking": "Installation wird geprüft",
|
||||
"store_phase_rolling_back": "Wird zurückgerollt",
|
||||
"store_phase_recording": "Herkunft wird vermerkt",
|
||||
"store_phase_restarting": "Plugin-Runner startet neu",
|
||||
"store_phase_done": "Fertig",
|
||||
"games_title": "Laufende Spiele",
|
||||
"games_state_launching": "Startet",
|
||||
"games_state_running": "Läuft",
|
||||
"games_state_exited": "Beendet",
|
||||
"games_state_grace": "Wartet auf Client",
|
||||
"games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt",
|
||||
"games_end_now": "Jetzt beenden",
|
||||
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
|
||||
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
|
||||
"session_game_on_exit": "Wenn das Spiel endet",
|
||||
"session_game_on_exit_help": "Wer das Spiel beendet, landet wieder in der eigenen Bibliothek statt auf deinem Desktop. Schalte es aus, wenn du den Desktop streamst und das Spiel nebensächlich ist.",
|
||||
"session_game_on_exit_end": "Sitzung beenden",
|
||||
"session_game_on_exit_keep": "Weiter streamen",
|
||||
"session_game_end_game": "Wenn die Sitzung endet",
|
||||
"session_game_end_game_help": "Ob das Stoppen (oder der Verlust) einer Sitzung auch das gestartete Spiel schließt. Gilt nie für ein Spiel, das du selbst gestartet hast – nur für eines, das dieser Host für die Sitzung gestartet hat.",
|
||||
"session_game_end_keep": "Weiterlaufen lassen",
|
||||
"session_game_end_on_quit": "Beim Stoppen schließen",
|
||||
"session_game_end_always": "Immer schließen",
|
||||
"session_game_always_warning": "Ein Spiel zu schließen kostet alles, was es nicht gespeichert hat. Der Host bittet es zuerst höflich und erzwingt es nur, wenn es sich weigert – aber ein Verbindungsabbruch ist kein bewusstes Stoppen, deshalb bekommt ein abgerissener Client erst das Zeitfenster unten. Eine dauerhaft offen gehaltene Anzeige bleibt davon unberührt: Diese Einstellung regelt das Spiel, nicht den Bildschirm.",
|
||||
"session_game_grace": "Zeitfenster für die Rückkehr",
|
||||
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
|
||||
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
|
||||
"session_game_inert": "Dieser Host kann keine Spiele starten – hier bewirken diese Einstellungen nichts",
|
||||
"session_game_nested_note": "In einer gamescope-Spielsitzung läuft das Spiel *innerhalb* der gestreamten Anzeige und lebt daher genau so lange wie diese – das entscheidet „Offen halten“ oben, nicht diese Einstellung. Ein bewusstes Stoppen reißt die Anzeige sofort ab und nimmt das Spiel mit, egal was du hier wählst.",
|
||||
"display_monitor_title": "Übertragener Bildschirm",
|
||||
"display_monitor_intro": "Standardmäßig erstellt der Host für jeden Client einen eigenen virtuellen Bildschirm in dessen Auflösung. Stattdessen kannst du einen Bildschirm übertragen, den dieser Computer bereits hat — was auf diesem Monitor zu sehen ist, sieht auch der Client.",
|
||||
"display_monitor_virtual": "Virtueller Bildschirm (Standard)",
|
||||
"display_monitor_virtual_hint": "Jeder Client bekommt einen eigenen Bildschirm in eigener Auflösung.",
|
||||
"display_monitor_mirror_hint": "Alle Clients sehen diesen Monitor in dessen Auflösung.",
|
||||
"display_monitor_none": "Dieser Host meldet keine Monitore.",
|
||||
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
|
||||
"display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.",
|
||||
"display_monitor_unsupported": "Das Übertragen eines dieser Bildschirme wird auf diesem Host noch nicht unterstützt — das gibt es bisher nur auf Linux-Hosts. Die Bildschirme sind hier aufgeführt, damit du siehst, was dieser Computer hat; Clients bekommen weiterhin ihren eigenen virtuellen Bildschirm.",
|
||||
"display_monitor_primary": "primär",
|
||||
"display_monitor_disabled": "aus",
|
||||
"display_monitor_saved": "Übertragener Bildschirm gespeichert",
|
||||
"update_title": "Updates",
|
||||
"update_available_badge": "Update verfügbar",
|
||||
"update_current": "Installiert",
|
||||
"update_install_kind": "Installationsart",
|
||||
"update_latest": "Neueste Version",
|
||||
"update_notes": "Versionshinweise",
|
||||
"update_up_to_date": "Du bist auf dem neuesten Stand.",
|
||||
"update_how": "Diesen Host aktualisieren mit:",
|
||||
"update_check_now": "Jetzt prüfen",
|
||||
"update_checking": "Prüfe…",
|
||||
"update_last_checked": "Zuletzt geprüft",
|
||||
"update_never_checked": "Noch nicht geprüft",
|
||||
"update_stale": "Der Update-Feed hat sich seit über 45 Tagen nicht geändert — Prüfungen gelingen, aber es kommt nichts Neues an. Falls das nicht stimmen kann, prüfe die Verbindung dieses Hosts zu git.unom.io.",
|
||||
"update_disabled": "Update-Prüfungen sind auf diesem Host deaktiviert (PUNKTFUNK_UPDATE_CHECK=0).",
|
||||
"update_error": "Letzte Prüfung fehlgeschlagen:",
|
||||
"update_copy": "Kopieren",
|
||||
"update_copied": "Kopiert"
|
||||
}
|
||||
|
||||
@@ -1,438 +1,460 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "management console",
|
||||
"display_settings_saved": "Display configuration saved",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtual displays",
|
||||
"nav_clients": "Paired clients",
|
||||
"nav_pairing": "Pairing",
|
||||
"nav_library": "Library",
|
||||
"nav_settings": "Settings",
|
||||
"nav_more": "More",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "This plugin isn't running",
|
||||
"plugin_offline_hint": "Start the scripting runner, then retry.",
|
||||
"plugin_retry": "Retry",
|
||||
"plugin_open_new_tab": "Open in new tab",
|
||||
"status_title": "Live status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Streaming",
|
||||
"status_idle": "Idle",
|
||||
"status_session": "Session",
|
||||
"status_sessions_active": "{count} active",
|
||||
"status_no_session": "No active session",
|
||||
"status_paired_count": "Paired clients",
|
||||
"status_pin_pending": "Pairing PIN pending",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Resolution",
|
||||
"stream_fps": "Frame rate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Stop session",
|
||||
"action_request_idr": "Request keyframe",
|
||||
"action_unpair": "Unpair",
|
||||
"host_identity": "Identity",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Operating system",
|
||||
"host_local_ip": "Local IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Unique ID",
|
||||
"host_compositors": "Compositors",
|
||||
"host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.",
|
||||
"compositor_available": "Available",
|
||||
"compositor_unavailable": "Unavailable",
|
||||
"compositor_default": "Default",
|
||||
"compositor_none": "This host has no compositor backends — they are a Linux concept. Virtual displays here run on the bundled pf-vdisplay driver.",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.",
|
||||
"gpu_automatic": "Automatic",
|
||||
"gpu_prefer": "Prefer",
|
||||
"gpu_preferred": "Preferred",
|
||||
"gpu_in_use": "In use · {backend}",
|
||||
"gpu_next_session": "Next session",
|
||||
"gpu_none": "No GPUs detected.",
|
||||
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
|
||||
"host_displays": "Virtual displays",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_config_title": "Configuration",
|
||||
"display_preset": "Preset",
|
||||
"display_preset_custom": "Custom",
|
||||
"display_preset_default": "Default",
|
||||
"display_preset_gaming_rig": "Headless box",
|
||||
"display_preset_shared_desktop": "Shared desktop",
|
||||
"display_preset_hotdesk": "Hot-desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Keep alive after disconnect",
|
||||
"display_keep_alive_off": "Off",
|
||||
"display_keep_alive_keep": "Keep for",
|
||||
"display_keep_alive_forever": "Forever",
|
||||
"display_keep_alive_seconds": "seconds",
|
||||
"display_topology": "Topology",
|
||||
"display_topology_auto": "Automatic",
|
||||
"display_topology_extend": "Extend",
|
||||
"display_topology_primary": "Primary",
|
||||
"display_topology_exclusive": "Exclusive",
|
||||
"display_max": "Max displays",
|
||||
"display_save": "Save",
|
||||
"display_effective": "In effect",
|
||||
"display_pending_note": "Changes apply from the next connection — a streaming session keeps the display it opened on.",
|
||||
"display_live": "Live displays",
|
||||
"display_none_live": "No virtual displays right now.",
|
||||
"display_state_active": "Active",
|
||||
"display_state_lingering": "Lingering",
|
||||
"display_state_pinned": "Pinned",
|
||||
"display_release_btn": "Release",
|
||||
"display_release_all": "Release all kept",
|
||||
"display_expires_in": "tears down in {sec}s",
|
||||
"display_sessions": "{count} streaming",
|
||||
"display_arrange": "Arrange displays",
|
||||
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
||||
"display_arrange_save": "Save arrangement",
|
||||
"display_custom_desc": "Set every option yourself.",
|
||||
"display_preset_current": "Active",
|
||||
"display_preset_soon": "coming soon",
|
||||
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
|
||||
"display_topology_help": "How the streamed display fits into the host's desktop. Extend: an extra screen alongside your monitors. Primary: the streamed display becomes the primary output, your monitors kept. Exclusive: the streamed display is the sole output — physical monitors are disabled while streaming and restored after. On a headless host it just sets whether the streamed display is the primary.",
|
||||
"display_conflict": "When another client connects",
|
||||
"display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.",
|
||||
"display_conflict_separate": "Own display",
|
||||
"display_conflict_steal": "Take over",
|
||||
"display_conflict_join": "Share view",
|
||||
"display_conflict_reject": "Busy — reject",
|
||||
"display_identity": "Per-client identity",
|
||||
"display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.",
|
||||
"display_game_session": "Dedicated game sessions",
|
||||
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dedicated",
|
||||
"display_experimental": "Experimental",
|
||||
"display_ddc": "Turn monitors off while streaming (DDC/CI)",
|
||||
"display_ddc_help": "Windows only, takes effect with Exclusive topology. Before disabling your physical monitors, the host also tells them to power their panel off over the DDC/CI monitor-control channel, and wakes them again when the stream ends. This can eliminate a periodic stutter some setups see when the streamed display is the only active one (the dark monitor keeps probing its inputs; a panel that was told to sleep doesn't). Monitors without DDC/CI support are skipped — if your monitor doesn't wake up afterwards, press its power button once and turn this off.",
|
||||
"display_ddc_disabled": "Off",
|
||||
"display_ddc_enabled": "On",
|
||||
"display_ddc_badge": "Monitors off via DDC/CI",
|
||||
"display_pnp": "Disable monitor devices while streaming (PnP)",
|
||||
"display_pnp_help": "Windows only, takes effect with Exclusive topology. On top of removing your physical monitors from the desktop, the host disables their Windows device entries for the duration of the stream and re-enables them afterwards. A standby monitor or TV that keeps waking its connection (auto input scan, instant-on) can no longer interrupt the stream — Windows ignores its wake events entirely while the device is disabled. If the host crashes mid-stream, the monitors are re-enabled the next time it starts; until then you can re-enable them manually in Device Manager.",
|
||||
"display_pnp_disabled": "Off",
|
||||
"display_pnp_enabled": "On",
|
||||
"display_pnp_badge": "Monitor devices disabled (PnP)",
|
||||
"display_identity_shared": "Shared",
|
||||
"display_identity_per_client": "Per client",
|
||||
"display_identity_per_client_mode": "Per client + resolution",
|
||||
"display_layout_mode": "Multi-monitor layout",
|
||||
"display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.",
|
||||
"display_layout_auto_row": "Auto (side by side)",
|
||||
"display_layout_manual": "Manual",
|
||||
"display_preset_custom_label": "Custom presets",
|
||||
"display_preset_save_as": "Save as preset…",
|
||||
"display_preset_name": "Preset name",
|
||||
"display_preset_edit": "Rename",
|
||||
"display_preset_update": "Update to current settings",
|
||||
"display_preset_delete": "Delete",
|
||||
"display_preset_delete_confirm": "Delete this custom preset?",
|
||||
"display_custom_title": "Custom settings",
|
||||
"display_unsaved": "Unsaved changes",
|
||||
"display_unsaved_hint": "These options only take effect once you save.",
|
||||
"display_all_saved": "All changes saved",
|
||||
"display_revert": "Discard changes",
|
||||
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
|
||||
"clients_title": "Paired clients",
|
||||
"clients_empty": "No paired clients yet.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerprint",
|
||||
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
|
||||
"pairing_title": "Pairing",
|
||||
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
|
||||
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "Submit PIN",
|
||||
"pairing_pin_sent": "PIN sent. If it's correct, the client finishes pairing and appears under Paired devices below.",
|
||||
"pairing_failed": "Couldn't submit the PIN — check it's correct and that the client is still waiting to pair.",
|
||||
"pairing_native_title": "Pair a device",
|
||||
"pairing_native_desc": "Show a one-time PIN here, then enter it in your Punktfunk app to pair this device.",
|
||||
"pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair Punktfunk devices.",
|
||||
"pairing_native_arm": "Pair a device",
|
||||
"pairing_native_enter": "Enter this PIN on your device:",
|
||||
"pairing_native_expires": "Expires in",
|
||||
"pairing_native_cancel": "Cancel",
|
||||
"pairing_native_devices": "Paired devices",
|
||||
"pairing_native_empty": "No devices paired yet.",
|
||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||
"pairing_protocol": "Protocol",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Waiting for approval",
|
||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||
"pairing_pending_approve": "Approve",
|
||||
"pairing_pending_deny": "Deny",
|
||||
"pairing_pending_name_prompt": "Name this device:",
|
||||
"pairing_pending_age_just_now": "just now",
|
||||
"pairing_pending_age_secs": "{s}s ago",
|
||||
"pairing_pending_age_mins": "{min} min ago",
|
||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||
"library_title": "Library",
|
||||
"library_empty": "No games found yet.",
|
||||
"library_sources_title": "Game sources",
|
||||
"library_sources_help": "Launchers this host scans for installed games. Turn one off to hide its games from every device — nothing is deleted, and turning it back on brings them right back.",
|
||||
"library_sources_failed": "Could not update the game source.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Custom",
|
||||
"library_add_title": "Add a custom game",
|
||||
"library_edit_title": "Edit custom game",
|
||||
"library_add_button": "Add custom game",
|
||||
"library_field_title": "Title",
|
||||
"library_field_portrait": "Portrait art URL",
|
||||
"library_field_hero": "Hero art URL",
|
||||
"library_field_header": "Header art URL",
|
||||
"library_field_logo": "Logo art URL",
|
||||
"library_field_command": "Launch command",
|
||||
"library_field_command_help": "Optional. The command the host runs to launch this title.",
|
||||
"library_field_platform": "Platform",
|
||||
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Description",
|
||||
"library_field_developer": "Developer",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Release year",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Comma-separated, e.g. RPG, Platformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Comma-separated labels for organizing, e.g. co-op, kids.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Players",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Save",
|
||||
"library_create": "Add",
|
||||
"library_cancel": "Cancel",
|
||||
"library_edit": "Edit",
|
||||
"library_delete": "Delete",
|
||||
"library_delete_confirm": "Delete this custom game? This can't be undone.",
|
||||
"settings_title": "Settings",
|
||||
"settings_token_label": "API token",
|
||||
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
||||
"settings_language": "Language",
|
||||
"settings_save": "Save",
|
||||
"settings_saved": "Saved.",
|
||||
"common_loading": "Loading…",
|
||||
"common_error": "Something went wrong.",
|
||||
"common_retry": "Retry",
|
||||
"common_yes": "Yes",
|
||||
"common_cancel": "Cancel",
|
||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||
"login_title": "Sign in",
|
||||
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
|
||||
"login_docs_link": "Visit the documentation",
|
||||
"login_password": "Password",
|
||||
"login_submit": "Sign in",
|
||||
"login_error": "Wrong password.",
|
||||
"login_signing_in": "Signing in…",
|
||||
"action_logout": "Sign out",
|
||||
"settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.",
|
||||
"nav_stats": "Performance",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "The host's recent log stream — follow live, filter by level, search.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Clear",
|
||||
"logs_search": "Search logs…",
|
||||
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
|
||||
"logs_dropped": "Some entries were evicted before they could be fetched",
|
||||
"stats_title": "Performance",
|
||||
"stats_subtitle": "Record a session's pipeline timings and review them as graphs.",
|
||||
"stats_capture_title": "Capture",
|
||||
"stats_capture_desc": "Arm capture, run a session, then stop to save a recording. Sampling runs at the host's aggregation boundary — no per-frame overhead.",
|
||||
"stats_recording": "Recording",
|
||||
"stats_idle": "Idle",
|
||||
"stats_start": "Start capture",
|
||||
"stats_stop": "Stop & save",
|
||||
"stats_elapsed": "Elapsed",
|
||||
"stats_samples": "Samples",
|
||||
"stats_kind": "Path",
|
||||
"stats_kind_native": "Native",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.",
|
||||
"stats_latency_title": "Latency by stage",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Per-stage pipeline time, stacked — the \"where does the time go\" view.",
|
||||
"stats_throughput_title": "Throughput",
|
||||
"stats_health_title": "Health",
|
||||
"stats_fps_new": "New fps",
|
||||
"stats_fps_repeat": "Repeat fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Target Mb/s",
|
||||
"stats_health_gamestream_note": "Packet loss, send-drops, and FEC recovery are receiver-side metrics the GameStream path doesn't instrument — only frame drops are shown here.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Frames dropped",
|
||||
"stats_packets_dropped": "Packets dropped",
|
||||
"stats_send_dropped": "Send drops",
|
||||
"stats_fec_recovered": "FEC recovered",
|
||||
"stats_recordings_title": "Recordings",
|
||||
"stats_recordings_empty": "No recordings yet. Start a capture to record one.",
|
||||
"stats_col_time": "Time",
|
||||
"stats_col_kind": "Path",
|
||||
"stats_col_resolution": "Resolution",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Duration",
|
||||
"stats_col_samples": "Samples",
|
||||
"stats_view": "View",
|
||||
"stats_download": "Download",
|
||||
"stats_delete": "Delete",
|
||||
"stats_delete_confirm": "Delete this recording? This can't be undone.",
|
||||
"stats_detail_title": "Recording detail",
|
||||
"stats_close": "Close",
|
||||
"stats_no_samples": "This recording has no samples.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
|
||||
"store_tab_browse": "Browse",
|
||||
"store_tab_installed": "Installed",
|
||||
"store_tab_sources": "Sources",
|
||||
"store_tier_verified": "Verified",
|
||||
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
|
||||
"store_tier_external": "External source",
|
||||
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
|
||||
"store_tier_unverified": "Unverified",
|
||||
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
|
||||
"store_tier_cli": "Installed via CLI",
|
||||
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
|
||||
"store_from_source": "from",
|
||||
"store_search_placeholder": "Search plugins…",
|
||||
"store_filter_all": "All sources",
|
||||
"store_empty": "No plugins in the catalog yet.",
|
||||
"store_no_match": "No plugin matches your search.",
|
||||
"store_by_author": "by {author}",
|
||||
"store_homepage": "Homepage",
|
||||
"store_install": "Install",
|
||||
"store_installed_label": "Installed",
|
||||
"store_update_to": "Update to {version}",
|
||||
"store_incompatible": "Not compatible with this host.",
|
||||
"store_blocked": "Blocked by the host: {reason}",
|
||||
"store_spec_open": "Install from package spec…",
|
||||
"store_runner_title": "Plugin runner",
|
||||
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
|
||||
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
|
||||
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
|
||||
"store_runner_enable": "Enable runner",
|
||||
"store_runner_disable": "Disable runner",
|
||||
"store_runner_state_running": "Running",
|
||||
"store_runner_state_stopped": "Stopped",
|
||||
"store_runner_state_disabled": "Disabled",
|
||||
"store_runner_state_missing": "Not installed",
|
||||
"store_runner_unit": "Service",
|
||||
"store_runner_principal": "Runs as",
|
||||
"store_runner_failed": "Could not change the plugin runner.",
|
||||
"store_installed_title": "Installed plugins",
|
||||
"store_installed_empty": "No plugins installed yet.",
|
||||
"store_running": "Running",
|
||||
"store_stopped": "Not running",
|
||||
"store_uninstall": "Uninstall",
|
||||
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
|
||||
"store_uninstall_failed": "Could not start the removal.",
|
||||
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
|
||||
"store_sources_title": "Catalog sources",
|
||||
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
|
||||
"store_refresh_all": "Refresh all",
|
||||
"store_refresh_failed": "Could not refresh the catalogs.",
|
||||
"store_source_builtin": "Built in",
|
||||
"store_source_signed": "Signed",
|
||||
"store_source_unsigned": "Unsigned",
|
||||
"store_source_stale": "Out of date",
|
||||
"store_source_entries": "{count} plugins",
|
||||
"store_source_fetched": "last fetched {when}",
|
||||
"store_source_never": "never",
|
||||
"store_source_remove": "Remove source",
|
||||
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
|
||||
"store_source_remove_failed": "Could not remove the source.",
|
||||
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
|
||||
"store_add_source_title": "Add a catalog source",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index URL",
|
||||
"store_field_source_key": "Public key (optional)",
|
||||
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
|
||||
"store_add_source": "Add source",
|
||||
"store_add_source_failed": "Could not save the source — check the name and the URL.",
|
||||
"store_source_trust_title": "Trust this source?",
|
||||
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
|
||||
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
|
||||
"store_source_trust_confirm": "I understand — add the source",
|
||||
"store_install_title": "Install {title}?",
|
||||
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
|
||||
"store_install_confirm": "Install",
|
||||
"store_install_external_title": "Install {title} from an external source?",
|
||||
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
|
||||
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
|
||||
"store_install_external_confirm": "Install anyway",
|
||||
"store_install_failed": "Could not start the install.",
|
||||
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
|
||||
"store_spec_title": "Install from a package spec",
|
||||
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
|
||||
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
|
||||
"store_spec_field": "Package spec",
|
||||
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Type the package spec again to confirm",
|
||||
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
|
||||
"store_spec_confirm": "Install unverified",
|
||||
"store_job_install": "Installing {target}",
|
||||
"store_job_uninstall": "Removing {target}",
|
||||
"store_job_done_install": "Installed.",
|
||||
"store_job_done_uninstall": "Removed.",
|
||||
"store_job_failed": "The job failed.",
|
||||
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
|
||||
"store_job_log": "Show log",
|
||||
"store_job_dismiss": "Dismiss",
|
||||
"store_phase_queued": "Queued",
|
||||
"store_phase_verifying": "Verifying the package",
|
||||
"store_phase_installing": "Installing",
|
||||
"store_phase_removing": "Removing",
|
||||
"store_phase_checking": "Checking the install",
|
||||
"store_phase_rolling_back": "Rolling back",
|
||||
"store_phase_recording": "Recording provenance",
|
||||
"store_phase_restarting": "Restarting the plugin runner",
|
||||
"store_phase_done": "Done",
|
||||
"games_title": "Running games",
|
||||
"games_state_launching": "Starting",
|
||||
"games_state_running": "Running",
|
||||
"games_state_exited": "Ended",
|
||||
"games_state_grace": "Waiting for client",
|
||||
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
|
||||
"games_end_now": "End now",
|
||||
"session_game_title": "When a game or a session ends",
|
||||
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
|
||||
"session_game_on_exit": "When the game exits",
|
||||
"session_game_on_exit_help": "Quitting the game hands the client back to its own library instead of leaving it on your desktop. Turn this off if you stream the desktop and treat the game as incidental.",
|
||||
"session_game_on_exit_end": "End the session",
|
||||
"session_game_on_exit_keep": "Keep streaming",
|
||||
"session_game_end_game": "When the session ends",
|
||||
"session_game_end_game_help": "Whether stopping (or losing) a session also closes the game it launched. Never applies to a game you started yourself — only one this host launched for the session.",
|
||||
"session_game_end_keep": "Leave it running",
|
||||
"session_game_end_on_quit": "Close it on Stop",
|
||||
"session_game_end_always": "Always close it",
|
||||
"session_game_always_warning": "Closing a game costs whatever it had not saved. The host asks it to close first and only forces the issue if it refuses — but a network drop is not someone pressing Stop, so a dropped client gets the reconnect window below before anything happens. A display kept forever stays up regardless; this setting governs the game, not the screen.",
|
||||
"session_game_grace": "Reconnect window",
|
||||
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
|
||||
"session_game_saved": "Session and game settings saved",
|
||||
"session_game_inert": "This host has no way to launch games, so these settings do nothing here",
|
||||
"session_game_nested_note": "On a gamescope game session the game runs *inside* the streamed display, so it lives exactly as long as that display does — which is what Keep alive above decides, not this setting. A deliberate Stop tears that display down at once and takes the game with it, whichever option you pick here.",
|
||||
"display_monitor_title": "Streamed screen",
|
||||
"display_monitor_intro": "By default the host creates its own virtual screen for each client, sized to that client. Instead, you can stream one of the screens this computer already has — what you see on that monitor is what the client sees.",
|
||||
"display_monitor_virtual": "Virtual screen (default)",
|
||||
"display_monitor_virtual_hint": "Each client gets its own screen at its own resolution.",
|
||||
"display_monitor_mirror_hint": "Every client sees this monitor, at its resolution.",
|
||||
"display_monitor_none": "This host reports no monitors.",
|
||||
"display_monitor_unavailable": "Monitors could not be listed on this host.",
|
||||
"display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.",
|
||||
"display_monitor_unsupported": "Streaming one of these screens isn't supported on this host yet — it's available on Linux hosts only. The screens are listed so you can see what this computer has; clients keep getting their own virtual screen.",
|
||||
"display_monitor_primary": "primary",
|
||||
"display_monitor_disabled": "off",
|
||||
"display_monitor_saved": "Streamed screen saved"
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Punktfunk",
|
||||
"app_tagline": "management console",
|
||||
"display_settings_saved": "Display configuration saved",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtual displays",
|
||||
"nav_clients": "Paired clients",
|
||||
"nav_pairing": "Pairing",
|
||||
"nav_library": "Library",
|
||||
"nav_settings": "Settings",
|
||||
"nav_more": "More",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "This plugin isn't running",
|
||||
"plugin_offline_hint": "Start the scripting runner, then retry.",
|
||||
"plugin_retry": "Retry",
|
||||
"plugin_open_new_tab": "Open in new tab",
|
||||
"status_title": "Live status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
"status_streaming": "Streaming",
|
||||
"status_idle": "Idle",
|
||||
"status_session": "Session",
|
||||
"status_sessions_active": "{count} active",
|
||||
"status_no_session": "No active session",
|
||||
"status_paired_count": "Paired clients",
|
||||
"status_pin_pending": "Pairing PIN pending",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Resolution",
|
||||
"stream_fps": "Frame rate",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"action_stop_session": "Stop session",
|
||||
"action_request_idr": "Request keyframe",
|
||||
"action_unpair": "Unpair",
|
||||
"host_identity": "Identity",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Operating system",
|
||||
"host_local_ip": "Local IP",
|
||||
"host_version": "Version",
|
||||
"host_abi": "ABI version",
|
||||
"host_codecs": "Codecs",
|
||||
"host_ports": "Ports",
|
||||
"host_uniqueid": "Unique ID",
|
||||
"host_compositors": "Compositors",
|
||||
"host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.",
|
||||
"compositor_available": "Available",
|
||||
"compositor_unavailable": "Unavailable",
|
||||
"compositor_default": "Default",
|
||||
"compositor_none": "This host has no compositor backends — they are a Linux concept. Virtual displays here run on the bundled pf-vdisplay driver.",
|
||||
"host_gpus": "GPUs",
|
||||
"host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.",
|
||||
"gpu_automatic": "Automatic",
|
||||
"gpu_prefer": "Prefer",
|
||||
"gpu_preferred": "Preferred",
|
||||
"gpu_in_use": "In use · {backend}",
|
||||
"gpu_next_session": "Next session",
|
||||
"gpu_none": "No GPUs detected.",
|
||||
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
|
||||
"host_displays": "Virtual displays",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_config_title": "Configuration",
|
||||
"display_preset": "Preset",
|
||||
"display_preset_custom": "Custom",
|
||||
"display_preset_default": "Default",
|
||||
"display_preset_gaming_rig": "Headless box",
|
||||
"display_preset_shared_desktop": "Shared desktop",
|
||||
"display_preset_hotdesk": "Hot-desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Keep alive after disconnect",
|
||||
"display_keep_alive_off": "Off",
|
||||
"display_keep_alive_keep": "Keep for",
|
||||
"display_keep_alive_forever": "Forever",
|
||||
"display_keep_alive_seconds": "seconds",
|
||||
"display_topology": "Topology",
|
||||
"display_topology_auto": "Automatic",
|
||||
"display_topology_extend": "Extend",
|
||||
"display_topology_primary": "Primary",
|
||||
"display_topology_exclusive": "Exclusive",
|
||||
"display_max": "Max displays",
|
||||
"display_save": "Save",
|
||||
"display_effective": "In effect",
|
||||
"display_pending_note": "Changes apply from the next connection — a streaming session keeps the display it opened on.",
|
||||
"display_live": "Live displays",
|
||||
"display_none_live": "No virtual displays right now.",
|
||||
"display_state_active": "Active",
|
||||
"display_state_lingering": "Lingering",
|
||||
"display_state_pinned": "Pinned",
|
||||
"display_release_btn": "Release",
|
||||
"display_release_all": "Release all kept",
|
||||
"display_expires_in": "tears down in {sec}s",
|
||||
"display_sessions": "{count} streaming",
|
||||
"display_arrange": "Arrange displays",
|
||||
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
||||
"display_arrange_save": "Save arrangement",
|
||||
"display_custom_desc": "Set every option yourself.",
|
||||
"display_preset_current": "Active",
|
||||
"display_preset_soon": "coming soon",
|
||||
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
|
||||
"display_topology_help": "How the streamed display fits into the host's desktop. Extend: an extra screen alongside your monitors. Primary: the streamed display becomes the primary output, your monitors kept. Exclusive: the streamed display is the sole output — physical monitors are disabled while streaming and restored after. On a headless host it just sets whether the streamed display is the primary.",
|
||||
"display_conflict": "When another client connects",
|
||||
"display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.",
|
||||
"display_conflict_separate": "Own display",
|
||||
"display_conflict_steal": "Take over",
|
||||
"display_conflict_join": "Share view",
|
||||
"display_conflict_reject": "Busy — reject",
|
||||
"display_identity": "Per-client identity",
|
||||
"display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.",
|
||||
"display_game_session": "Dedicated game sessions",
|
||||
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dedicated",
|
||||
"display_experimental": "Experimental",
|
||||
"display_ddc": "Turn monitors off while streaming (DDC/CI)",
|
||||
"display_ddc_help": "Windows only, takes effect with Exclusive topology. Before disabling your physical monitors, the host also tells them to power their panel off over the DDC/CI monitor-control channel, and wakes them again when the stream ends. This can eliminate a periodic stutter some setups see when the streamed display is the only active one (the dark monitor keeps probing its inputs; a panel that was told to sleep doesn't). Monitors without DDC/CI support are skipped — if your monitor doesn't wake up afterwards, press its power button once and turn this off.",
|
||||
"display_ddc_disabled": "Off",
|
||||
"display_ddc_enabled": "On",
|
||||
"display_ddc_badge": "Monitors off via DDC/CI",
|
||||
"display_pnp": "Disable monitor devices while streaming (PnP)",
|
||||
"display_pnp_help": "Windows only, takes effect with Exclusive topology. On top of removing your physical monitors from the desktop, the host disables their Windows device entries for the duration of the stream and re-enables them afterwards. A standby monitor or TV that keeps waking its connection (auto input scan, instant-on) can no longer interrupt the stream — Windows ignores its wake events entirely while the device is disabled. If the host crashes mid-stream, the monitors are re-enabled the next time it starts; until then you can re-enable them manually in Device Manager.",
|
||||
"display_pnp_disabled": "Off",
|
||||
"display_pnp_enabled": "On",
|
||||
"display_pnp_badge": "Monitor devices disabled (PnP)",
|
||||
"display_identity_shared": "Shared",
|
||||
"display_identity_per_client": "Per client",
|
||||
"display_identity_per_client_mode": "Per client + resolution",
|
||||
"display_layout_mode": "Multi-monitor layout",
|
||||
"display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.",
|
||||
"display_layout_auto_row": "Auto (side by side)",
|
||||
"display_layout_manual": "Manual",
|
||||
"display_preset_custom_label": "Custom presets",
|
||||
"display_preset_save_as": "Save as preset…",
|
||||
"display_preset_name": "Preset name",
|
||||
"display_preset_edit": "Rename",
|
||||
"display_preset_update": "Update to current settings",
|
||||
"display_preset_delete": "Delete",
|
||||
"display_preset_delete_confirm": "Delete this custom preset?",
|
||||
"display_custom_title": "Custom settings",
|
||||
"display_unsaved": "Unsaved changes",
|
||||
"display_unsaved_hint": "These options only take effect once you save.",
|
||||
"display_all_saved": "All changes saved",
|
||||
"display_revert": "Discard changes",
|
||||
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
|
||||
"clients_title": "Paired clients",
|
||||
"clients_empty": "No paired clients yet.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerprint",
|
||||
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
|
||||
"pairing_title": "Pairing",
|
||||
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
|
||||
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
|
||||
"pairing_pin_label": "PIN",
|
||||
"pairing_submit": "Submit PIN",
|
||||
"pairing_pin_sent": "PIN sent. If it's correct, the client finishes pairing and appears under Paired devices below.",
|
||||
"pairing_failed": "Couldn't submit the PIN — check it's correct and that the client is still waiting to pair.",
|
||||
"pairing_native_title": "Pair a device",
|
||||
"pairing_native_desc": "Show a one-time PIN here, then enter it in your Punktfunk app to pair this device.",
|
||||
"pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair Punktfunk devices.",
|
||||
"pairing_native_arm": "Pair a device",
|
||||
"pairing_native_enter": "Enter this PIN on your device:",
|
||||
"pairing_native_expires": "Expires in",
|
||||
"pairing_native_cancel": "Cancel",
|
||||
"pairing_native_devices": "Paired devices",
|
||||
"pairing_native_empty": "No devices paired yet.",
|
||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||
"pairing_protocol": "Protocol",
|
||||
"pairing_protocol_native": "punktfunk/1",
|
||||
"pairing_protocol_moonlight": "Moonlight",
|
||||
"pairing_pending_title": "Waiting for approval",
|
||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||
"pairing_pending_approve": "Approve",
|
||||
"pairing_pending_deny": "Deny",
|
||||
"pairing_pending_name_prompt": "Name this device:",
|
||||
"pairing_pending_age_just_now": "just now",
|
||||
"pairing_pending_age_secs": "{s}s ago",
|
||||
"pairing_pending_age_mins": "{min} min ago",
|
||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||
"library_title": "Library",
|
||||
"library_empty": "No games found yet.",
|
||||
"library_sources_title": "Game sources",
|
||||
"library_sources_help": "Launchers this host scans for installed games. Turn one off to hide its games from every device — nothing is deleted, and turning it back on brings them right back.",
|
||||
"library_sources_failed": "Could not update the game source.",
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Custom",
|
||||
"library_add_title": "Add a custom game",
|
||||
"library_edit_title": "Edit custom game",
|
||||
"library_add_button": "Add custom game",
|
||||
"library_field_title": "Title",
|
||||
"library_field_portrait": "Portrait art URL",
|
||||
"library_field_hero": "Hero art URL",
|
||||
"library_field_header": "Header art URL",
|
||||
"library_field_logo": "Logo art URL",
|
||||
"library_field_command": "Launch command",
|
||||
"library_field_command_help": "Optional. The command the host runs to launch this title.",
|
||||
"library_field_platform": "Platform",
|
||||
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Description",
|
||||
"library_field_developer": "Developer",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Release year",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Comma-separated, e.g. RPG, Platformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Comma-separated labels for organizing, e.g. co-op, kids.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Players",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Save",
|
||||
"library_create": "Add",
|
||||
"library_cancel": "Cancel",
|
||||
"library_edit": "Edit",
|
||||
"library_delete": "Delete",
|
||||
"library_delete_confirm": "Delete this custom game? This can't be undone.",
|
||||
"settings_title": "Settings",
|
||||
"settings_token_label": "API token",
|
||||
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
||||
"settings_language": "Language",
|
||||
"settings_save": "Save",
|
||||
"settings_saved": "Saved.",
|
||||
"common_loading": "Loading…",
|
||||
"common_error": "Something went wrong.",
|
||||
"common_retry": "Retry",
|
||||
"common_yes": "Yes",
|
||||
"common_cancel": "Cancel",
|
||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||
"login_title": "Sign in",
|
||||
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
|
||||
"login_docs_link": "Visit the documentation",
|
||||
"login_password": "Password",
|
||||
"login_submit": "Sign in",
|
||||
"login_error": "Wrong password.",
|
||||
"login_signing_in": "Signing in…",
|
||||
"action_logout": "Sign out",
|
||||
"settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.",
|
||||
"nav_stats": "Performance",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "The host's recent log stream — follow live, filter by level, search.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Clear",
|
||||
"logs_search": "Search logs…",
|
||||
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
|
||||
"logs_dropped": "Some entries were evicted before they could be fetched",
|
||||
"logs_download": "Download logs",
|
||||
"logs_share": "Share logs",
|
||||
"logs_copy": "Copy logs to clipboard",
|
||||
"logs_copied": "Logs copied to clipboard",
|
||||
"logs_share_failed": "Couldn't share the logs",
|
||||
"stats_title": "Performance",
|
||||
"stats_subtitle": "Record a session's pipeline timings and review them as graphs.",
|
||||
"stats_capture_title": "Capture",
|
||||
"stats_capture_desc": "Arm capture, run a session, then stop to save a recording. Sampling runs at the host's aggregation boundary — no per-frame overhead.",
|
||||
"stats_recording": "Recording",
|
||||
"stats_idle": "Idle",
|
||||
"stats_start": "Start capture",
|
||||
"stats_stop": "Stop & save",
|
||||
"stats_elapsed": "Elapsed",
|
||||
"stats_samples": "Samples",
|
||||
"stats_kind": "Path",
|
||||
"stats_kind_native": "Native",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.",
|
||||
"stats_latency_title": "Latency by stage",
|
||||
"stats_latency_axis": "µs",
|
||||
"stats_latency_desc": "Per-stage pipeline time, stacked — the \"where does the time go\" view.",
|
||||
"stats_throughput_title": "Throughput",
|
||||
"stats_health_title": "Health",
|
||||
"stats_fps_new": "New fps",
|
||||
"stats_fps_repeat": "Repeat fps",
|
||||
"stats_mbps": "Mb/s",
|
||||
"stats_bitrate_target": "Target Mb/s",
|
||||
"stats_health_gamestream_note": "Packet loss, send-drops, and FEC recovery are receiver-side metrics the GameStream path doesn't instrument — only frame drops are shown here.",
|
||||
"stats_p99": "p99",
|
||||
"stats_p50": "p50",
|
||||
"stats_frames_dropped": "Frames dropped",
|
||||
"stats_packets_dropped": "Packets dropped",
|
||||
"stats_send_dropped": "Send drops",
|
||||
"stats_fec_recovered": "FEC recovered",
|
||||
"stats_recordings_title": "Recordings",
|
||||
"stats_recordings_empty": "No recordings yet. Start a capture to record one.",
|
||||
"stats_col_time": "Time",
|
||||
"stats_col_kind": "Path",
|
||||
"stats_col_resolution": "Resolution",
|
||||
"stats_col_codec": "Codec",
|
||||
"stats_col_duration": "Duration",
|
||||
"stats_col_samples": "Samples",
|
||||
"stats_view": "View",
|
||||
"stats_download": "Download",
|
||||
"stats_delete": "Delete",
|
||||
"stats_delete_confirm": "Delete this recording? This can't be undone.",
|
||||
"stats_detail_title": "Recording detail",
|
||||
"stats_close": "Close",
|
||||
"stats_no_samples": "This recording has no samples.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
|
||||
"store_tab_browse": "Browse",
|
||||
"store_tab_installed": "Installed",
|
||||
"store_tab_sources": "Sources",
|
||||
"store_tier_verified": "Verified",
|
||||
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
|
||||
"store_tier_external": "External source",
|
||||
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
|
||||
"store_tier_unverified": "Unverified",
|
||||
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
|
||||
"store_tier_cli": "Installed via CLI",
|
||||
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
|
||||
"store_from_source": "from",
|
||||
"store_search_placeholder": "Search plugins…",
|
||||
"store_filter_all": "All sources",
|
||||
"store_empty": "No plugins in the catalog yet.",
|
||||
"store_no_match": "No plugin matches your search.",
|
||||
"store_by_author": "by {author}",
|
||||
"store_homepage": "Homepage",
|
||||
"store_install": "Install",
|
||||
"store_installed_label": "Installed",
|
||||
"store_update_to": "Update to {version}",
|
||||
"store_incompatible": "Not compatible with this host.",
|
||||
"store_blocked": "Blocked by the host: {reason}",
|
||||
"store_spec_open": "Install from package spec…",
|
||||
"store_runner_title": "Plugin runner",
|
||||
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
|
||||
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
|
||||
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
|
||||
"store_runner_enable": "Enable runner",
|
||||
"store_runner_disable": "Disable runner",
|
||||
"store_runner_state_running": "Running",
|
||||
"store_runner_state_stopped": "Stopped",
|
||||
"store_runner_state_disabled": "Disabled",
|
||||
"store_runner_state_missing": "Not installed",
|
||||
"store_runner_unit": "Service",
|
||||
"store_runner_principal": "Runs as",
|
||||
"store_runner_failed": "Could not change the plugin runner.",
|
||||
"store_installed_title": "Installed plugins",
|
||||
"store_installed_empty": "No plugins installed yet.",
|
||||
"store_running": "Running",
|
||||
"store_stopped": "Not running",
|
||||
"store_uninstall": "Uninstall",
|
||||
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
|
||||
"store_uninstall_failed": "Could not start the removal.",
|
||||
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
|
||||
"store_sources_title": "Catalog sources",
|
||||
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
|
||||
"store_refresh_all": "Refresh all",
|
||||
"store_refresh_failed": "Could not refresh the catalogs.",
|
||||
"store_source_builtin": "Built in",
|
||||
"store_source_signed": "Signed",
|
||||
"store_source_unsigned": "Unsigned",
|
||||
"store_source_stale": "Out of date",
|
||||
"store_source_entries": "{count} plugins",
|
||||
"store_source_fetched": "last fetched {when}",
|
||||
"store_source_never": "never",
|
||||
"store_source_remove": "Remove source",
|
||||
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
|
||||
"store_source_remove_failed": "Could not remove the source.",
|
||||
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
|
||||
"store_add_source_title": "Add a catalog source",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index URL",
|
||||
"store_field_source_key": "Public key (optional)",
|
||||
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
|
||||
"store_add_source": "Add source",
|
||||
"store_add_source_failed": "Could not save the source — check the name and the URL.",
|
||||
"store_source_trust_title": "Trust this source?",
|
||||
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
|
||||
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
|
||||
"store_source_trust_confirm": "I understand — add the source",
|
||||
"store_install_title": "Install {title}?",
|
||||
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
|
||||
"store_install_confirm": "Install",
|
||||
"store_install_external_title": "Install {title} from an external source?",
|
||||
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
|
||||
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
|
||||
"store_install_external_confirm": "Install anyway",
|
||||
"store_install_failed": "Could not start the install.",
|
||||
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
|
||||
"store_spec_title": "Install from a package spec",
|
||||
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
|
||||
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
|
||||
"store_spec_field": "Package spec",
|
||||
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Type the package spec again to confirm",
|
||||
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
|
||||
"store_spec_confirm": "Install unverified",
|
||||
"store_job_install": "Installing {target}",
|
||||
"store_job_uninstall": "Removing {target}",
|
||||
"store_job_done_install": "Installed.",
|
||||
"store_job_done_uninstall": "Removed.",
|
||||
"store_job_failed": "The job failed.",
|
||||
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
|
||||
"store_job_log": "Show log",
|
||||
"store_job_dismiss": "Dismiss",
|
||||
"store_phase_queued": "Queued",
|
||||
"store_phase_verifying": "Verifying the package",
|
||||
"store_phase_installing": "Installing",
|
||||
"store_phase_removing": "Removing",
|
||||
"store_phase_checking": "Checking the install",
|
||||
"store_phase_rolling_back": "Rolling back",
|
||||
"store_phase_recording": "Recording provenance",
|
||||
"store_phase_restarting": "Restarting the plugin runner",
|
||||
"store_phase_done": "Done",
|
||||
"games_title": "Running games",
|
||||
"games_state_launching": "Starting",
|
||||
"games_state_running": "Running",
|
||||
"games_state_exited": "Ended",
|
||||
"games_state_grace": "Waiting for client",
|
||||
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
|
||||
"games_end_now": "End now",
|
||||
"session_game_title": "When a game or a session ends",
|
||||
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
|
||||
"session_game_on_exit": "When the game exits",
|
||||
"session_game_on_exit_help": "Quitting the game hands the client back to its own library instead of leaving it on your desktop. Turn this off if you stream the desktop and treat the game as incidental.",
|
||||
"session_game_on_exit_end": "End the session",
|
||||
"session_game_on_exit_keep": "Keep streaming",
|
||||
"session_game_end_game": "When the session ends",
|
||||
"session_game_end_game_help": "Whether stopping (or losing) a session also closes the game it launched. Never applies to a game you started yourself — only one this host launched for the session.",
|
||||
"session_game_end_keep": "Leave it running",
|
||||
"session_game_end_on_quit": "Close it on Stop",
|
||||
"session_game_end_always": "Always close it",
|
||||
"session_game_always_warning": "Closing a game costs whatever it had not saved. The host asks it to close first and only forces the issue if it refuses — but a network drop is not someone pressing Stop, so a dropped client gets the reconnect window below before anything happens. A display kept forever stays up regardless; this setting governs the game, not the screen.",
|
||||
"session_game_grace": "Reconnect window",
|
||||
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
|
||||
"session_game_saved": "Session and game settings saved",
|
||||
"session_game_inert": "This host has no way to launch games, so these settings do nothing here",
|
||||
"session_game_nested_note": "On a gamescope game session the game runs *inside* the streamed display, so it lives exactly as long as that display does — which is what Keep alive above decides, not this setting. A deliberate Stop tears that display down at once and takes the game with it, whichever option you pick here.",
|
||||
"display_monitor_title": "Streamed screen",
|
||||
"display_monitor_intro": "By default the host creates its own virtual screen for each client, sized to that client. Instead, you can stream one of the screens this computer already has — what you see on that monitor is what the client sees.",
|
||||
"display_monitor_virtual": "Virtual screen (default)",
|
||||
"display_monitor_virtual_hint": "Each client gets its own screen at its own resolution.",
|
||||
"display_monitor_mirror_hint": "Every client sees this monitor, at its resolution.",
|
||||
"display_monitor_none": "This host reports no monitors.",
|
||||
"display_monitor_unavailable": "Monitors could not be listed on this host.",
|
||||
"display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.",
|
||||
"display_monitor_unsupported": "Streaming one of these screens isn't supported on this host yet — it's available on Linux hosts only. The screens are listed so you can see what this computer has; clients keep getting their own virtual screen.",
|
||||
"display_monitor_primary": "primary",
|
||||
"display_monitor_disabled": "off",
|
||||
"display_monitor_saved": "Streamed screen saved",
|
||||
"update_title": "Updates",
|
||||
"update_available_badge": "Update available",
|
||||
"update_current": "Installed",
|
||||
"update_install_kind": "Install type",
|
||||
"update_latest": "Latest release",
|
||||
"update_notes": "Release notes",
|
||||
"update_up_to_date": "You're up to date.",
|
||||
"update_how": "Update this host with:",
|
||||
"update_check_now": "Check now",
|
||||
"update_checking": "Checking…",
|
||||
"update_last_checked": "Last checked",
|
||||
"update_never_checked": "Not checked yet",
|
||||
"update_stale": "The update feed hasn't changed in over 45 days — checks succeed but nothing new arrives. If that seems wrong, check this host's connectivity to git.unom.io.",
|
||||
"update_disabled": "Update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0).",
|
||||
"update_error": "Last check failed:",
|
||||
"update_copy": "Copy",
|
||||
"update_copied": "Copied"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import {
|
||||
getGetUpdateStatusQueryKey,
|
||||
useForceUpdateCheck,
|
||||
useGetUpdateStatus,
|
||||
} from "@/api/gen/update/update";
|
||||
import type { UpdateStatus } from "@/api/gen/model";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* Container: the host update-check card (U0 — notify-only). Reading status is what keeps the
|
||||
* host's manifest cache warm (the host kicks its own background refresh when the cache is >6 h
|
||||
* old), so a modest poll doubles as the check cadence while the console is open. Apply buttons
|
||||
* arrive with the per-channel apply legs (U1 Windows, U2 Linux helper); until then the card's
|
||||
* action is the exact update command for this install kind.
|
||||
*/
|
||||
export const UpdateSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const status = useGetUpdateStatus({ query: { refetchInterval: 60_000 } });
|
||||
const check = useForceUpdateCheck();
|
||||
|
||||
const checkNow = () =>
|
||||
check.mutate(undefined, {
|
||||
onSuccess: (fresh) => {
|
||||
qc.setQueryData(getGetUpdateStatusQueryKey(), fresh);
|
||||
},
|
||||
});
|
||||
|
||||
return <UpdateCard state={status} onCheck={checkNow} busy={check.isPending} />;
|
||||
};
|
||||
|
||||
export const UpdateCard: FC<{
|
||||
state: Loadable<UpdateStatus>;
|
||||
onCheck: () => void;
|
||||
busy: boolean;
|
||||
}> = ({ state, onCheck, busy }) => {
|
||||
const s = state.data;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{m.update_title()}</CardTitle>
|
||||
{s?.available && <Badge>{m.update_available_badge()}</Badge>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QueryState
|
||||
isLoading={state.isLoading}
|
||||
error={state.error}
|
||||
refetch={state.refetch}
|
||||
>
|
||||
{s && (
|
||||
<>
|
||||
<dl className="grid grid-cols-1 gap-3">
|
||||
<UpdateRow
|
||||
label={m.update_current()}
|
||||
value={
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
{s.current_version}
|
||||
<Badge variant="secondary">{s.channel}</Badge>
|
||||
<Badge variant="outline" title={m.update_install_kind()}>
|
||||
{s.install_kind}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<UpdateRow
|
||||
label={m.update_latest()}
|
||||
value={
|
||||
s.manifest ? (
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
{s.manifest.version}
|
||||
{s.manifest.notes_url && (
|
||||
<a
|
||||
href={s.manifest.notes_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-normal underline underline-offset-2"
|
||||
>
|
||||
{m.update_notes()}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.update_never_checked()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{s.available ? (
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<p className="text-sm">{m.update_how()}</p>
|
||||
<CommandLine command={s.channel_hint} />
|
||||
</div>
|
||||
) : (
|
||||
s.manifest && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.update_up_to_date()}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{s.manifest?.stale && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
{m.update_stale()}
|
||||
</p>
|
||||
)}
|
||||
{s.last_error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{m.update_error()} {s.last_error}
|
||||
</p>
|
||||
)}
|
||||
{s.check_disabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.update_disabled()}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCheck}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? m.update_checking() : m.update_check_now()}
|
||||
</Button>
|
||||
{s.last_checked_unix != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.update_last_checked()}{" "}
|
||||
{new Date(s.last_checked_unix * 1000).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const UpdateRow: FC<{ label: string; value: ReactNode }> = ({
|
||||
label,
|
||||
value,
|
||||
}) => (
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<dt className="text-sm text-muted-foreground">{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** The copy-pastable update command, with a small clipboard affordance. */
|
||||
const CommandLine: FC<{ command: string }> = ({ command }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
navigator.clipboard
|
||||
.writeText(command)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
/* clipboard denied — the text is selectable, nothing to do */
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto rounded bg-muted px-2 py-1.5 text-xs">
|
||||
{command}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={copy}>
|
||||
{copied ? m.update_copied() : m.update_copy()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { FC } from "react";
|
||||
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { GpuSection } from "./GpuCard";
|
||||
import { UpdateSection } from "./UpdateCard";
|
||||
import { HostView } from "./view";
|
||||
|
||||
export const SectionHost: FC = () => {
|
||||
@@ -9,5 +10,12 @@ export const SectionHost: FC = () => {
|
||||
const host = useGetHostInfo();
|
||||
const compositors = useListCompositors();
|
||||
|
||||
return <HostView host={host} compositors={compositors} gpu={<GpuSection />} />;
|
||||
return (
|
||||
<HostView
|
||||
host={host}
|
||||
compositors={compositors}
|
||||
gpu={<GpuSection />}
|
||||
update={<UpdateSection />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,9 @@ export const HostView: FC<{
|
||||
compositors: Loadable<AvailableCompositor[]>;
|
||||
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
|
||||
gpu?: ReactNode;
|
||||
}> = ({ host, compositors, gpu }) => {
|
||||
/** The update-check card (a self-contained container — see `UpdateCard.tsx`). */
|
||||
update?: ReactNode;
|
||||
}> = ({ host, compositors, gpu, update }) => {
|
||||
const h = host.data;
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
@@ -88,6 +90,8 @@ export const HostView: FC<{
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
{update}
|
||||
|
||||
{gpu}
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pause, Play, Trash2 } from "lucide-react";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Copy, Download, Pause, Play, Share2, Trash2 } from "lucide-react";
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLogsGet } from "@/api/gen/logs/logs";
|
||||
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||
@@ -8,6 +9,14 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import {
|
||||
detectShareMode,
|
||||
downloadText,
|
||||
logFilename,
|
||||
logsToText,
|
||||
type ShareMode,
|
||||
shareLogs,
|
||||
} from "./export";
|
||||
|
||||
const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
|
||||
type MinLevel = (typeof LEVELS)[number];
|
||||
@@ -39,6 +48,13 @@ export const LogsSection: FC = () => {
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [dropped, setDropped] = useState(false);
|
||||
const [shareMode, setShareMode] = useState<ShareMode | null>(null);
|
||||
|
||||
// Probed after mount: the server render has no `navigator`, and guessing there would mismatch
|
||||
// on hydration. Until then the share button is simply absent.
|
||||
useEffect(() => {
|
||||
setShareMode(detectShareMode());
|
||||
}, []);
|
||||
|
||||
const query = useLogsGet(
|
||||
{ after: cursor > 0 ? cursor : undefined },
|
||||
@@ -60,6 +76,8 @@ export const LogsSection: FC = () => {
|
||||
setCursor(data.next);
|
||||
}, [data]);
|
||||
|
||||
// The card hands back the entries its filters currently match, so an export carries exactly what
|
||||
// the viewer shows — never the DOM-bounded tail of it.
|
||||
return (
|
||||
<LogsCard
|
||||
entries={entries}
|
||||
@@ -69,43 +87,71 @@ export const LogsSection: FC = () => {
|
||||
setEntries([]);
|
||||
setDropped(false);
|
||||
}}
|
||||
onDownload={(shown) =>
|
||||
downloadText(logsToText(shown), logFilename(new Date()))
|
||||
}
|
||||
onShare={async (shown) => {
|
||||
const outcome = await shareLogs(
|
||||
logsToText(shown),
|
||||
logFilename(new Date()),
|
||||
);
|
||||
if (outcome === "copied") toast.success(m.logs_copied());
|
||||
else if (outcome === "failed") toast.error(m.logs_share_failed());
|
||||
}}
|
||||
shareMode={shareMode}
|
||||
dropped={dropped}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** Pure log viewer: level/min filter + text search (local UI state), follow + clear controls. */
|
||||
/**
|
||||
* Pure log viewer: level/min filter + text search (local UI state), follow, clear, and export.
|
||||
* Export is the filters' full result, not the rendered tail — the `SHOW` cap is a DOM budget and
|
||||
* has no business truncating a file destined for a bug report.
|
||||
*/
|
||||
export const LogsCard: FC<{
|
||||
entries: LogEntry[];
|
||||
follow: boolean;
|
||||
onFollow: (follow: boolean) => void;
|
||||
onClear: () => void;
|
||||
onDownload: (shown: LogEntry[]) => void;
|
||||
onShare: (shown: LogEntry[]) => void;
|
||||
shareMode: ShareMode | null;
|
||||
dropped: boolean;
|
||||
}> = ({ entries, follow, onFollow, onClear, dropped }) => {
|
||||
}> = ({
|
||||
entries,
|
||||
follow,
|
||||
onFollow,
|
||||
onClear,
|
||||
onDownload,
|
||||
onShare,
|
||||
shareMode,
|
||||
dropped,
|
||||
}) => {
|
||||
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
|
||||
const [search, setSearch] = useState("");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const matched = useMemo(() => {
|
||||
const min = RANK[minLevel] ?? 0;
|
||||
const q = search.trim().toLowerCase();
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
(RANK[e.level] ?? 0) >= min &&
|
||||
(q === "" ||
|
||||
e.msg.toLowerCase().includes(q) ||
|
||||
e.target.toLowerCase().includes(q)),
|
||||
)
|
||||
.slice(-SHOW);
|
||||
return entries.filter(
|
||||
(e) =>
|
||||
(RANK[e.level] ?? 0) >= min &&
|
||||
(q === "" ||
|
||||
e.msg.toLowerCase().includes(q) ||
|
||||
e.target.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [entries, minLevel, search]);
|
||||
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
|
||||
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
|
||||
|
||||
// Keep the tail in view while following (entries are append-only, so length is a good signal).
|
||||
useEffect(() => {
|
||||
if (!follow) return;
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [follow, filtered.length]);
|
||||
}, [follow, visible.length]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -131,6 +177,32 @@ export const LogsCard: FC<{
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{dropped && <Badge variant="secondary">{m.logs_dropped()}</Badge>}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={matched.length === 0}
|
||||
title={m.logs_download()}
|
||||
aria-label={m.logs_download()}
|
||||
onClick={() => onDownload(matched)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
{shareMode && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={matched.length === 0}
|
||||
title={shareLabel}
|
||||
aria-label={shareLabel}
|
||||
onClick={() => onShare(matched)}
|
||||
>
|
||||
{shareMode === "share" ? (
|
||||
<Share2 className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={follow ? "secondary" : "outline"}
|
||||
@@ -154,10 +226,10 @@ export const LogsCard: FC<{
|
||||
ref={listRef}
|
||||
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
{visible.length === 0 ? (
|
||||
<p className="p-2 text-muted-foreground">{m.logs_empty()}</p>
|
||||
) : (
|
||||
filtered.map((e) => (
|
||||
visible.map((e) => (
|
||||
<div key={e.seq} className="whitespace-pre-wrap break-words">
|
||||
<span className="text-muted-foreground">
|
||||
{fmtTime(e.ts_ms)}{" "}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||
|
||||
/**
|
||||
* How the log entries can leave the page. Probed at runtime rather than assumed: `share` is Web
|
||||
* Share level 2 (mobile Safari/Chrome), `copy` is every secure-context desktop browser, and `null`
|
||||
* is a page served over plain HTTP to a browser without Web Share — there the clipboard API is
|
||||
* absent too, so the button is left out instead of offered as a no-op.
|
||||
*/
|
||||
export type ShareMode = "share" | "copy";
|
||||
|
||||
export type ShareOutcome = "shared" | "copied" | "cancelled" | "failed";
|
||||
|
||||
const MIME = "text/plain";
|
||||
|
||||
/**
|
||||
* One line per entry, in the on-screen column order but with the full date and UTC offset — a bare
|
||||
* wall-clock time is ambiguous the moment the file leaves the browser, and bug reports span days.
|
||||
*/
|
||||
export const logsToText = (entries: LogEntry[]): string =>
|
||||
entries
|
||||
.map((e) => `${stamp(e.ts_ms)} ${e.level.padEnd(5)} ${e.target} ${e.msg}`)
|
||||
.join("\n");
|
||||
|
||||
/** `punktfunk-logs-20260730-142231.log` — sorts chronologically in a downloads folder. */
|
||||
export const logFilename = (now: Date): string =>
|
||||
`punktfunk-logs-${p(now.getFullYear(), 4)}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}.log`;
|
||||
|
||||
export const downloadText = (text: string, filename: string): void => {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: MIME }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const detectShareMode = (): ShareMode | null => {
|
||||
if (typeof navigator === "undefined") return null; // server render
|
||||
// `canShare` decides on the file's type, not its bytes, so a stand-in probe file is enough.
|
||||
if (canShareFiles([logFile("probe", "punktfunk-logs.log")])) return "share";
|
||||
return typeof navigator.clipboard?.writeText === "function" ? "copy" : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hand the logs to the OS share sheet, falling back to the clipboard. Everything up to the
|
||||
* `navigator.share`/`writeText` call is synchronous on purpose: both APIs require the calling task
|
||||
* to still be the user's click, and an `await` in between would forfeit that on Safari.
|
||||
*/
|
||||
export const shareLogs = async (
|
||||
text: string,
|
||||
filename: string,
|
||||
): Promise<ShareOutcome> => {
|
||||
const files = [logFile(text, filename)];
|
||||
if (canShareFiles(files)) {
|
||||
try {
|
||||
await navigator.share({ files, title: filename });
|
||||
return "shared";
|
||||
} catch (err) {
|
||||
// A dismissed share sheet is a deliberate cancel, not something to report back.
|
||||
return err instanceof DOMException && err.name === "AbortError"
|
||||
? "cancelled"
|
||||
: "failed";
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return "copied";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
const logFile = (text: string, filename: string): File =>
|
||||
new File([text], filename, { type: MIME });
|
||||
|
||||
// Callers reach this from a click or from the guarded probe above, so `navigator` is always there.
|
||||
const canShareFiles = (files: File[]): boolean =>
|
||||
typeof navigator.canShare === "function" && navigator.canShare({ files });
|
||||
|
||||
const p = (n: number, w = 2): string => String(n).padStart(w, "0");
|
||||
|
||||
/** Local ISO 8601 with a numeric offset, e.g. `2026-07-30T14:22:31.123+02:00`. */
|
||||
const stamp = (ts: number): string => {
|
||||
const d = new Date(ts);
|
||||
const date = `${p(d.getFullYear(), 4)}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||
const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
|
||||
// getTimezoneOffset() counts minutes *behind* UTC, so east of Greenwich is negative.
|
||||
const off = -d.getTimezoneOffset();
|
||||
const sign = off < 0 ? "-" : "+";
|
||||
const abs = Math.abs(off);
|
||||
return `${date}T${time}${sign}${p(Math.floor(abs / 60))}:${p(abs % 60)}`;
|
||||
};
|
||||
@@ -16,14 +16,54 @@ const entry = (
|
||||
): LogEntry => ({ seq, ts_ms: BASE + seq * 750, level, target, msg });
|
||||
|
||||
const fixtureEntries: LogEntry[] = [
|
||||
entry(1, "INFO", "punktfunk_host", "punktfunk-host 0.4.2 (punktfunk_core ABI v2)"),
|
||||
entry(2, "INFO", "punktfunk_host::mgmt", "management API listening over HTTPS addr=0.0.0.0:47990"),
|
||||
entry(3, "DEBUG", "punktfunk_host::discovery", "mDNS advertise _punktfunk._udp pair=required"),
|
||||
entry(4, "INFO", "punktfunk_host::punktfunk1", "session start mode=1920x1080@60 codec=hevc"),
|
||||
entry(5, "INFO", "punktfunk_host::inject", "virtual Xbox 360 created (Windows XUSB companion)"),
|
||||
entry(6, "WARN", "punktfunk_host::inject", "gamepad driver not attached to Global\\pfxusb-shm-0 after 3s — is the pf_xusb driver installed? (punktfunk-host.exe driver install --gamepad)"),
|
||||
entry(7, "ERROR", "punktfunk_host::inject", "virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)"),
|
||||
entry(8, "INFO", "punktfunk_host::encode", "NVENC opened 1920x1080 nv12 gop=inf rfi=on"),
|
||||
entry(
|
||||
1,
|
||||
"INFO",
|
||||
"punktfunk_host",
|
||||
"punktfunk-host 0.4.2 (punktfunk_core ABI v2)",
|
||||
),
|
||||
entry(
|
||||
2,
|
||||
"INFO",
|
||||
"punktfunk_host::mgmt",
|
||||
"management API listening over HTTPS addr=0.0.0.0:47990",
|
||||
),
|
||||
entry(
|
||||
3,
|
||||
"DEBUG",
|
||||
"punktfunk_host::discovery",
|
||||
"mDNS advertise _punktfunk._udp pair=required",
|
||||
),
|
||||
entry(
|
||||
4,
|
||||
"INFO",
|
||||
"punktfunk_host::punktfunk1",
|
||||
"session start mode=1920x1080@60 codec=hevc",
|
||||
),
|
||||
entry(
|
||||
5,
|
||||
"INFO",
|
||||
"punktfunk_host::inject",
|
||||
"virtual Xbox 360 created (Windows XUSB companion)",
|
||||
),
|
||||
entry(
|
||||
6,
|
||||
"WARN",
|
||||
"punktfunk_host::inject",
|
||||
"gamepad driver not attached to Global\\pfxusb-shm-0 after 3s — is the pf_xusb driver installed? (punktfunk-host.exe driver install --gamepad)",
|
||||
),
|
||||
entry(
|
||||
7,
|
||||
"ERROR",
|
||||
"punktfunk_host::inject",
|
||||
"virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)",
|
||||
),
|
||||
entry(
|
||||
8,
|
||||
"INFO",
|
||||
"punktfunk_host::encode",
|
||||
"NVENC opened 1920x1080 nv12 gop=inf rfi=on",
|
||||
),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
@@ -36,6 +76,8 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// The real page layout (LogsView) with the pure viewer card + fixture entries in its slot.
|
||||
// `shareMode` is probed from the browser on the live page; the stories pin one of each so both the
|
||||
// desktop (clipboard) and mobile (share sheet) affordance stay covered by the screenshot run.
|
||||
export const Following: Story = {
|
||||
args: {
|
||||
viewer: (
|
||||
@@ -44,6 +86,9 @@ export const Following: Story = {
|
||||
follow
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
onDownload={noop}
|
||||
onShare={noop}
|
||||
shareMode="copy"
|
||||
dropped={false}
|
||||
/>
|
||||
),
|
||||
@@ -58,6 +103,9 @@ export const PausedWithGap: Story = {
|
||||
follow={false}
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
onDownload={noop}
|
||||
onShare={noop}
|
||||
shareMode="share"
|
||||
dropped
|
||||
/>
|
||||
),
|
||||
|
||||