Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7e6f71c0 | ||
|
|
9e3fba10c1 | ||
|
|
fd4f032d20 | ||
|
|
892e683f0e | ||
|
|
d7fa5847f1 | ||
|
|
e473a4be7b | ||
|
|
21d9190324 | ||
|
|
7ae8866a5c | ||
|
|
a8099e0f5b | ||
|
|
b4b24f8b57 | ||
|
|
c23fc84bef | ||
|
|
674b16d8eb | ||
|
|
d801cb72f2 | ||
|
|
b03acc9153 | ||
|
|
ace01f06a2 | ||
|
|
e4ec4cec31 | ||
|
|
230d253b06 | ||
|
|
def215ae8e | ||
|
|
dfcc530ee7 | ||
|
|
6b5307618f | ||
|
|
cdacd5636e | ||
|
|
47f01149bf | ||
|
|
20568d988f | ||
|
|
2b81bd286f |
@@ -248,7 +248,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
|
||||
+15
-3
@@ -339,8 +339,19 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
|
||||
# has the why: this box runs many jobs in parallel and drops packets under that load).
|
||||
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
|
||||
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
|
||||
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
|
||||
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
|
||||
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
|
||||
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
|
||||
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
|
||||
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
|
||||
# 30s is enough to ride out a load burst without making that wait a minute and a half.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: it generates the orval API client + paraglide messages that
|
||||
# typechecking imports.
|
||||
- name: Build
|
||||
@@ -368,8 +379,9 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
# Retried — see the web job above; this is the job the flake was measured on.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -417,7 +429,7 @@ jobs:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
|
||||
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
|
||||
|
||||
@@ -231,7 +231,9 @@ jobs:
|
||||
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
|
||||
# neither consumes nor commits, whose only effect here is to make the install depend on
|
||||
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
|
||||
@@ -61,3 +61,9 @@ jobs:
|
||||
punktfunk-host detect-conflicts
|
||||
- name: Re-running is a no-op install
|
||||
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
! command -v punktfunk-host
|
||||
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
|
||||
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
|
||||
|
||||
@@ -111,3 +111,20 @@ jobs:
|
||||
name: punktfunk-linux-client-screenshots
|
||||
path: clients/linux/screenshots
|
||||
retention-days: 30
|
||||
|
||||
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
|
||||
# blocked reusing these shots for the docs. Publish them to the generic package registry
|
||||
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
|
||||
# public repo:
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
|
||||
- name: Publish screenshots to the package registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
|
||||
for f in clients/linux/screenshots/*.png; do
|
||||
name=$(basename "$f")
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
|
||||
echo "published $BASE/$name"
|
||||
done
|
||||
|
||||
@@ -37,15 +37,18 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
|
||||
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
|
||||
# is the worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Build the SDK (file:../sdk dependency source)
|
||||
working-directory: sdk
|
||||
run: |
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
|
||||
@@ -176,7 +176,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
|
||||
|
||||
@@ -39,8 +39,11 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
|
||||
# worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -40,8 +40,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
|
||||
# explicitly since build-storybook has no prebuild hook of its own.
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
- name: Generate API client + i18n messages
|
||||
run: bun run codegen
|
||||
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
|
||||
|
||||
@@ -56,8 +56,12 @@
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
|
||||
# from one assembled layout:
|
||||
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
|
||||
# (stable path Steam can launch: overlay + Big Picture work)
|
||||
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
|
||||
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -283,6 +287,28 @@ jobs:
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
|
||||
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
|
||||
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
|
||||
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
|
||||
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
|
||||
- name: Pack + sign installer + portable zip
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-client-installer.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
@@ -301,7 +327,10 @@ jobs:
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
# The installer + portable zip (the default download; docs point at these alias URLs).
|
||||
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
|
||||
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
@@ -324,10 +353,11 @@ jobs:
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
|
||||
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
|
||||
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
|
||||
# don't collide.
|
||||
- name: Attach client artifacts to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -335,6 +365,6 @@ jobs:
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
+322
-50
@@ -14,16 +14,19 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
## v0.31.0
|
||||
|
||||
90 commits since v0.30.0 (65 non-merge).
|
||||
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
|
||||
|
||||
Nothing versioned moves. `WIRE_VERSION` stays **2**, the C ABI stays **24** — `include/punktfunk_core.h`
|
||||
is byte-identical to the v0.30.0 tag — the driver protocol, gamepad channel and plugin index schema
|
||||
are all unchanged, and no `trust::Settings` field, capability bit or control-message type byte was
|
||||
added. Every 0.30.x host, client, driver and plugin keeps interoperating in both directions, with no
|
||||
re-pairing.
|
||||
One versioned surface moves, additively: the **C ABI goes 24 → 25**, a single new symbol
|
||||
(`punktfunk_set_log_callback`) that lets an embedder hear the core's own log lines. Nothing else
|
||||
does — `WIRE_VERSION` stays **2**, the driver protocol, gamepad channel and plugin index schema are
|
||||
unchanged, and no `trust::Settings` field, capability bit or control-message type byte was added.
|
||||
No existing C function changed its signature or behaviour and no `#[repr(C)]` struct grew a field,
|
||||
so an embedder that adopts nothing rebuilds against the new header and is done. Every 0.30.x host,
|
||||
client, driver and plugin keeps interoperating in both directions, with no re-pairing.
|
||||
|
||||
What did move is beneath the versioned surfaces, and three parts of it are worth a packager's or
|
||||
embedder's attention: the Linux host package installs **three new system files** (a udev rule, a
|
||||
Beneath the versioned surfaces, four things are worth a packager's or embedder's attention: the
|
||||
**Windows client's default download changes** to a per-user installer plus a portable zip, with the
|
||||
MSIX kept for the Store; the Linux host package installs **three new system files** (a udev rule, a
|
||||
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
|
||||
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
|
||||
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
|
||||
@@ -35,7 +38,7 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| | v0.30.0 | v0.31.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 24 | **24** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.30.0 tag; the only new `pub` items in `punktfunk-core` are three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`), Rust-only, no `pub const` for cbindgen to pick up |
|
||||
| C ABI | 24 | **25** | one additive step: v25 adds `punktfunk_set_log_callback` and the `PunktfunkLogCb` typedef (below). No existing declaration moved and no struct grew a field. Also new in `punktfunk-core`, Rust-only: three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`) |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
@@ -43,20 +46,39 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
|
||||
| `api/openapi.json` | 0.29.0 | **0.31.0** | **the stamp only** — no management-API surface moved this cycle. The file had been left at 0.29.0 while the crate was already 0.31.0; #337's regenerate-and-diff caught it and it was regenerated, which is a one-line change to both copies. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
|
||||
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
|
||||
|
||||
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have
|
||||
unpublished code changes, called out in the table so they are cut deliberately rather than
|
||||
discovered.
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`), so their rows record what the registry holds, not
|
||||
what this tag ships. Both were cut during this cycle rather than left owed — a plugin resolves them
|
||||
from the registry, so a fix that never ships there never reaches one.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None on any versioned surface.** No wire change, no C ABI change, no driver-protocol change, no
|
||||
plugin-contract change. Four things are worth attention anyway; none breaks a build:
|
||||
**None that break a build.** No wire change, no driver-protocol change, no plugin-contract change.
|
||||
The C ABI moves 24 → 25 by **addition only**:
|
||||
|
||||
- **v25 — `punktfunk_set_log_callback(max_level, cb, user)`.** The core logs through `tracing`; an
|
||||
embedder that installs no Rust subscriber hears none of it — transport warnings, connection events,
|
||||
handshake notes — and a client log bundle carries the shell's half alone, which is exactly what an
|
||||
Apple TV field report turned out to be. The call registers a `log::Log` backend behind a C callback
|
||||
(`PunktfunkLogCb`: level, target, message, user), gated by `log::set_max_level` so anything above
|
||||
the ceiling costs no formatting; `NULL` detaches, and it answers `Unsupported` when another log
|
||||
backend already owns the process (`android_logger`). Both strings are borrowed for the call only,
|
||||
and an interior NUL drops the line rather than truncating it. `punktfunk-core` now declares
|
||||
`tracing`'s `log` feature explicitly — it had been on transitively via quinn, which an ABI promise
|
||||
must not rest on. An embedder that never calls it is byte-compatible with v24; see
|
||||
`docs/embedding-the-c-abi.md` §2.6.
|
||||
- **One header comment was wrong and is corrected, with no signature change:**
|
||||
`punktfunk_connect_ex10`'s summary still stated the pre-2026-08-16 rule that only a format other
|
||||
than 48000/16 requests the lossless plane. Any non-zero format at all does, 48000/16 included —
|
||||
which is what its own warning already said and what the code always did. Embedders reading the
|
||||
summary were reading the old rule.
|
||||
|
||||
Five more things are worth attention; none breaks a build:
|
||||
|
||||
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
|
||||
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
|
||||
@@ -78,6 +100,11 @@ plugin-contract change. Four things are worth attention anyway; none breaks a bu
|
||||
monitors disabled for the session now (closes #284).
|
||||
- **Three new system files in the Linux host package** — the DualSense audio path does not work
|
||||
without them. Downstream repackagers: see the packaging section.
|
||||
- **The Windows client's default download is a per-user installer, not the MSIX.** The MSIX stays,
|
||||
for the Store; the installer and a portable zip are what the download page now offers, and the
|
||||
release carries `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip` alongside it.
|
||||
Anyone scripting against the MSIX asset name is unaffected; anyone scripting against "the Windows
|
||||
client download" gets a different artifact. See the Windows client section.
|
||||
|
||||
### DualSense audio and haptics on Linux: five faults, and the files they needed
|
||||
|
||||
@@ -295,6 +322,25 @@ gains two direct deps already in the graph.
|
||||
`frameRatePowerSavingsBalanced`) raises the render-range floor — so the ineffective pins were
|
||||
removed again and `pf.present` gained the cadence loop's late-permille / jitter / cushion /
|
||||
re-anchors / qDepth.
|
||||
- **Colour tagging, which the SurfaceView path never had to do.** MediaCodec tags its own window
|
||||
buffers; with `AImageReader` → `ASurfaceControl` the transaction is the only carrier, and a
|
||||
dataspace of 0 means `setBufferDataSpace` is never called. Two consequences, both fixed inside the
|
||||
cycle: **HDR** was seeded from a hardcoded `BT2020_ITU_PQ` guess and then overwritten by whatever
|
||||
the codec echoed on the first output-format change — a decoder that omits color-transfer (common)
|
||||
echoes None, clobbering the dataspace to 0 before the first present, so P010 buffers composited as
|
||||
sRGB, and an HLG stream was mis-seeded PQ. The initial dataspace now derives from `client.color`
|
||||
(PQ vs HLG, range) and a format change only *refines* it when the codec actually reports an HDR
|
||||
transfer, never resets it — the SurfaceView path's semantics. And **SDR** was untagged entirely:
|
||||
a limited-range BT.709 buffer read as full range shows black (16) as grey, so SDR now maps to
|
||||
`ADATASPACE_BT709` and every ASC buffer is tagged.
|
||||
- **One owner for the system bars.** Console → stream rides an `AnimatedContent` cross-fade, so the
|
||||
outgoing console shell stays composed until the fade ends and its
|
||||
`onDispose { show(systemBars()) }` fired *after* `StreamScreen`'s hide — parking the status and
|
||||
gesture bars over the video for the whole session. Hide/show now lives once in `App.kt`, keyed on
|
||||
the resolved intent (streaming or console fronting = immersive, touch shell = bars back), and both
|
||||
screens' per-screen bar management is deleted.
|
||||
- **Idle gates** (from the console-ui sweep): the reachability sweep only probes while the console is
|
||||
attached, and the render thread drops to half rate after 60 s without input.
|
||||
|
||||
### Hyprland / sway: `topology: exclusive` (closes #284)
|
||||
|
||||
@@ -314,20 +360,92 @@ with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **Th
|
||||
exercised on a live sway** — no box in the fleet runs one; both argv shapes are pinned by tests and
|
||||
the read-back turns a wrong guess into a warning naming the outputs. Six new unit tests.
|
||||
|
||||
### Gaming Mode takeover: the mask was the relogin storm
|
||||
### Gaming Mode takeover: it no longer touches the display manager at all
|
||||
|
||||
On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin path, so every
|
||||
autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind sessions in 3.7 min,
|
||||
system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy crash-looping ~16
|
||||
starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per event, read the pad at
|
||||
~1.4 Hz. `dm_plan` loses its `mask` input and `dm_survives_masked_unit`; the mask is laid **only after
|
||||
the stop has landed** and every restore path unmasks before restarting; a planned DM stop that does
|
||||
not land now **fails the takeover** and the caller degrades to ATTACH. `skip` is `!any_live` on every
|
||||
flavor; `any_live` now counts `deactivating` and `reloading`. New `DmHelperError::shape()`;
|
||||
`watch_for_relogin_storm()` (two `read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s,
|
||||
detect-only); `systemctl_system` captures stderr at DEBUG (the "requires interactive authentication"
|
||||
line was going to the journal on the *successful* path). `cargo test -p pf-vdisplay --lib gamescope`
|
||||
52 passed, 1 ignored.
|
||||
This landed in two steps within the cycle, and the second retired the first — read the end state.
|
||||
|
||||
**The storm.** On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin
|
||||
path, so every autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind
|
||||
sessions in 3.7 min, system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy
|
||||
crash-looping ~16 starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per
|
||||
event, read the pad at ~1.4 Hz. Masking without stopping the display manager is not a weaker
|
||||
defence; it is the storm's engine.
|
||||
|
||||
**Then stopping the DM proved wrong too.** With no display manager there is nothing on the box able
|
||||
to start a desktop session, so Steam's own "Switch to Desktop" sat on its modal until a reboot
|
||||
(field report 2026-08-18, `.41`). It could not even be detected and worked around: on a
|
||||
steamos-manager box every trace of that switch is written by the component we had just stopped —
|
||||
the `~/.config/steamos-session-select` sentinel is never written (that is the ChimeraOS/Nobara
|
||||
layout), `/var/lib/sddm/state.conf` only advances when sddm actually *starts* a session,
|
||||
`get-default-login-mode` stays `game` for a non-persistent switch, and `graphical-session.target`
|
||||
going inactive fires at takeover time as well.
|
||||
|
||||
**End state: idle the autologin, leave the display manager alone.** The takeover drops a unit
|
||||
override over the `gamescope-session-plus@` template replacing `ExecStart` with a process that
|
||||
sleeps. The autologin still *succeeds*, so there is no failed unit to relogin against; the session
|
||||
runs nothing, so Steam is free; and the DM is alive, so the box can service its own session switch.
|
||||
No privilege, no DM-flavour matrix, no detection. Measured on `.41` in both directions: takeover
|
||||
leaves `steam` down, `sddm` active, the unit `active (running)` with `NRestarts=0`; the switch that
|
||||
used to hang brings Plasma up in ~10 s; the restore puts Steam back within 5 s. The drop-in lives
|
||||
under `$XDG_RUNTIME_DIR` (a copy outliving the host would be a box whose Game Mode silently does
|
||||
nothing), is swept unconditionally at startup, and its removal sits above every early return in the
|
||||
restore — the desktop-active return is exactly the path that would leak it. The restore *restarts*
|
||||
rather than starts, because `start` on an active-but-idle unit is a no-op that would log success
|
||||
over it.
|
||||
|
||||
With nothing stopping a display manager any more, the whole chain built to survive doing so is
|
||||
deleted: `try_stop_display_manager`, `ensure_host_survives_dm_stop`, `host_is_under_user_manager`,
|
||||
`cgroup_under_user_manager`, `linger_enabled` and `dm_plan`'s mask input — 142 lines out, 17 in.
|
||||
**Two shipped facts became false and are corrected:** the takeover no longer has to stop the display
|
||||
manager, and it no longer needs the `punktfunk` group (the docs and the shipped Bazzite `host.env`
|
||||
both said it did). That group still gates the usbip nodes the virtual Steam Deck pad attaches
|
||||
through, which is what the advice now narrows to. Kept from the first step: `any_live` counts
|
||||
`deactivating` and `reloading` (a unit mid-teardown used to read as a dead leftover, so a box that
|
||||
*is* in gaming mode sampled as idle); `DmHelperError::shape()`; `watch_for_relogin_storm()` (two
|
||||
`read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s, detect-only, and it states that
|
||||
no audio, input or PipeWire measurement taken during a storm is valid); and `systemctl_system`
|
||||
capturing stderr at DEBUG, since that verb is *expected* to fail on an unprivileged host and its
|
||||
"requires interactive authentication" line was going to the journal on the successful path.
|
||||
|
||||
### KWin 6.6 creates our virtual output disabled, and refuses to stream it
|
||||
|
||||
On KWin ≥ 6.6 `streamVirtualOutput` creates the output on the backend and then hands
|
||||
`workspace()->findOutput(output)` to the stream — null for an output the workspace does not manage
|
||||
(`wantsToManage` = `isEnabled() && !isNonDesktop()`). An output KWin creates **disabled** is
|
||||
therefore refused with "Could not find output", translated into the session's language and logged
|
||||
nowhere, because disabling an output is a perfectly valid configuration that applies successfully.
|
||||
6.4/6.5 passed the backend output straight through and streamed it either way. It repeats forever:
|
||||
the host asks for a *stable* per-client output name precisely so KWin persists that client's scale
|
||||
and mode against it, so a stored configuration naming it `enabled: false` is reapplied to every
|
||||
future session for that client — and the user cannot fix it in System Settings, because the output
|
||||
only exists for the few milliseconds the request is alive. The host now enables the output and
|
||||
retries. Related, from the same investigation: a **translated** KWin refusal used to burn all 8
|
||||
retries because the match was against KWin's message rather than our own prefix.
|
||||
|
||||
### Windows client: a per-user installer and a portable zip, because Steam must spawn the exe
|
||||
|
||||
A user report — launching through Big Picture does not work and the Steam overlay never appears —
|
||||
turned out to be nothing to do with the app being UWP (it is full-trust Win32 under MSIX too) and
|
||||
everything to do with the MSIX install **shape**: the exe lives under the ACL'd `WindowsApps`
|
||||
directory that Steam's non-Steam-game picker cannot browse, and alias / `shell:AppsFolder`
|
||||
activation defeats the overlay's injection. Steam has to spawn the exe itself, from a normal path.
|
||||
|
||||
- **`punktfunk-client.iss`** — a per-user Inno Setup install (no UAC) to
|
||||
`%LOCALAPPDATA%\Programs\Punktfunk`, re-creating in `HKCU` what the MSIX manifest granted: the
|
||||
`punktfunk://` scheme, the Start entries, and `{app}` on the user PATH for the `punktfunk` CLI. It
|
||||
fetches the Windows App Runtime when missing.
|
||||
- **`pack-client-installer.ps1`** consumes `pack-msix.ps1`'s layout (one assembly, three artifacts),
|
||||
signs the four exes individually and emits `setup.exe` plus a portable zip — same signing backends
|
||||
and fail-closed-on-tags rule as its siblings, and no `.cer`, because an exe runs untrusted.
|
||||
- **`windows-client.yml`** packs after the MSIX and publishes/attaches the new artifacts;
|
||||
canary/latest aliases are `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip`.
|
||||
- **`deeplink.rs`**: `write_shortcut` targets the app-execution alias only under package identity —
|
||||
an unpackaged install has no alias but does have a stable path, so it targets `current_exe()`.
|
||||
`has_package_identity()` is now shared with `main.rs`'s AppUserModelID probe.
|
||||
- Uninstall is `Settings → Apps → Installed apps` (per-user, no admin prompt) or
|
||||
`unins000.exe /VERYSILENT`; a portable unzip registers nothing and is deleted by hand. Documented
|
||||
in install-client (with a "Launching through Steam" section), channels, clients, uninstall, and
|
||||
both copies of `platforms.json`.
|
||||
|
||||
### Windows host: two session-killers
|
||||
|
||||
@@ -351,6 +469,74 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
|
||||
instead of `launching` forever. Fixture in `a_pid_only_launch_reports_its_exit` widened 4 → 8 s
|
||||
(it passed only because of the bug); new ignored test drives the field report.
|
||||
|
||||
### `scripts/install.sh`: a guided Linux host install (preview)
|
||||
|
||||
Plain POSIX `sh`, dash-clean, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`. Detect the
|
||||
distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows and unknown
|
||||
distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora 45 hit the
|
||||
documented floors with the right docs link) → install using the `data/platforms.json` lines
|
||||
**verbatim** (channel and the Fedora group are edited into the string at run time) → run
|
||||
`punktfunk-host detect-conflicts` (exit 1 = an active Sunshine-family host) → offer to keep both by
|
||||
moving the management API port (`PUNKTFUNK_MGMT_BIND`, default 47991, which the firewall step then
|
||||
opens) → input group (`ujust` on Bazzite) → optional `punktfunk` group, GameStream compat and shared
|
||||
clipboard, all defaulting to no → firewalld/ufw profiles → enable host + console (+ the plugin
|
||||
runner where it is not) → optional linger → verify (unit active, UDP 9777 bound) and print the
|
||||
console URL, the password command and the pairing steps.
|
||||
|
||||
`--dry-run` prints every command and changes nothing; `--uninstall` reverses the install and the
|
||||
service enable per family (user units off first, then only the punktfunk packages actually
|
||||
installed, then the repo — config, groups and firewall stay, as `/docs/uninstall` states). Every
|
||||
prompt has a `PUNKTFUNK_INSTALL_*` environment twin so `--yes` (or no terminal) runs unattended, and
|
||||
stdin is never read, because under `curl | sh` stdin *is* the script. Re-running is safe. The
|
||||
end-of-run check catches the two NVIDIA silent failures on every family — no driver at all, and a
|
||||
module the kernel refused to load under Secure Boot — via an `nvidia-smi` probe pointing at the
|
||||
troubleshooting anchor.
|
||||
|
||||
It is labelled **PREVIEW** on purpose: the per-distro docs pages remain the documented default until
|
||||
it has mileage. CI runs it: a new `installer-smoke.yml` exercises install and `--uninstall` per
|
||||
package family, and `check-docs-drift.sh` gate 7 runs the 16-file os-release detection matrix
|
||||
through the real script under `--dry-run` on every push. One bug fixed by the first smoke run: the
|
||||
`/dev/tty` probe used `-r`/`-w`, which answer yes in a container that has the node but no
|
||||
controlling terminal, so the redirect failed — it opens the device instead now.
|
||||
|
||||
### One home per fact: `data/platforms.json`, and CI gates against drift
|
||||
|
||||
Install commands, repo URLs and port numbers had drifted across four surfaces. They now live in
|
||||
`data/platforms.json` and nowhere else: the docs-site install pages quote it through an
|
||||
`<Install platform="…"/>` MDX component reading a byte-identical snapshot at
|
||||
`docs-site/src/data/platforms.json` (the Docker build context is `docs-site/` alone, the same
|
||||
arrangement `openapi.json` uses), `<Ports/>` renders the port table from it, the website download
|
||||
page vendors it, and `install.sh` runs it. `scripts/ci/check-docs-drift.sh` gates the parse, the
|
||||
snapshot sync, undocumented `PUNKTFUNK_*` knobs (against a checked-in baseline) and the detection
|
||||
matrix; `check-docs-links.sh` covers dead links.
|
||||
|
||||
⚠ **Two consequences for whoever cuts this release.** The website vendors `platforms.json` and only
|
||||
refreshes when someone runs `bun run sync-platforms` in punktfunk-website and commits — the release
|
||||
flow in `docs/releases/README.md` gained that step, and `platforms.json` **did** change this cycle
|
||||
(the Windows client download). And the `.gitea/PULL_REQUEST_TEMPLATE.md` now asks the one question
|
||||
CI cannot: did a user-facing fact change, and is the page that owns it updated in the same PR.
|
||||
|
||||
### Clients can send their logs to the host, on every platform that has a console
|
||||
|
||||
0.30 shipped "Send logs to host" on the Gaming Mode console alone and named the Apple and Android
|
||||
legs as follow-ups. Both landed here.
|
||||
|
||||
- **Apple** — a `ClientLog` drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)` with the
|
||||
same call shape, writing os_log *and* a process-global ring bounded at 4096 lines / 768 KiB (under
|
||||
the host's 1 MiB cap), stamped wall-clock ISO-8601 so a bundle lines up with the host log;
|
||||
`.debug` stays out of the ring, which is the Steam Deck DPB lesson applied in advance. 13 `Logger`
|
||||
declarations swapped. `MgmtTransport`/`MgmtConnection` POST a length-framed body on the same
|
||||
pooled, pinned mTLS connection; `SendLogs.toHost` requires identity and pinned fingerprint, the
|
||||
same gates as the library. Reachable from the host card's context menu and the gamepad host
|
||||
options. Paired with ABI v25 above, the Swift client finally hears the core's own lines too
|
||||
(`core.<crate>`, info ceiling by default, `PUNKTFUNK_CORE_LOG_LEVEL` raises it).
|
||||
- **Android** — `pf-client-core`'s logring RING half (note/render/wallclock, std-only) is
|
||||
Android-enabled, with `send_to_host` still desktop-gated alongside the ureq fetches; `wallclock`
|
||||
moves in from the session's ring layer so every feeder stamps lines identically. `JNI_OnLoad`
|
||||
installs a `RingTee`, so every `log` record goes to logcat **and** into the ring in the desktop
|
||||
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
|
||||
upload rides the client's own mTLS.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
|
||||
@@ -402,44 +588,130 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
|
||||
(screenshot harness only).
|
||||
- **New environment variables:** `PUNKTFUNK_PAD_SINK_VOLUME` (`=0` skips both pad-sink pins),
|
||||
`PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS` (pad-arrival grace), `PUNKTFUNK_USBIP_TRACE` (byte-level
|
||||
USB/IP trace prefix, off by default), and the three Apple screenshot-harness hooks above.
|
||||
USB/IP trace prefix, off by default), `PUNKTFUNK_CORE_LOG_LEVEL` (Apple: raises the ABI v25 log
|
||||
sink's ceiling above its info default), the three Apple screenshot-harness hooks above, and nine
|
||||
`PUNKTFUNK_INSTALL_*` twins for `install.sh`'s prompts (`_YES`, `_CHANNEL`, `_GAMESTREAM`,
|
||||
`_CLIPBOARD`, `_PUNKTFUNK_GROUP`, `_LINGER`, `_MGMT_PORT`, `_DRY_RUN`, `_OS_RELEASE`).
|
||||
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
|
||||
- **A Steam Deck never learned a host's wake MAC, so Wake-on-LAN was skipped there in silence.**
|
||||
Every wake gate reads `!host.mac.is_empty()`, and the MAC only ever reached the store through
|
||||
`trust::learn_mac`, whose two callers were the GTK and WinUI hosts pages — neither of which runs
|
||||
in Gaming Mode. Rather than add the missing call twice, the three per-field learners (`learn_mac`,
|
||||
`learn_os`, `learn_mgmt_port`) collapse into one `learn_from_advert`, called wherever an advert
|
||||
meets a saved record: both desktop hosts pages, the console home, and the CLI's `discover`.
|
||||
Remembering one call is not something a front-end can half-do; remembering three is what produced
|
||||
this (#322).
|
||||
- **`HostRow` gains `clipboard_sync`** (`#[serde(default)]`) and `ConsoleCmd` two variants,
|
||||
`BindProfile` and `SetClipboard` — additive and default-tolerant. From the 2026-08-19 console-ui
|
||||
sweep, which also brought touch deferred-tap and drag-to-scroll to the console (a swipe across the
|
||||
settings list used to cycle whatever value it landed on, because `MenuList` presses focus *and*
|
||||
activate), Controller haptics/speaker rows, and two Android idle gates (the reachability sweep
|
||||
only probes while the console is attached, and the render thread halves its rate after 60 s
|
||||
without input).
|
||||
- **Cancelling a connect returns the console immediately.** The takeover could only be dismissed by
|
||||
a session phase coming back from the embedder and nothing guaranteed one would: Android's shell
|
||||
sent no phase at all on the cancelled path, and the desktop shell waited on a pump parked inside
|
||||
the blocking `NativeClient::connect*`, which had no abort — 15 s on a normal dial, **185 s** on a
|
||||
request-access connect the host holds pending approval. The private `connect_*` inner fn takes a
|
||||
trailing `cancel: Option<Arc<AtomicBool>>`; not exported through the C ABI.
|
||||
- **A portable Playnite's covers survive the art confinement.** A Playnite unzipped outside the
|
||||
users base keeps its library beside the exe, so every cover it exports sits outside every default
|
||||
art root: the games synced and all **70** covers were dropped, with `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
the only way out. The Playnite install dirs are art roots now, exactly as Steam's install root
|
||||
already was, and `playnite_install_dirs` learned to find a portable copy at all — it registers no
|
||||
uninstall entry and sits under no profile, but it does register the `playnite://` handler, which
|
||||
is the very registration the launch path already follows. So a portable install also gets its
|
||||
Fullscreen launcher tile, which it never had. The confinement is not loosened: roots come from the
|
||||
host's own registry and filesystem probes, never from the plugin lane that supplies the art path.
|
||||
Paired with the plugin-kit fix below, a fixed host no longer needs a cache file deleted.
|
||||
- **`plugin-kit`: `startup` and `manual` sync reasons always publish.** The fingerprint says the
|
||||
plugin would compute the same entries again; it does *not* say the host still holds them — and the
|
||||
host may accept a payload and store less of it (an art path outside its roots is stripped and the
|
||||
games kept, deliberately, because a cover must not cost a library). Once that happened the
|
||||
fingerprint was a permanent "no changes", and the only way out was deleting the plugin's cache
|
||||
file, which is exactly the advice a portable-Playnite library with 70 dropped covers was given.
|
||||
The two triggers with a person behind them now always apply.
|
||||
- **Nix:** nixpkgs bumped because its gamescope 3.16.24 no longer took our patch 0009 (the publish
|
||||
tier was red on every build); `enableWsi` is a nixpkgs *function argument* defaulting to false, so
|
||||
the plain derivation shipped a compositor with **no WSI layer at all** and nothing under it could
|
||||
obtain an HDR10 swapchain — our own postInstall assertion caught it. Also: the prune makes `$out`
|
||||
writable first (reshade installs read-only), the bun builds are serialised and the OOM is measured
|
||||
against the real 7 GiB cgroup cap rather than guessed at, and a dispatch opt-in compared against
|
||||
the string `"true"` silently skipped when the API delivered a real JSON boolean — the step was
|
||||
skipped and the job still reported success.
|
||||
- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules`
|
||||
(+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix),
|
||||
`scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs.
|
||||
- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the
|
||||
five triage labels; single-context domain docs). A host audio-source comment corrected
|
||||
(`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission).
|
||||
- **CI:** Nix publish job records `df` after the build as well as before.
|
||||
- **CI:** the Nix publish job records `df` after the build as well as before; the
|
||||
`linux-client-screenshots` run publishes its PNGs to the generic package registry as well as the
|
||||
v3 artifact store (which is browser-only, so nothing could reuse the shots for the docs — that is
|
||||
how the get-started track got its fifth screenshot, a client's host list); and **every Linux
|
||||
`bun install` is now wrapped in `scripts/ci/retry.sh`**. That last one is a real failure, not
|
||||
tidying: `bun install` streams download-and-extract, so a tarball truncated by the runner's
|
||||
packet loss under parallel load surfaces as `error: Fail extracting tarball for "<pkg>"` and
|
||||
names a package that is perfectly intact — measured on run 19630, where docs-site died on
|
||||
`@rolldown/binding-linux-x64-gnu` while the web job installed the same registry in the same run
|
||||
and run 19632 installed the identical lockfile seven minutes later. The tarball's sha512 matches
|
||||
the lockfile and bun 1.3.13 and 1.3.14 both extract it from disk, so neither the package nor the
|
||||
floating `oven/bun:1` bump was ever at fault. `retry.sh`'s header had already diagnosed this
|
||||
class and said to wrap every single-shot network command; `bun install` was the one still
|
||||
unwrapped. Three attempts rather than the usual five, so a genuinely stale lockfile still fails
|
||||
fast under `--frozen-lockfile`.
|
||||
- **The web console's Virtual displays page** put the Streamed-screen and session-lifetime cards
|
||||
below the tab shell, so both rendered on both tabs; they are policy surfaces and now sit inside
|
||||
the Configuration tab, leaving the Live tab as the live list plus arrangement.
|
||||
|
||||
### Verification status
|
||||
|
||||
Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`):
|
||||
`cargo fmt --all --check` clean — **after** a whitespace-only commit on the release branch: two files
|
||||
(`pf-console-ui/src/screens/controllers.rs`, `punktfunk-host/src/audio/linux/pad_card_volume.rs`)
|
||||
had landed on main formatted differently from rustfmt 1.96.0, so `ci.yml`'s Format step was red on
|
||||
the tip this is cut from; `cargo metadata --offline` ok with the `Cargo.lock` diff versions-only
|
||||
(36/36 lines); `cargo test -p punktfunk-core` **272 passed** in the unit suite; the android.yml Play
|
||||
notes gate run verbatim — 498/500 characters and not byte-identical to any prior release's; both
|
||||
openapi copies `cmp` identical and unchanged since the tag; `include/punktfunk_core.h` regenerated
|
||||
by the build and `git diff` clean against the tag.
|
||||
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
|
||||
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the android.yml
|
||||
Play notes gate run verbatim — 456/500 characters and not byte-identical to any prior release's;
|
||||
both openapi copies `cmp` identical, both stamped 0.31.0; notes voice scan clean outside the
|
||||
For developers section.
|
||||
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**: it links the staticlib with
|
||||
`-lopus` and this machine has no libopus (`ld: library 'opus' not found`), which is an environment
|
||||
gap, not a code fault. The header it exercises is byte-identical to v0.30.0's, where the harness
|
||||
passed (261 + 1 + 8), and nothing in `punktfunk-core`'s C surface changed. The CI runner is its
|
||||
first execution for this tag.
|
||||
⚠ **This release was cut more than once.** The first cut (`601f040f`, merged as #320) was never
|
||||
tagged, and 41 more non-merge commits landed on top of it — the Windows client installer, the
|
||||
guided Linux installer, the docs overhaul, ABI v25, the KWin 6.6 repair and the takeover's final
|
||||
shape among them; a handful more (the Virtual displays tab fix, the fifth get-started screenshot)
|
||||
arrived while the second cut was being written. This section, the version table and the notes are
|
||||
all re-measured on the latest tip; where the cuts disagreed, the earlier text was **rewritten
|
||||
rather than appended to**, because none of
|
||||
the intervening work ever shipped. Specifically: the "C ABI unchanged / header byte-identical"
|
||||
claim is gone (it is 25 now), the openapi row moved off 0.29.0, the SDK and plugin-kit rows record
|
||||
cuts that have happened rather than cuts that were owed, and the Gaming Mode takeover section
|
||||
describes idling the autologin rather than stopping the display manager — a within-cycle correction
|
||||
no user could have seen.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows runner
|
||||
log redirect (`scripting-run.cmd`), the tray's `Option<u16>` port on Windows, and the sway half of
|
||||
`topology: exclusive` (no live sway in the fleet, as with #283).
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**, and this time the header *did*
|
||||
change: it links the staticlib with `-lopus` and this machine has no libopus (`ld: library 'opus'
|
||||
not found`), which is an environment gap, not a code fault. `punktfunk_set_log_callback` is
|
||||
therefore compiled by cbindgen and by the Rust unit tests here, but the generated header has not
|
||||
been compiled by a C compiler on this cut — the CI runner is its first. Worth naming because ABI 25
|
||||
is the one versioned surface that moved.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows client
|
||||
installer and portable zip (`punktfunk-client.iss`, `pack-client-installer.ps1` — the pack step is a
|
||||
Windows runner's), the Windows runner log redirect (`scripting-run.cmd`), the tray's `Option<u16>`
|
||||
port on Windows, and the sway half of `topology: exclusive` (no live sway in the fleet, as with
|
||||
#283).
|
||||
|
||||
⚠ **Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink
|
||||
capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min
|
||||
of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; and the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3) — the fallback presenter is
|
||||
byte-for-byte the 0.30 one.
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3), with the fallback presenter
|
||||
byte-for-byte the 0.30 one; the Mac Accessibility intercept (the tap ahead of Spotlight, inside the
|
||||
sandbox) needs a granted Accessibility switch the dev machine does not have; and `install.sh` is
|
||||
smoke-tested per package family in CI containers but is shipped **preview** precisely because it has
|
||||
no real-box mileage, Bazzite above all.
|
||||
|
||||
⚠ **Owed outside this repository:** `data/platforms.json` changed this cycle (the Windows client
|
||||
download), and the website's download page vendors a copy that only refreshes when someone runs
|
||||
`bun run sync-platforms` in punktfunk-website and commits — step 1 of `docs/releases/README.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+18
-5
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
// `read`, not `load`: this verb never hands a record's id back, so it has no business
|
||||
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
|
||||
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
|
||||
// both together) — after which the ids one of them already handed out no longer resolve.
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
// The one write this verb does make, and why it doesn't contradict the above: an advert
|
||||
// is the only place a host's wake MAC is ever published, and this verb is the only one
|
||||
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
|
||||
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
|
||||
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
|
||||
// taught the record something new — so a steady-state panel refresh touches no disk.
|
||||
for (d, saved) in &rows {
|
||||
if let Some(k) = saved {
|
||||
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
|
||||
}
|
||||
}
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
|
||||
};
|
||||
let host = &known.hosts[i];
|
||||
if host.mac.is_empty() {
|
||||
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
|
||||
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
|
||||
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
|
||||
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if !has(args, "--wait") {
|
||||
|
||||
@@ -1087,33 +1087,20 @@ impl HostsPage {
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
|
||||
let online = self.adverts.values().any(|a| matches(k, a))
|
||||
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.mac.is_empty())
|
||||
{
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the icon then survives the host going offline.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.os.is_empty())
|
||||
{
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — and this one is not cosmetic: without it a host
|
||||
// that moved off 47990 loses its library the moment mDNS is unavailable, because
|
||||
// the advert was the only place the real port ever lived.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && a.mgmt_port.is_some())
|
||||
{
|
||||
if let Some(p) = a.mgmt_port {
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
|
||||
// its OS chain (so the icon survives it going offline), and its management port
|
||||
// — the last one not cosmetic, since a host that moved off 47990 loses its
|
||||
// library the moment mDNS is unavailable and the advert is the only place the
|
||||
// real port ever lived.
|
||||
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
saved.push_back(HostCard {
|
||||
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
|
||||
|
||||
@@ -800,11 +800,21 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
// Write the advertised mgmt port down while the host is visible, so this console
|
||||
// keeps working against a moved port once it is not. No-op (and no disk write)
|
||||
// Write down everything the advert teaches while the host is visible: the mgmt
|
||||
// port (so this console keeps working against a moved one once it is not), the
|
||||
// OS chain, and the wake MAC — which matters most here, because this console and
|
||||
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
|
||||
// record that never learned a MAC can never be woken. No-op (and no disk write)
|
||||
// when unchanged, so this is safe on every refresh tick.
|
||||
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
|
||||
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
|
||||
if let Some(a) = advert {
|
||||
pf_client_core::trust::learn_from_advert(
|
||||
&h.fp_hex,
|
||||
&h.addr,
|
||||
h.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
|
||||
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
|
||||
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
|
||||
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
|
||||
|
||||
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
|
||||
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
|
||||
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
|
||||
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
|
||||
compatibility).
|
||||
|
||||
## Get it
|
||||
|
||||
Install the signed MSIX from the package registry — see
|
||||
Install the signed installer from the package registry — see
|
||||
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
|
||||
|
||||
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
|
||||
```
|
||||
|
||||
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
|
||||
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
|
||||
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -79,7 +82,7 @@ src/
|
||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
||||
probe.rs · wol.rs speed probe · Wake-on-LAN
|
||||
logfile.rs log tee to %LOCALAPPDATA%
|
||||
packaging/ MSIX manifest, signing, pack script
|
||||
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
# punktfunk Windows client — MSIX packaging
|
||||
# punktfunk Windows client — packaging
|
||||
|
||||
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
|
||||
The Windows client ships **three ways, packed from one assembled layout** by CI
|
||||
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels):
|
||||
|
||||
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
|
||||
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
|
||||
install shape breaks the top user-reported flows: the exe lands under the ACL'd
|
||||
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
|
||||
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
|
||||
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
|
||||
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
|
||||
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
|
||||
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
|
||||
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
|
||||
nothing registered.
|
||||
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
|
||||
compatibility. Everything below the fold documents this path.
|
||||
|
||||
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
|
||||
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
|
||||
signs its container).
|
||||
|
||||
# MSIX packaging
|
||||
|
||||
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
|
||||
portable .zip, from the layout pack-msix.ps1 already assembled.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim — one assembly,
|
||||
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
|
||||
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
|
||||
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
|
||||
|
||||
Steps:
|
||||
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
|
||||
2. sign the four exes individually (the MSIX only signs its container),
|
||||
3. zip the stage -> the portable build,
|
||||
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
|
||||
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
|
||||
|
||||
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
|
||||
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
|
||||
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust — an untrusted
|
||||
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
|
||||
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
|
||||
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
|
||||
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
|
||||
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
|
||||
[switch]$NoSign # skip signing (local debug)
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
|
||||
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
|
||||
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
|
||||
}
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$iss = Join-Path $here 'punktfunk-client.iss'
|
||||
|
||||
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
|
||||
function Find-Iscc {
|
||||
foreach ($p in @(
|
||||
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
|
||||
'C:\Program Files\Inno Setup 6\ISCC.exe')) {
|
||||
if (Test-Path $p) { return $p }
|
||||
}
|
||||
$c = Get-Command iscc -ErrorAction SilentlyContinue
|
||||
if ($c) { return $c.Source }
|
||||
throw "ISCC.exe (Inno Setup 6, any 6.x) not found - install it (choco install innosetup -y)."
|
||||
}
|
||||
function Find-SdkTool([string]$name) {
|
||||
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
|
||||
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
|
||||
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
|
||||
Select-Object -Last 1
|
||||
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$iscc = Find-Iscc
|
||||
Write-Host "ISCC: $iscc"
|
||||
|
||||
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
|
||||
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
|
||||
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
|
||||
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
$stage = Join-Path $OutDir 'portable'
|
||||
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
foreach ($f in $required) {
|
||||
$src = Join-Path $LayoutDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $src (Join-Path $stage $f) -Force
|
||||
}
|
||||
$licSrc = Join-Path $LayoutDir 'licenses'
|
||||
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
|
||||
|
||||
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
|
||||
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
if ($NoSign -and $requireCert) {
|
||||
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
|
||||
}
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'none'
|
||||
$signtool = $null
|
||||
if (-not $NoSign) {
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "signtool: $signtool"
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to " +
|
||||
"an interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
}
|
||||
elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
}
|
||||
else {
|
||||
$signMode = 'selfsigned'
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
|
||||
-KeyUsage DigitalSignature -FriendlyName 'punktfunk client installer (self-signed)' `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
|
||||
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
|
||||
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
|
||||
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
|
||||
function Sign-File([string]$Path) {
|
||||
if ($NoSign) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
|
||||
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
|
||||
"untrusted within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- sign the inner exes, zip the stage (portable build), then build + sign the installer ------
|
||||
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
|
||||
Sign-File (Join-Path $stage $f)
|
||||
}
|
||||
|
||||
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
|
||||
Write-Host "==> portable zip: $zip"
|
||||
|
||||
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
|
||||
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
|
||||
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
|
||||
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
|
||||
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
|
||||
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
|
||||
$brandStage = Join-Path $OutDir 'branding'
|
||||
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
|
||||
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
|
||||
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
|
||||
|
||||
$defines = @(
|
||||
"/DMyAppVersion=$Version",
|
||||
"/DArch=$Arch",
|
||||
"/DLayoutDir=$stage",
|
||||
"/DBrandingDir=$brandStage",
|
||||
"/DOutputDir=$OutDir"
|
||||
)
|
||||
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
|
||||
& $iscc @defines $issLocal
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
|
||||
|
||||
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
|
||||
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
|
||||
Sign-File $setup
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> installer: $setup"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA."
|
||||
}
|
||||
elseif ($signMode -ne 'none') {
|
||||
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
|
||||
}
|
||||
if ($env:GITHUB_ENV) {
|
||||
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
|
||||
;
|
||||
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
|
||||
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
|
||||
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
|
||||
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
|
||||
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
|
||||
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
|
||||
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
|
||||
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
|
||||
;
|
||||
; Built by pack-client-installer.ps1, e.g.:
|
||||
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
|
||||
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
|
||||
;
|
||||
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
|
||||
; elevation and uninstall leaves nothing behind):
|
||||
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
|
||||
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
|
||||
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
|
||||
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
|
||||
; Microsoft.WindowsAppRuntime.2 PackageDependency
|
||||
; -> download + run the runtime installer when missing ([Code])
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Arch
|
||||
#define Arch "x64"
|
||||
#endif
|
||||
#ifndef LayoutDir
|
||||
#define LayoutDir "."
|
||||
#endif
|
||||
#ifndef BrandingDir
|
||||
#define BrandingDir "..\..\..\packaging\windows\branding"
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
|
||||
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
|
||||
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
|
||||
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
|
||||
AppName=Punktfunk
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher=unom
|
||||
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
||||
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
|
||||
; see the header (Steam overlay / Big Picture).
|
||||
DefaultDirName={userpf}\Punktfunk
|
||||
PrivilegesRequired=lowest
|
||||
DisableProgramGroupPage=yes
|
||||
UsePreviousAppDir=yes
|
||||
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
|
||||
MinVersion=10.0.17763
|
||||
#if Arch == "arm64"
|
||||
ArchitecturesAllowed=arm64
|
||||
ArchitecturesInstallIn64BitMode=arm64
|
||||
#else
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
#endif
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
|
||||
#if VER >= EncodeVer(6,6,0)
|
||||
WizardStyle=modern dynamic windows11
|
||||
#else
|
||||
WizardStyle=modern
|
||||
#endif
|
||||
SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||
UninstallDisplayName=Punktfunk {#MyAppVersion}
|
||||
UninstallDisplayIcon={app}\punktfunk-client.exe
|
||||
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
|
||||
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
|
||||
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
|
||||
ChangesEnvironment=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
|
||||
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
|
||||
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
|
||||
; skipped by "the MSIX build already signed them".
|
||||
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
|
||||
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
|
||||
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
|
||||
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
|
||||
Comment: "Controller-driven couch interface for TVs and HTPCs"
|
||||
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
|
||||
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
|
||||
; command line, so this lands in the same positional URL parse in main() that the packaged
|
||||
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
|
||||
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
|
||||
ValueData: "{app}\punktfunk-client.exe,0"
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
|
||||
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
|
||||
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
|
||||
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
|
||||
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
|
||||
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
|
||||
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
|
||||
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
|
||||
|
||||
[Code]
|
||||
const
|
||||
EnvKey = 'Environment'; { the HKCU per-user environment key }
|
||||
|
||||
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
|
||||
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
|
||||
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
|
||||
retargeted from the HKLM machine key to HKCU.) }
|
||||
function PathNeedsAdd(Param: String): Boolean;
|
||||
var
|
||||
OrigPath: String;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
begin
|
||||
Result := True; { no Path value at all - the append creates it }
|
||||
exit;
|
||||
end;
|
||||
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
|
||||
end;
|
||||
|
||||
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
|
||||
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
|
||||
procedure RemoveAppFromPath;
|
||||
var
|
||||
OrigPath, NewPath, Entry: String;
|
||||
Target: String;
|
||||
P: Integer;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
exit;
|
||||
Target := Uppercase(ExpandConstant('{app}'));
|
||||
NewPath := '';
|
||||
OrigPath := OrigPath + ';';
|
||||
repeat
|
||||
P := Pos(';', OrigPath);
|
||||
Entry := Trim(Copy(OrigPath, 1, P - 1));
|
||||
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
|
||||
if (Entry <> '') and (Uppercase(Entry) <> Target) then
|
||||
begin
|
||||
if NewPath <> '' then NewPath := NewPath + ';';
|
||||
NewPath := NewPath + Entry;
|
||||
end;
|
||||
until OrigPath = '';
|
||||
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
RemoveAppFromPath;
|
||||
end;
|
||||
|
||||
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
|
||||
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
|
||||
runtime installer and run it quietly — it registers Store-signed framework packages, which
|
||||
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
|
||||
carry, because the app itself reports the missing runtime on first launch too. }
|
||||
function AppRuntimeMissing(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
|
||||
download below is idempotent and the runtime installer no-ops when it is present. }
|
||||
if not Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
Result := ResultCode <> 0;
|
||||
end;
|
||||
|
||||
procedure EnsureAppRuntime;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
Installer: String;
|
||||
begin
|
||||
if not AppRuntimeMissing() then
|
||||
exit;
|
||||
Installer := 'windowsappruntimeinstall.exe';
|
||||
try
|
||||
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
|
||||
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
|
||||
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
|
||||
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
|
||||
except
|
||||
SuppressibleMsgBox(
|
||||
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
|
||||
'Punktfunk needs it to start. Install it from ' + #13#10 +
|
||||
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
|
||||
'and then launch Punktfunk normally.',
|
||||
mbInformation, MB_OK, IDOK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
|
||||
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
|
||||
if CurStep = ssInstall then
|
||||
Exec(ExpandConstant('{sys}\taskkill.exe'),
|
||||
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
|
||||
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
|
||||
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
|
||||
if CurStep = ssPostInstall then
|
||||
EnsureAppRuntime;
|
||||
end;
|
||||
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.iter()
|
||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||
// it once it sleeps (no-op / no disk write when unchanged).
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& !h.mac.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the tile's mark then survives the host going offline.
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — load-bearing, unlike the two above: a host moved off
|
||||
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
|
||||
if let Some(p) = hosts
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
|
||||
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
|
||||
// offline), and its management port — the last load-bearing rather than cosmetic, as
|
||||
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
|
||||
// the port down. No-op, and no disk write, when unchanged.
|
||||
if let Some(a) = hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& h.mgmt_port.is_some()
|
||||
})
|
||||
.and_then(|h| h.mgmt_port)
|
||||
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
{
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
|
||||
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
|
||||
INBOX.lock().unwrap().push(url);
|
||||
}
|
||||
|
||||
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
|
||||
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
|
||||
/// (`set_app_user_model_id` in main.rs).
|
||||
pub(crate) fn has_package_identity() -> bool {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
|
||||
// PROBE — it writes nothing and only reports whether this process is packaged.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
|
||||
///
|
||||
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
|
||||
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
|
||||
/// still works if it isn't, because it invokes the client directly — which is the whole point
|
||||
/// of a shortcut being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
|
||||
/// it valid across updates, since the install path changes and the alias doesn't.
|
||||
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
|
||||
/// internet shortcut. Both would work while the scheme is registered; only this one still works
|
||||
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
|
||||
/// being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
|
||||
/// installed: under MSIX the install path changes on every update but the app execution alias
|
||||
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
|
||||
/// but a stable install dir, so unpackaged runs target the absolute exe path.
|
||||
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
|
||||
use windows::core::{Interface, HSTRING};
|
||||
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
|
||||
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
|
||||
.map_err(|_| "USERPROFILE isn't set".to_string())?;
|
||||
let path = desktop.join(format!("{}.lnk", file_name(label)));
|
||||
// Alias when packaged, absolute path when not — see the doc comment above.
|
||||
let target = if has_package_identity() {
|
||||
"punktfunk-client.exe".to_string()
|
||||
} else {
|
||||
std::env::current_exe()
|
||||
.map_err(|e| format!("current exe: {e}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
|
||||
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
|
||||
// synchronous call; nothing here dereferences a pointer the caller supplied.
|
||||
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
|
||||
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| format!("shell link: {e}"))?;
|
||||
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
|
||||
link.SetPath(&HSTRING::from(target.as_str()))
|
||||
.ok()
|
||||
.map_err(|e| format!("shortcut target: {e}"))?;
|
||||
link.SetArguments(&HSTRING::from(url))
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
|
||||
@@ -173,18 +173,12 @@ fn main() {
|
||||
/// processes are left alone. Must run before any window exists.
|
||||
#[cfg(windows)]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
|
||||
// documented identity PROBE — it writes nothing and only reports whether this process is
|
||||
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
if deeplink::has_package_identity() {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
// No buffer: just probe whether the process has package identity.
|
||||
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
|
||||
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
|
||||
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
|
||||
/// data the live host advertised, purely because it came earlier in the file.
|
||||
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
|
||||
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
|
||||
/// hand a stale record at the same address the data the live host advertised, purely because it
|
||||
/// came earlier in the file.
|
||||
fn learn_target<'a>(
|
||||
known: &'a mut KnownHosts,
|
||||
fp_hex: &str,
|
||||
@@ -692,61 +692,62 @@ fn learn_target<'a>(
|
||||
known.hosts.get_mut(i)
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
if mac.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
return;
|
||||
}
|
||||
h.mac = mac.to_vec();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched
|
||||
/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
if os.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.os == os {
|
||||
return;
|
||||
}
|
||||
h.os = os.to_string();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
|
||||
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
|
||||
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
|
||||
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
|
||||
/// management port — and report whether anything actually moved, so the caller writes only when
|
||||
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
|
||||
///
|
||||
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
|
||||
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
|
||||
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
|
||||
if mgmt_port == 0 {
|
||||
return;
|
||||
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
|
||||
/// TXT, and forgetting a MAC already learned would cost the user their wake.
|
||||
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
|
||||
let mut changed = false;
|
||||
if !mac.is_empty() && h.mac != mac {
|
||||
h.mac = mac.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
if !os.is_empty() && h.os != os {
|
||||
h.os = os.to_string();
|
||||
changed = true;
|
||||
}
|
||||
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
|
||||
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
|
||||
h.mgmt_port = mgmt_port;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
|
||||
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
|
||||
/// the record already says all three, so a surface can call this on every discovery tick.
|
||||
///
|
||||
/// ONE call rather than three. Each field used to be learned by its own function, which meant
|
||||
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
|
||||
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
|
||||
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
|
||||
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
|
||||
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
|
||||
///
|
||||
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
|
||||
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
|
||||
/// a MAC is no reason to become one.
|
||||
///
|
||||
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
|
||||
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
|
||||
pub fn learn_from_advert(
|
||||
fp_hex: &str,
|
||||
addr: &str,
|
||||
port: u16,
|
||||
mac: &[String],
|
||||
os: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let mut known = KnownHosts::read();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
if apply_advert(h, mac, os, mgmt_port) {
|
||||
let _ = known.save();
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||
@@ -785,7 +786,7 @@ pub fn touch_last_used(fp_hex: &str) {
|
||||
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
|
||||
/// fingerprint alone — the identity a just-connected client is certain of.
|
||||
///
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] can only fire
|
||||
/// where an advert is visible, whereas this fires on any successful connect, including a host
|
||||
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
|
||||
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
|
||||
@@ -2293,6 +2294,33 @@ mod tests {
|
||||
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
|
||||
}
|
||||
|
||||
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
|
||||
/// the same advert reports no change — which is what lets every surface call this on every
|
||||
/// discovery tick without churning the store.
|
||||
#[test]
|
||||
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
|
||||
let mut h = KnownHost::default();
|
||||
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
|
||||
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// The same advert a tick later: nothing moved, so there is nothing to persist.
|
||||
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
|
||||
// would cost the user their wake, so an absent field must never overwrite a known one.
|
||||
assert!(!apply_advert(&mut h, &[], "", None));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
|
||||
assert!(!apply_advert(&mut h, &[], "", Some(0)));
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// A host that genuinely moved: the new value wins.
|
||||
assert!(apply_advert(&mut h, &[], "", Some(47992)));
|
||||
assert_eq!(h.mgmt_port, Some(47992));
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
|
||||
@@ -66,6 +66,16 @@ use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
|
||||
const POINTER_METADATA: u32 = 4;
|
||||
const POINTER_EMBEDDED: u32 = 2;
|
||||
|
||||
/// Marks the one KWin refusal a retry can clear: the disabled-output repair ran and changed the
|
||||
/// box between attempts ([`kwin_output_mgmt::enable_disabled_output`]).
|
||||
///
|
||||
/// It is load-bearing in TWO places and both are easy to break. The opener keys on it to skip the
|
||||
/// `KWin virtual output failed` wrapper below — and that wrapper's prefix is exactly what the
|
||||
/// host's `is_permanent_build_error` matches to short-circuit the retry loop, so a repaired
|
||||
/// refusal carrying it would be classified permanent and the retry that consumes the repair would
|
||||
/// never run. It is also the human-readable half of the message; keep it a phrase, not a code.
|
||||
const REPAIRED_HINT: &str = "enabled it over output management";
|
||||
|
||||
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
|
||||
const VOUT_NAME: &str = "punktfunk";
|
||||
|
||||
@@ -268,6 +278,10 @@ impl VirtualDisplay for KwinDisplay {
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
match setup_rx.recv_timeout(OPENER_BUDGET) {
|
||||
Ok(Ok(v)) => Ok((v, stop)),
|
||||
// Repaired: report it as-is. The wrapper below would prepend the phrase the host
|
||||
// reads as "permanent, do not retry", and this is the one refusal whose retry is
|
||||
// the entire point — the repair only fixes the NEXT request.
|
||||
Ok(Err(e)) if e.contains(REPAIRED_HINT) => bail!("{e}"),
|
||||
// KWin's reason is TRANSLATED into the session's language, so it is often
|
||||
// unsearchable for the person reading the log. Say what it means once, here.
|
||||
Ok(Err(e)) => bail!(
|
||||
@@ -1793,14 +1807,41 @@ fn run(
|
||||
);
|
||||
|
||||
// Pump events until KWin reports the node id (or an error, or the budget).
|
||||
let node_id = await_created(
|
||||
//
|
||||
// A refusal here is where the KWin >= 6.6 disabled-output trap lands, and it is repairable
|
||||
// FROM INSIDE THIS SCOPE and nowhere else: KWin destroys the output when our stream is
|
||||
// destroyed, so the connection has to stay up while we enable it (see
|
||||
// [`kwin_output_mgmt::enable_disabled_output`] for why the output is still alive at all, and
|
||||
// why enabling it fixes the NEXT request rather than this one).
|
||||
let node_id = match await_created(
|
||||
&conn,
|
||||
&mut queue,
|
||||
&mut state,
|
||||
stop,
|
||||
"stream_virtual_output",
|
||||
started,
|
||||
)?;
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
// `Virtual-<name>` is the address KWin exposes our output under (the same prefix the
|
||||
// topology path resolves against).
|
||||
match crate::kwin_output_mgmt::enable_disabled_output(&format!("Virtual-{name}")) {
|
||||
// Deliberately does NOT carry the "KWin virtual output failed" prefix: that string
|
||||
// is what marks a KWin refusal PERMANENT for the session's retry loop, and this is
|
||||
// the one refusal where something DID change between attempts. Retrying is the
|
||||
// whole point of repairing.
|
||||
Some(repaired) => bail!(
|
||||
"KWin created the virtual output disabled and refused to stream it ({e}); \
|
||||
{REPAIRED_HINT} (head {repaired}) — the retry picks up the configuration \
|
||||
KWin just persisted"
|
||||
),
|
||||
// Nothing to repair (no such head, already enabled, or the apply was refused):
|
||||
// the refusal stands, and its own prefix keeps it permanent so the session fails
|
||||
// fast instead of burning the retry budget on an unchanged box.
|
||||
None => return Err(e),
|
||||
}
|
||||
}
|
||||
};
|
||||
setup_tx
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
@@ -1274,6 +1274,76 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
|
||||
complete
|
||||
}
|
||||
|
||||
/// Enable a virtual output KWin created but left DISABLED, addressed by the `Virtual-<name>`
|
||||
/// prefix it exposes ours under. Returns the head's name when one matched, was disabled, and the
|
||||
/// enable applied.
|
||||
///
|
||||
/// This is the repair for the KWin ≥ 6.6 refusal (`"Could not find output"`, translated into the
|
||||
/// session's language). `streamVirtualOutput` there creates the output on the backend and then
|
||||
/// hands `workspace()->findOutput(output)` to the stream — and that returns null for an output the
|
||||
/// workspace does not manage, which `wantsToManage` defines as `isEnabled() && !isNonDesktop()`.
|
||||
/// KWin 6.4/6.5 passed the backend output straight through, so a disabled one streamed anyway;
|
||||
/// from 6.6 it is a hard refusal, and one that repeats forever: the host asks for a STABLE
|
||||
/// per-client name so KWin persists that client's scale and mode, and a stored setup naming it
|
||||
/// `enabled: false` is therefore reapplied to every future session.
|
||||
///
|
||||
/// Two properties of KWin make the repair possible, both verified against Plasma/6.7:
|
||||
///
|
||||
/// * `sendFailed` only sends the event — it does not emit `finished`, and `removeVirtualOutput` is
|
||||
/// wired to `finished`. So the disabled output stays alive for exactly as long as the caller
|
||||
/// holds its (failed) stream open, which is the window this runs in.
|
||||
/// * `WaylandServer::handleOutputAdded` offers EVERY backend output to the output-device registry,
|
||||
/// gating only placeholders and non-desktop ones. A disabled output has no `wl_output` — that
|
||||
/// side is gated on the workspace — but it is addressable over `kde_output_management_v2`.
|
||||
///
|
||||
/// Enabling it through output management is a user-applied configuration, so KWin persists it
|
||||
/// against that output's identity: the caller's next `stream_virtual_output` under the same name
|
||||
/// finds a stored setup that enables it. Which is why the caller must RETRY after this returns
|
||||
/// `Some` — the request that failed cannot be salvaged, only the one after it.
|
||||
pub(crate) fn enable_disabled_output(prefix: &str) -> Option<String> {
|
||||
let mut sess = Session::open("enable_disabled").ok()?;
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Newest-wins, exactly as the supersede resolve elsewhere in this file: a reconnect can leave
|
||||
// a predecessor of the same name briefly announced, and enabling THAT one repairs an output
|
||||
// that is already going away.
|
||||
let dev = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| d.name.as_deref().is_some_and(|n| n.starts_with(prefix)) && d.proxy.is_some())
|
||||
.max_by_key(|d| (d.global, d.seq))
|
||||
.cloned()?;
|
||||
let name = dev.name.clone()?;
|
||||
if dev.enabled {
|
||||
// Not the shape we repair. Say so rather than applying a no-op config that would `applied`
|
||||
// successfully and read as a fix — the caller decides whether to retry on this.
|
||||
tracing::debug!(
|
||||
%name,
|
||||
"KWin output management: our virtual output is already enabled — nothing to repair"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let proxy = dev.proxy.as_ref()?;
|
||||
let config = sess.new_config();
|
||||
config.enable(proxy, 1);
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if !ok {
|
||||
tracing::warn!(
|
||||
%name,
|
||||
reason = ?sess.state.failure_reason,
|
||||
"KWin output management: could not enable the virtual output KWin created disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
tracing::info!(
|
||||
%name,
|
||||
"KWin output management: KWin created our virtual output DISABLED and refused to stream \
|
||||
it; enabled it — KWin persists that, so the retry's request comes back enabled"
|
||||
);
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
|
||||
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
|
||||
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
|
||||
|
||||
@@ -5345,6 +5345,17 @@ mod tests {
|
||||
"spawn gamescope (is it installed? `apt install gamescope`)"
|
||||
));
|
||||
assert!(is_permanent_build_error("virtual displays require Linux"));
|
||||
// The ONE KWin refusal that must stay retryable: pf-vdisplay repaired the box (it enabled
|
||||
// the output KWin created disabled, which KWin persists), so the next attempt is not the
|
||||
// same attempt. That path deliberately reports WITHOUT the `KWin virtual output failed`
|
||||
// prefix above — if it ever regains it, the retry that consumes the repair stops running
|
||||
// and the repair is dead code.
|
||||
assert!(!is_permanent_build_error(
|
||||
"create virtual output: KWin created the virtual output disabled and refused to \
|
||||
stream it (stream_virtual_output failed: Não foi possível encontrar saída); enabled \
|
||||
it over output management (head Virtual-punktfunk-a1b2) — the retry picks up the \
|
||||
configuration KWin just persisted"
|
||||
));
|
||||
// Transient: negotiation/timeout races — exactly what backoff is for.
|
||||
assert!(!is_permanent_build_error(
|
||||
"first frame: no PipeWire frame within 10s (node 42): format negotiation never completed"
|
||||
|
||||
+3
-3
@@ -185,11 +185,11 @@
|
||||
"id": "windows-client",
|
||||
"name": "Windows client",
|
||||
"installs": "client",
|
||||
"packageManager": "msix",
|
||||
"packageManager": "installer",
|
||||
"docs": "/docs/install-client#windows",
|
||||
"install": [
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
|
||||
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
|
||||
".\\punktfunk-client-setup_x64.exe"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,8 @@ track per machine; switching is a one-line change.
|
||||
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
|
||||
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
|
||||
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
|
||||
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
|
||||
| **Windows client** (installer) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-setup_x64.exe` | `…/latest/…` + the release page |
|
||||
| **Windows client** (MSIX / portable zip) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` (or `…_x64-portable.zip`) | `…/latest/…` + the release page |
|
||||
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
|
||||
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
|
||||
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
|
||||
|
||||
@@ -4,346 +4,273 @@ description: Every setting a Punktfunk client stores — what it does, what it d
|
||||
---
|
||||
|
||||
The host has [its own settings reference](/docs/configuration). This page is the other half: the
|
||||
settings each **client** keeps, which together decide what a session looks like.
|
||||
|
||||
Most of them are a *request*. The client asks, the host answers in the handshake — so a setting the
|
||||
host can't honor is usually a quiet downgrade rather than an error.
|
||||
settings each **client** keeps. Most are a *request* — the client asks, the host answers in the
|
||||
handshake, and a setting the host can't honor is a quiet downgrade rather than an error.
|
||||
|
||||
## Where the settings live
|
||||
|
||||
The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same way — **General**,
|
||||
**Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings*
|
||||
elsewhere. The Apple TV app shows one scrolling list instead, as does any client's settings screen
|
||||
reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the client's
|
||||
**console home**, whose settings screen is one steppable list of sections — **Stream**, **Video**,
|
||||
**Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**, **Profiles**. On a
|
||||
Steam Deck that list *is* the settings surface: the [Decky plugin](/docs/steam-deck) is a launcher
|
||||
and keeps no settings of its own, and its **Open Punktfunk** button puts the console home one tap
|
||||
from the Quick Access Menu. The console home is part of the client — it is not the host's
|
||||
[web console](/docs/web-console).
|
||||
elsewhere. The Apple TV app and any settings screen reached with a controller show one steppable
|
||||
list instead — **Stream**, **Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**,
|
||||
**Interface**, **Profiles** — the client's **console home** (not the host's
|
||||
[web console](/docs/web-console)). On a Steam Deck that list *is* the settings surface: the
|
||||
[Decky plugin](/docs/steam-deck) is a launcher with no settings of its own, and its **Open
|
||||
Punktfunk** button opens the console home from the Quick Access Menu.
|
||||
|
||||
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the console home
|
||||
writes, so a change in either shows up in the other. Windows uses
|
||||
`%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their own stores.
|
||||
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json` (shared with the console home);
|
||||
Windows in `%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their
|
||||
own stores.
|
||||
|
||||
Changes apply to the **next** session — a running stream keeps what it started with. (*Match window*
|
||||
is the exception in effect, not in reading: it too is read at connect, but once a session is running
|
||||
with it on, every window resize renegotiates the mode.)
|
||||
|
||||
Not every client offers every setting; the names below are the Linux app's, and differences that
|
||||
matter are noted per setting.
|
||||
Changes apply to the **next** session. (*Match window* is also read at connect, but once on, every
|
||||
window resize renegotiates the mode.) Names below are the Linux app's; differences that matter are
|
||||
noted per setting.
|
||||
|
||||
## Video
|
||||
|
||||
**Resolution** — *default: Native display.* The host builds a virtual display at exactly this size
|
||||
and streams it; nothing is scaled. Native resolves at connect to the mode of the display your window
|
||||
is on. The Apple app instead stores an explicit size (1920 × 1080 out of the box): on iPhone, iPad
|
||||
and Mac a **Use this display's mode** button fills in what you're looking at, and the Apple TV app
|
||||
picks a combined **Stream mode** preset instead ("This TV (native)", 720p, 1080p or 4K at 60 Hz). If
|
||||
the host has been pinned to stream a *real* monitor rather than make one, your request is declined
|
||||
and your client scales what it gets — see
|
||||
[Virtual displays](/docs/virtual-displays#stream-a-real-monitor-instead).
|
||||
**Resolution** — *default: Native display.* The host builds a virtual display at exactly this size;
|
||||
nothing is scaled. Native resolves at connect to the mode of the display your window is on. The
|
||||
Apple app stores an explicit size (1920 × 1080 out of the box) with a **Use this display's mode**
|
||||
button on iPhone/iPad/Mac; Apple TV picks a combined **Stream mode** preset ("This TV (native)",
|
||||
720p, 1080p or 4K at 60 Hz). A host pinned to stream a *real* monitor declines the request and your
|
||||
client scales — see [Virtual displays](/docs/virtual-displays#stream-a-real-monitor-instead).
|
||||
|
||||
**Match window** — *default: off.* The stream mode follows your window instead, and each resize
|
||||
renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen
|
||||
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and
|
||||
console-home screens (in the console home it is an option inside the Resolution picker, and a
|
||||
Gaming-Mode stream is always fullscreen, so there it lands on native); not by Android.
|
||||
**Match window** — *default: off.* The stream mode follows your window; each resize renegotiates
|
||||
the host's display and encoder. Fullscreen degenerates to the display's native mode. Linux,
|
||||
Windows, Mac, iPhone/iPad and the console home (inside the Resolution picker; a Gaming-Mode stream
|
||||
is always fullscreen, so there it lands on native); not Android.
|
||||
|
||||
**Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app
|
||||
stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display,
|
||||
on a Mac you type one in, and on Apple TV the rate rides along with the Stream mode preset above.
|
||||
stores an explicit rate (60 Hz default): iPhone and iPad offer the device's displayable rates, on a
|
||||
Mac you type one in, and Apple TV's rate rides with the Stream mode preset.
|
||||
|
||||
**Bitrate** — *default: Automatic.* For H.264, HEVC and AV1, Automatic means the host's own default,
|
||||
**20 Mbps**, and it turns on two things an explicit rate switches off: adaptive bitrate, and a short
|
||||
link-capacity probe about two seconds in that measures what your link really carries and lets the
|
||||
rate climb past 20 Mbps. An explicit rate is fixed for the session, and clamped by the host to
|
||||
**500 kbps – 8 Gbps**. A host card's menu has a **Test network speed…** entry that measures your link
|
||||
and suggests a value.
|
||||
**Bitrate** — *default: Automatic.* For H.264, HEVC and AV1, Automatic means the host's default,
|
||||
**20 Mbps**, plus two things an explicit rate switches off: adaptive bitrate, and a link-capacity
|
||||
probe about two seconds in that lets the rate climb past 20 Mbps. An explicit rate is fixed for the
|
||||
session, clamped to **500 kbps – 8 Gbps**. A host card's menu has **Test network speed…** to suggest
|
||||
a value.
|
||||
|
||||
PyroWave is the exception: it has no useful low-rate regime, so its Automatic rate is a fixed
|
||||
per-pixel budget for the negotiated mode (hundreds of Mbps), and both adaptive bitrate and the
|
||||
capacity probe stay off for the whole session.
|
||||
PyroWave has no useful low-rate regime: its Automatic is a fixed per-pixel budget for the negotiated
|
||||
mode (hundreds of Mbps), with adaptive bitrate and the probe off for the whole session.
|
||||
|
||||
**Render scale** — *default: Native (1×).* The host renders and encodes at your chosen mode
|
||||
multiplied by this, and your device resamples the result to its window. Above 1× supersamples for
|
||||
sharpness, at more bandwidth *and* more decode work; below 1× is lighter on both the host and the
|
||||
link. The stops run 0.5× to 4×. The result is floored to an even size and capped per axis at
|
||||
**Render scale** — *default: Native (1×).* The host renders and encodes at your mode times this;
|
||||
your device resamples to its window. Above 1× supersamples at more bandwidth and decode work; below
|
||||
1× is lighter on both. Stops 0.5×–4×; the result is floored to an even size and capped per axis at
|
||||
4096 px for H.264, 8192 px otherwise. Offered everywhere.
|
||||
|
||||
**Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can
|
||||
also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264.
|
||||
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or an
|
||||
Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
|
||||
that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the
|
||||
device has a hardware AV1 decoder; Android never offers PyroWave.
|
||||
**Video codec** — *default: Automatic.* A soft preference: your choice when the host can produce
|
||||
it, else the best codec you both speak, in the order HEVC → AV1 → H.264. **PyroWave** is never
|
||||
auto-picked — pick it explicitly on Linux, Windows, the console home, or an Apple device whose
|
||||
decode probe passes; elsewhere asking for it lands on that same order. See
|
||||
[PyroWave](/docs/pyrowave). Android and Apple hide AV1 without a hardware AV1 decoder; Android never
|
||||
offers PyroWave.
|
||||
|
||||
**10-bit HDR** — *default: on.* Off means "never send me 10-bit", and the host then never upgrades.
|
||||
On, the stream goes 10-bit BT.2020 PQ only when the host has HDR content *and* the encoder can do
|
||||
10-bit. Android disables the toggle, and never advertises HDR, on a panel that can't present HDR10.
|
||||
Full detail: [HDR](/docs/hdr).
|
||||
**10-bit HDR** — *default: on.* Off means "never send me 10-bit". On, the stream goes 10-bit
|
||||
BT.2020 PQ only when the host has HDR content *and* the encoder can do 10-bit. Android disables the
|
||||
toggle, and never advertises HDR, on a panel that can't present HDR10. Full detail: [HDR](/docs/hdr).
|
||||
|
||||
**Full chroma (4:4:4)** — *default: off.* Crisp small text and thin lines, at more bandwidth. It
|
||||
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
|
||||
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
|
||||
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
|
||||
decode probe to pass). The console home offers the toggle; Android doesn't.
|
||||
**Full chroma (4:4:4)** — *default: off.* Crisp small text and thin lines, at more bandwidth. Needs
|
||||
HEVC or PyroWave, the host's 4:4:4 policy on, a capture path that delivers full chroma, and a GPU
|
||||
that can encode it; if any gate fails the host says 4:2:0 before your decoder is built. Apple
|
||||
(hardware decode probe required), Linux, Windows and the console home; not Android.
|
||||
|
||||
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
|
||||
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
|
||||
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens those
|
||||
hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console home; the
|
||||
Apple and Android apps have it too, stored under the same name, so a
|
||||
**Prioritize** — *default: Lowest latency.* **Lowest latency** shows every frame the moment the
|
||||
display can take it — a network hiccup becomes an occasional repeated or skipped frame.
|
||||
**Smoothness** holds a small buffer that evens hiccups out, at that buffer's worth of added delay.
|
||||
Linux, Windows, the console home, Apple and Android — stored under the same name everywhere, so a
|
||||
[profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before
|
||||
showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of
|
||||
delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of
|
||||
jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is
|
||||
offered, and only once you have picked **Smoothness**.
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* Frames held back before showing. Each
|
||||
absorbs roughly one screen refresh of jitter and costs one refresh of delay — on a 120 Hz screen,
|
||||
two frames ≈ 17 ms both ways. Appears wherever **Prioritize** is offered, once **Smoothness** is
|
||||
picked.
|
||||
|
||||
**V-Sync** — *default: on.* Tear-free presentation. Off asks the GPU to show each frame the instant
|
||||
it's ready instead of waiting for the screen's next refresh: the lowest delay a display can give
|
||||
you, at the cost of visible tearing on fast motion. It is **best-effort** — not every driver or
|
||||
compositor offers a tearing mode, and where none is available the stream stays tear-free. The
|
||||
Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off" from
|
||||
"off but unavailable". Linux and Windows apps and the console home.
|
||||
**V-Sync** — *default: on.* Tear-free presentation. Off shows each frame the instant it's ready:
|
||||
the lowest delay a display can give, with visible tearing on fast motion. Best-effort — where the
|
||||
driver or compositor has no tearing mode the stream stays tear-free, and the Detailed
|
||||
[stats overlay](/docs/stats) names the mode actually in use. Linux, Windows, console home.
|
||||
|
||||
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
|
||||
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
|
||||
frame being ready and the screen being willing to show it. Applies to **fullscreen** sessions (a
|
||||
windowed one is at the compositor's mercy) and is harmless on a fixed-refresh screen. It needs a
|
||||
graphics driver that offers the modern queue-free display mode; on an older driver it does nothing
|
||||
unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the
|
||||
older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay
|
||||
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps
|
||||
and the console home.
|
||||
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, the panel
|
||||
refreshes in step with the stream. Applies to **fullscreen** sessions; harmless on a fixed-refresh
|
||||
screen. Needs a driver with the modern queue-free display mode; on an older driver it does nothing
|
||||
unless `PUNKTFUNK_VRR_FIFO=1` is set (see [configuration](/docs/configuration)). The stats overlay
|
||||
reports `vrr yes` once it has *measured* the panel following. Linux, Windows, console home.
|
||||
|
||||
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
|
||||
output. Advisory: a host without that backend quietly auto-detects instead.
|
||||
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the
|
||||
virtual output. Advisory: a host without that backend auto-detects instead.
|
||||
|
||||
## Audio
|
||||
|
||||
**Audio channels** — *default: Stereo.* You can ask for **5.1** or **7.1**; anything else is read as
|
||||
stereo. The count the host will really send comes back in the handshake, and your client builds its
|
||||
decoder from *that*, never from the request. What surround means differs by host: a **Linux** host
|
||||
claims a sink advertising exactly that many channels, so applications produce real surround, while a
|
||||
**Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1
|
||||
from a stereo endpoint is an upmix, not new channels. Offered everywhere.
|
||||
**Audio channels** — *default: Stereo.* **5.1** or **7.1** on request; anything else reads as
|
||||
stereo. The count the host will really send comes back in the handshake and your decoder is built
|
||||
from *that*. A **Linux** host claims a sink with exactly that many channels (real surround); a
|
||||
**Windows** host loopback-captures the current output endpoint and lets Windows convert — 5.1 from
|
||||
a stereo endpoint is an upmix. Offered everywhere.
|
||||
|
||||
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the Apple
|
||||
app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the row is
|
||||
spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending anything —
|
||||
see [Muting your microphone](/docs/input#muting-your-microphone).
|
||||
app.* Sends this device's microphone to the host's virtual mic. Spelled *Stream microphone* on
|
||||
Linux and Windows; **Ctrl+Alt+Shift+V** mutes it mid-stream — see
|
||||
[Muting your microphone](/docs/input#muting-your-microphone).
|
||||
|
||||
**Echo cancellation** — *default: on.* Stops the host's audio, playing out of this device's speakers,
|
||||
from being picked up by the microphone and sent straight back. It hands the microphone to the
|
||||
system's own canceller rather than doing the work itself: on **Linux** that means capturing from an
|
||||
echo-cancelled PipeWire source when your desktop provides one, on **Windows** asking WASAPI for the
|
||||
Communications stream category so the endpoint's processing engages, and on **Apple** and
|
||||
**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its
|
||||
own processing, or if the canceller makes your voice sound thin. The row sits under the microphone
|
||||
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and
|
||||
console-home clients. What it can and can't fix is in [Why do I hear myself](/docs/echo).
|
||||
**Echo cancellation** — *default: on.* Stops the host's audio, playing from this device's speakers,
|
||||
from re-entering the microphone. It uses the system's own canceller: an echo-cancelled PipeWire
|
||||
source on **Linux**, the WASAPI Communications stream category on **Windows**, the platform
|
||||
voice-processing mode on **Apple** and **Android**. Turn it off if your microphone runs its own
|
||||
processing or the canceller thins your voice. Sits under the microphone toggle, greyed out while
|
||||
the mic is off. Linux, Windows, Apple, Android, console home. See
|
||||
[Why do I hear myself](/docs/echo).
|
||||
|
||||
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream
|
||||
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the
|
||||
**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV,
|
||||
Android and the console home have none, and the Windows app has none and ignores a stored speaker
|
||||
choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather than
|
||||
silently snapping back to the default; the Mac shows it as "Unavailable device". A Steam Deck in
|
||||
Gaming Mode therefore has no endpoint picker at all: the session uses whatever the Desktop-Mode app
|
||||
last stored, and the system default otherwise.
|
||||
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint plays the
|
||||
stream, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the **Mac** app
|
||||
(plus a microphone *channel* picker) have these; the Windows app ignores a stored speaker choice. A
|
||||
vanished device keeps a "(not detected)" entry on Linux, "Unavailable device" on the Mac. A Steam
|
||||
Deck in Gaming Mode has no endpoint picker: the session uses what the Desktop-Mode app last stored.
|
||||
|
||||
## Input
|
||||
|
||||
Touch modes, mouse modes and the in-stream chords have their own page: [Input](/docs/input). Five
|
||||
more settings are worth naming here.
|
||||
|
||||
**Forward controllers** — *default: on*, on every client. Off, the controllers connected to *this*
|
||||
device are not sent to the host at all. That is what you want when your controller already reaches
|
||||
the host by some other route —
|
||||
[USB passthrough](/docs/automation#recipe-full-controller-passthrough-virtualhere) such as
|
||||
VirtualHere, or a pad plugged into the host itself. Leaving forwarding on there hands the host two
|
||||
controllers for one pair of hands, and games read both: a stick drifts because the second pad is
|
||||
centred, or a menu takes every input twice.
|
||||
**Forward controllers** — *default: on*, everywhere. Off, controllers connected to *this* device
|
||||
are not sent to the host — what you want when the controller already reaches the host another way
|
||||
([USB passthrough](/docs/automation#recipe-full-controller-passthrough-virtualhere) such as
|
||||
VirtualHere, or a pad plugged into the host), where forwarding would hand games two controllers for
|
||||
one pair of hands.
|
||||
|
||||
On Linux and Windows, opening a controller is what *claims* it — the client's SDL takes the device
|
||||
node — and a passthrough tool cannot bind a claimed device; with this off the session never opens
|
||||
the controller at all, leaving it free for VirtualHere to hand over. The consequence: the
|
||||
[controller escape chord](/docs/input#leaving-with-a-controller) is read off forwarded pads, so it is
|
||||
unavailable on those two while this is off — leave a stream with the keyboard chord or the client's
|
||||
own UI. The Apple and Android apps claim nothing, so their chords keep working either way; the
|
||||
Android app does stop its DualSense and Steam Controller 2 USB captures, which *do* claim the device.
|
||||
|
||||
The rows below it — which pad, and what type — have nothing to act on while this is off, and every
|
||||
client greys them out to say so.
|
||||
On Linux and Windows, opening a controller *claims* it (SDL takes the device node), and a
|
||||
passthrough tool can't bind a claimed device; off, the session never opens the pad. Consequence:
|
||||
the [controller escape chord](/docs/input#leaving-with-a-controller) is read off forwarded pads, so
|
||||
on those two it is unavailable while this is off — leave with the keyboard chord or the client's
|
||||
UI. The Apple and Android apps claim nothing, so their chords keep working; Android does stop its
|
||||
DualSense and Steam Controller 2 USB captures, which do claim the device. The rows below grey out
|
||||
while this is off.
|
||||
|
||||
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
|
||||
which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and
|
||||
DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client declares
|
||||
a type per pad as it connects — Automatic declares what that controller really is, an explicit
|
||||
choice declares your choice — and the host builds each virtual pad from that. A type the host has no
|
||||
backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host, for
|
||||
instance, or any Sony pad on a Linux host that can't open `/dev/uhid`.
|
||||
which matches each physical controller. Pickers offer Xbox 360, Xbox One, DualSense and DualShock 4
|
||||
everywhere, plus Steam Deck on Linux, Android and the console home. The host builds each virtual
|
||||
pad from the declared type; a type the host has no backend for degrades to an Xbox 360 pad (Xbox
|
||||
One on a Windows host, any Sony pad on a Linux host that can't open `/dev/uhid`).
|
||||
|
||||
That degrade matters for **motion**. An Xbox-class virtual pad has no gyroscope in its HID
|
||||
contract, so a session that ends up on one throws every motion sample away — your controller's gyro
|
||||
does nothing. Automatic lands there for any controller punktfunk doesn't recognise as Sony or Valve (an 8BitDo
|
||||
with a gyro, say), and so does a Switch Pro streaming to a Windows host, which has no Nintendo
|
||||
backend to build. **If you want motion, pick a DualSense-class type** — DualSense, DualSense Edge,
|
||||
DualShock 4, Switch Pro or Steam Deck all carry a motion plane. The clients detect this case and say
|
||||
so on-screen for a few seconds when it happens; the setting applies from the next session, not the
|
||||
one you are in.
|
||||
|
||||
On a **Steam Deck as the client**, motion also needs Steam Input switched off for punktfunk — with
|
||||
it on, Steam hands the app its own virtual Xbox pad, which has no gyro to forward no matter which
|
||||
type you pick.
|
||||
That degrade matters for **motion**: an Xbox-class virtual pad has no gyroscope, so a session on
|
||||
one throws every motion sample away. Automatic lands there for any pad not recognised as Sony or
|
||||
Valve (an 8BitDo with a gyro, say), and for a Switch Pro streaming to a Windows host. **If you want
|
||||
motion, pick a DualSense-class type** — DualSense, DualSense Edge, DualShock 4, Switch Pro or Steam
|
||||
Deck all carry a motion plane. Clients say so on-screen when it happens; the setting applies from
|
||||
the next session. On a **Steam Deck as the client**, motion also needs Steam Input off for
|
||||
punktfunk — with it on, Steam hands the app its own virtual Xbox pad.
|
||||
|
||||
**Forwarded controller** (*Use controller* on Apple and the console home) — *default: Automatic*,
|
||||
which forwards *every* connected controller, each as its own player, on Linux, Windows, Apple and the
|
||||
console home. Pinning one restricts the session to that controller alone — single-player. The Android
|
||||
app has no such picker.
|
||||
which forwards *every* connected controller, each as its own player. Pinning one restricts the
|
||||
session to that controller alone. Linux, Windows, Apple, console home; not Android.
|
||||
|
||||
**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, on every
|
||||
client. Where the guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to
|
||||
host** forwards them raw, **This device** keeps them local. Automatic forwards everywhere except
|
||||
Gaming Mode, where SteamOS opens its own menus for those buttons no matter what — forwarding raw
|
||||
there opens *both* menus at once, the local one covering the stream. The full story, including how
|
||||
to reach the host's menus when the raw press stays local, is on the
|
||||
[Input page](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access).
|
||||
**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, everywhere.
|
||||
Where guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to host** forwards
|
||||
them raw, **This device** keeps them local. Automatic forwards everywhere except Gaming Mode, where
|
||||
SteamOS opens its own menus for those buttons regardless — forwarding raw there opens both menus at
|
||||
once. Full story:
|
||||
[the guide button](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access).
|
||||
|
||||
**Hold Select for guide** — *default: Automatic*, on every client. The gesture that presses the
|
||||
host's guide button from any controller: hold Select (Back/View) on its own for about a third of a
|
||||
second, and keep holding for the host's long-press (a Gaming-Mode host's Quick Access Menu, on a
|
||||
regular pad). Automatic arms it only where the raw guide press can't reach the host cleanly —
|
||||
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
|
||||
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
|
||||
**Hold Select for guide** — *default: Automatic*, everywhere. The gesture that presses the host's
|
||||
guide button from any controller: hold Select (Back/View) alone ~⅓ s; keep holding for the host's
|
||||
long-press. Automatic arms it only where the raw press can't reach the host cleanly — Gaming Mode,
|
||||
iPhone/iPad, Apple TV — because the gesture costs: a Select *tap* arrives a beat late, and a game
|
||||
expecting a *held* Select would trigger it. **On**/**Off** overrule.
|
||||
|
||||
**Controller haptics** — *default: on*, and **Controller speaker** — *default: on* on the Linux and
|
||||
Windows apps, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a
|
||||
DualSense's voice-coil haptics, and the little speaker in the middle of the pad. Both need a
|
||||
**wired** DualSense or DualSense Edge — over Bluetooth a controller exposes no audio device at all,
|
||||
and both settings quietly do nothing. Neither costs anything without a host that sends them: the
|
||||
plane is negotiated, and silence is never encoded or transmitted. Turn **Controller speaker** off if
|
||||
you would rather all game audio came out of your speakers or headset.
|
||||
**Controller haptics** — *default: on*, and **Controller speaker** — *default: on* on Linux and
|
||||
Windows, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a
|
||||
DualSense's voice-coil haptics and the pad's speaker. Both need a **wired** DualSense or DualSense
|
||||
Edge — Bluetooth exposes no audio device, and both settings quietly do nothing. The plane is
|
||||
negotiated, so neither costs anything without a host that sends them. Linux, Windows and Android.
|
||||
On Linux the client also switches the pad's sound card to Pro Audio while it needs the voice coils
|
||||
and puts it back afterwards — see
|
||||
[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too).
|
||||
|
||||
Offered by the Linux, Windows and Android apps. On Linux, the client also switches the controller's
|
||||
sound card to Pro Audio while it needs the voice coils, and puts it back afterwards — see
|
||||
[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too)
|
||||
for why that is necessary and how to turn it off.
|
||||
**Capture system shortcuts** — *default: on.* Linux, Windows (spelled *Capture system shortcuts
|
||||
(Alt+Tab, Win, …)*), macOS and the console home; on a Deck it matters only for an attached keyboard
|
||||
(gamescope holds nothing back). On, Alt+Tab and the Windows/Super key reach the host while input is
|
||||
captured; off, they act locally. Either way the chords return when you release capture with
|
||||
**Ctrl+Alt+Shift+Q**, the window loses focus, or the stream ends —
|
||||
[Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving it on means
|
||||
**Ctrl+Alt+Shift+Q is your way out**, since Alt+Tab no longer is.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the
|
||||
console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself: Gaming Mode is gamescope, which has nothing to
|
||||
hold back. On, Alt+Tab and the Windows key (Super on Linux)
|
||||
reach the host while the stream has input captured. Off, they act on this machine instead — what you
|
||||
want when the stream shares a screen with local work. Either way the chords come back the moment you
|
||||
release capture with **Ctrl+Alt+Shift+Q**, the window loses focus, or the stream ends, and
|
||||
[Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving this on does mean
|
||||
**Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is.
|
||||
On macOS the chords in question are the **⌘** ones — on, ⌘Q, ⌘W, ⌘H and the rest go to the host
|
||||
while input is captured (⌘Q arrives as Super+Q); off, they act on the Mac, which means ⌘Q quits
|
||||
Punktfunk mid-stream. **⌘⎋ always stays local** — it releases capture, as does ⌃⌥⇧Q, and ⌃⌘F keeps
|
||||
working on the window. ⌘Tab, ⌘Space and the Mission Control keys never reach the host either way —
|
||||
macOS claims them first.
|
||||
|
||||
On macOS the chords in question are the **⌘** ones — ⌘Q above all, which reaches the host as Super+Q,
|
||||
one of the most-bound chords on a Linux desktop. On, ⌘Q, ⌘W, ⌘H and the rest go to the host instead
|
||||
of this app's menu bar while input is captured. Off, they act on the Mac as usual, which means ⌘Q
|
||||
quits Punktfunk mid-stream. **⌘⎋ always stays local whichever way the toggle is set** — it is what
|
||||
releases capture, as is ⌃⌥⇧Q, and ⌃⌘F keeps working on the window. A few chords never reach the host
|
||||
either way, because macOS claims them before any app can see them: ⌘Tab, ⌘Space, and the Mission
|
||||
Control keys.
|
||||
|
||||
On Linux this needs a compositor that supports keyboard-shortcuts-inhibit — KDE Plasma, GNOME and
|
||||
the wlroots compositors all do, and X11 sessions grab the keyboard directly. Under
|
||||
[gamescope](/docs/gamescope) there is nothing to inhibit: it hands the session everything already.
|
||||
On Linux this needs a compositor with keyboard-shortcuts-inhibit — KDE Plasma, GNOME and wlroots
|
||||
compositors have it, X11 sessions grab the keyboard directly. Under [gamescope](/docs/gamescope)
|
||||
there is nothing to inhibit.
|
||||
|
||||
**Invert scroll direction** — *default: off*, i.e. the host scrolls the way this machine does.
|
||||
|
||||
## Behavior
|
||||
|
||||
**Auto-wake on connect** — *default: on.* Connecting to a saved host that looks offline sends
|
||||
Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already
|
||||
learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by
|
||||
broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this
|
||||
toggle, as does the console home — and on a Steam Deck it governs the
|
||||
[Decky plugin's](/docs/steam-deck) launches too, because the plugin starts every stream through the
|
||||
client. The console home also offers wake as an
|
||||
explicit action on an offline host, whatever the toggle says. See
|
||||
[Wake-on-LAN](/docs/wake-on-lan).
|
||||
Wake-on-LAN and waits — only for a host whose MAC this client has learned. Turn it off for hosts
|
||||
reached over a VPN, where the wake only adds delay. Linux, Windows, Apple, Android and the console
|
||||
home; on a Steam Deck it also governs the [Decky plugin's](/docs/steam-deck) launches. The console
|
||||
home additionally offers wake as an explicit action on an offline host, whatever the toggle says.
|
||||
See [Wake-on-LAN](/docs/wake-on-lan).
|
||||
|
||||
**Show game library** — *Apple and Android only, default: on.* Browse a paired host's games and
|
||||
launch one directly. The Linux and Windows apps have nothing to switch on — **Browse library…** sits
|
||||
on every paired host's card — and neither does the console home, whose **Library** button was always
|
||||
offered on any paired host. See [Game library](/docs/game-library).
|
||||
launch one directly. Linux and Windows have **Browse library…** on every paired host's card, and
|
||||
the console home a **Library** button — nothing to switch. See [Game library](/docs/game-library).
|
||||
|
||||
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
|
||||
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
|
||||
when you return to the host list. The console home carries the row for the desktop client that
|
||||
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV and
|
||||
Android have no equivalent.
|
||||
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window returns
|
||||
with the host list. The console home carries the row for the desktop client that shares the store —
|
||||
a Gaming-Mode launch is fullscreen regardless. Not on iPhone, iPad, Apple TV or Android.
|
||||
|
||||
## Interface
|
||||
|
||||
These change how the client itself looks and behaves. None touches a stream, so none can live in a
|
||||
[profile](/docs/profiles-and-links) — they are decisions about the device in front of you.
|
||||
How the client itself looks. None touches a stream, so none can live in a
|
||||
[profile](/docs/profiles-and-links).
|
||||
|
||||
**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the
|
||||
controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and
|
||||
settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to
|
||||
stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the
|
||||
controller-optimized home is a separate entry point rather than a switch, so there is nothing to
|
||||
turn off. An Android TV is always in this mode — its remote is the only input it has.
|
||||
**Gamepad-optimized browsing** — *default: on.* Swaps the touch/desktop home for the
|
||||
controller-optimized one: host carousel, larger focus targets, a swipeable cover browser, steppable
|
||||
settings. Apple and Android have the switch; on Linux, Windows and the Steam Deck the
|
||||
controller-optimized home is a separate entry point. An Android TV is always in this mode.
|
||||
|
||||
**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides
|
||||
*when* that switch takes effect. **With a controller**: the controller-optimized home appears as a
|
||||
pad connects and the touch interface returns when the last one disconnects. **Always** keeps the
|
||||
controller-optimized home either way — for a phone or tablet docked to a TV, where the pad isn't
|
||||
always awake. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
|
||||
there.)
|
||||
**Show it** — *default: With a controller.* Shown while the switch above is on. **With a
|
||||
controller**: the controller-optimized home appears as a pad connects, the touch interface returns
|
||||
when the last one disconnects. **Always** keeps it either way — for a phone or tablet docked to a
|
||||
TV. Apple and Android (an Android TV is in that mode regardless).
|
||||
|
||||
**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop
|
||||
drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**,
|
||||
**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**,
|
||||
**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop
|
||||
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point: it is
|
||||
true black — most of the frame is pixels switched off, which on an OLED or AMOLED panel means no
|
||||
glow and no power drawn, with only a faint violet ember left in one corner. Stored under the same
|
||||
name on every client, so a phone, a Deck and a desktop set to Mint all look alike.
|
||||
|
||||
The row lives in the controller-optimized settings themselves — the screen you reach with **X** from
|
||||
the controller-optimized home — on every platform that has one, which includes the Steam Deck and the
|
||||
Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its
|
||||
ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real
|
||||
controller to open and the palettes would otherwise be unreachable from the Siri Remote.
|
||||
**Background** — *default: Violet.* The colour family of the controller-optimized home's backdrop.
|
||||
Thirteen: seven dark — **Violet**, **OLED**, **Nebula**, **Abyss**, **Ember**, **Moss**,
|
||||
**Graphite** — and six pale — **Holo**, **Sunset**, **Bloom**, **Dawn**, **Mint**, **Opal** — which
|
||||
flip the interface to dark text on a light field. The backdrop recolours as you step the row.
|
||||
**OLED** is true black: most of the frame is pixels switched off — no glow, no power on an
|
||||
OLED/AMOLED panel. Stored under the same name on every client. The row lives in the
|
||||
controller-optimized settings (**X** from the controller-optimized home) everywhere that has one,
|
||||
including the Steam Deck and the Linux/Windows console home; the Apple TV carries it in ordinary
|
||||
Settings next to **Show it** instead, so it's reachable from the Siri Remote.
|
||||
|
||||
## Overlay
|
||||
|
||||
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
||||
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
|
||||
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
|
||||
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The
|
||||
console home has the tier picker too, as **Statistics overlay** under **Interface**. The shortcuts,
|
||||
and every number in the overlay, are in [Understanding the stats overlay](/docs/stats).
|
||||
superset of the last. This picks the tier a session *starts* at; cycle live in-stream with a
|
||||
per-platform shortcut. The Apple app also picks the corner (Top/Bottom × Left/Right). The console
|
||||
home has the tier picker under **Interface**. Shortcuts and every number:
|
||||
[Understanding the stats overlay](/docs/stats).
|
||||
|
||||
## Settings that are facts about your device
|
||||
|
||||
A few of these describe the machine you're sitting at rather than how you want a host streamed. They
|
||||
stay global and **cannot be put in a settings profile**:
|
||||
These describe the machine you're sitting at, stay global, and **cannot be put in a settings
|
||||
profile**:
|
||||
|
||||
- **Video decoder** and **GPU** — the decode path and adapter this device uses. Automatic is
|
||||
vendor-ordered and falls back on its own; change it only when debugging, and note that
|
||||
`PUNKTFUNK_DECODER` overrides it
|
||||
([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux,
|
||||
Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has
|
||||
more than one adapter — the console home has none, and a Deck has a single adapter anyway. The
|
||||
Apple and Android apps have neither.
|
||||
vendor-ordered and falls back on its own; change only when debugging; `PUNKTFUNK_DECODER`
|
||||
overrides it ([Configuration](/docs/configuration#client-side-native-clients)). Decoder picker:
|
||||
Linux, Windows, console home. GPU picker: Windows, and Linux with more than one adapter. Apple
|
||||
and Android have neither.
|
||||
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
|
||||
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
|
||||
preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a
|
||||
preference too, and does live in a profile — a work profile can decline to forward what a game
|
||||
profile forwards.
|
||||
- **Auto-wake on connect**, and **Show game library** where it still exists (the Apple and Android
|
||||
apps) — decisions about this device and this network, not about how a given host is streamed.
|
||||
- Everything under **Interface** — **Gamepad-optimized browsing**, **Show it** and **Background**.
|
||||
How this client looks has nothing to do with how a host streams to it.
|
||||
- **Forwarded controller** — which physical pad is in your hands. (The *type* the host creates is a
|
||||
preference and can live in a profile, as can **Forward controllers**.)
|
||||
- **Auto-wake on connect**, and **Show game library** where it exists (Apple, Android).
|
||||
- Everything under **Interface**.
|
||||
|
||||
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
|
||||
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —
|
||||
|
||||
@@ -113,7 +113,7 @@ per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and
|
||||
on Intel and other GPUs (Intel's driver advertises Vulkan Video, but DXVA is the proven path there).
|
||||
It has [10-bit/HDR present](/docs/hdr#per-client), WASAPI audio + mic, SDL3 controllers (rumble,
|
||||
lightbar, DualSense), network discovery, the host's **game library** with cover art, and the full
|
||||
PIN-pairing trust surface. It builds for `x86_64` and `aarch64` and ships as a **signed MSIX**.
|
||||
PIN-pairing trust surface. It builds for `x86_64` and `aarch64` and ships as a **signed installer** (plus a portable zip, and an MSIX for Microsoft Store compatibility).
|
||||
|
||||
The package installs **two** Start-menu entries — **Punktfunk**, the desktop window, and
|
||||
**Punktfunk Console**, a controller-driven fullscreen interface for a TV or HTPC (host list, pairing,
|
||||
@@ -148,7 +148,7 @@ It ships as a sideloadable `.ipk` (homebrew package) rather than through the LG
|
||||
## Scripting: the `punktfunk` CLI
|
||||
|
||||
`punktfunk` is the headless client — the same core the graphical apps use, with no window. It ships
|
||||
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows MSIX**, so
|
||||
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows installer**, so
|
||||
if you have a desktop client you already have it:
|
||||
|
||||
```sh
|
||||
@@ -213,7 +213,7 @@ has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
|
||||
| A Linux desktop or laptop | **[`punktfunk-client`](#linux-desktop-client-gtk4)** (GTK4) |
|
||||
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the [GTK4 client](#linux-desktop-client-gtk4) in Desktop Mode |
|
||||
| An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
|
||||
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed MSIX) or **[Moonlight](/docs/moonlight)** |
|
||||
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed installer) or **[Moonlight](/docs/moonlight)** |
|
||||
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **[Moonlight](/docs/moonlight)** |
|
||||
| A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
|
||||
| Scripts, plugins, home automation | The headless **[`punktfunk`](#scripting-the-punktfunk-cli)** CLI |
|
||||
|
||||
@@ -42,13 +42,11 @@ uplink keeps running underneath, so unmuting is instant.
|
||||
While muted, a **Microphone muted** badge sits in the top-right corner of the stream — separate
|
||||
from the [stats overlay](/docs/stats), so it shows even with stats off.
|
||||
|
||||
The mute lasts for that stream only — the next session starts unmuted, and nothing is written to
|
||||
your settings. If the stream isn't sending a microphone at all (**Stream microphone** off in
|
||||
[client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears.
|
||||
|
||||
**Linux and Windows** clients only — a Steam Deck stream is the Linux client, so an attached
|
||||
keyboard gets the chord. The Apple and Android clients have no mute shortcut yet; turn **Stream
|
||||
microphone** off in their settings instead.
|
||||
The mute lasts for that stream only — the next session starts unmuted; nothing is written to your
|
||||
settings. With **Stream microphone** off in [client settings](/docs/client-settings#audio) the
|
||||
shortcut does nothing and no badge appears. **Linux and Windows** clients only (a Steam Deck stream
|
||||
is the Linux client, so an attached keyboard gets the chord); on Apple and Android turn **Stream
|
||||
microphone** off in settings instead.
|
||||
|
||||
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
|
||||
for with the chord stays released until you opt back in. Either way, keys and buttons you were
|
||||
@@ -104,10 +102,9 @@ watching for the chord either way.
|
||||
### Statistics with a controller
|
||||
|
||||
The **Apple** apps reserve a second chord: **Select + X**, which cycles the
|
||||
[stats overlay](/docs/stats) one level each time you complete it. For when you have a pad but no
|
||||
keyboard for **⌃⌥⇧S** and no free screen for the three-finger tap; on **Apple TV** it is the only
|
||||
way there with a pad. X is deliberately none of the four leave-chord buttons, so reaching for one
|
||||
chord never trips the other. Both buttons still reach the game; only the overlay changes locally.
|
||||
[stats overlay](/docs/stats) one level each time you complete it — for a pad with no keyboard and
|
||||
no free screen for the three-finger tap; on **Apple TV** it is the only way there with a pad. Both
|
||||
buttons still reach the game; only the overlay changes locally.
|
||||
|
||||
On the **Siri Remote**, **hold Play/Pause** for about half a second instead. A quick tap is still a
|
||||
right click, sent when you let go.
|
||||
@@ -115,26 +112,23 @@ right click, sent when you let go.
|
||||
### The guide button (Xbox / PS / Steam) and Quick Access
|
||||
|
||||
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
|
||||
meant to open menus **on the host**: the Steam overlay, or a Gaming-Mode host's Steam menu. Some
|
||||
devices want that button for themselves, so every client also carries a gesture that works
|
||||
everywhere:
|
||||
|
||||
**Hold Select (Back / View) on its own for about a third of a second.** The host sees its guide
|
||||
button held down for as long as you hold — a long press, which is how SteamOS opens the **Quick
|
||||
Access Menu** for a regular pad. A quick tap of Select still reaches the game, delivered when you
|
||||
let go (a beat late). Select pressed as part of a combo — including the leave chord above — passes
|
||||
through untouched.
|
||||
meant to open menus **on the host**. Some devices want that button for themselves, so every client
|
||||
also carries a gesture that works everywhere: **hold Select (Back / View) on its own for about a
|
||||
third of a second**. The host sees its guide button held for as long as you hold — a long press,
|
||||
which is how SteamOS opens the **Quick Access Menu** for a regular pad. A quick tap of Select still
|
||||
reaches the game, delivered when you let go (a beat late); Select in a combo — including the leave
|
||||
chord above — passes through untouched.
|
||||
|
||||
What the raw button does, per client:
|
||||
|
||||
- **Linux & Windows desktop, macOS, Android** — the guide press is forwarded to the host. If Steam
|
||||
Big Picture or the Xbox Game Bar is also watching for it *on the device in your hands*, both may
|
||||
react — that's a local setting on that device, not something the stream can suppress.
|
||||
- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default:
|
||||
SteamOS always opens its own menus for them, so forwarding the raw press as well opened BOTH menus
|
||||
at once, the Deck's on top of the stream. Reach the host's menus with **hold-Select**, or with the
|
||||
Punktfunk panel's **Host menus** buttons ([Steam Deck page](/docs/steam-deck)). The old behavior
|
||||
is one setting away: **Steam / guide button → Send to host**.
|
||||
- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default
|
||||
(SteamOS always opens its own menus for them; forwarding the raw press too opens both menus at
|
||||
once). Reach the host's menus with **hold-Select**, or the Punktfunk panel's **Host menus**
|
||||
buttons ([Steam Deck page](/docs/steam-deck)); **Steam / guide button → Send to host** restores
|
||||
the old behavior.
|
||||
- **iPhone / iPad** — iOS reserves the Home press for its own Game Overlay, so hold-Select is the
|
||||
reliable route to the host's overlay. On iOS 27 or later you can also hand the button to the app
|
||||
yourself, in the system's per-controller Home-button setting.
|
||||
@@ -200,11 +194,9 @@ Touch passthrough depends on the host being able to inject touch, and that varie
|
||||
| Sway, Hyprland and other wlroots compositors | Not injected — contacts are dropped |
|
||||
| gamescope Gaming Mode | Degraded to a single absolute pointer — see [gamescope](/docs/gamescope) |
|
||||
|
||||
The gamescope row is a rule, not an exception: wherever the compositor offers no touchscreen device
|
||||
to drive, only the first finger is used, as an absolute pointer. Tapping still clicks; pinches and
|
||||
other multi-finger gestures do not survive.
|
||||
|
||||
The trackpad and pointer models are unaffected by all of this: they send ordinary mouse events.
|
||||
Wherever the compositor offers no touchscreen device to drive, only the first finger is used, as
|
||||
an absolute pointer — tapping still clicks; pinches and multi-finger gestures don't survive. The
|
||||
trackpad and pointer models are unaffected: they send ordinary mouse events.
|
||||
|
||||
## Pen and stylus
|
||||
|
||||
@@ -237,10 +229,9 @@ The Linux, Windows, macOS and Apple TV clients do not send stylus input.
|
||||
the virtual gamepads need, step 3 of your [install guide](/docs/install). Without it the host never
|
||||
offers pen at all.
|
||||
|
||||
**If the host is too old, or pen is switched off**, nothing breaks: the client keeps folding the
|
||||
stylus into its ordinary touch or pointer path. You can still draw — just without pressure and tilt.
|
||||
Whether pen splits out is decided by the host, not by your touch mode: you can be in Trackpad mode
|
||||
and still draw with full fidelity.
|
||||
**If the host is too old, or pen is switched off**, the client folds the stylus into its ordinary
|
||||
touch or pointer path — you can still draw, without pressure and tilt. Whether pen splits out is
|
||||
decided by the host, not your touch mode.
|
||||
|
||||
**Operators** can turn the whole feature off by setting `PUNKTFUNK_PEN=0` in the host's `host.env`
|
||||
(see [Configuration](/docs/configuration)). The host then stops advertising pen to Punktfunk and
|
||||
|
||||
@@ -22,7 +22,7 @@ Already installed? Skip to [Keeping a client up to date](#keeping-a-client-up-to
|
||||
|--------|---------|
|
||||
| **Linux** desktop / laptop | [Flatpak](#linux-desktop-flatpak) (any distro) or native apt/rpm/Arch packages |
|
||||
| **Steam Deck** | [Decky plugin](/docs/steam-deck) for Gaming Mode, or [Flatpak in Desktop Mode](#steam-deck) |
|
||||
| **Windows** | [Signed MSIX](#windows) from the package registry |
|
||||
| **Windows** | [Signed installer](#windows) from the package registry (portable zip and MSIX too) |
|
||||
| **macOS** | [Notarized `.dmg`](#macos) from the releases page |
|
||||
| **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) |
|
||||
| **Android / Android TV** | [Google Play](#android), or sideload the APK |
|
||||
@@ -100,38 +100,51 @@ See [packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packa
|
||||
|
||||
## Windows
|
||||
|
||||
The Windows client ships as a **signed MSIX** in the package registry, signed with a publicly
|
||||
trusted certificate — nothing to import or trust by hand.
|
||||
The Windows client ships as a **signed installer** in the package registry, signed with a publicly
|
||||
trusted certificate — nothing to import or trust by hand. It installs per-user (no admin prompt) to
|
||||
`%LOCALAPPDATA%\Programs\Punktfunk`.
|
||||
|
||||
1. Download the package. Each channel keeps one fixed URL, so this line always fetches the current
|
||||
build — in PowerShell:
|
||||
1. Download the installer. Each channel keeps one fixed URL, so this line always fetches the
|
||||
current build — in PowerShell:
|
||||
|
||||
```powershell
|
||||
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix
|
||||
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe
|
||||
```
|
||||
|
||||
Swap `_x64` for `_arm64` on an Arm device, and `latest` for `canary` to track `main`. The same
|
||||
file is attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
|
||||
build is kept under its own version on the
|
||||
[packages page](https://git.unom.io/unom/-/packages) (generic group, `punktfunk-client-windows`).
|
||||
2. Install it:
|
||||
|
||||
```powershell
|
||||
# use the _arm64 file instead on an Arm device
|
||||
Add-AppxPackage .\punktfunk-client-windows_x64.msix
|
||||
```
|
||||
|
||||
If Windows reports a missing dependency, install the
|
||||
2. Run it. The installer registers `punktfunk://` links, puts the headless `punktfunk` command on
|
||||
your PATH, and fetches the
|
||||
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
|
||||
(the MSIX depends on `Microsoft.WindowsAppRuntime.2`), then re-run `Add-AppxPackage`.
|
||||
automatically if this PC doesn't have it yet.
|
||||
3. Launch **Punktfunk** from the Start menu and pick your host. A second entry, **Punktfunk
|
||||
Console**, is the same client as a controller-driven fullscreen interface for a TV or HTPC.
|
||||
|
||||
Install from a signed-in desktop session. Over a remote, non-interactive session (SSH, an RMM
|
||||
tool) `Add-AppxPackage` can fail with `0x80070005` when the Windows App Runtime is in use and
|
||||
Windows can't restart the apps holding it.
|
||||
### Launching through Steam (overlay, Big Picture)
|
||||
|
||||
3. Launch **Punktfunk** from the Start menu and pick your host. The package also adds a second
|
||||
entry, **Punktfunk Console** — the same client as a controller-driven fullscreen interface for a
|
||||
TV or HTPC — and the headless `punktfunk` command on your PATH.
|
||||
Because the client is a normal exe at a stable path, you can hand it to Steam: **Add a Non-Steam
|
||||
Game** → browse to `%LOCALAPPDATA%\Programs\Punktfunk\punktfunk-client.exe` (or
|
||||
`punktfunk-console.exe` for the couch interface). Launched that way, the **Steam overlay** and
|
||||
controller configs work in the stream, and it's launchable from **Big Picture**. This is exactly
|
||||
what the older MSIX package couldn't do — Steam can neither browse nor inject into an app under
|
||||
`WindowsApps` — so if you set that up before, reinstall with the installer above and re-add it.
|
||||
|
||||
### Portable zip and MSIX
|
||||
|
||||
Two alternates, same signed binaries, published next to the installer on every build:
|
||||
|
||||
- **Portable** — `…/latest/punktfunk-client-windows_x64-portable.zip`: unzip anywhere and run
|
||||
`punktfunk-client.exe`. Nothing is registered, so `punktfunk://` links and the `punktfunk`
|
||||
command on PATH stay with the installer. Needs the
|
||||
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
|
||||
installed once.
|
||||
- **MSIX** — `…/latest/punktfunk-client-windows_x64.msix`, kept for Microsoft Store
|
||||
compatibility: `Add-AppxPackage .\punktfunk-client-windows_x64.msix`. If Windows reports a
|
||||
missing dependency, install the Windows App Runtime 2.x above and re-run it. Install from a
|
||||
signed-in desktop session — over SSH/RMM, `Add-AppxPackage` can fail with `0x80070005`. Note the
|
||||
Steam integration above does **not** work from the MSIX.
|
||||
|
||||
> The Windows client's hardware decode and HDR10 present are validated on glass on NVIDIA and Intel
|
||||
> (including HDR pass-through on the Intel D3D11VA path). If anything misbehaves,
|
||||
@@ -218,7 +231,8 @@ but keeping them close is the least surprising. (Updating the **host** is its ow
|
||||
| **Linux Flatpak** | `flatpak update --user io.unom.Punktfunk` — **without `sudo`** (see the [Flatpak section](#linux-desktop-flatpak)) |
|
||||
| **Linux apt / dnf / pacman** | your normal `sudo apt upgrade` / `sudo dnf upgrade` / `sudo pacman -Syu`, or the app's own updater below |
|
||||
| **Fedora Atomic (layered)** | `rpm-ostree upgrade` on its own is not enough — see the note below the table |
|
||||
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. Coming from **0.28.1 or earlier**, see the note below the table |
|
||||
| **Windows installer** | download the newer `punktfunk-client-setup_<arch>.exe` as in [Windows](#windows) and run it — it upgrades in place, keeping your saved hosts and pairing. Switching **from the MSIX**, see the note below the table |
|
||||
| **Windows MSIX / portable** | no self-update — download the newer `.msix` and re-run `Add-AppxPackage` (from **0.28.1 or earlier**, see the note below the table), or unzip the newer portable build over the old one |
|
||||
| **macOS `.dmg`** | download the newer `Punktfunk-<version>.dmg` and drag it over the copy in Applications |
|
||||
| **iOS / iPadOS / tvOS** | TestFlight updates it |
|
||||
| **Android** | Google Play updates it; if you sideloaded, download the APK again and install over it |
|
||||
@@ -253,6 +267,11 @@ This is one-time; releases after that upgrade in place. A packaged app's setting
|
||||
package, so removing the old one also removes this client's identity and its paired hosts — expect
|
||||
to [pair](/docs/pairing) again once. Nothing on the host side is affected.
|
||||
|
||||
**Switching from the MSIX to the installer** (for the [Steam integration](#launching-through-steam-overlay-big-picture),
|
||||
or just to follow the new default): remove the MSIX first — `Get-AppxPackage unom.Punktfunk |
|
||||
Remove-AppxPackage` — then run the installer. Same caveat as above: the packaged app's saved hosts
|
||||
and pairing identity go with the package, so expect to pair again once.
|
||||
|
||||
### The Linux client can update itself
|
||||
|
||||
The native Linux client checks its own channel and can apply the update in place, whichever package
|
||||
|
||||
@@ -30,13 +30,13 @@ console, and tells you how to pair:
|
||||
|
||||
<Installer />
|
||||
|
||||
Prefer to read what runs first (it's plain `sh`, ~350 lines):
|
||||
Prefer to read what runs first (it's plain `sh`, ~450 lines):
|
||||
|
||||
<Installer inspect />
|
||||
|
||||
It asks before anything optional (Moonlight compat, the shared clipboard, the `punktfunk` group,
|
||||
starting at boot) and every answer has a default, so `sh install.sh --yes` — or piping it with no
|
||||
terminal — runs unattended; `--channel canary`, `--mgmt-port`, `--no-start` and the environment
|
||||
terminal — runs unattended; `--channel canary`, `--mgmt-port`, `--no-start`, `--uninstall` and the environment
|
||||
twins (`PUNKTFUNK_INSTALL_YES`, `PUNKTFUNK_INSTALL_CHANNEL`, `PUNKTFUNK_INSTALL_GAMESTREAM`,
|
||||
`PUNKTFUNK_INSTALL_CLIPBOARD`, `PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP`, `PUNKTFUNK_INSTALL_LINGER`,
|
||||
`PUNKTFUNK_INSTALL_MGMT_PORT`) are listed by `--help`. It covers Ubuntu/Debian, Fedora, Arch-family
|
||||
|
||||
@@ -36,15 +36,15 @@ stream on a host this device already trusts.
|
||||
|
||||
## Choosing access when you admit a device
|
||||
|
||||
Approving and deciding what the device may do are one dialog. Alongside the name:
|
||||
Approving and deciding what the device may do are one dialog:
|
||||
|
||||
- **Access level** — **Full control**, **Controller only**, or **View only** (an **Advanced**
|
||||
expander has the individual toggles — [Access levels](/docs/access-levels)).
|
||||
- **Expires** — **Forever**, or 1 h / 4 h / 8 h / custom.
|
||||

|
||||
|
||||
Defaults are *Full control · Forever* — right for your own new laptop. For a friend's device there
|
||||
is a one-click **Approve as guest**: Controller only, for 4 hours, then it expires on its own. The
|
||||
same two controls sit on the **Pair a device** card, and apply to whichever device completes the PIN.
|
||||
The levels are **Full control**, **Controller only** and **View only** (**Advanced** opens the
|
||||
individual toggles — [Access levels](/docs/access-levels)); expiry is **Never** or 1 h / 4 h / 8 h /
|
||||
custom. The defaults are right for your own new laptop; **Approve as guest** is for a friend's
|
||||
device — Controller only, for 4 hours, then it expires on its own. The same two controls sit on the
|
||||
**Pair a device** card, and apply to whichever device completes the PIN.
|
||||
|
||||
## Managing paired devices
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ description: First-party plugins — bring each of your game launchers into the
|
||||
|
||||
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
|
||||
plugin runs alongside the host, reconciles titles into your [game library](/docs/game-library) as a
|
||||
provider — so they appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
|
||||
provider — so they appear in the grid on every client — and can add its own page to the
|
||||
[web console](/docs/web-console).
|
||||
|
||||
**Your game library is built from plugins.** From v0.28.0 the host scans no launcher on its own;
|
||||
install the one for each launcher you use.
|
||||
@@ -29,8 +30,7 @@ off succeeds and starts nothing, so check it first:
|
||||
1. Open the [web console](/docs/web-console) → **Plugins** → **Installed** and look at the **Plugin
|
||||
runner** card. *Not installed*: install the runner package first (see
|
||||
[Troubleshooting](#troubleshooting)). *Disabled*: press **Enable runner** — once per host.
|
||||
*Running* means you're set; *Stopped* means the runner is enabled but not up right now, and its
|
||||
log says why (see [Troubleshooting](#troubleshooting)).
|
||||
*Stopped*: the runner is enabled but not up, and its log says why.
|
||||
2. Go to **Browse**, pick a plugin from the catalog and confirm. The host installs it and restarts
|
||||
the runner, and the plugin's own page appears in the console's nav.
|
||||
|
||||
@@ -39,14 +39,13 @@ off succeeds and starts nothing, so check it first:
|
||||
### What "Verified" means
|
||||
|
||||
Every catalogued plugin pins **one exact version** and its package hash. **Verified** means somebody
|
||||
at unom reviewed *that exact package* — not the project in general, not whatever it publishes next;
|
||||
when a plugin releases a new version, the store keeps offering the reviewed one until the new release
|
||||
is reviewed too. Before anything is downloaded, the host re-checks the pinned hash against the
|
||||
registry, so a package quietly republished under the same version number is refused.
|
||||
at unom reviewed *that exact package* — not the project in general; a new release keeps offering the
|
||||
reviewed version until the new one is reviewed too. Before anything is downloaded, the host
|
||||
re-checks the pinned hash against the registry, so a package quietly republished under the same
|
||||
version number is refused.
|
||||
|
||||
A catalog can also **revoke** a version. When an advisory covers an entry, the console shows the
|
||||
reason and won't install that version — on **Browse** as a red-ringed panel with the install button
|
||||
disabled, on **Installed** as a warning against the plugin you already have. It never removes
|
||||
A catalog can also **revoke** a version: the console shows the advisory and won't install it — a
|
||||
red-ringed panel on **Browse**, a warning on **Installed** if you already have it. It never removes
|
||||
running code for you.
|
||||
|
||||
The three badges:
|
||||
@@ -54,18 +53,15 @@ The three badges:
|
||||
| Badge | Where it came from |
|
||||
|---|---|
|
||||
| **Verified** | The built-in catalog. unom reviewed this exact package. |
|
||||
| **External source**, *from <source>* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. This badge is amber, not the red **Unverified** below. |
|
||||
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the plugin stays marked this way for as long as it is installed. |
|
||||
| **External source**, *from <source>* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. Amber, not the red **Unverified** below. |
|
||||
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the mark stays for as long as it is installed. |
|
||||
|
||||
### Adding another catalog
|
||||
|
||||
**Sources** → *Add a catalog source*: a name and the URL of its index. Optionally paste the source's
|
||||
`ed25519:…` public key — with a key set, the host refuses any index from that source that isn't
|
||||
correctly signed, rather than falling back to an unsigned one.
|
||||
|
||||
Adding a source is a trust decision you make once: its plugins become installable on this host,
|
||||
always attributed to it and never carrying the Verified badge, which belongs to the built-in
|
||||
catalog alone.
|
||||
correctly signed. Adding a source is a one-time trust decision: its plugins become installable,
|
||||
always attributed to it, never carrying **Verified**.
|
||||
|
||||
To publish a plugin to the built-in catalog, open a pull request against
|
||||
[`punktfunk-plugin-index`](https://git.unom.io/unom/punktfunk-plugin-index) — its README covers the
|
||||
@@ -73,9 +69,6 @@ format and what review looks for.
|
||||
|
||||
## Installing from the CLI
|
||||
|
||||
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
|
||||
the plugins directory, pointing it at the package registry, and starting the supervisor.
|
||||
|
||||
<Tabs items={['Linux', 'Windows']}>
|
||||
<Tab value="Linux">
|
||||
|
||||
@@ -85,43 +78,43 @@ punktfunk-host plugins enable # turn the runner on (once)
|
||||
```
|
||||
|
||||
On **SteamOS** the [host installer](/docs/steamos-host) ships the runner automatically (user-scoped
|
||||
under `~/.local` — the read-only `/usr` can't take the package). If the console reports the runner
|
||||
isn't installed on an older setup, re-run `scripts/steamdeck/update.sh` once.
|
||||
under `~/.local`). If the console reports the runner isn't installed on an older setup, re-run
|
||||
`scripts/steamdeck/update.sh` once.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Windows">
|
||||
|
||||
Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**.
|
||||
The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned. The runner task
|
||||
itself runs as the low-privilege `NT AUTHORITY\LocalService` account — `plugins enable` sets that
|
||||
up (including read access to the runner's scoped API token).
|
||||
Run these from an **elevated** PowerShell — the plugins directory lives under
|
||||
`%ProgramData%\punktfunk`, which is admin-owned. The runner task itself runs as the low-privilege
|
||||
`NT AUTHORITY\LocalService` account; `plugins enable` sets that up (including read access to the
|
||||
runner's scoped API token).
|
||||
|
||||
```powershell
|
||||
punktfunk-host plugins add playnite # or: rom-manager
|
||||
punktfunk-host plugins enable # turn the runner on (once)
|
||||
```
|
||||
|
||||
If `punktfunk-host` isn't found, open a **new** terminal (the installer adds it to `PATH`), or use
|
||||
the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add playnite`.
|
||||
If `punktfunk-host` isn't found, open a **new** terminal, or use the full path:
|
||||
`& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add playnite`.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
The plugin's page then appears in the [web console](/docs/web-console)'s nav automatically.
|
||||
|
||||
The runner is **on by default** on a new install — your game sources are plugins, so a host without
|
||||
it would show an empty library. (A host that predates this stays however you left it; turn it on
|
||||
with `punktfunk-host plugins enable`, once.) The runner discovers plugins when it starts, so one
|
||||
installed later needs a restart (`systemctl --user restart punktfunk-scripting`, or `Restart` the
|
||||
`PunktfunkScripting` task) — the console does that restart for you as part of installing.
|
||||
The runner is **on by default** on a new install (a host that predates this stays however you left
|
||||
it — `punktfunk-host plugins enable`, once). The runner discovers plugins when it starts, so one
|
||||
installed later needs a runner restart — `systemctl --user restart punktfunk-scripting` on Linux,
|
||||
`Restart` the `PunktfunkScripting` task on Windows; the console does that restart for you as part
|
||||
of installing.
|
||||
|
||||
Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting`
|
||||
on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming;
|
||||
you just lose plugin-provided game sources and any automation.
|
||||
Don't want it? `systemctl --user mask punktfunk-scripting` on Linux, or disable the
|
||||
`PunktfunkScripting` scheduled task on Windows. The host keeps streaming; you lose plugin-provided
|
||||
game sources and automation.
|
||||
|
||||
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
|
||||
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
|
||||
and it carries its catalog badge instead.
|
||||
A plugin installed from the CLI shows in the console as **Installed via CLI** — the console knows
|
||||
what is installed, not who vouched for it. Install the same plugin from **Browse** and it carries
|
||||
its catalog badge instead.
|
||||
|
||||
### The rest of the commands
|
||||
|
||||
@@ -144,33 +137,31 @@ always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-
|
||||
|
||||
## Updating and removing a plugin
|
||||
|
||||
**Update.** The console's **Installed** tab grows an **Update to <version>** button whenever the
|
||||
catalog pins a newer version than the one you have; it installs through the same confirmation as a
|
||||
fresh install, and the console restarts the runner for you. There is no `plugins update` command —
|
||||
from a terminal, re-run `punktfunk-host plugins add <name>`. That installs the newest version the
|
||||
package registry has rather than the version the catalog pins, and does **not** restart the runner:
|
||||
restart it yourself (`systemctl --user restart punktfunk-scripting`, or `Restart` the
|
||||
`PunktfunkScripting` task) so the new code is picked up.
|
||||
**Update.** The **Installed** tab grows an **Update to <version>** button whenever the catalog
|
||||
pins a newer version; it installs through the same confirmation and restarts the runner for you.
|
||||
There is no `plugins update` command — from a terminal, re-run `punktfunk-host plugins add <name>`,
|
||||
which installs the newest version the *registry* has (not the catalog pin) and does **not** restart
|
||||
the runner: restart it yourself so the new code is picked up.
|
||||
|
||||
**Remove.** The **Uninstall** (bin) button on the **Installed** tab removes the package *and*
|
||||
restarts the runner, so the plugin stops straight away. `punktfunk-host plugins remove <name>`
|
||||
removes the package only — restart the runner yourself, as above, to stop a plugin still running.
|
||||
**Remove.** The **Uninstall** (bin) button removes the package *and* restarts the runner.
|
||||
`punktfunk-host plugins remove <name>` removes the package only — restart the runner yourself to
|
||||
stop a plugin still running.
|
||||
|
||||
Uninstalling removes the package only; a plugin's own config and cache stay where it wrote them —
|
||||
Uninstalling removes the package only; a plugin's own config and cache stay —
|
||||
`~/.config/punktfunk/plugin-state/<plugin>/` on Linux,
|
||||
`%ProgramData%\punktfunk\plugin-state\<plugin>\` on Windows — so re-installing later picks your
|
||||
settings back up. Delete that directory yourself if you want it gone.
|
||||
settings back up.
|
||||
|
||||
To stop *every* plugin without uninstalling anything, turn the runner off: **Disable runner** on the
|
||||
Installed tab, or `punktfunk-host plugins disable`.
|
||||
To stop *every* plugin without uninstalling anything: **Disable runner** on the Installed tab, or
|
||||
`punktfunk-host plugins disable`.
|
||||
|
||||
## ROM Manager
|
||||
|
||||
`@punktfunk/plugin-rom-manager` — point it at your ROM directories and it scans them, matches each
|
||||
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro thumbnails),
|
||||
and reconciles the result into your library as the `rom-manager` provider. ~25 built-in platforms
|
||||
(NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a console page to
|
||||
configure it all.
|
||||
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro
|
||||
thumbnails), and reconciles the result into your library as the `rom-manager` provider. ~25
|
||||
built-in platforms (NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a
|
||||
console page to configure it all.
|
||||
|
||||
```sh
|
||||
punktfunk-host plugins add rom-manager
|
||||
@@ -212,21 +203,20 @@ Then add a root or two — from the console's **ROM Manager** page, or in the co
|
||||
</Tabs>
|
||||
|
||||
`plugin-state` is where a plugin reads and writes its own files — on Windows it is the one
|
||||
directory `plugins enable` grants the low-privilege runner write access to. A config file placed
|
||||
directory `plugins enable` grants the low-privilege runner write access to; a config file placed
|
||||
anywhere else under `%ProgramData%\punktfunk` is not read.
|
||||
|
||||
Full options and the platform/emulator list are in
|
||||
Full options and the platform/emulator list:
|
||||
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
|
||||
|
||||
## Playnite
|
||||
|
||||
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (Steam,
|
||||
GOG, Epic, Xbox, itch, emulators, manually-added games — everything Playnite manages) into your
|
||||
library. Launching a title hands it back to Playnite, which performs the real launch, so there are no
|
||||
per-store launch commands to maintain. Covers are served by the host, so it scales to large libraries.
|
||||
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (every
|
||||
store and emulator Playnite manages) into your library. Launching a title hands it back to
|
||||
Playnite, so there are no per-store launch commands to maintain; covers are served by the host.
|
||||
|
||||
Playnite is Windows-only, so both halves live on the **Windows host**. Because Playnite keeps its
|
||||
library locked while running, there are **two parts**:
|
||||
Playnite keeps its library locked while running, so there are **two parts**, both on the **Windows
|
||||
host**:
|
||||
|
||||
1. **The plugin** — from an elevated PowerShell:
|
||||
|
||||
@@ -236,20 +226,19 @@ library locked while running, there are **two parts**:
|
||||
```
|
||||
|
||||
2. **The Punktfunk Sync extension** (in Playnite) — download `punktfunk-sync.pext` from the
|
||||
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions) and **double-click
|
||||
it** to install it in Playnite like any add-on, then restart Playnite once.
|
||||
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions), **double-click**
|
||||
to install it like any add-on, restart Playnite once.
|
||||
|
||||
Open the console's **Playnite** page — it shows "Exporter connected", and your games sync within
|
||||
seconds of any library change. Filters (installed-only, per-store, hidden) live on that page or in
|
||||
`%ProgramData%\punktfunk\plugin-state\playnite\config.json`. Details are in
|
||||
The console's **Playnite** page shows "Exporter connected", and your games sync within seconds of
|
||||
any library change. Filters (installed-only, per-store, hidden) live on that page or in
|
||||
`%ProgramData%\punktfunk\plugin-state\playnite\config.json`. Details:
|
||||
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
|
||||
|
||||
## VirtualHere (USB passthrough)
|
||||
|
||||
`@punktfunk/plugin-virtualhere` — hands a **physical USB device** on your couch machine to the
|
||||
host while you play, and gives it back afterwards. The game sees the real device — the answer for a
|
||||
racing wheel, a HOTAS, pedals, an arcade stick, or any controller whose value is that it is not
|
||||
emulated.
|
||||
racing wheel, HOTAS, pedals or arcade stick.
|
||||
|
||||
<Callout type="warn">
|
||||
This plugin drives [VirtualHere](https://www.virtualhere.com/), a commercial USB-over-IP product
|
||||
@@ -258,31 +247,28 @@ Punktfunk — you install and license it yourself. The plugin is not affiliated
|
||||
VirtualHere.
|
||||
</Callout>
|
||||
|
||||
You need both halves of VirtualHere running before the plugin is any use:
|
||||
You need both halves of VirtualHere running first:
|
||||
|
||||
- **The USB Server on the couch**, sharing the device. Free for one device; beyond that, and to run
|
||||
the client as a service, VirtualHere requires a purchased licence.
|
||||
- **The USB Client on the host**, ideally installed as a service so it survives logging out.
|
||||
|
||||
Servers exist for Windows, Linux, macOS and Android couches. **There is no VirtualHere server for
|
||||
iOS or tvOS**, so iPhones, iPads and Apple TVs cannot pass devices through.
|
||||
iOS or tvOS.**
|
||||
|
||||
```sh
|
||||
punktfunk-host plugins add virtualhere
|
||||
punktfunk-host plugins enable
|
||||
```
|
||||
|
||||
Then open the console's **VirtualHere** page. The **Devices** tab lists whatever the couch is
|
||||
sharing; pick one and it writes a rule matching the device *by name*, which keeps working after the
|
||||
couch reboots or the device moves to another port. By default the device is handed over when video
|
||||
starts and returned when it stops, so the couch keeps its own controller the rest of the time —
|
||||
you can widen that to the whole session, or to the entire time a client is connected.
|
||||
The console's **VirtualHere** page → **Devices** lists whatever the couch is sharing; pick one and
|
||||
it writes a rule matching the device *by name*, which survives reboots and port changes. By default
|
||||
the device is handed over when video starts and returned when it stops; you can widen that to the
|
||||
whole session, or to the entire time a client is connected.
|
||||
|
||||
If nothing happens, the **Diagnostics** tab walks the whole two-sided setup and tells you which
|
||||
part to fix. The same checks run as `punktfunk-plugin-virtualhere doctor` — the useful thing to
|
||||
paste into a support thread.
|
||||
|
||||
Full configuration is in
|
||||
If nothing happens, the **Diagnostics** tab walks the two-sided setup and names the part to fix —
|
||||
the same checks as `punktfunk-plugin-virtualhere doctor`, the useful thing to paste into a support
|
||||
thread. Full configuration:
|
||||
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-virtualhere).
|
||||
|
||||
## Troubleshooting
|
||||
@@ -291,38 +277,31 @@ Full configuration is in
|
||||
installer's `PATH` change, or call the exe by full path. On Linux the host package installs it to
|
||||
`/usr/bin/punktfunk-host`.
|
||||
|
||||
**"the plugin runner isn't installed"** — the runner ships as its own package. On Ubuntu:
|
||||
`sudo apt install punktfunk-scripting`. On Fedora: `sudo dnf install punktfunk-scripting` from the
|
||||
[same RPM repo you installed the host from](/docs/fedora). On Arch:
|
||||
`sudo pacman -Syu punktfunk-scripting` (a full `-Syu`, like every other install from that repo).
|
||||
On SteamOS, re-run `scripts/steamdeck/install.sh` (or
|
||||
`scripts/steamdeck/update.sh`). On NixOS it comes with `services.punktfunk.scripting.enable` (on by
|
||||
default whenever the host is). On Windows, re-run the installer and keep the scripting component.
|
||||
**"the plugin runner isn't installed"** — the runner ships as its own package. Ubuntu:
|
||||
`sudo apt install punktfunk-scripting`. Fedora: `sudo dnf install punktfunk-scripting` from the
|
||||
[same RPM repo as the host](/docs/fedora). Arch: `sudo pacman -Syu punktfunk-scripting` (a full
|
||||
`-Syu`). SteamOS: re-run `scripts/steamdeck/install.sh` (or `update.sh`). NixOS: comes with
|
||||
`services.punktfunk.scripting.enable` (on by default whenever the host is). Windows: re-run the
|
||||
installer and keep the scripting component.
|
||||
|
||||
If the runner *is* installed and the host still says it isn't, the host could not find the
|
||||
`punktfunk-scripting` executable. It looks beside its own binary, then on `PATH`, then in the
|
||||
packaged `/usr` and `~/.local` layouts — so a runner installed somewhere else needs
|
||||
`PUNKTFUNK_SCRIPTING` pointed at it (see [Configuration](/docs/configuration)). The console installs
|
||||
plugins from inside the host *service*, whose `PATH` is usually much shorter than your shell's: a
|
||||
runner that `punktfunk-host plugins add` finds and the console does not is that difference, and the
|
||||
env var is the fix.
|
||||
`punktfunk-scripting` executable — it looks beside its own binary, then on `PATH`, then in the
|
||||
packaged `/usr` and `~/.local` layouts. Point `PUNKTFUNK_SCRIPTING` at it (see
|
||||
[Configuration](/docs/configuration)). The console installs plugins from inside the host *service*,
|
||||
whose `PATH` is much shorter than your shell's — a runner that `plugins add` finds and the console
|
||||
doesn't is that difference, and the env var is the fix.
|
||||
|
||||
**Where a plugin's log output goes** — the console's **Logs** page, under the **Plugins** filter.
|
||||
The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the
|
||||
host's, on one timeline, with the same search and download. Each is tagged `plugin:<name>` — the
|
||||
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
|
||||
plugin, restarting a crashed one, refusing an unsafe file).
|
||||
|
||||
An empty Plugins view almost always means the runner isn't running — it is a separate service. Check
|
||||
with `punktfunk-host plugins status`.
|
||||
Plugin lines sit next to the host's, on one timeline, tagged `plugin:<name>` (`plugin:runner` for
|
||||
the supervisor). An empty Plugins view almost always means the runner isn't running — check with
|
||||
`punktfunk-host plugins status`.
|
||||
|
||||
<Callout>
|
||||
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
|
||||
comes back, and says so in the log if the buffer overflowed.
|
||||
</Callout>
|
||||
|
||||
**Reading the runner's log directly** — rarely needed, but the ground truth if the runner can't
|
||||
reach the host at all:
|
||||
**Reading the runner's log directly** — the ground truth if the runner can't reach the host at all:
|
||||
|
||||
<Tabs items={['Linux', 'Windows']}>
|
||||
<Tab value="Linux">
|
||||
@@ -334,19 +313,17 @@ journalctl --user -u punktfunk-scripting -f
|
||||
</Tab>
|
||||
<Tab value="Windows">
|
||||
|
||||
The runner task writes its output to `%ProgramData%\punktfunk\plugin-state\runner.log` (the
|
||||
previous run is kept as `runner.log.1`). Read — or send — this file when the console's Plugins view
|
||||
stays empty although the runner is running: everything the runner and its plugins printed lands
|
||||
here even when they can't reach the host.
|
||||
The runner task writes to `%ProgramData%\punktfunk\plugin-state\runner.log` (previous run:
|
||||
`runner.log.1`) — everything the runner and its plugins printed lands here even when they can't
|
||||
reach the host.
|
||||
|
||||
```powershell
|
||||
Get-Content "$env:ProgramData\punktfunk\plugin-state\runner.log" -Tail 100
|
||||
```
|
||||
|
||||
If the file doesn't exist, the task started before `punktfunk-host plugins enable` ever ran (which
|
||||
is what makes `plugin-state` writable for the runner's `LocalService` account) — run it from an
|
||||
elevated prompt, then read the file. To watch a start live instead, run the runner in the
|
||||
foreground (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
|
||||
is what makes `plugin-state` writable for `LocalService`) — run it from an elevated prompt. To
|
||||
watch a start live, run the runner in the foreground (<kbd>Ctrl</kbd>+<kbd>C</kbd> stops it):
|
||||
|
||||
```powershell
|
||||
& "$env:ProgramFiles\punktfunk\bun\bun.exe" "$env:ProgramFiles\punktfunk\scripting\runner-cli.js"
|
||||
@@ -357,9 +334,9 @@ foreground (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
|
||||
|
||||
**A plugin can't reach a service running on the same box (Linux)** — plugins that drive a local
|
||||
daemon usually talk to it over a socket or FIFO in `/tmp`. The runner's unit shipped with
|
||||
`PrivateTmp=yes` in earlier releases, hiding the real `/tmp`: the plugin would launch the vendor's
|
||||
binary and then time out reaching the daemon, while the same command worked in your own shell. On an
|
||||
older host, or with a drop-in that reinstates it, put the real `/tmp` back:
|
||||
`PrivateTmp=yes` in earlier releases, hiding the real `/tmp`: the plugin launches the vendor's
|
||||
binary and times out reaching the daemon, while the same command works in your shell. On an older
|
||||
host, or with a drop-in that reinstates it:
|
||||
|
||||
```sh
|
||||
systemctl --user edit punktfunk-scripting
|
||||
@@ -377,5 +354,4 @@ by the runner. The kit owns lifecycle, config and state, the library sync engine
|
||||
plugin's console page; `@punktfunk/host` (`definePlugin`) is the lower-level host client underneath
|
||||
it. Start from the
|
||||
[plugin-kit README](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit) and
|
||||
[ROM Manager](https://git.unom.io/unom/punktfunk-plugin-rom-manager), which is the reference
|
||||
implementation — or any of the three plugins above.
|
||||
[ROM Manager](https://git.unom.io/unom/punktfunk-plugin-rom-manager), the reference implementation.
|
||||
|
||||
@@ -45,6 +45,8 @@ certificate is the host's own, so your browser warns once — continue) and log
|
||||
(SteamOS: the install script printed it; it's in `~/.config/punktfunk/web.env`).
|
||||
- **Windows:** the installer showed it on its last page.
|
||||
|
||||

|
||||
|
||||
Lost it? [Forgot your password](/docs/forgot-password). Everything else about the console:
|
||||
[The Web Console](/docs/web-console).
|
||||
|
||||
@@ -54,11 +56,16 @@ On the device you want to stream *to*, install the app — [Install a Client](/d
|
||||
has the link for every device (Mac, iPhone/iPad/Apple TV, Linux, Windows, Android, Steam Deck), and
|
||||
any Moonlight client works too once you [turn GameStream on](/docs/moonlight).
|
||||
|
||||
Open the app: your host is already in the list. Select it and **connect**. Back in the console, the
|
||||
device appears under **Pairing → Waiting for approval** — click **Approve** and it's in, no PIN to
|
||||
type. (Prefer a PIN? Click **Pair a device** in the console and type the 4-digit code into the
|
||||
client.) Pairing happens once; the device reconnects on its own from then on.
|
||||
Details: [Pairing & Trust](/docs/pairing).
|
||||
Open the app: your host is already in the list.
|
||||
|
||||

|
||||
|
||||
Select it and **connect**, then click **Approve**
|
||||
next to the device in the console's **Pairing** page — no PIN to type. (Prefer a PIN? **Pair a device**
|
||||
shows a 4-digit code to type into the client.) Pairing happens once; the device reconnects on its
|
||||
own from then on. Details: [Pairing & Trust](/docs/pairing).
|
||||
|
||||

|
||||
|
||||
## 5. Stream
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ Jump to what you installed:
|
||||
|
||||
## Linux hosts
|
||||
|
||||
If you installed with the guided script, `sh install.sh --uninstall` runs this section and the
|
||||
package removal for your family in one go (fetch it again with `curl -fsSLO https://punktfunk.unom.io/install.sh`);
|
||||
what it leaves behind is the same list below.
|
||||
|
||||
### Stop the services first
|
||||
|
||||
The Linux packages ship systemd **user** units, and `systemctl --user enable` writes symlinks into
|
||||
@@ -289,6 +293,18 @@ Then remove the repository as described under the host sections above, if this b
|
||||
it. To clear the client's own state without uninstalling — saved hosts and stream settings, keeping
|
||||
the paired identity — run `punktfunk-client --reset` instead.
|
||||
|
||||
### Windows client (installer)
|
||||
|
||||
Uninstall **Punktfunk** from **Settings → Apps → Installed apps** (it's a per-user install, so no
|
||||
admin prompt), or silently:
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\Punktfunk\unins000.exe" /VERYSILENT
|
||||
```
|
||||
|
||||
The uninstaller removes the Start-menu entries, the `punktfunk://` registration, and its own PATH
|
||||
entry. A **portable** unzip has nothing registered — just delete the folder.
|
||||
|
||||
### Windows client (MSIX)
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -11,8 +11,7 @@ settings like scaling.
|
||||
|
||||
Set it in the **web console** (the **Virtual displays** page), or edit
|
||||
`~/.config/punktfunk/display-settings.json` (`%ProgramData%\punktfunk\display-settings.json` on
|
||||
Windows). A change applies to the **next** connection — a running session keeps the display it
|
||||
opened on.
|
||||
Windows). A change applies to the **next** connection.
|
||||
|
||||
> **You rarely need to touch this.** The default matches how Punktfunk has always worked; reach for
|
||||
> a preset when you want a specific experience.
|
||||
@@ -23,43 +22,33 @@ opened on.
|
||||
To stream a monitor the host **already has** instead, see
|
||||
[Stream a real monitor instead](#stream-a-real-monitor-instead) — it turns most of this page off.
|
||||
|
||||
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
|
||||
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
|
||||
> *and* GNOME/Mutter) and **multi-monitor layout** (several clients as monitors of one desktop) are
|
||||
> all enforced. A reconnect — even a fast one — always resumes the kept display instead of spawning
|
||||
> a second. Gaps, noted inline: the Linux `primary` physical-keep *effect*, and multi-display for a
|
||||
> *single* client (the next stage).
|
||||
|
||||
## Stream a real monitor instead
|
||||
|
||||
> **Linux only.** A Windows host enumerates its monitors but has no backend that can capture one, so
|
||||
> the Streamed screen card is read-only there and every Windows session gets a virtual display.
|
||||
> **Linux only.** A Windows host enumerates its monitors but has no backend that can capture one —
|
||||
> the Streamed screen card is read-only there.
|
||||
|
||||
For a wall-mounted shop-floor PC, a lab bench machine or a media box's TV output, you want *that*
|
||||
screen, not a new one. Set **Virtual displays → Streamed screen** in the console to a listed
|
||||
monitor and Punktfunk streams that physical monitor instead of creating a virtual display; every
|
||||
client sees it at *its* resolution.
|
||||
Set **Virtual displays → Streamed screen** in the console to a listed monitor and Punktfunk streams
|
||||
that physical monitor instead of creating a virtual display; every client sees it at *its*
|
||||
resolution.
|
||||
|
||||
- The monitor is **never touched** — not resized, moved, disabled or restored. Keep-alive, topology
|
||||
and multi-monitor layout don't apply: there's no display of ours to apply them to.
|
||||
and multi-monitor layout don't apply.
|
||||
- **The resolution is the monitor's**, not yours. A client asking for a different one is told no and
|
||||
scales its own picture; the mid-stream resize machinery is switched off.
|
||||
scales its own picture; mid-stream resize is off.
|
||||
- **Every client sees the same screen** — two clients are two viewers of one monitor.
|
||||
- Naming a monitor this host **doesn't have, while it has others**, is a **hard error**, not a
|
||||
fallback: the session fails with `no monitor named "DP-9" — this host has: HDMI-1`. The one
|
||||
exception is a session with **no physical heads at all** — a nested or headless compositor: the
|
||||
pin is set aside with a warning in the log, you get an ordinary virtual display, and the pin
|
||||
applies again the next time a session with real heads runs.
|
||||
- Naming a monitor this host **doesn't have, while it has others**, is a **hard error**: the session
|
||||
fails with `no monitor named "DP-9" — this host has: HDMI-1`. The exception is a session with
|
||||
**no physical heads at all** (nested or headless compositor): the pin is set aside with a log
|
||||
warning, you get an ordinary virtual display, and the pin applies again on the next session with
|
||||
real heads.
|
||||
- **Virtual screen (default)** in the same card puts you back on the normal path.
|
||||
|
||||
Supported on **KDE/KWin**, **GNOME/Mutter**, **Sway/wlroots**, **Hyprland** and **gamescope Game
|
||||
Mode** (a Steam Deck / Bazzite couch box, where gamescope drives the screen) — each through the
|
||||
compositor's own screen-recording API, so there is **no chooser dialog**. That matters for a host
|
||||
running unattended as a [service](/docs/running-as-a-service): a background `systemd --user` daemon
|
||||
has nobody to answer a permission prompt. On gamescope only the head the session is driving is
|
||||
listed — mirroring attaches to the session's own composited stream, so that screen keeps showing
|
||||
what the person in front of it sees and nothing is relaunched. A *nested* or headless gamescope (the
|
||||
per-client sessions the host spawns itself) has no head, so the picker is empty there.
|
||||
Mode** — each through the compositor's own screen-recording API, so there is **no chooser dialog**
|
||||
(a background [service](/docs/running-as-a-service) has nobody to answer one). On gamescope only the
|
||||
head the session is driving is listed — mirroring attaches to the session's own composited stream,
|
||||
so the screen keeps showing what the person in front of it sees. A *nested* or headless gamescope
|
||||
has no head, so the picker is empty there.
|
||||
|
||||
### Naming the monitor from the host
|
||||
|
||||
@@ -83,8 +72,8 @@ To pin it **from the host's configuration** instead — the appliance route —
|
||||
PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
|
||||
```
|
||||
|
||||
The environment variable **wins over the console setting**, so an operator's declaration can't be
|
||||
re-aimed by a click; the console shows the Streamed screen card as locked while it's set.
|
||||
The environment variable **wins over the console setting**; the console shows the card as locked
|
||||
while it's set.
|
||||
|
||||
Check the whole path — mirror, capture, frames — without a client:
|
||||
|
||||
@@ -98,10 +87,9 @@ the mouse on the host while it runs or a working mirror reads as a stall.
|
||||
### Absolute input follows the pin
|
||||
|
||||
Pinning a monitor also re-aims **absolute** mouse and pen input to that head's origin, so a click
|
||||
lands where you point on *that* screen. Heads are matched by position, not size — two monitors can
|
||||
be the same size, and getting that wrong puts the pointer silently on the wrong screen. The host
|
||||
resolves the pin at startup and whenever the console writes it, so no restart is needed; the log
|
||||
line is `capture monitor: …`.
|
||||
lands where you point on *that* screen. Heads are matched by position, not size. The host resolves
|
||||
the pin at startup and whenever the console writes it — no restart; the log line is
|
||||
`capture monitor: …`.
|
||||
|
||||
To check it with no client involved:
|
||||
|
||||
@@ -110,12 +98,10 @@ punktfunk-host anchor-test --monitor HDMI-A-1
|
||||
```
|
||||
|
||||
It lists this host's heads, says whether the box has the same-size pair the matching exists for,
|
||||
walks the pointer through the centre and corners so you can watch which screen it moves on, and
|
||||
prints the region it mapped into. `--none` runs the same walk unanchored, as an A/B.
|
||||
|
||||
The anchor rides the **libei** injector — the backend a GNOME/Mutter host uses. On KWin, Sway and
|
||||
Hyprland the host injects through a different protocol, and `anchor-test` stops and says so rather
|
||||
than reporting a green run that proves nothing.
|
||||
walks the pointer through the centre and corners, and prints the region it mapped into. `--none`
|
||||
runs the same walk unanchored, as an A/B. The anchor rides the **libei** injector — the
|
||||
GNOME/Mutter backend. On KWin, Sway and Hyprland the host injects through a different protocol, and
|
||||
`anchor-test` stops and says so rather than reporting a green run that proves nothing.
|
||||
|
||||
## Pick a preset
|
||||
|
||||
@@ -132,20 +118,16 @@ further down.
|
||||
|
||||
## Save your own preset
|
||||
|
||||
Once you've dialed in a setup — by tweaking a preset or setting every option under **Custom** —
|
||||
**save it as your own named preset** and switch back to it in one click.
|
||||
|
||||
- **Save as preset** — names the settings currently in force (all the options below **plus**
|
||||
*Dedicated game sessions*) and adds it to the picker alongside the built-ins.
|
||||
- **Apply** — writes exactly those settings, like picking a built-in.
|
||||
- **Edit / delete** — rename it, update it to your current settings, or remove it. Deleting never
|
||||
changes what's running — it only takes the card out of the picker.
|
||||
- **Edit / delete** — rename, update to your current settings, or remove. Deleting never changes
|
||||
what's running.
|
||||
|
||||
The built-in presets deliberately leave *Dedicated game sessions* alone, so switching presets never
|
||||
changes your game-launch routing; a **custom preset captures your full setup**, including that axis
|
||||
— it's *your* saved configuration, not a curated behavior bundle. Custom presets live on the host in
|
||||
`display-presets.json` (next to `display-settings.json`); the catalog and the active policy are
|
||||
independent, so editing a preset never disturbs a running session.
|
||||
The built-in presets leave *Dedicated game sessions* alone, so switching presets never changes your
|
||||
game-launch routing; a **custom preset captures your full setup**, including that axis. Custom
|
||||
presets live on the host in `display-presets.json` (next to `display-settings.json`); editing a
|
||||
preset never disturbs a running session.
|
||||
|
||||
## Options reference
|
||||
|
||||
@@ -154,28 +136,25 @@ Choose **Custom** in the console to set these directly.
|
||||
### Keep alive
|
||||
|
||||
How long the virtual display survives after your last session disconnects. On a gamescope game host
|
||||
this also keeps the **game itself running**, so you can reconnect straight back into it.
|
||||
this also keeps the **game itself running**.
|
||||
|
||||
- **Off** — tear the display down at session end.
|
||||
- **A duration** (seconds) — keep it that long; a reconnect inside the window drops you straight
|
||||
back in, with no re-negotiation and no desktop reshuffle.
|
||||
- **A duration** (seconds) — a reconnect inside the window drops you straight back in, with no
|
||||
re-negotiation and no desktop reshuffle.
|
||||
- **Forever** — keep it until you stop the host or **release it** from the console (**Virtual
|
||||
displays** → *Release*). The headless-box model.
|
||||
|
||||
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
|
||||
immediately — a short linger makes reconnects smoother on both.
|
||||
Default: **10 seconds**.
|
||||
|
||||
**A reconnect always resumes the kept display** — the host recognises your device and hands back the
|
||||
same display, even a second or two after dropping (before it has noticed you left). **Deliberately
|
||||
quitting** (closing the client, not a network drop) tears the display down at once, skipping the
|
||||
linger. How quickly a *dropped* client is noticed is the QUIC idle
|
||||
timeout — 8 s by default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see
|
||||
[Legacy environment knobs](#legacy-environment-knobs)) to free kept displays sooner.
|
||||
**A reconnect always resumes the kept display** — even a second or two after dropping.
|
||||
**Deliberately quitting** (closing the client, not a network drop) tears the display down at once,
|
||||
skipping the linger. How quickly a *dropped* client is noticed is the QUIC idle timeout — 8 s by
|
||||
default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see
|
||||
[Legacy environment knobs](#legacy-environment-knobs)).
|
||||
|
||||
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
|
||||
> linger expires or you release the display. Intentional for a dedicated gaming box — but don't set
|
||||
> a long/forever keep-alive with Exclusive on a machine whose monitors you also use in person; use
|
||||
> **Shared desktop** there.
|
||||
> linger expires or you release the display. Intentional for a dedicated gaming box — on a machine
|
||||
> whose monitors you also use in person, use **Shared desktop**.
|
||||
|
||||
### Topology
|
||||
|
||||
@@ -184,11 +163,9 @@ What Punktfunk does with your monitor layout while it streams.
|
||||
- **Extend** — add the virtual display alongside your real monitors; touch nothing else.
|
||||
- **Primary** — make the virtual display your primary output; physical monitors stay on.
|
||||
- **Exclusive** — the virtual display becomes your **only** enabled output (physical monitors are
|
||||
disabled, then restored when streaming ends). This makes the streamed surface *be* the desktop,
|
||||
so panels and windows land on it.
|
||||
disabled, then restored when streaming ends), so panels and windows land on it.
|
||||
- **Automatic** *(default)* — Exclusive on Windows and on an auto-detected KDE/GNOME desktop;
|
||||
Extend when you've pinned a
|
||||
specific compositor with `PUNKTFUNK_COMPOSITOR` (a test/CI posture).
|
||||
Extend when you've pinned a specific compositor with `PUNKTFUNK_COMPOSITOR`.
|
||||
|
||||
Per-backend support:
|
||||
|
||||
@@ -198,62 +175,51 @@ Per-backend support:
|
||||
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
|
||||
| Exclusive | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
**Primary** has no equivalent on **Sway/wlroots and Hyprland** — a Wayland fact, not a missing
|
||||
feature: there is no primary-output concept. They have a *focused* output, and the host points that
|
||||
at the streamed display — at session start, and again immediately before it launches anything from
|
||||
your library. Both open new windows on the focused monitor, which is what puts the game on the
|
||||
display you're streaming. Primary therefore behaves as Extend, and the host says so in the log.
|
||||
**Sway/wlroots and Hyprland have no primary-output concept**; they have a *focused* output, and the
|
||||
host points that at the streamed display — at session start, and again immediately before it
|
||||
launches anything from your library, which is what puts the game on the streamed display. Primary
|
||||
therefore behaves as Extend (the host says so in the log), and a window that opens *later* follows
|
||||
whatever has focus then — clicking a physical monitor mid-launch can still pull a window over.
|
||||
|
||||
Because it's focus rather than promotion, a window that opens *later* (a launcher spawning a second
|
||||
window, a game re-parenting itself) follows whatever has focus then — so if you're also sitting at
|
||||
the machine, clicking a physical monitor mid-launch can still pull a window over to it.
|
||||
**Exclusive** on both compositor families:
|
||||
|
||||
**Exclusive** does disable your physical monitors on both, and switches them back on when the last
|
||||
streaming display is torn down. Two compositor-specific details:
|
||||
|
||||
- Punktfunk only disables monitors it did not create, so a second client streaming at the same time
|
||||
never goes dark.
|
||||
- On Hyprland the restore is a `hyprctl reload`, because nothing else re-enables a monitor that a
|
||||
rule disabled — a re-applied monitor rule is accepted and ignored. The reload re-reads your
|
||||
Hyprland config, which puts your monitors back; the side effect is that settings changed at
|
||||
runtime with `hyprctl keyword` are dropped too, and a non-Lua config re-runs its `exec =` lines
|
||||
(`exec-once` is not re-run). This only happens if a session actually disabled something.
|
||||
- Punktfunk only disables monitors it did not create, so a second concurrent client never goes dark.
|
||||
- On Hyprland the restore is a `hyprctl reload` — nothing else re-enables a monitor a rule disabled.
|
||||
The reload re-reads your Hyprland config; settings changed at runtime with `hyprctl keyword` are
|
||||
dropped and a non-Lua config re-runs its `exec =` lines (`exec-once` is not). Only happens if a
|
||||
session actually disabled something.
|
||||
|
||||
### Conflict handling · identity · layout
|
||||
|
||||
- **Conflict handling** — what happens when a *different* client connects while one is already
|
||||
streaming and asks for a different resolution: give it its own display (**separate**), take the
|
||||
box over (**steal**), share the existing display at its current mode (**join**), or refuse it
|
||||
(**reject**). On Linux, `separate` gives each client its own display on the shared desktop. On
|
||||
**Windows** a second client is **rejected** (a clean "host busy") even under `separate` — two
|
||||
clients can't yet share one virtual display's capture there (a later stage), so the live session
|
||||
is protected instead. A same-client *reconnect* never conflicts — it resumes.
|
||||
- **Conflict handling** — a *different* client connects mid-stream asking for a different
|
||||
resolution: give it its own display (**separate**), take the box over (**steal**), share the
|
||||
existing display at its current mode (**join**), or refuse it (**reject**). On **Windows** a
|
||||
second client is **rejected** ("host busy") even under `separate` — two clients can't yet share
|
||||
one virtual display's capture there. A same-client *reconnect* never conflicts — it resumes.
|
||||
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
|
||||
remembers its settings (see [Persistent scaling](#persistent-scaling)): one shared identity, one
|
||||
**per client**, or one **per client + resolution**.
|
||||
- **Layout / max displays** — when several clients each become a monitor of one desktop, this places
|
||||
them side by side (**auto**) or exactly where you arrange them in the console (**manual**, keyed to
|
||||
each client), up to **max displays**. Arrange them on the **Virtual displays** page once two or more
|
||||
are streaming.
|
||||
- **Layout / max displays** — several clients as monitors of one desktop: side by side (**auto**) or
|
||||
exactly where you arrange them in the console (**manual**, keyed to each client), up to **max
|
||||
displays**. Arrange them on the **Virtual displays** page once two or more are streaming.
|
||||
|
||||
### Dedicated game sessions
|
||||
|
||||
**Dedicated game sessions** control how a session that *launches a game from
|
||||
[your library](/docs/game-library)* is served (Linux hosts):
|
||||
How a session that *launches a game from [your library](/docs/game-library)* is served (Linux
|
||||
hosts):
|
||||
|
||||
- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session on a
|
||||
Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or spawned into your live KDE /
|
||||
GNOME / Sway desktop.
|
||||
- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution and
|
||||
refresh**, with just the game inside — no Steam Big Picture to navigate, no game-mode desktop.
|
||||
Steam titles launch with the client hidden (`steam -silent`); non-Steam titles start almost
|
||||
instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**, the game
|
||||
keeps running when you disconnect and you re-attach straight back into it.
|
||||
- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session
|
||||
on a Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or your live KDE / GNOME
|
||||
/ Sway desktop.
|
||||
- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution
|
||||
and refresh**, with just the game inside. Steam titles launch with the client hidden
|
||||
(`steam -silent`); non-Steam titles start almost instantly (gamescope up in ~1 s). Combined with
|
||||
**keep alive**, the game keeps running when you disconnect.
|
||||
|
||||
Dedicated needs `gamescope` installed on the host; without it a launch falls back to **Auto**
|
||||
routing. This axis is independent of the preset — pick it on the **Virtual displays** page. On a box
|
||||
already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores it
|
||||
when the session ends. (GameStream / Moonlight launches follow the same routing.)
|
||||
Dedicated needs `gamescope` installed; without it a launch falls back to **Auto**. This axis is
|
||||
independent of the preset. On a box already in Steam game mode, a dedicated Steam launch frees game
|
||||
mode's Steam first and restores it when the session ends. (GameStream / Moonlight launches follow
|
||||
the same routing.)
|
||||
|
||||
## When a game ends, and when a session does
|
||||
|
||||
@@ -263,48 +229,44 @@ only to a game **this host launched for the session**: a game you started yourse
|
||||
|
||||
### When the game exits
|
||||
|
||||
**End the session** (default). Quit the game and your client goes back to its own library. A
|
||||
dedicated game session has always done this; it now works on every path — your live KDE/GNOME/Sway
|
||||
desktop, an attached gamescope, and Moonlight.
|
||||
|
||||
**Keep streaming** if you stream the desktop and treat the game as incidental.
|
||||
**End the session** (default) — quit the game and your client goes back to its own library, on
|
||||
every path (live desktop, attached gamescope, Moonlight). **Keep streaming** if you stream the
|
||||
desktop and treat the game as incidental.
|
||||
|
||||
### When the session ends
|
||||
|
||||
Whether stopping — or losing — a session also closes the game.
|
||||
|
||||
- **Leave it running** (default). Nothing is ever closed. Disconnect, and the game plays on for when
|
||||
you come back.
|
||||
- **Close it on Stop** — closing the client, or pressing *Stop* in the console, closes the game.
|
||||
A network drop does not: you get your game back when you reconnect.
|
||||
- **Always close it** — a drop closes it too, but only after a **reconnect window** (5 minutes by
|
||||
default). Reconnect inside the window and nothing happens; the console shows the countdown, with
|
||||
an **End now** button if you'd rather not wait.
|
||||
- **Leave it running** (default). Nothing is ever closed.
|
||||
- **Close it on Stop** — closing the client, or *Stop* in the console, closes the game. A network
|
||||
drop does not.
|
||||
- **Always close it** — a drop closes it too, after a **reconnect window** (5 minutes by default).
|
||||
Reconnect inside the window and nothing happens; the console shows the countdown, with an **End
|
||||
now** button.
|
||||
|
||||
Closing a game costs whatever it hadn't saved, which is why nothing closes by default. The host asks
|
||||
first — a polite close, the same as clicking the window's X, so the game runs its own shutdown — and
|
||||
only forces the issue after ten seconds of being ignored.
|
||||
Closing a game costs whatever it hadn't saved, which is why nothing closes by default. The host
|
||||
asks first — a polite close, the same as clicking the window's X — and only forces the issue after
|
||||
ten seconds of being ignored.
|
||||
|
||||
> **Keep alive and this setting are different clocks.** Keep-alive decides how long the *display*
|
||||
> outlives a disconnect (10 s by default); the reconnect window decides how long the *game* does
|
||||
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
|
||||
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
|
||||
> outlives a disconnect (10 s default); the reconnect window decides how long the *game* does
|
||||
> (5 min). A display set to **Forever** stays up regardless of what happens to the game.
|
||||
|
||||
### On a gamescope session, the display has the final say
|
||||
|
||||
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam Deck
|
||||
or a Bazzite couch box — the game runs *inside* the streamed display, so it lives exactly as long as
|
||||
that display does, and **Keep alive decides that, not the setting above**:
|
||||
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam
|
||||
Deck or Bazzite couch box — the game runs *inside* the streamed display and lives exactly as long
|
||||
as it does, so **Keep alive decides, not the setting above**:
|
||||
|
||||
| you disconnect by | what happens to the game |
|
||||
|---|---|
|
||||
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is deliberately skipped for a real stop — and the game goes with it, even on *Leave it running* |
|
||||
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is skipped for a real stop — and the game goes with it, even on *Leave it running* |
|
||||
| dropping out (network, sleep) | the display lingers for your keep-alive window, then tears down; the game ends with it |
|
||||
| dropping out, keep-alive **Forever** | the display is pinned, so the game genuinely survives — and *Always close it* still ends it when the reconnect window closes |
|
||||
|
||||
So on a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
|
||||
window long enough to come back in), not just this setting. On a desktop session — KWin, GNOME, Sway —
|
||||
the game is an ordinary process next to your desktop and the setting above is the whole story.
|
||||
On a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
|
||||
window long enough to come back in). On a desktop session — KWin, GNOME, Sway — the game is an
|
||||
ordinary process and the setting above is the whole story.
|
||||
|
||||
### Automation
|
||||
|
||||
@@ -320,8 +282,8 @@ display identity*, so your desktop environment keys its per-monitor settings to
|
||||
| Host | Supported | How |
|
||||
|---|---|---|
|
||||
| **Windows** | ✅ today | Set scaling in Settings while streaming — Windows remembers it per client. |
|
||||
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). |
|
||||
| **GNOME / Mutter** | ✅ today | GNOME's virtual-monitor API exposes no stable identity to key config on, so the **host persists the scale itself**: set scaling in Settings while streaming — the host captures the change, remembers it per client, and reapplies it on reconnect. |
|
||||
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. |
|
||||
| **GNOME / Mutter** | ✅ today | GNOME's virtual-monitor API exposes no stable identity, so the **host persists the scale itself**: set scaling in Settings while streaming — the host captures it per client and reapplies on reconnect. |
|
||||
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
|
||||
|
||||
## Legacy environment knobs
|
||||
@@ -335,51 +297,48 @@ them — when a settings file exists, it wins.
|
||||
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
|
||||
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
|
||||
|
||||
One knob has no console equivalent — it's transport tuning, not display policy:
|
||||
One knob has no console equivalent — transport tuning, not display policy:
|
||||
|
||||
- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long the host waits before declaring a
|
||||
*dropped* client gone, which is when a kept display starts its linger (or is freed). Lower it (e.g.
|
||||
`3000`) to reclaim kept displays sooner after an ungraceful drop; it's clamped to ≥1 s and its
|
||||
keep-alive ping scales with it, so a live session never false-disconnects. A deliberate quit is
|
||||
instant regardless. Also `--idle-timeout-ms` on `punktfunk1-host`.
|
||||
- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long before a *dropped* client is
|
||||
declared gone, which is when a kept display starts its linger (or is freed). Lower it (e.g.
|
||||
`3000`) to reclaim kept displays sooner; it's clamped to ≥1 s and its keep-alive ping scales with
|
||||
it, so a live session never false-disconnects. A deliberate quit is instant regardless. Also
|
||||
`--idle-timeout-ms` on `punktfunk1-host`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**My physical monitors stayed off after I disconnected.** Keep-alive is set together with Exclusive
|
||||
topology — the display (and your isolated desktop) is kept for the linger window. Release it from
|
||||
the console (**Virtual displays**), or switch to the **Shared desktop** preset so streaming never
|
||||
disables your real monitors.
|
||||
topology — the display is kept for the linger window. Release it from the console (**Virtual
|
||||
displays**), or switch to the **Shared desktop** preset.
|
||||
|
||||
**The virtual output shows only my wallpaper.** Your topology is Extend, so the streamed display is
|
||||
an empty extension. Use **Primary** or **Exclusive** so your desktop lands on it.
|
||||
|
||||
**KWin can't create the virtual output.** On a normal Plasma session KWin runs its **DRM backend**,
|
||||
which creates virtual outputs at any version. The 6.5.6 floor applies only to the **virtual backend**
|
||||
(`kwin_wayland --virtual`, used for headless and test sessions) — below that the request fails with
|
||||
"Could not find output". On **KWin 6.6+** that same message also covers an output KWin *did* create
|
||||
and then left disabled; [KDE Plasma](/docs/kde#troubleshooting) walks that one. See
|
||||
which creates virtual outputs at any version. The 6.5.6 floor applies only to the **virtual
|
||||
backend** (`kwin_wayland --virtual`, headless and test sessions) — below that the request fails
|
||||
with "Could not find output". On **KWin 6.6+** that same message also covers an output KWin *did*
|
||||
create and then left disabled; [KDE Plasma](/docs/kde#troubleshooting) walks that one. See
|
||||
[requirements](/docs/requirements).
|
||||
|
||||
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box, disconnecting
|
||||
and reconnecting within game mode reuses the still-warm session (or cleanly recreates it) instead of
|
||||
landing on a dead stream — and switching between game mode and the KDE / GNOME desktop mid-stream
|
||||
follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your
|
||||
library; a game mode / desktop session keeps streaming.
|
||||
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box,
|
||||
disconnect/reconnect within game mode reuses the still-warm session (or cleanly recreates it), and
|
||||
switching between game mode and the desktop mid-stream follows the switch. If a launched game
|
||||
**exits**, a dedicated session ends and returns you to your library; a game mode / desktop session
|
||||
keeps streaming.
|
||||
|
||||
**My keep-alive / topology / layout settings do nothing.** Check whether **Streamed screen** is set
|
||||
to a real monitor — those options are about a display Punktfunk created, and when it's mirroring one
|
||||
of yours there is nothing to keep alive or rearrange. Switch the card back to *Virtual screen
|
||||
(default)*.
|
||||
to a real monitor — those options are about a display Punktfunk created. Switch the card back to
|
||||
*Virtual screen (default)*.
|
||||
|
||||
**The console won't let me change Streamed screen.** `PUNKTFUNK_CAPTURE_MONITOR` is set in this
|
||||
host's [`host.env`](/docs/configuration) and outranks the console. Unset it (and restart the host)
|
||||
to choose from the console instead.
|
||||
host's [`host.env`](/docs/configuration) and outranks the console. Unset it (and restart the host).
|
||||
|
||||
**My session fails with "no monitor named …".** The pinned connector isn't among this host's
|
||||
monitors — renamed, unplugged, or the host is now in a different session. Run
|
||||
`punktfunk-host list-monitors` on the host to see the real names. Punktfunk will not quietly stream
|
||||
a different screen.
|
||||
monitors — renamed, unplugged, or the host is in a different session. Run
|
||||
`punktfunk-host list-monitors` to see the real names. Punktfunk will not quietly stream a different
|
||||
screen.
|
||||
|
||||
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless box**
|
||||
preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes
|
||||
instantly — return to game mode on the box (or restart the host) to hand the TV back.
|
||||
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless
|
||||
box** preset (keep alive = *forever*), a managed Steam session is held indefinitely — return to
|
||||
game mode on the box (or restart the host) to hand the TV back.
|
||||
|
||||
@@ -14,15 +14,12 @@ are here for.
|
||||
|
||||
## How it works
|
||||
|
||||
While running, the host advertises itself over mDNS, including `mac` — the address of the card
|
||||
carrying the IP clients reach it on first, then any other non-loopback cards as fallbacks, at most
|
||||
four.
|
||||
|
||||
Each app stores those addresses on its **saved host** record. Linux, Windows and Android refresh
|
||||
them whenever they see the host advertise; the Apple app when you save the host and on every
|
||||
connect. A sleeping host stops advertising, but the client still has the addresses on disk.
|
||||
|
||||
That ordering is the whole prerequisite:
|
||||
While running, the host advertises itself over mDNS, including `mac` — the card carrying the IP
|
||||
clients reach it on first, then any other non-loopback cards as fallbacks, at most four. Each app
|
||||
stores those addresses on its **saved host** record: Linux, Windows and Android refresh them
|
||||
whenever they see the host advertise, the Apple app when you save the host and on every connect. A
|
||||
sleeping host stops advertising, but the client still has the addresses on disk. That ordering is
|
||||
the whole prerequisite:
|
||||
|
||||
> **The client must have seen the host awake at least once**, on a network where the host's mDNS
|
||||
> advert reached it. Until then no address is known and there is nothing to wake with — the client
|
||||
@@ -31,10 +28,9 @@ That ordering is the whole prerequisite:
|
||||
|
||||
The packet goes **out of every one of the client's network interfaces** — from a socket bound to
|
||||
that interface's own address, aimed at both its subnet broadcast address and `255.255.255.255` — on
|
||||
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. The spread
|
||||
is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it, and a
|
||||
broadcast sent without binding an interface leaves by the default route only — on a machine running
|
||||
a VPN or a mesh network, not the LAN the host sleeps on.
|
||||
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. (A
|
||||
sleeping machine has no ARP entry, and an unbound broadcast leaves by the default route only — on a
|
||||
VPN or mesh machine, not the LAN the host sleeps on.)
|
||||
|
||||
Neither the advert nor a magic packet is authenticated. A wrong address only makes the wake fail;
|
||||
the host's certificate fingerprint still gates the connection. See [Security](/docs/security).
|
||||
@@ -59,11 +55,9 @@ Two things can still stop it, neither visible from Punktfunk:
|
||||
## Waking from a client
|
||||
|
||||
**Auto-wake on connect** is a client setting, **on by default**, in Settings under **Session**
|
||||
([Client settings](/docs/client-settings#behavior) covers what sits beside it); the TV and
|
||||
controller layouts list it among the other general settings. It is a property of the device and
|
||||
the network, so it is *not* part of a
|
||||
[settings profile](/docs/profiles-and-links#what-a-profile-cant-change) — "Game" and "Work" cannot
|
||||
disagree about it.
|
||||
([Client settings](/docs/client-settings#behavior)); the TV and controller layouts list it among
|
||||
the general settings. A property of the device and the network, so *not* part of a
|
||||
[settings profile](/docs/profiles-and-links#what-a-profile-cant-change).
|
||||
|
||||
With auto-wake on, opening a saved host that is not advertising:
|
||||
|
||||
@@ -108,12 +102,9 @@ shows an explanation with a link to system settings if you decline.
|
||||
### On the Steam Deck
|
||||
|
||||
The [Decky plugin](/docs/steam-deck) has no wake button or wake setting of its own. It starts every
|
||||
stream through the client, so the wake is the client's, on exactly the terms above — packet, 6-second
|
||||
re-send, once-a-second watch, dial only when the host is really back. It follows **Wake hosts
|
||||
automatically** in the client's own settings (**Open Punktfunk → Settings** from the same panel)
|
||||
and is a no-op until the client has learned that host's MAC address. (The plugin used to fire a
|
||||
packet itself and stretch the connect budget to 75 seconds to cover the resume; a wait that watches
|
||||
for the host beats a fixed budget, so that is gone.)
|
||||
stream through the client, so the wake is the client's, on exactly the terms above. It follows
|
||||
**Wake hosts automatically** in the client's own settings (**Open Punktfunk → Settings** from the
|
||||
same panel) and is a no-op until the client has learned that host's MAC address.
|
||||
|
||||
### From the command line
|
||||
|
||||
@@ -173,10 +164,9 @@ Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it fr
|
||||
```
|
||||
|
||||
The warning line names the interface and the exact command to fix it. The host only reports; it
|
||||
never changes the card's settings. It stays silent when it cannot tell — `iw` or `ethtool` missing,
|
||||
a driver that doesn't answer, or not enough privilege — rather than guessing, and says nothing at
|
||||
all when mDNS adverts are off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), because then no address is
|
||||
published either.
|
||||
never changes the card's settings, stays silent when it cannot tell (`iw`/`ethtool` missing, a
|
||||
driver that doesn't answer, not enough privilege), and says nothing when mDNS adverts are off
|
||||
(`PUNKTFUNK_MDNS=0` or `--no-mdns`) — then no address is published either.
|
||||
|
||||
Read the line on the web console's **Logs** page, or with
|
||||
`journalctl --user -u punktfunk-host`. See [Troubleshooting](/docs/troubleshooting#still-stuck).
|
||||
|
||||
@@ -112,9 +112,10 @@ appears under **Waiting for approval** instead; approving it pairs it immediatel
|
||||
|
||||
Nine destinations in the sidebar (a **More** tab on a phone holds the last five):
|
||||
|
||||
- **Dashboard** — live status: whether video and audio are streaming, the active sessions with
|
||||
their codec, resolution, frame rate and bitrate, which games are running, and how many clients
|
||||
are paired. Buttons stop a session or ask the encoder for a fresh keyframe.
|
||||

|
||||
|
||||
- **Dashboard** — the live status above: what's streaming, which games run, how many clients are
|
||||
paired. Buttons stop a session or ask the encoder for a fresh keyframe.
|
||||
- **Host** — this host's identity (hostname, OS, local IP, version, unique id), the codecs it
|
||||
advertises, its ports, the **Updates** card (see [Updating the Host](/docs/updating)), the
|
||||
**GPUs** card — Automatic, or prefer one GPU for capture and encode, applied to the next session
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -185,11 +185,11 @@
|
||||
"id": "windows-client",
|
||||
"name": "Windows client",
|
||||
"installs": "client",
|
||||
"packageManager": "msix",
|
||||
"packageManager": "installer",
|
||||
"docs": "/docs/install-client#windows",
|
||||
"install": [
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
|
||||
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
|
||||
".\\punktfunk-client-setup_x64.exe"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,8 @@ release is born complete and the announcement always has something to say.
|
||||
catches renamed knobs and dead links, not a stale sentence). If an install command, repo URL or
|
||||
port changed, `data/platforms.json` changed with it — then run `bun run sync-platforms` in
|
||||
punktfunk-website and commit, because its download page vendors that file and only refreshes
|
||||
when someone does.
|
||||
when someone does. Same pass for the website itself: does the landing page still describe what
|
||||
this release ships (features, platforms, the blog post the CMS expects per release)?
|
||||
2. **Tag & push.** `git tag -a vX.Y.Z … && git push origin vX.Y.Z` fans out to the build
|
||||
workflows. Whichever one wins the create race seeds the release body from this file
|
||||
(`scripts/ci/gitea-release.sh` → `ensure_release`, and its PowerShell twin). The release page
|
||||
|
||||
@@ -2,25 +2,38 @@ Wire-compatible with 0.30.x — everything you have already paired keeps working
|
||||
|
||||
Most of this release is things that were wrong in ways nothing announced. A DualSense's rumble and speaker never actually worked in a game streamed from a Linux host, for five separate reasons stacked on top of each other, and every one of them is fixed. On Windows, ending a session was silently killing the whole host, and launching a game from the library on 0.30 could drop your stream a second later. Android phones and TVs now show the same controller console the desktop does — one interface on three platforms — and the picture on a phone arrives markedly earlier with no dropped frames. On Linux hosts, another device on the box can no longer make your desktop audio stutter, and a Gaming Mode takeover on some machines was starving your controller with a login storm of our own making.
|
||||
|
||||
Two things also change how you get Punktfunk in the first place. The Windows client is now an ordinary installer rather than a Store-style package, which is what finally lets Steam launch it — overlay, controller configs, Big Picture. And a Linux host can be set up with a single command, if you would rather not follow a page of them.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Windows: the client is a normal installer now, and Steam can launch it.** Add it as a non-Steam game and the Steam overlay, your controller configuration and Big Picture all work — which the Store-style package could never do. Read *Before you update* if you already have it installed.
|
||||
- **One command sets up a Linux host.** `curl -fsSL https://punktfunk.unom.io/install.sh | sh` takes a machine from nothing to a host you can pair with, including living alongside an existing Sunshine or Apollo. It is a preview; the per-system pages remain the documented way.
|
||||
- **DualSense rumble and speaker work in-game from a Linux host.** The controller was dying 400 ms after it appeared, every message the game sent it was reported as failed, its sound card was invisible to the sound system, and what got through arrived 48 dB too quiet. All fixed; the details are below.
|
||||
- **Windows: ending a session no longer kills the host, and launching a game no longer drops your stream.** The first looked like a mystery reconnect; the second arrived with 0.30.
|
||||
- **Android has the desktop's console, and a much faster picture.** One controller interface across Windows, Linux and Android — including 32-bit TV boxes — and on the reference phone end-to-end latency went from 30 ms to about 18 ms with dropped frames going from 40–50 a second to none.
|
||||
- **Desktop audio on a Linux host no longer stutters because of somebody else's device.** In one 15-minute session, 15 % of what the listener heard was silence the host had papered over gaps with — because a controller's sound card, attached over the network and connected to nothing, was setting the pace for the whole box.
|
||||
- **A Gaming Mode takeover on a machine that logs itself in was choking its own controller input** — the pad enumerated fine and then reported at about 1.4 times a second instead of 250. The cause was ours, and it is gone.
|
||||
- **KDE Plasma 6.6 could refuse to start a session, in your own language, forever.** No number of retries helped and there was nothing to fix in System Settings. The host repairs it now.
|
||||
- **Every client can send its logs to the host.** Mac, iPhone, iPad, Apple TV and Android join the Gaming Mode console — and the Apple app finally keeps a log worth sending.
|
||||
- **Hyprland and Sway users: the default display mode now really turns your desk monitors off during a session.** It said it did before and did nothing. Read *Before you update*.
|
||||
|
||||
## Before you update
|
||||
|
||||
- **Windows client: the download you want has changed, and switching to it costs one re-pair.** The default is now `punktfunk-client-setup_x64.exe` — an ordinary per-user installer, no administrator prompt, which upgrades in place and keeps your saved hosts and pairing. A portable zip and the old Store-style package are still offered as alternates. **If you already have the Store-style package and you want the Steam integration, remove it first** — `Get-AppxPackage unom.Punktfunk | Remove-AppxPackage` — and then run the installer: that package keeps its settings, saved hosts and pairing identity inside itself, so you will pair with your hosts once more. If you had added Punktfunk to Steam, add the new one; it lives at `%LOCALAPPDATA%\Programs\Punktfunk\punktfunk-client.exe`. Nothing on the host side is affected, and if you do not care about Steam, staying on what you have is fine.
|
||||
- **Steam Deck and Bazzite hosts: two things we told you are no longer true.** Taking over Gaming Mode no longer stops your login manager, so Steam's own "Switch to Desktop" works during and after a session instead of hanging until you reboot — and for that reason the takeover no longer needs the `punktfunk` group. Keep that group only if you use the virtual Steam Deck controller, which still needs it. The comments in the Bazzite `host.env` we ship changed to match.
|
||||
- **NixOS hosts: two things, and the first one is that builds were failing outright.** The version of our patched compositor that came with the pinned packages had drifted far enough that it no longer accepted our changes, and since HDR is on by default that failure sat on every `services.punktfunk.host.enable = true` build; the fix moves your `nixpkgs` input, so expect a rebuild. Second, there is now a binary cache, so that rebuild — and every future one — need not compile the workspace and the compositor from source, which took about an hour. Add the cache and its public key to your configuration; the install guide has the snippet, and the cache serves its own public key. Both of these come undone if your flake overrides Punktfunk's `nixpkgs` input: every store path changes, so nothing in the cache matches and everything rebuilds. Separately, one of the controller-audio fixes below is a sound-configuration file the other Linux packages install into a system location NixOS does not have; it needs a package override there.
|
||||
- **Plugin authors: update your dependency.** `@punktfunk/host` 0.1.5 and `@punktfunk/plugin-kit` 0.4.3 are published, and plugins take them from the registry — so the fix that makes a plugin follow a moved management port, the cap on re-scanning, and the republish-on-restart behaviour below only reach your plugin once it depends on those versions.
|
||||
- **Hyprland and Sway hosts:** the "exclusive" display setting — which is what the default resolves to on these desktops — now genuinely disables your own monitors for the length of a session and brings them back afterwards, exactly as it always has on KDE. Until now it was accepted, reported back as active, and quietly behaved as "extend", so your monitors stayed on. If you would rather keep them on, set the display topology to *extend*. Two things to know: on Hyprland, bringing the monitors back reloads your Hyprland configuration, which also drops any other runtime tweaks you have applied since login and re-runs its startup commands; and the Sway half is written to the same contract but has not been exercised on a live Sway machine, so if it misbehaves, please say so.
|
||||
- **NixOS hosts:** there is now a binary cache, so a host no longer takes an hour to build. Add the cache and its public key to your configuration — the install guide has the snippet, and the cache serves its own public key. If your flake overrides Punktfunk's `nixpkgs` input, the cache cannot help you: every package changes and rebuilds. Separately, one of the controller-audio fixes below is a sound-configuration file the other Linux packages install into a system location NixOS does not have; it needs a package override there.
|
||||
- **Other Linux hosts:** several controller-audio fixes arrive as system files inside the host package rather than as code — a device rule, a sound-system policy and a sound-card configuration. They take effect after the package is installed and the controller is reattached (or the machine rebooted).
|
||||
- **Linux hosts, if you look in your sound settings mid-session:** you will now see a virtual output *and* a recording stream both named after Punktfunk. That is the host's own audio output, and it is not a leak — the troubleshooting guide has a new section on it and on what to do when it tells you another device is clocking your audio.
|
||||
- **If you moved the host's management port and hand-edited the tray's start-up command to match, you can undo that.** The tray, every plugin and the plugin runner now follow the port the host actually bound, so nothing needs telling.
|
||||
|
||||
## New
|
||||
|
||||
- **The Windows client installs like an ordinary program, and Steam can launch it.** The download is now a signed per-user installer — no administrator prompt — that puts Punktfunk in your own account and registers what you would expect: the Start-menu entries for Punktfunk and Punktfunk Console, `punktfunk://` links, and the `punktfunk` command on your path. It fetches the Windows runtime it needs if the machine does not have it. **The reason this matters beyond tidiness:** someone reported that launching through Big Picture did nothing and the Steam overlay never appeared. That was never about the app — it was the shape of the old Store-style package, whose program file lives in a protected folder Steam's "add a non-Steam game" browser cannot open, and which Windows starts in a way that stops the overlay attaching. Steam has to start the program itself, from an ordinary path. Now it can: add it as a non-Steam game and the overlay, your per-game controller configuration and Big Picture launching all work. There is also a portable zip that registers nothing at all — unzip it anywhere, delete the folder to remove it — and the Store-style package remains for the Microsoft Store.
|
||||
- **One command sets up a Linux host, as a preview.** `curl -fsSL https://punktfunk.unom.io/install.sh | sh` works out which system you are on, installs exactly the packages the documentation would have told you to, and then handles the parts people get stuck on: it notices an existing Sunshine, Apollo or Vibeshine and offers to keep both by moving Punktfunk's management port, puts you in the right group, writes a firewall rule, starts the host and web console, checks they are actually running, and prints your console address, the command for its password and the pairing steps. Everything optional defaults to no. `--dry-run` shows every command without running any, `--uninstall` reverses it, and answers can be given up front so it can run unattended. It also catches the two graphics-driver failures that otherwise produce a host that streams badly with no explanation — no driver at all, and a driver the machine refused to load because of Secure Boot. It is labelled **preview** deliberately: the per-system pages stay the documented route until it has more mileage, and NixOS, SteamOS and Windows are pointed at their own instructions rather than half-handled.
|
||||
- **Every client can send its logs to the host.** In 0.30 this existed only on the Gaming Mode console. Mac, iPhone, iPad and Apple TV get it in the host card's menu and on the controller-driven host options, and Android phones and TVs get it from the console — where the button previously said it was unavailable. On Apple it is new twice over: the app had never kept a log of its own to send, so a report from an Apple TV arrived carrying only the host's half of the story. It now keeps a rolling log — bounded, so it cannot grow without limit — and for the first time that log includes what the streaming engine itself has to say about connections and network problems, which was previously invisible on those devices.
|
||||
- **The console learns three things from the desktop.** Controller haptics and Controller speaker are now switches in the console's Controller tab rather than settings you could only reach elsewhere; each saved machine gets its own Shared clipboard toggle; and you can set a machine's default profile from its menu.
|
||||
- **The documentation was rewritten, for two audiences.** There is a get-started track — one task per page, a couple of minutes each, with real screenshots of the console instead of prose describing it — and a Switching from Sunshine page covering what maps to what and how to run both at once. Every install command on those pages is now quoted from one file that the build checks, so the commands you are given cannot drift from the ones that are tested. Several things the pages had told you were simply wrong and are now corrected — among them that GameStream compatibility is opt-in on every route, a missing step in the Ubuntu and Debian instructions, a missing firewall step on Bazzite, and NixOS getting a page of its own.
|
||||
- **NixOS users get prebuilt binaries.** Every other install channel shipped binaries; Nix compiled the whole workspace and our patched compositor from source — roughly an hour, on the critical path of enabling the host at all. A signed binary cache is now published on every change to the main branch, so a build that once took an hour takes the time it takes to download.
|
||||
- **Hyprland and Sway hosts can run a session on the virtual display alone.** With the display topology on *exclusive*, your own monitors are switched off while the session runs and switched back on when its display goes away — never all at once, so the desktop is never left with nothing to show, and never a monitor belonging to a second session or a second host on the same machine. *Primary* is still treated as *extend* on these desktops, and now says so on its own rather than sharing a warning with *exclusive*: Wayland has no notion of a primary output, only a focused one, and the streamed display already holds that.
|
||||
- **The Android app's controller interface is the desktop's.** Plug in or pair a controller and the phone or TV shows the same console Windows and Linux do — same screens, same navigation, same motion — instead of an Android-only recreation of it that had to be fixed three times over. It runs on every Android device, including the 32-bit TV boxes that were previously left out. On a phone the console now takes the whole panel, tucking the system bars away for the duration and bringing them back on a swipe, and the library gives the sort bar's height back to the covers unless you have actually pulled it down. Connected controllers is a page of the console itself now, with the permission prompts, the rumble test and the controller-audio self-test where they were before.
|
||||
@@ -32,6 +45,10 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
|
||||
|
||||
## Improved
|
||||
|
||||
- **Touching the console does what touching things should.** On a touchscreen, swiping across the settings list used to change whichever setting your finger happened to land on — a press was both "focus this" and "choose this". A tap is now a tap, delivered where you put your finger down, and a drag scrolls the list. Using a mouse is unchanged.
|
||||
- **The Android console is easier on the battery.** It only looks for machines on the network while the console is actually in front of you, and it halves its drawing rate after a minute without input.
|
||||
- **A portable Playnite gets its covers — and a tile of its own.** If your Playnite is unzipped somewhere of your choosing rather than installed, its whole library lives beside the program, which put every cover outside the folders the host is allowed to read art from: one reported library synced its games and dropped all 70 covers, with no way round it but setting an environment variable. Those folders are now included, exactly as Steam's are, and finding a portable Playnite at all also gives it the launcher tile it never had. If you set that environment variable as a workaround, you can drop it.
|
||||
- **Fixing something on the host side no longer needs a cache file deleted.** A plugin remembers what it last sent so it does not resend it endlessly — but the host can accept a submission and keep less of it than was sent, which is deliberate, since a cover it cannot serve must not cost you the whole library. Once that happened, the plugin's memory said "nothing to do" forever: you would fix the host and nothing would republish. Restarting, or pressing Sync now, always republishes again.
|
||||
- **Windows: the plugin runner writes a log file you can read.** A field report on a 0.30 host had plugins installed, the runner running, an empty library and "no logs at all" — and that was by design, since the runner's only way to speak was through the host it could not reach. It now writes a plain log file next to its plugin state, and the console's empty-library hint tells you where it is.
|
||||
- **Apple gamepad screens move like the desktop's.** Screen transitions in the Mac, iPhone, iPad and Apple TV controller shell use the same spring the desktop console uses, and they can be interrupted — press B mid-flight and the same spring carries you back. Reduce Motion crossfades instead of snapping.
|
||||
- **The Apple library fits a phone.** The grid fills the width instead of leaving a fifth of it empty on a phone; in a landscape phone's height it holds two rows instead of one; the shoulder-button hint hides on any phone and the sort bar has become a tray you pull down with ▲ and dismiss with ▼, A or B, so the field keeps every point of height it has. Navigating the grid no longer scrolls twice for one move, and a diagonal flick of the stick is one move, not two.
|
||||
@@ -43,6 +60,12 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
|
||||
|
||||
## Fixed
|
||||
|
||||
- **On KDE Plasma 6.6, a session could refuse to start — in your own language — and stay broken.** Plasma 6.6 changed the rules: it will not stream a display it created switched off, and the refusal it sends is a translated sentence with nothing written to any log, because switching a display off is a perfectly ordinary thing to have configured. It also repeated forever, because we deliberately ask for a display named consistently so Plasma remembers your resolution and scale against it — which meant it also remembered "off" and reapplied it to every future session. You could not even fix it in System Settings, since the display exists only for the fraction of a second the request is alive. The host now switches it on and tries again. Separately, that translated refusal used to be mistaken for a temporary problem on any non-English desktop and burned through eight retries over eleven seconds before giving up; it now fails immediately and says what it means.
|
||||
- **Gaming Mode: "Switch to Desktop" hung until you rebooted.** Taking over Gaming Mode used to work by stopping the piece of the system that logs you in, which also happens to be the only thing on the machine able to start a desktop session — so Steam's own switch sat on its dialog forever. The takeover now leaves it running and simply parks the automatic gaming login for the duration. Measured on the machine that reported it: the switch that used to hang brings the desktop up in about ten seconds, and Steam is back within five when the session ends.
|
||||
- **Wake-on-LAN never worked from a Steam Deck.** Waking a sleeping machine needs its network address, and the only two places that ever recorded one were the Windows and Linux desktop windows — neither of which exists in Gaming Mode. So a Deck's saved machines never had one, every wake was skipped in silence, and nothing said so: no packet, no error. All the places that see a machine announce itself now remember it. Two things fall out of the same fix: a Deck's saved machine keeps its icon instead of losing it the moment the network goes quiet, and the advice in `punktfunk wake` — "connect to it once while it's awake" — is corrected, because connecting never taught it anything.
|
||||
- **The web console's Virtual displays page showed the same two cards on both tabs.** The Streamed-screen and session-lifetime cards sat below the tab strip rather than inside it, so whichever tab you were on you saw them — which read as the Configuration tab's contents leaking into the Live displays tab. They are settings, so they now live in Configuration, and the Live tab is just the live list and its arrangement.
|
||||
- **Cancelling a connection left you looking at "Cancelling…".** The console waited for the machine you were dialling to confirm, which it had no obligation to do — on Android it never did, so the screen stayed until the app was killed, and on the desktop it waited out the dial: fifteen seconds normally, and just over three minutes when the host was holding your request for someone to approve. Pressing B is your decision and now takes effect at once, and the dial is actually abandoned.
|
||||
- **NixOS hosts could not be built at all.** Our patched compositor no longer applied to the version of it that came with the pinned packages, and because HDR is on by default that failure was on the path of every host build. Two more faults were hiding behind it and are fixed in the same breath: the packages we build from turn off the piece that lets a game reach HDR unless explicitly asked, so the compositor would have installed under our name and quietly denied HDR to everything under it; and the cleanup step could not remove files that install into read-only folders.
|
||||
- **Ending a session killed the entire host on Windows.** The service restarted it about six seconds later, so it read as a mystery reconnect rather than a crash — four times on one machine, every one of them a session teardown. The cause was a single log line written at a moment when the logging machinery could already be gone, and the crash handler then hid the evidence by trying to log the same way and failing the same way. Neither happens now, and if anything of that shape ever recurs, the message naming it will actually be written down.
|
||||
- **Launching a game from a Windows host on 0.30 could drop your stream a second later**, with the console reporting nothing running. Almost every Windows launch is a hand-off to Steam, Playnite or the shell — a process that quits a second after the launcher takes over. 0.30 learned to track the process it started, and for a title without any way to recognise its window it took that hand-off's exit as the game's and closed the connection. It would also, in one shape, have asked your whole Steam client to close when the game ended. Both fixed; a title the host cannot track shows as untracked instead of "launching" forever.
|
||||
- **DualSense rumble and speaker in a game streamed from a Linux host — five faults, in order:**
|
||||
@@ -56,7 +79,7 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
|
||||
- **A Gaming Mode takeover on a machine that logs itself in flooded the box with logins.** On a host set to log its user in automatically, the way the takeover held the desktop's login manager back sat squarely in that manager's retry path — so every automatic login failed in milliseconds and it tried again, with no back-off: 962 logins in under four minutes, the system's buttons re-scanned 5,688 times, a load of 26 on 12 cores, and every program that listens for new devices drowning in the noise. To you it looked like "my DualSense is not detected, or only with an insane delay": the pad enumerated perfectly and then delivered input at about 1.4 Hz. That method of holding back is gone; the takeover now stops the login manager first and only then steps in. If it cannot stop it, it no longer tries anyway — it falls back to streaming the desktop's own session, which is a working stream, rather than fighting a login loop that costs you your controller.
|
||||
- **Audio hiccups on the client that nothing counted.** On the Linux desktop client and the Steam Deck the audio callback was running on the wrong thread at ordinary priority, and when that thread was late the sound system played silence for us and moved on — an underrun no counter ever saw. On the host, the audio pacer measured its schedule against the wall clock while the audio it carried did not, so every small hole left it a little further behind until a bigger hole repaid the debt as a burst of silence frames — one field log showed 33–72 % of departures late and the worst nearly 100 ms behind. Both fixed, and holes no longer open and close with a click.
|
||||
- **A Steam Deck's support bundle covered three seconds instead of the whole session.** The video decoder writes a dozen bookkeeping lines per frame, and at 120 fps that flushed the entire log ring — 2,037,456 lines evicted from a 27-minute session, including the one audio line three rounds of investigation had been waiting for. The chatter is filtered before it reaches the ring.
|
||||
- **Android: the picture arrived late and dropped frames on a phone that could easily keep up.** The reference phone decodes a frame in 4–5 ms and was still showing 30 ms end to end with 40–50 skipped frames a second, because it was pacing against a display clock that Android quietly slows down for game processes. Frames are now composited straight onto the display on the panel's real timing, and each one reports back exactly when it landed. On glass: end-to-end 30 ms → about 18 ms, skipped frames 40–50 a second → 0. Whether the panel *holds* 120 Hz turns out to be the phone maker's power policy, not the app's — nothing an app can ask lifts it — so if you want 120 on such a phone, set your phone's minimum refresh rate.
|
||||
- **Android: the picture arrived late and dropped frames on a phone that could easily keep up.** The reference phone decodes a frame in 4–5 ms and was still showing 30 ms end to end with 40–50 skipped frames a second, because it was pacing against a display clock that Android quietly slows down for game processes. Frames are now composited straight onto the display on the panel's real timing, and each one reports back exactly when it landed. On glass: end-to-end 30 ms → about 18 ms, skipped frames 40–50 a second → 0. That new path carries the stream's colour properly too — high dynamic range is described by what the two ends agreed on rather than guessed at from the decoder, and ordinary content is labelled so the phone stops washing blacks out to grey — and the status and gesture bars no longer park themselves over the video for the whole session. Whether the panel *holds* 120 Hz turns out to be the phone maker's power policy, not the app's — nothing an app can ask lifts it — so if you want 120 on such a phone, set your phone's minimum refresh rate.
|
||||
- **Android: the controller went dead after opening the Controllers or Licences page from the console** — every press was dropped until you force-stopped the app. Also, plugging in a controller could leave you on a grey screen for the rest of the session if the console could not draw; it now hands you back to the touch interface instead.
|
||||
- **Mac, iPad and Apple TV: clicking a host connects to it again.** A change earlier in this cycle had made the host card open the game library instead, with "connect" pushed into the menu — the opposite of every other client. Reverted: tap to connect, "Browse Library…" back in the menu, everything else the library work landed kept.
|
||||
- **The Apple grid's first day on real hardware.** A single stick flick was read as up-then-right on the way out of the dead zone; the grid drew over the pinned title; rows vanished while still in view; one step down scrolled the row above half away; and "Copy link" was a face button on a gamepad interface. All addressed — X now opens a small options menu for the title.
|
||||
@@ -64,10 +87,12 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
|
||||
|
||||
## Thanks
|
||||
|
||||
Several of these were found because someone sent a log detailed enough to disprove the obvious. The Steam Deck bundle that turned out to hold three seconds of decoder chatter is what made the log-ring bug visible; the audio-clock investigation went through four field logs and a purpose-built probe before a single column in a diagnostic tool named a controller's sound card; and the report of a Windows host with plugins installed, a runner running and nothing to show for it described exactly the failure the runner could not report on its own. Thank you.
|
||||
Several of these were found because someone sent a log detailed enough to disprove the obvious. The Steam Deck bundle that turned out to hold three seconds of decoder chatter is what made the log-ring bug visible; the audio-clock investigation went through four field logs and a purpose-built probe before a single column in a diagnostic tool named a controller's sound card; and the report of a Windows host with plugins installed, a runner running and nothing to show for it described exactly the failure the runner could not report on its own.
|
||||
|
||||
Several more came from reports that were specific about the thing that did not happen, which is what made each of them findable: Big Picture not launching and the Steam overlay never appearing — a report about a program's install shape, not about streaming at all; ⌘Space doing nothing on a Mac with the setting switched on; a Playnite library that synced its games and dropped exactly 70 covers; a Portuguese-language KDE desktop where the same failure looked like a flaky one; a Steam Deck that would not wake a machine and produced no error to look at; and an Apple TV whose log bundle arrived carrying only the host's half — which is the report that got the client a log of its own. Thank you.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and embedder detail — including the version table and the notes on what moved — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.0/CHANGELOG.md).
|
||||
|
||||
The short version: nothing versioned moves — no wire, ABI, driver-protocol or plugin-contract change — and the C header is byte-identical to 0.30.0's. Three things are worth reading before you package or embed this release: the Linux host package now installs three new system files (a device rule, a sound-system policy and a sound-card configuration) that the controller-audio fixes depend on; the Linux desktop-audio capture changed topology by default, with a one-release escape hatch back to the 0.30 shape; and the Android app's Compose console is deleted outright, which removes its screenshot scenes.
|
||||
The short version: the streaming protocol does not move, and neither does the driver protocol or the plugin contract, so nothing needs re-pairing in any direction. The embedding interface moves one step by pure addition — a single new call that hands an embedder the engine's own log lines, which is what gave the Apple client something worth sending in the first place; adopt it or ignore it, nothing else changed shape. Four things are worth reading before you package or embed this release: the Windows client's default download is now a per-user installer plus a portable zip, with the Store package kept as an alternate; the Linux host package installs three new system files (a device rule, a sound-system policy and a sound-card configuration) that the controller-audio fixes depend on; the Linux desktop-audio capture changed topology by default, with a one-release escape hatch back to the 0.30 shape; and the Android app's Compose console is deleted outright, which removes its screenshot scenes.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
• The controller interface is now the same console the desktop app shows — on every phone and TV, 32-bit boxes included — with a Controllers page of its own.
|
||||
• A much faster picture: frames land on the panel's real timing, so on the reference phone latency fell from 30 to about 18 ms and dropped frames from 40–50 a second to none.
|
||||
• Fixed: the gamepad going dead after opening Controllers or Licences, a grey screen when the console could not draw, and audio gaps when the buffer needed to grow.
|
||||
• The controller interface is now the same console the desktop shows — on every phone and TV, 32-bit boxes included — with its own Controllers page.
|
||||
• A much faster picture: frames land on the panel's real timing, so latency on the reference phone fell from 30 to about 18 ms and dropped frames to none.
|
||||
• New: send logs to the host, wake a sleeping PC, controller haptics and speaker switches.
|
||||
• Fixed: swiping the settings list no longer changes values.
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
# states — every `install` line of an apt/pacman/dnf/sysext host platform must appear in
|
||||
# the script verbatim (it edits channel/group into the string at run time, never the
|
||||
# literal), and the script must parse under sh.
|
||||
# 7. The installer under --dry-run against faked os-release files detects every family it claims
|
||||
# to (and --uninstall prints each family's removal) — the committed half of the manual
|
||||
# 16-file matrix PR #345 was verified with. Needs curl on PATH (the script's own prerequisite).
|
||||
#
|
||||
# Textual gates, so textual limits: gate 2/3 match token spelling, not env reads — a var name in
|
||||
# a code comment counts as "exists", and a quoted constant that isn't an env var counts toward
|
||||
@@ -123,4 +126,35 @@ elif command -v node >/dev/null 2>&1; then
|
||||
node -e "$installer_check" || fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- gate 7: installer detection matrix (--dry-run)
|
||||
# Faked os-release files through the real script, nothing executed: each family must be detected
|
||||
# and print its own package-manager line, both for the install and for --uninstall; the unsupported
|
||||
# ones must stop with their pointer. A fix to the installer adds its case here.
|
||||
osr=$(mktemp -d)
|
||||
installer_case() { # name os-release-body expected-substring [extra args...]
|
||||
name=$1; printf '%b' "$2" > "$osr/$name"; want=$3; shift 3
|
||||
out=$(PUNKTFUNK_INSTALL_OS_RELEASE="$osr/$name" sh scripts/install.sh --dry-run --yes --no-start "$@" 2>&1)
|
||||
case "$out" in *"$want"*) ;; *)
|
||||
echo "::error::scripts/install.sh --dry-run $* on a fake $name os-release did not print '$want':"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
fail=1 ;;
|
||||
esac
|
||||
}
|
||||
installer_case debian 'ID=debian\nVERSION_ID=13\n' 'sudo apt install -y punktfunk-host'
|
||||
installer_case ubuntu 'ID=ubuntu\nID_LIKE=debian\nVERSION_ID=26.04\n' 'sudo apt install -y punktfunk-host'
|
||||
installer_case mint22 'ID=linuxmint\nID_LIKE="ubuntu debian"\nVERSION_ID=22.1\n' 'cannot host'
|
||||
installer_case fedora 'ID=fedora\nVERSION_ID=44\n' 'sudo dnf install -y punktfunk'
|
||||
installer_case fedora43 'ID=fedora\nVERSION_ID=43\n' '/rpm/bazzite'
|
||||
installer_case arch 'ID=arch\n' 'sudo pacman -Syu --noconfirm punktfunk-host'
|
||||
installer_case cachyos 'ID=cachyos\nID_LIKE="arch"\n' 'sudo pacman -Syu --noconfirm punktfunk-host'
|
||||
installer_case bazzite 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext.sh install'
|
||||
installer_case nixos 'ID=nixos\n' 'docs/nixos'
|
||||
installer_case steamos 'ID=steamos\nID_LIKE=arch\n' 'docs/steamos-host'
|
||||
installer_case gentoo 'ID=gentoo\n' 'build-from-source'
|
||||
installer_case debian-rm 'ID=debian\nVERSION_ID=13\n' 'sources.list.d/punktfunk.list' --uninstall
|
||||
installer_case fedora-rm 'ID=fedora\nVERSION_ID=44\n' 'yum.repos.d/punktfunk.repo' --uninstall
|
||||
installer_case arch-rm 'ID=arch\n' '/etc/pacman.conf' --uninstall
|
||||
installer_case bazzite-rm 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext remove' --uninstall
|
||||
rm -rf "$osr"
|
||||
|
||||
exit "$fail"
|
||||
|
||||
+58
-6
@@ -31,6 +31,7 @@ LINGER=${PUNKTFUNK_INSTALL_LINGER:-} # 1/0, empty = ask (default no)
|
||||
MGMT_PORT=${PUNKTFUNK_INSTALL_MGMT_PORT:-47991} # where the management API moves to on a conflict
|
||||
START=1
|
||||
DRY=${PUNKTFUNK_INSTALL_DRY_RUN:-0}
|
||||
UNINSTALL=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -45,6 +46,7 @@ usage: sh install.sh [options]
|
||||
--linger | --no-linger start the host at boot with nobody logged in (default no)
|
||||
--mgmt-port N port to move the management API to if Sunshine/Apollo holds 47990 (default $MGMT_PORT)
|
||||
--no-start install and configure, but don't enable the services
|
||||
--uninstall stop the services and remove the packages + repo (config stays: $DOCS/uninstall)
|
||||
--dry-run print every command it would run, change nothing
|
||||
-h, --help this text
|
||||
|
||||
@@ -67,6 +69,7 @@ while [ $# -gt 0 ]; do
|
||||
--mgmt-port) shift; MGMT_PORT=${1:-} ;;
|
||||
--mgmt-port=*) MGMT_PORT=${1#*=} ;;
|
||||
--no-start) START=0 ;;
|
||||
--uninstall) UNINSTALL=1 ;;
|
||||
--dry-run) DRY=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
@@ -106,7 +109,10 @@ run() {
|
||||
cmd=$(printf '%s' "$cmd" | sed \
|
||||
-e 's/^sudo apt install /sudo apt install -y /' \
|
||||
-e 's/^sudo dnf install /sudo dnf install -y /' \
|
||||
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /')
|
||||
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /' \
|
||||
-e 's/^sudo apt purge /sudo apt purge -y /' \
|
||||
-e 's/^sudo dnf remove /sudo dnf remove -y /' \
|
||||
-e 's/^sudo pacman -Rns /sudo pacman -Rns --noconfirm /')
|
||||
fi
|
||||
printf ' + %s\n' "$cmd"
|
||||
[ "$DRY" = 1 ] && return 0
|
||||
@@ -172,6 +178,43 @@ else
|
||||
fi
|
||||
say "Detected $PRETTY → $FAMILY (guide: $DOCS_PAGE)"
|
||||
|
||||
# ---------------------------------------------------------------------------- --uninstall
|
||||
# The reverse of step 1 + step 6, as $DOCS/uninstall spells it out per family: user units off first
|
||||
# (package removal can't see the enable symlinks in $HOME), then only the punktfunk packages that
|
||||
# are actually installed, then the repo. Config, groups and firewall rules stay — the page lists them.
|
||||
if [ "$UNINSTALL" = 1 ]; then
|
||||
say "Uninstalling the host ($DOCS/uninstall)"
|
||||
run 'systemctl --user disable --now punktfunk-host punktfunk-web punktfunk-scripting 2>/dev/null || true'
|
||||
case "$FAMILY" in
|
||||
apt)
|
||||
pkgs=$(dpkg-query -W -f='${Package} ${db:Status-Status}\n' 'punktfunk*' 2>/dev/null | awk '$2=="installed"{printf "%s ", $1}')
|
||||
[ -n "$pkgs" ] && run "sudo apt purge $pkgs"
|
||||
run 'sudo rm -f /etc/apt/sources.list.d/punktfunk.list /etc/apt/keyrings/punktfunk.asc'
|
||||
run 'sudo apt update'
|
||||
;;
|
||||
dnf)
|
||||
pkgs=$(rpm -qa --qf '%{NAME} ' 'punktfunk*' 2>/dev/null)
|
||||
[ -n "$pkgs" ] && run "sudo dnf remove $pkgs"
|
||||
run 'sudo rm -f /etc/yum.repos.d/punktfunk.repo'
|
||||
;;
|
||||
pacman)
|
||||
pkgs=$(pacman -Qq 2>/dev/null | grep '^punktfunk' | tr '\n' ' ')
|
||||
[ -n "$pkgs" ] && run "sudo pacman -Rns $pkgs"
|
||||
run "sudo sed -i '/^\\[punktfunk\\(-canary\\)\\{0,1\\}\\]\$/,/^Server = /d' /etc/pacman.conf"
|
||||
;;
|
||||
sysext)
|
||||
run 'sudo punktfunk-sysext remove'
|
||||
;;
|
||||
esac
|
||||
cat <<EOF
|
||||
|
||||
Removed. Left on purpose: ~/.config/punktfunk (identity, pairings, host.env, plugins — a reinstall
|
||||
picks them up), the punktfunk / punktfunk-update groups, and any firewall rules you opened.
|
||||
The one-command cleanups for each are on $DOCS/uninstall#linux-hosts
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Version floors the package can't express: below these the install succeeds and nothing can stream.
|
||||
major=${VERSION_ID%%.*}
|
||||
case "$ID" in
|
||||
@@ -361,11 +404,20 @@ if [ "$START" = 1 ] && [ "$DRY" != 1 ]; then
|
||||
if command -v ss >/dev/null 2>&1 && ss -lun 2>/dev/null | grep -q ':9777 '; then ok "listening on UDP 9777 (punktfunk/1)"
|
||||
else warn "nothing on UDP 9777 yet — give it a second, then: journalctl --user -u punktfunk-host -e"; fi
|
||||
fi
|
||||
# GPU drivers are the docs pages' job (one step, per distro) — but the one silent failure worth
|
||||
# calling out: Fedora + NVIDIA with Fedora's own ffmpeg has no NVENC, and the RPM only Recommends
|
||||
# RPM Fusion's build, so the install succeeded and encoding won't.
|
||||
if [ "$FAMILY" = dnf ] && grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
|
||||
# GPU drivers are the docs pages' job (one step, per distro) — but the silent failures worth
|
||||
# calling out, because the install succeeded and streaming won't: an NVIDIA card whose kernel
|
||||
# module didn't load (Secure Boot blocks the unenrolled key — nvidia-smi can't talk to it), or no
|
||||
# driver at all; and Fedora + NVIDIA with Fedora's own ffmpeg, which has no NVENC (the RPM only
|
||||
# Recommends RPM Fusion's build).
|
||||
if grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null; then
|
||||
if ! command -v nvidia-smi >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU without the NVIDIA driver — nothing can encode until it's installed: step 1 of $DOCS_PAGE"
|
||||
elif ! nvidia-smi >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU, but nvidia-smi can't talk to the driver — the kernel module didn't load (Secure Boot? run: mokutil --sb-state): $DOCS/troubleshooting#nvidia-smi-says-it-cant-communicate-with-the-driver"
|
||||
fi
|
||||
if [ "$FAMILY" = dnf ] && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
|
||||
fi
|
||||
fi
|
||||
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
[ -n "$ip" ] || ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/host",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
+1
-1
@@ -8,4 +8,4 @@
|
||||
*
|
||||
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
|
||||
*/
|
||||
export const SDK_VERSION = "0.1.4";
|
||||
export const SDK_VERSION = "0.1.5";
|
||||
|
||||
@@ -56,7 +56,10 @@ import { m } from "@/paraglide/messages";
|
||||
* (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement
|
||||
* table below act on whatever is currently streaming.
|
||||
*/
|
||||
export const DisplaySection: FC = () => {
|
||||
export const DisplaySection: FC<{
|
||||
/** See [`DisplayTabs`] — the page's other configuration cards, tabbed along with the form. */
|
||||
configurationExtra?: ReactNode;
|
||||
}> = ({ configurationExtra }) => {
|
||||
const qc = useQueryClient();
|
||||
const { confirm } = useDialogs();
|
||||
const q = useGetDisplaySettings();
|
||||
@@ -181,6 +184,7 @@ export const DisplaySection: FC = () => {
|
||||
return (
|
||||
<DisplayTabs
|
||||
dirty={dirty}
|
||||
configurationExtra={configurationExtra}
|
||||
live={<LiveDisplays />}
|
||||
configuration={
|
||||
<>
|
||||
@@ -245,8 +249,13 @@ export const DisplaySection: FC = () => {
|
||||
export const DisplayTabs: FC<{
|
||||
dirty: boolean;
|
||||
configuration: ReactNode;
|
||||
/** Further self-contained cards that belong to the Configuration tab (streamed screen,
|
||||
* session⇄game lifetime). Rendered as siblings of the config card, INSIDE the tab: parked
|
||||
* below the tab shell they showed on both tabs, which read as the live tab's content being
|
||||
* duplicated into Configuration. */
|
||||
configurationExtra?: ReactNode;
|
||||
live: ReactNode;
|
||||
}> = ({ dirty, configuration, live }) => (
|
||||
}> = ({ dirty, configuration, configurationExtra, live }) => (
|
||||
<Tabs defaultValue="configuration" className="gap-card">
|
||||
<TabsList>
|
||||
<TabsTrigger value="configuration">
|
||||
@@ -266,10 +275,11 @@ export const DisplayTabs: FC<{
|
||||
<TabsTrigger value="live">{m.display_live()}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="configuration">
|
||||
<TabsContent value="configuration" className="flex flex-col gap-card">
|
||||
<Card>
|
||||
<CardContent className="space-y-4">{configuration}</CardContent>
|
||||
</Card>
|
||||
{configurationExtra}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="live">
|
||||
|
||||
@@ -14,6 +14,10 @@ import { SessionGameCard } from "./SessionGameCard";
|
||||
* The session⇄game lifetime card sits here rather than on its own page because it is the same
|
||||
* question one step further out: keep-alive decides how long a *display* outlives a disconnect, and
|
||||
* this decides whether the *game* does.
|
||||
*
|
||||
* Both extra cards ride INSIDE the Configuration tab (they are policy surfaces): as plain siblings
|
||||
* below the tab shell they were visible from either tab, which read as the Live tab's content
|
||||
* bleeding into Configuration.
|
||||
*/
|
||||
export const SectionDisplays: FC = () => {
|
||||
useLocale();
|
||||
@@ -21,9 +25,14 @@ export const SectionDisplays: FC = () => {
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
|
||||
<DisplaySection />
|
||||
<MonitorCard />
|
||||
<SessionGameCard />
|
||||
<DisplaySection
|
||||
configurationExtra={
|
||||
<>
|
||||
<MonitorCard />
|
||||
<SessionGameCard />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user