Compare commits

...
10 Commits
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 1c836afc02 chore(release): bump workspace version to 0.22.3
ci / web (push) Successful in 2m4s
ci / docs-site (push) Successful in 2m25s
android-screenshots / screenshots (push) Successful in 2m12s
ci / rust-arm64 (push) Successful in 7m8s
apple / swift (push) Successful in 4m38s
decky / build-publish (push) Successful in 52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m44s
deb / build-publish-client-arm64 (push) Successful in 1m32s
arch / build-publish (push) Successful in 10m25s
linux-client-screenshots / screenshots (push) Successful in 3m16s
audit / bun-audit (plugin-kit) (push) Successful in 28s
audit / bun-audit (sdk) (push) Successful in 27s
audit / bun-audit (web) (push) Successful in 26s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 15s
audit / license-gate (push) Successful in 5m11s
sbom / sbom (push) Successful in 35s
android / android (push) Successful in 7m33s
web-screenshots / screenshots (push) Successful in 4m12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
audit / cargo-audit (push) Successful in 39s
docker / deploy-docs (push) Successful in 42s
docker / builders-arm64cross (push) Successful in 20s
flatpak / build-publish (push) Successful in 6m50s
deb / build-publish-host (push) Successful in 5m19s
deb / build-publish (push) Successful in 6m58s
ci / rust (push) Successful in 17m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m3s
apple / screenshots (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m46s
release / apple (push) Successful in 25m45s
windows-host / package (push) Successful in 10m58s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Failing after 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m26s
Windows-installer patch release. 0.22.1 and 0.22.2 shipped without the web
console in them at all — confirmed from the tag builds' own logs, where the
console build step is `skipped` on a cache hit and the job is still green —
which also removed the only code that stopped bun before the installer
replaced it, hence the "DeleteFile failed; code 5" dialog. Both fixed here,
along with updates no longer disabling an operator's plugin runner.

Lock touched for the 30 workspace members only. `base64` stays at 0.22.1 and
the eight gtk-rs crates stay at 0.22.0 — they share our version space and a
blanket sed would move them to versions that do not exist. Diff is
versions-only, 30 insertions and 30 deletions; `cargo metadata --locked`
resolves; `cargo fmt --all --check` clean in both the main and the
packaging/windows/drivers workspaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 beefeaf9d7 fix(ci/windows-host): refuse to pack an installer that is missing a payload
Every payload the job bundles is now asserted before packing: the console, the
bun runtime, the plugin runner, the FFmpeg DLLs and VB-CABLE. Each is optional
to pack-host-installer.ps1 — right for a local debug pack, and the reason
0.22.1 and 0.22.2 shipped with no web console: one unset variable omitted it
behind a single line of log and the build stayed green.

CI knows it bundles all five, so a missing input belongs here as a failure
rather than downstream as a quietly smaller installer. FFmpeg is the one that
would hurt most and was silent too: an amf-qsv host link-imports avcodec, so
an installer missing those DLLs ships a host that cannot start at all, and
FFMPEG_DIR is a fallback to a provisioned path that nothing verified.

The shape is borrowed from the packer's own VB-CABLE check, which already
throws on a supplied-but-empty dir "instead of silently shipping an installer
without the virtual mic - exactly the field regression this bundling fixes".
Same lesson, applied to the rest.

Both paths were exercised on the Windows runner: all five unset fails with
exit 1 naming each one, all five present passes with exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 5b6fe7882a fix(ci/windows-host): a cached console no longer ships an installer without one
WEB_OUTPUT_DIR was exported on the last line of "Build + smoke-boot web
console", a step that is skipped on a cache hit. So the second and every
later build with an unchanged web/ and sdk/ left it unset — and
pack-host-installer.ps1 reads an unset WEB_OUTPUT_DIR as "don't bundle the
console" and says so in one line of log before carrying on happily.

The installers that fell out of that have no {app}\web at all: no
web-run.cmd, so `web setup` bails with "web launcher missing" before it
registers anything, so there is no PunktfunkWeb task, no console, and no
firewall rule for 47992. A user is left with a working host, a tray that says
"Open web console (not responding)" for ever, and nothing to reinstall their
way out of, because every rebuild reproduces it.

It also explains the bun.exe lock reported separately. bun.exe ships under
WithWeb OR WithScripting, but the pre-copy stop was #ifdef WithWeb — so in a
console-less installer bun still shipped while the only code that stopped bun
was compiled out, and replacing a running bun.exe is the "DeleteFile failed;
code 5" modal.

Exporting the variable from its own unconditional step fixes it. The throw
alongside is the actual lesson: a missing web\.output now fails the job
instead of silently redefining what the installer is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 c64ada5649 fix(windows/installer): an update stops every bun before it replaces bun.exe
Updating to 0.22.1 could die on a modal: "C:\Program Files\punktfunk\bun\
bun.exe — DeleteFile failed; code 5. Access denied." Windows will not delete a
running executable, so that message means a bun was still alive when the copy
reached it, and the installer had no way to recover.

Two things run that bun, and neither is a child of the host service, so
stopping the service — which the installer does correctly wait for — never
touched either. Both are Task Scheduler tasks: PunktfunkWeb (SYSTEM, the
console) and PunktfunkScripting (LocalService, the plugin runner). The only
pre-copy stop we had looked for exactly one of them, and found processes by
task name or by who was listening on 47992/3000. The plugin runner matched
neither clause: different task, and it listens on no port at all. So anyone
who had run `plugins enable` held bun.exe mapped through the whole install and
failed this way on every single update.

The web console could lose the race too. Stop-ScheduledTask returns once
termination has been *requested* and Stop-Process is TerminateProcess, so the
old code went straight from asking to copying; the Rust twin of that routine
has always ended with a deliberate one-second settle. And neither task was
disabled for the duration, while both carry restart-on-failure (web ten times
a minute apart, scripting 999) and the web task also has a logon trigger — so
a force-kill invited a respawn into the middle of a copy that takes well over
a minute at lzma2/max.

StopBunRuntimes now disables both tasks before stopping them, kills any bun
whose image lives under the install dir — by path, so a developer's own bun
survives — keeps the old port sweep for pre-bun installs that ran node on
3000, and then waits until both are actually gone rather than assuming.
bun.exe also gains restartreplace, so if some bun still escapes all of that,
the file lands on the next restart instead of dead-ending the install.

Disabling a task is not free, though: unlike a stopped one it does not come
back at the next boot, so an install that aborts anywhere after that point
would take the console down for good. Hence two restores — a [Run] entry for
the normal flow, and DeinitializeSetup, which Inno calls even when the user
cancels. Both re-enable only what was enabled before the copy, and enabling an
enabled task does nothing, so the pair is idempotent.

Putting things back also turned up a second bug worth naming: the scripting
entry re-registers its task and then unconditionally disables it, which is
right on a first install and has silently switched an operator's plugin runner
off on every upgrade since. The restore runs after both re-registrations and
honours the prior state, so a fresh install still leaves the opt-in runner off.

The Pascal and the PowerShell were both compiled and run on the Windows CI
runner (ISCC, plus the query halves of each command) — but the install path
itself is not yet exercised on glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 b275e6d34c feat(web,docs): the Updates card — version, channel, install kind, and the exact update command
android / android (push) Canceled after 1m54s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / docs-site (push) Successful in 1m15s
arch / build-publish (push) Canceled after 1m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
ci / rust (push) Canceled after 59s
ci / rust-arm64 (push) Canceled after 2m31s
ci / web (push) Canceled after 0s
deb / build-publish (push) Canceled after 18s
deb / build-publish-host (push) Canceled after 19s
deb / build-publish-client-arm64 (push) Canceled after 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m28s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
decky / build-publish (push) Successful in 50s
Notify-only (U0): polls /update/status (which keeps the host's manifest cache
warm), Check-now with the 30s limit surfaced, release-notes link, stale-feed and
check-disabled states, copyable per-install-kind command. en+de strings; docs-site
'Updating the Host' page wired into the install section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:47 +02:00
enricobuehlerandClaude Fable 5 c4c4f217bf ci(update): build+sign+publish the update manifest — stable at announce, canary after the canary installer
bash+openssl signer (raw-64-byte ed25519 over exact bytes, base64 .sig — the
plugin-index format) with the pinned-key cross-check, manifest-then-sig upload
order, and a live-feed self-verify. announce.yml re-hashes the installer against
its sidecar and fail-closes without UPDATE_MANIFEST_KEY; pre-release tags never
enter the stable feed. windows-host.yml grows a Linux canary-manifest job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:47 +02:00
enricobuehlerandClaude Fable 5 cc01562631 feat(host): update check — signed per-channel manifest, install-kind detection, /api/v1/update surface
The U0 leg of planning:host-update-from-web-console.md: a signed update manifest
(Ed25519, keys pinned in the binary via the plugin-store verify path, serial floor
persisted against rollback, channel-bound, 45-day stale hint) fetched lazily behind
GET /update/status + rate-limited POST /update/check, admin lane only (plugin lane
whole-prefix denied, absent from the cert allowlist). Install kind + channel come
from root-owned facts; deb/rpm/pacman builds now stamp /usr/share/punktfunk/install-kind.
Emits update.available once per discovered version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:46 +02:00
enricobuehlerandClaude Opus 5 940bd0b7ec feat(core/abr): the startup capacity probe's target is configurable
ci / docs-site (push) Successful in 1m20s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 1m44s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
apple / swift (push) Successful in 4m43s
android / android (push) Successful in 6m18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-host (push) Successful in 6m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 5m16s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 39s
arch / build-publish (push) Successful in 9m18s
apple / screenshots (push) Canceled after 4m51s
ci / rust (push) Successful in 9m43s
deb / build-publish-client-arm64 (push) Canceled after 8m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8m5s
windows-host / package (push) Canceled after 10m19s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 7m26s
release / apple (push) Canceled after 9m48s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 3m23s
The Automatic startup probe bursts at a fixed 2 Gbps two seconds into every
session, deliberately far above any plausible link so the burst measures the
link rather than itself. On links the burst DISTURBS, that backfires: on an
LG G5 (webOS 10.3), three back-to-back connects to the same Gamescope host
split two ways — the two where the probe finished in ~1-2 s had video within
2-4 s, while the one that hit the 6 s timeout showed no video for 14 s. Even
a "successful" probe on that link reported send_dropped=20211. The webOS
client already caps its own speed test at 320 Mbps because an unbounded
firehose starves a 2-3 core TV; core then bursts the same hardware at 2 Gbps.

PUNKTFUNK_ABR_PROBE_KBPS now sets that target, so an embedder that caps its
own speed test can cap ours to match. Unset, zero, or unparseable keeps the
2 Gbps default, so every existing session behaves exactly as before. The
existing opt-out (PUNKTFUNK_ABR_PROBE=0) is no substitute: it leaves the
climb ceiling pinned at the negotiated ~20 Mbps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:24:57 +02:00
enricobuehlerandClaude Fable 5 77517bbe21 style(web): let biome format the message catalogues and the Logs story
ci / web (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m33s
ci / docs-site (push) Successful in 1m37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / deploy-docs (push) Successful in 37s
deb / build-publish-host (push) Successful in 4m29s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Successful in 6m6s
arch / build-publish (push) Successful in 9m36s
windows-host / package (push) Successful in 20m26s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m39s
Cosmetic only, and kept out of the feature commit it rode in on so neither
has to be read through the other. `biome check --write` reindents both
message catalogues from two spaces to tabs (877 lines each, no key, order,
or value touched — verified by comparing the parsed objects) and rewraps the
Logs story fixtures onto one argument per line with trailing commas.

Worth knowing for next time: the catalogues are also written by inlang, which
formats with two spaces, so an edit made through that tooling will pull them
back. Nothing depends on either shape — `bun run codegen` compiles 440
messages for both locales either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:28:37 +02:00
enricobuehlerandClaude Fable 5 8d6241efae feat(web): the logs page can hand a log off — as a file, or to the share sheet
The Logs page could only be read in place. Getting a host log into a bug
report meant selecting a screenful of monospace text and hoping the scroll
container gave up the rest.

Two controls in the toolbar now do it properly. Download writes a .log file
named for the moment it was taken; the second button hands the same text to
the OS share sheet where there is one (phones, iPads), and copies it to the
clipboard everywhere else — which is why it is probed at runtime rather than
guessed, and why the button is absent on the one combination where neither
exists (plain HTTP, no Web Share).

Both export what the filters currently match, not the rendered tail: the
1000-row cap is a DOM budget and has nothing to say about how long a file
may be. Lines carry the full date and UTC offset, since a bare wall-clock
time stops meaning anything the moment the file leaves the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:07:15 +02:00
32 changed files with 3331 additions and 946 deletions
+31
View File
@@ -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 }}
+72
View File
@@ -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)
@@ -376,6 +391,33 @@ jobs:
# 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
env:
@@ -460,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')
Generated
+30 -30
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.22.2"
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.2"
version = "0.22.3"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2896,7 +2896,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2914,7 +2914,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2935,7 +2935,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2959,7 +2959,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"ash",
"bindgen",
@@ -2968,7 +2968,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"libc",
@@ -2980,7 +2980,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2994,11 +2994,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.22.2"
version = "0.22.3"
[[package]]
name = "pf-inject"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3027,14 +3027,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3082,7 +3082,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-paths",
@@ -3094,7 +3094,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -3302,7 +3302,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3313,7 +3313,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"android_logger",
"jni",
@@ -3329,7 +3329,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"async-channel",
@@ -3346,7 +3346,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3381,7 +3381,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"aes-gcm",
"bytes",
@@ -3413,7 +3413,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"aes",
"aes-gcm",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3511,7 +3511,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"anyhow",
"ksni",
@@ -3534,7 +3534,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.22.2"
version = "0.22.3"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -51,7 +51,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.22.2"
version = "0.22.3"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+224 -1
View File
@@ -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)"
}
]
}
+14 -3
View File
@@ -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"
);
+14
View File
@@ -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",
+1
View File
@@ -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")]
+5 -1
View File
@@ -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;
+6 -1
View File
@@ -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
}
+25
View File
@@ -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"
+131
View File
@@ -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",
),
}
}
+401
View File
@@ -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());
}
}
+420
View File
@@ -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());
}
}
+1
View File
@@ -15,6 +15,7 @@
"steamos-host",
"windows-host",
"web-console",
"updating",
"---Configure your desktop---",
"configuration",
"kde",
+60
View File
@@ -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`.
+27
View File
@@ -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.
+6
View File
@@ -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"
+10
View File
@@ -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"
+1
View File
@@ -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
+5
View File
@@ -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
+117 -11
View File
@@ -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
@@ -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
+132
View File
@@ -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"
+458 -436
View File
@@ -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"
}
+458 -436
View File
@@ -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"
}
+184
View File
@@ -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>
);
};
+9 -1
View File
@@ -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 />}
/>
);
};
+5 -1
View File
@@ -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>
+88 -16
View File
@@ -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)}{" "}
+94
View File
@@ -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)}`;
};
+56 -8
View File
@@ -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
/>
),