Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ef285f08 | ||
|
|
5bec450402 | ||
|
|
cd6ce34892 | ||
|
|
2a60f94f74 | ||
|
|
60406c9d72 | ||
|
|
65f697651e | ||
|
|
a00c4d2a6a | ||
|
|
e12ef6633c | ||
|
|
c420ae4676 | ||
|
|
bfd0de8973 | ||
|
|
1dd5df0127 | ||
|
|
08c45b96eb | ||
|
|
a52d60e242 | ||
|
|
44cb7f7815 | ||
|
|
52b89a1592 | ||
|
|
57446f9ed9 | ||
|
|
a4f6e259e3 | ||
|
|
6d82716598 | ||
|
|
f584eebb92 | ||
|
|
cd0a370229 | ||
|
|
3301f5aa60 | ||
|
|
1f0b12de6d | ||
|
|
3def50a88a | ||
|
|
8f9e451395 | ||
|
|
0d22333831 | ||
|
|
47602f7e59 | ||
|
|
f415c7d090 | ||
|
|
e86c6367e1 | ||
|
|
0fd44d8242 | ||
|
|
6807d7951c | ||
|
|
f50721aedb | ||
|
|
5d3301ed9b | ||
|
|
bd140bd232 | ||
|
|
d161c12680 | ||
|
|
7537e8e6b2 | ||
|
|
5711fafa38 | ||
|
|
fa946a16b9 | ||
|
|
9a29eb4a7a | ||
|
|
1b52942bf8 | ||
|
|
6bec7c7cc6 | ||
|
|
8f4e71f8dc | ||
|
|
9ddf802665 | ||
|
|
f7eb844274 | ||
|
|
e5046a2811 | ||
|
|
19d37c44b3 | ||
|
|
5be399a4f6 | ||
|
|
329df4c1f4 | ||
|
|
5fc5da3256 | ||
|
|
a8922b454a |
@@ -0,0 +1,6 @@
|
||||
<!-- What and why — the diff says how. -->
|
||||
|
||||
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
|
||||
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
|
||||
live in `data/platforms.json`. (CONTRIBUTING.md "Where facts live"; `docs-drift` in CI only
|
||||
catches the mechanical half.)
|
||||
@@ -175,6 +175,19 @@ jobs:
|
||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The deep half of the docs-drift gates (the `docs-drift` job checks the docs-site copy
|
||||
# and the textual rest): the committed spec must match what the binary actually serves.
|
||||
# Build already compiled punktfunk-host with default features, so this re-links at worst.
|
||||
# Byte diff on purpose — the generator is deterministic, and if that ever stops being
|
||||
# true it deserves to surface here.
|
||||
- name: OpenAPI spec drift gate
|
||||
run: |
|
||||
cargo run -p punktfunk-host --locked -- openapi > /tmp/openapi.regen.json
|
||||
diff -u api/openapi.json /tmp/openapi.regen.json >/dev/null || {
|
||||
echo "::error::api/openapi.json is stale — regenerate: cargo run -p punktfunk-host -- openapi > api/openapi.json && cp api/openapi.json docs-site/public/openapi.json"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
@@ -390,3 +403,28 @@ jobs:
|
||||
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
|
||||
- name: bun.nix drift gate
|
||||
run: sh scripts/ci/check-bun-nix.sh
|
||||
|
||||
# Docs drift gates — pure git-grep textual checks, no cargo, no bun install (the deep half,
|
||||
# regenerating the OpenAPI spec from the built host, rides in the `rust` job above). Same
|
||||
# reasoning as bun-nix for being UNFILTERED: docs drift arrives through commits that look
|
||||
# unrelated to docs — a renamed env var, a removed subcommand, a moved page.
|
||||
docs-drift:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# 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
|
||||
- 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
|
||||
# still exist, data/platforms.json parses.
|
||||
- name: Docs drift gates
|
||||
run: sh scripts/ci/check-docs-drift.sh
|
||||
# Internal links only: /docs/* page links in docs-site content, relative file links in
|
||||
# the repo's markdown. External URLs and #anchors are deliberately not checked.
|
||||
- name: Docs link check
|
||||
run: sh scripts/ci/check-docs-links.sh
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Smoke test for the guided installer (scripts/install.sh, docs-and-onboarding overhaul WP4).
|
||||
# Runs the script unattended inside a clean container per package family against the REAL
|
||||
# package registry — the one path a textual gate can't cover: does the repo line, the key import
|
||||
# and the install actually work today on a fresh box. `--no-start` because a container has no
|
||||
# user systemd; the script degrades to printing the enable command, which is also under test.
|
||||
#
|
||||
# Path-filtered on purpose: it pulls ~100 MB of packages per family, so it runs when the script
|
||||
# or its fact source changes, not on every push (check-docs-drift.sh gate 6 covers the cheap
|
||||
# half — the install lines in the script must match data/platforms.json verbatim — on every push).
|
||||
name: installer-smoke
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- scripts/install.sh
|
||||
- data/platforms.json
|
||||
- .gitea/workflows/installer-smoke.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- scripts/install.sh
|
||||
- data/platforms.json
|
||||
- .gitea/workflows/installer-smoke.yml
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: smoke (${{ matrix.family }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# actions/checkout needs git + node + CA certs in the container; curl is the
|
||||
# script's own prerequisite (it says so and stops without it).
|
||||
- family: debian-13
|
||||
image: debian:trixie
|
||||
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
|
||||
- family: fedora-44
|
||||
image: fedora:44
|
||||
prep: dnf install -y -q curl git nodejs
|
||||
- family: arch
|
||||
image: archlinux:base
|
||||
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
steps:
|
||||
- name: Prepare the container (${{ matrix.family }})
|
||||
run: ${{ matrix.prep }}
|
||||
- uses: actions/checkout@v4
|
||||
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
|
||||
# Root without sudo → the script's sudo shim, another path under test.
|
||||
- name: Run the installer unattended
|
||||
run: sh scripts/install.sh --yes --no-start
|
||||
- name: The host is installed and conflict-free
|
||||
run: |
|
||||
punktfunk-host --version
|
||||
punktfunk-host detect-conflicts
|
||||
- name: Re-running is a no-op install
|
||||
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
|
||||
@@ -173,7 +173,19 @@ jobs:
|
||||
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
|
||||
# here — so record the headroom, or a future failure is a guess.
|
||||
- name: Environment
|
||||
run: df -h / /nix /tmp || true
|
||||
# Disk AND memory. This job's recurring failure is an OOM kill, and `df` cannot explain
|
||||
# one — a run that dies at exit 137 with only disk numbers in the log is a guess.
|
||||
run: |
|
||||
df -h / /nix /tmp || true
|
||||
free -h 2>/dev/null || grep -E '^(MemTotal|MemAvailable|SwapTotal)' /proc/meminfo || true
|
||||
nproc 2>/dev/null || true
|
||||
# THE number for this job's recurring exit 137. `free` and /proc/meminfo report the HOST
|
||||
# inside a container, so they showed 125Gi total / 48Gi available on a run that then got
|
||||
# bun SIGKILLed (19444) — a cgroup cap is invisible to them and is the only remaining
|
||||
# explanation. cgroup v2 first, then v1; "max" means uncapped.
|
||||
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|
||||
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|
||||
|| echo "no cgroup memory limit readable"
|
||||
|
||||
# Evaluates + instantiates every flake output without building any of it.
|
||||
- name: nix flake check (eval only)
|
||||
@@ -333,9 +345,16 @@ jobs:
|
||||
echo "published → $CACHE_URL"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
# Accept BOTH shapes. A checkbox dispatched from the Gitea UI arrives as the STRING
|
||||
# "true", but an API dispatch (scripts, cross-repo automation) can deliver a real JSON
|
||||
# boolean, and `== 'true'` silently misses it — the step is skipped, the run goes green,
|
||||
# and the log looks identical to a run that genuinely had nothing to do. MEASURED
|
||||
# 2026-08-19: dispatched with build-gamescope while verifying a flake.lock bump, and this
|
||||
# step skipped while the job reported success — a green that proved nothing about the
|
||||
# very package being fixed. Still no `inputs.*`: that context is the thing Gitea's parser
|
||||
# is least reliable about, which is why this file used github.event.inputs to begin with.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
if: ${{ github.event.inputs.build-rust == 'true' || github.event.inputs.build-rust == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
|
||||
@@ -345,6 +364,6 @@ jobs:
|
||||
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||
# something actually builds it.
|
||||
- name: Build the patched gamescope (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' || github.event.inputs.build-gamescope == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| `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 |
|
||||
| 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/plugin-kit` | 0.4.2 | **0.4.2** | unchanged in `package.json` — but `sync-engine.ts` gained `minInterval` (below), so a `plugin-kit-v0.4.3` cut is **owed** for the same reason |
|
||||
| `@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
|
||||
|
||||
+29
-3
@@ -83,15 +83,41 @@ Two more gates that only apply to some changes:
|
||||
instead of waiting for the CI job that compiles it.
|
||||
|
||||
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
|
||||
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
|
||||
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
|
||||
and copy the snapshot the docs site serves:
|
||||
and CI fails if the committed copy drifts. `api/openapi.json` is gated the same way: the `rust` job
|
||||
regenerates the spec and diffs it against the committed file, and the `docs-drift` job checks that
|
||||
`docs-site/public/openapi.json` — the snapshot the docs site serves — is a byte-for-byte copy of it.
|
||||
Touch the management API and CI stays red until you regenerate and re-copy:
|
||||
|
||||
```sh
|
||||
cargo run -p punktfunk-host -- openapi > api/openapi.json
|
||||
cp api/openapi.json docs-site/public/openapi.json
|
||||
```
|
||||
|
||||
## Where facts live (docs vs READMEs vs website)
|
||||
|
||||
Every user-facing fact has exactly one canonical home; everything else links to it. Duplicated
|
||||
walkthroughs are how the docs drifted before — don't add new ones.
|
||||
|
||||
| Surface | Owns | Never contains |
|
||||
|---|---|---|
|
||||
| [docs-site](https://docs.punktfunk.unom.io) (`docs-site/content/`) | All user-facing facts: install, config, features, troubleshooting | Design rationale |
|
||||
| READMEs (root, `packaging/*`, `scripts/*`) | Dev/packager rationale and pointers into the docs | User walkthroughs duplicated from docs-site |
|
||||
| [punktfunk.unom.io](https://punktfunk.unom.io) (separate repo) | Marketing, downloads, blog | Instructions — it deep-links the docs instead |
|
||||
| punktfunk-planning (private) | Design rationale, RFCs, plans | Anything user-facing |
|
||||
|
||||
Docs pages are written for one of two audiences, not both at once: the **get-started track**
|
||||
(quickstart, install, pairing — short, one task per page, happy path only) assumes no Linux
|
||||
expertise; the **reference track** (configuration, CLI, API, per-compositor pages) is allowed to be
|
||||
dense. When a change touches a user-facing fact, update the docs-site page that owns it in the same
|
||||
PR.
|
||||
|
||||
CI enforces the cheap half of this (`scripts/ci/check-docs-drift.sh` and `check-docs-links.sh`):
|
||||
the OpenAPI snapshot must match `api/openapi.json`, the docs-site copy of `data/platforms.json` must
|
||||
match the canonical one, `scripts/install.sh` must carry the file's install lines verbatim, every `PUNKTFUNK_*` variable the docs mention
|
||||
must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables and undocumented
|
||||
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
|
||||
baseline in the script), and internal docs links must resolve.
|
||||
|
||||
Match the surrounding code's comment density and naming. Commit messages end with the
|
||||
`Co-Authored-By` trailer (see `git log`).
|
||||
|
||||
|
||||
Generated
+1
@@ -3582,6 +3582,7 @@ dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"if-addrs",
|
||||
"libc",
|
||||
"log",
|
||||
"opus",
|
||||
"proptest",
|
||||
"quinn",
|
||||
|
||||
@@ -109,36 +109,11 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
|
||||
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
|
||||
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
|
||||
installed first:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
|
||||
# (on Bazzite take host.env.bazzite instead)
|
||||
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
|
||||
|
||||
systemctl --user enable --now punktfunk-host # the streaming host
|
||||
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
|
||||
```
|
||||
|
||||
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
|
||||
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
|
||||
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
|
||||
line before the replacement — the install guide has the snippet). Then open
|
||||
`https://<host-ip>:47992` and pair.
|
||||
|
||||
How the virtual display and input are wired up depends on your desktop — see
|
||||
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
The per-platform guide walks you through the rest — first run, the web console, pairing, and the
|
||||
desktop-specific wiring ([KDE](https://docs.punktfunk.unom.io/docs/kde) ·
|
||||
[GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway).
|
||||
|
||||
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
|
||||
nothing to run by hand — open the web console and pair. Use
|
||||
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
|
||||
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
|
||||
`setup.exe` over the old install; uninstall from Add/Remove Programs.
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway)).
|
||||
|
||||
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.29.0"
|
||||
"version": "0.31.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
|
||||
@@ -49,6 +49,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
@@ -101,6 +104,26 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// System bars have ONE owner: this effect. The stream and the console shell both want the
|
||||
// whole panel (bars hidden, a swipe shows them transiently); the touch shell wants them back.
|
||||
// It cannot live inside the screens themselves: `AnimatedContent` below keeps the outgoing
|
||||
// screen composed until its fade ends, so a per-screen `onDispose { show(...) }` fired AFTER
|
||||
// the incoming screen's hide — console → stream left the status and gesture bars parked over
|
||||
// the video. Keyed on the resolved intent, not the screens.
|
||||
val immersive = session != null || gamepadUi
|
||||
DisposableEffect(immersive) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
if (immersive) {
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
} else {
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
|
||||
|
||||
@@ -58,7 +58,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -420,10 +419,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(true)
|
||||
}
|
||||
controller?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// System bars: NOT hidden here — App.kt owns hide/show (one owner; the AnimatedContent
|
||||
// handoff broke per-screen ownership, see the `immersive` effect there).
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
@@ -817,7 +814,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(false)
|
||||
|
||||
@@ -65,6 +65,7 @@ internal object ConsoleJson {
|
||||
.put("online", online)
|
||||
.put("mgmt_port", advert?.mgmtPort ?: h.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", !online && h.mac.isNotEmpty())
|
||||
.put("clipboard_sync", h.clipboardSync)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
|
||||
.put("pin", JSONObject.NULL)
|
||||
@@ -106,6 +107,7 @@ internal object ConsoleJson {
|
||||
.put("online", true)
|
||||
.put("mgmt_port", d.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", false)
|
||||
.put("clipboard_sync", false)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", d.os)
|
||||
.put("pin", JSONObject.NULL)
|
||||
@@ -129,6 +131,7 @@ internal object ConsoleJson {
|
||||
.put("online", true)
|
||||
.put("mgmt_port", h.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", false)
|
||||
.put("clipboard_sync", h.clipboardSync)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", h.os)
|
||||
.put("pin", pin?.let(::profileChip) ?: JSONObject.NULL)
|
||||
|
||||
@@ -37,8 +37,10 @@ import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -213,6 +215,14 @@ object SkiaConsole {
|
||||
main.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (handle == 0L) return
|
||||
// Only while the console is ON SCREEN (attached): parked behind the touch UI
|
||||
// or a stream there is nobody to show the presence pips to — and mid-stream
|
||||
// the radio belongs to the session, which is exactly why discovery stops for
|
||||
// it. The timer keeps ticking so probes resume within a cadence of re-attach.
|
||||
if (onConnected == null) {
|
||||
main.postDelayed(this, 12_000)
|
||||
return
|
||||
}
|
||||
val targets = knownHostStore.all().filter { kh -> discovered.none { kh.matches(it) } }
|
||||
ioPool.execute {
|
||||
val up = targets.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
|
||||
@@ -536,12 +546,14 @@ object SkiaConsole {
|
||||
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
|
||||
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
|
||||
c.optJSONObject("Pair")?.let(::pair)
|
||||
c.optJSONObject("SendLogs")?.let { notice("Sending logs isn't available on this device yet") }
|
||||
c.optJSONObject("SendLogs")?.let(::sendLogs)
|
||||
c.optJSONObject("SaveHost")?.let(::saveHost)
|
||||
c.optJSONObject("UpdateHost")?.let(::updateHost)
|
||||
c.optJSONObject("ForgetHost")?.let(::forgetHost)
|
||||
c.optJSONObject("Wake")?.let(::wake)
|
||||
c.optJSONObject("SetPin")?.let(::setPin)
|
||||
c.optJSONObject("BindProfile")?.let(::bindProfile)
|
||||
c.optJSONObject("SetClipboard")?.let(::setClipboard)
|
||||
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
|
||||
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
|
||||
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
|
||||
@@ -582,6 +594,22 @@ object SkiaConsole {
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/** `ConsoleCmd::BindProfile` — the host's default binding (`KnownHost.profileId`); null clears. */
|
||||
private fun bindProfile(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
val pid = c.optString("profile_id")
|
||||
.takeIf { c.has("profile_id") && !c.isNull("profile_id") && it.isNotEmpty() }
|
||||
knownHostStore.save(kh.copy(profileId = pid))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/** `ConsoleCmd::SetClipboard` — the per-host clipboard trust toggle. */
|
||||
private fun setClipboard(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
knownHostStore.save(kh.copy(clipboardSync = c.optBoolean("on")))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
private fun setPin(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
val pid = c.optString("profile_id"); val pin = c.optBoolean("pin")
|
||||
@@ -591,6 +619,52 @@ object SkiaConsole {
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/**
|
||||
* `ConsoleCmd::SendLogs` — the native log ring (`nativeRenderLogs`) posted to this
|
||||
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
|
||||
* uses; the result comes back as a notice, in the desktop console's wording. The header
|
||||
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) — client
|
||||
* log bundle`).
|
||||
*/
|
||||
private fun sendLogs(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
|
||||
val hostName = c.optString("host_name").ifEmpty { addr }
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
notice("Identity not ready yet — try again in a moment")
|
||||
return
|
||||
}
|
||||
val version = appContext?.let { app ->
|
||||
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
|
||||
} ?: "?"
|
||||
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
|
||||
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
|
||||
ioPool.execute {
|
||||
val err = runCatching {
|
||||
val body = NativeBridge.nativeRenderLogs(header)
|
||||
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
|
||||
id.certPem, id.privateKeyPem, addr, fp,
|
||||
)
|
||||
val req = Request.Builder()
|
||||
.url("https://$addr:$mgmt/api/v1/client-logs")
|
||||
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
|
||||
}
|
||||
}.getOrElse { it.message ?: "upload failed" }
|
||||
main.post {
|
||||
notice(
|
||||
if (err.isEmpty()) {
|
||||
"Logs sent to $hostName — download them from its web console's Logs page"
|
||||
} else {
|
||||
"Couldn't send logs — $err"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pair(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val port = c.optInt("port")
|
||||
val pin = c.optString("pin"); val name = c.optString("device_name")
|
||||
|
||||
@@ -31,9 +31,6 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
|
||||
import io.unom.punktfunk.MainActivity
|
||||
@@ -116,19 +113,11 @@ fun SkiaConsoleShell(
|
||||
}
|
||||
|
||||
// The console owns the whole panel while it fronts the app, exactly like the stream: the
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently), restored on the
|
||||
// way out. This is both the space win AND the safe-area fix — hidden bars report zero insets,
|
||||
// so the scroll clips that used to end at the visible gesture-bar line (scrolled rows sliced
|
||||
// off mid-air with bare backdrop below) now run to the panel edge. Only the display cutout
|
||||
// stays a real inset.
|
||||
DisposableEffect(activity) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
onDispose { controller.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently). This is both the
|
||||
// space win AND the safe-area fix — hidden bars report zero insets, so the scroll clips that
|
||||
// used to end at the visible gesture-bar line now run to the panel edge. Only the display
|
||||
// cutout stays a real inset. The hide/show itself lives in App.kt (one owner; a per-screen
|
||||
// `onDispose { show }` fired after the stream's hide during the AnimatedContent cross-fade).
|
||||
|
||||
// The safe area, in surface pixels: system bars ∪ display cutout — the NP3's landscape punch
|
||||
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
|
||||
@@ -298,10 +287,14 @@ fun SkiaConsoleShell(
|
||||
})
|
||||
// Touch → the console's pointer (surface pixels): the escape hatch when no
|
||||
// pad is attached, and the natural way to press a legend hint on a phone.
|
||||
// A finger's down is kind 6 (the shell defers it so a swipe scrolls); a
|
||||
// mouse — which Android delivers through this same listener — keeps kind 1
|
||||
// and acts on the press, as a mouse should.
|
||||
setOnTouchListener { v, ev ->
|
||||
if (handle == 0L) return@setOnTouchListener false
|
||||
val kind = when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> 1
|
||||
MotionEvent.ACTION_DOWN ->
|
||||
if (ev.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) 1 else 6
|
||||
MotionEvent.ACTION_MOVE -> 0
|
||||
MotionEvent.ACTION_UP -> 2
|
||||
MotionEvent.ACTION_CANCEL -> 5
|
||||
|
||||
@@ -146,6 +146,14 @@ object NativeBridge {
|
||||
name: String,
|
||||
): String
|
||||
|
||||
/**
|
||||
* The native client's recent log ring rendered as one text bundle, oldest first,
|
||||
* prefixed by [header] (this app's identity line) — the body for "Send logs to host"
|
||||
* (`POST /api/v1/client-logs` over the same mTLS client the library fetch uses).
|
||||
* Never empty; cheap (string copy, no I/O).
|
||||
*/
|
||||
external fun nativeRenderLogs(header: String): String
|
||||
|
||||
/**
|
||||
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
|
||||
* (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection
|
||||
|
||||
@@ -249,6 +249,13 @@ impl ConsoleHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// No input for this long = the console is being looked at, not used — halve the redraw
|
||||
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
|
||||
/// afterglow at full smoothness and only calms a genuinely parked screen.
|
||||
const IDLE_AFTER: Duration = Duration::from_secs(60);
|
||||
/// One extra ~vsync period per frame while idle: 60 Hz → ~30, 120 Hz → ~40.
|
||||
const IDLE_FRAME_STEP: Duration = Duration::from_millis(16);
|
||||
|
||||
/// The render thread. Owns EGL + Skia + the console; runs until `Cmd::Quit`.
|
||||
fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotStore>) -> Result<()> {
|
||||
let egl = EglContext::new()?;
|
||||
@@ -267,6 +274,8 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
let mut was_editing = console.editing();
|
||||
let mut saved_gen = store.saved_gen();
|
||||
let mut menu_out: Vec<MenuEvent> = Vec::new();
|
||||
// When the last input arrived — the idle throttle's clock (see the draw site below).
|
||||
let mut last_input = Instant::now();
|
||||
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
|
||||
// torn down mid-create); a run of them is a context that is not coming back — most likely
|
||||
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
|
||||
@@ -304,21 +313,28 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
return Ok(());
|
||||
}
|
||||
Cmd::Menu(ev) => {
|
||||
last_input = Instant::now();
|
||||
if let Some(p) = console.menu(ev) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
Cmd::PadSample(s) => {
|
||||
last_input = Instant::now();
|
||||
sample = s;
|
||||
poll_now = true;
|
||||
}
|
||||
Cmd::Pointer(p) => {
|
||||
last_input = Instant::now();
|
||||
console.pointer(p);
|
||||
}
|
||||
Cmd::Key { key, shift, repeat } => {
|
||||
last_input = Instant::now();
|
||||
console.key(key, shift, repeat);
|
||||
}
|
||||
Cmd::Text(t) => console.text(&t),
|
||||
Cmd::Text(t) => {
|
||||
last_input = Instant::now();
|
||||
console.text(&t);
|
||||
}
|
||||
Cmd::Phase(ph) => {
|
||||
match &ph {
|
||||
Phase::Connecting => console.session_phase(SessionPhase::Connecting),
|
||||
@@ -413,6 +429,13 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
|
||||
// Draw, if there is somewhere to draw.
|
||||
// ponytail: half-rate after 60 s without input — one extra frame period between
|
||||
// swaps, so an idle carousel stops redrawing a phone's panel at its full rate
|
||||
// (the aurora still breathes, at half tempo). Any input restores full rate on
|
||||
// its own frame; damage-driven rendering if a TV box ever needs more.
|
||||
if last_input.elapsed() >= IDLE_AFTER {
|
||||
std::thread::sleep(IDLE_FRAME_STEP);
|
||||
}
|
||||
if let (Some(s), Some(g)) = (surface.as_mut(), gpu.as_mut()) {
|
||||
let (w, h) = (s.width, s.height);
|
||||
let need_wrap = match &skia {
|
||||
|
||||
@@ -323,8 +323,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleMenu
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsolePointer(handle, kind, x, y, dy)` — touch/mouse in surface pixels:
|
||||
/// kind 0 move, 1 primary down, 2 primary up, 3 secondary down (= Back), 4 wheel (`dy` steps,
|
||||
/// + = up), 5 cancel.
|
||||
/// kind 0 move, 1 primary down (a mouse — acts immediately), 2 primary up, 3 secondary down
|
||||
/// (= Back), 4 wheel (`dy` steps, + = up), 5 cancel, 6 primary down from a finger/stylus on
|
||||
/// the glass — the shell defers it so a swipe scrolls instead of acting on contact.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePointer(
|
||||
_env: EnvUnowned,
|
||||
@@ -341,6 +342,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
touch: false,
|
||||
},
|
||||
2 => PointerInput::Up {
|
||||
x,
|
||||
@@ -351,9 +353,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Secondary,
|
||||
touch: false,
|
||||
},
|
||||
4 => PointerInput::Wheel { x, y, dy },
|
||||
5 => PointerInput::Cancel,
|
||||
6 => PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
if let Some(h) = host(handle) {
|
||||
|
||||
@@ -115,7 +115,7 @@ pub(super) struct AscBackend {
|
||||
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
|
||||
panel_seed_ns: i64,
|
||||
last_latch_ns: i64,
|
||||
/// HDR `ADataSpace` for the transaction (`0` = SDR / leave default).
|
||||
/// `ADataSpace` for the transaction (BT709 for SDR — never untagged; see `color_dataspace`).
|
||||
dataspace: i32,
|
||||
/// Layer frame-rate vote (source Hz), applied once.
|
||||
frame_rate: f32,
|
||||
@@ -143,7 +143,7 @@ impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the HDR `ADataSpace` (`0` = SDR); `source_hz` the negotiated stream rate.
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
@@ -571,9 +571,9 @@ impl AscBackend {
|
||||
}
|
||||
|
||||
impl AscBackend {
|
||||
/// Update the HDR `ADataSpace` applied to every subsequent transaction (from the codec's
|
||||
/// output format once it is known — the analogue of the SurfaceView path's
|
||||
/// `apply_hdr_dataspace`). `0` leaves the surface SDR.
|
||||
/// Update the `ADataSpace` applied to every subsequent transaction (a refinement from the
|
||||
/// codec's output format — the analogue of the SurfaceView path's `apply_hdr_dataspace`; the
|
||||
/// negotiated colour set the initial value at create).
|
||||
pub(super) fn set_dataspace(&mut self, dataspace: i32) {
|
||||
if self.dataspace != dataspace {
|
||||
self.dataspace = dataspace;
|
||||
|
||||
@@ -15,8 +15,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use super::asc_presenter::{asc_backend_selected, AscBackend};
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, hdr_dataspace, install_render_callback, release_render_callback,
|
||||
DisplayTracker,
|
||||
apply_hdr_dataspace, color_dataspace, hdr_dataspace, install_render_callback,
|
||||
release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
@@ -192,11 +192,9 @@ pub(super) fn run_async(
|
||||
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
|
||||
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
|
||||
let mut asc = if asc_backend_selected() {
|
||||
let initial_ds = if client.color.is_hdr() {
|
||||
i32::from(ndk::data_space::DataSpace::Bt2020ItuPq)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
|
||||
// output format later corrects; many decoders never echo `color-transfer` at all.
|
||||
let initial_ds = color_dataspace(&client.color);
|
||||
AscBackend::create(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
@@ -449,7 +447,12 @@ pub(super) fn run_async(
|
||||
if fmt_dirty {
|
||||
if let Some(a) = asc.as_mut() {
|
||||
// ASC carries the HDR signal on the transaction, not the SurfaceView window.
|
||||
a.set_dataspace(hdr_dataspace(&codec).map_or(0, i32::from));
|
||||
// Refine only when the codec actually reports an HDR transfer — a `None` echo
|
||||
// (decoders commonly omit `color-transfer`) must not clobber the negotiated
|
||||
// dataspace back to SDR before the first present.
|
||||
if let Some(ds) = hdr_dataspace(&codec) {
|
||||
a.set_dataspace(i32::from(ds));
|
||||
}
|
||||
} else {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
|
||||
@@ -274,3 +274,26 @@ pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the *negotiated* session colour ([`ColorInfo`], carried on Welcome) to the `ADataSpace`
|
||||
/// the presenter should tag buffers with. This is the authoritative source — the wire contract
|
||||
/// says clients configure the presenter from these code points, not from what the decoder happens
|
||||
/// to echo back (many decoders omit `color-transfer` from the output format).
|
||||
///
|
||||
/// SDR maps to `BT709` (limited-range video), never `0`/untagged: an untagged buffer on an
|
||||
/// ASurfaceControl transaction leaves SurfaceFlinger to guess, and a full-range guess shows
|
||||
/// limited-range black (16) as gray — the elevated-blacks bug.
|
||||
// ponytail: full-range SDR would need hand-composed dataspace bits (no named constant); the host
|
||||
// only encodes limited-range SDR today (ColorInfo::SDR_BT709), so BT709 covers every SDR session.
|
||||
pub(super) fn color_dataspace(color: &punktfunk_core::quic::ColorInfo) -> i32 {
|
||||
use punktfunk_core::quic::ColorInfo;
|
||||
let full = color.full_range != 0;
|
||||
let ds = match color.transfer {
|
||||
ColorInfo::TRC_PQ if full => DataSpace::Bt2020Pq,
|
||||
ColorInfo::TRC_PQ => DataSpace::Bt2020ItuPq,
|
||||
ColorInfo::TRC_HLG if full => DataSpace::Bt2020Hlg,
|
||||
ColorInfo::TRC_HLG => DataSpace::Bt2020ItuHlg,
|
||||
_ => DataSpace::Bt709, // SDR — limited-range BT.709 video
|
||||
};
|
||||
i32::from(ds)
|
||||
}
|
||||
|
||||
@@ -333,7 +333,8 @@ impl Layer {
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
/// tagged with `seq`. `dataspace` is the HDR `ADataSpace` value (`0` = leave default/SDR).
|
||||
/// tagged with `seq`. `dataspace` is the `ADataSpace` value (`0` = leave the layer default —
|
||||
/// only the `setBufferDataSpace`-less API-29 fallback ever presents untagged).
|
||||
/// `frame_rate` votes the layer's rate once (`0.0` skips). Returns `false` if the transaction
|
||||
/// could not be created (the caller then frees the buffer itself).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -34,6 +34,9 @@ mod audio;
|
||||
// shell over EGL/GLES, on every ABI (the armv7 Skia archive is self-hosted — see Cargo.toml).
|
||||
#[cfg(target_os = "android")]
|
||||
mod console;
|
||||
// "Send logs to host": the log-ring upload (`pf-client-core` is Android-target-only here).
|
||||
#[cfg(target_os = "android")]
|
||||
mod logs;
|
||||
// The RESOLVED audio format + its ms ⇄ sample arithmetic, split out of `audio` and — unlike it —
|
||||
// ungated, because that arithmetic is what a rate the ladder does not divide gets wrong (44 100 Hz
|
||||
// used to come out 2.3 % off in every direction at once) and it must be provable without a phone.
|
||||
@@ -60,22 +63,58 @@ mod wol;
|
||||
// it off the main thread to light saved-host "online" pips independently of mDNS.
|
||||
mod probe;
|
||||
|
||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
||||
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
/// Every `log` record, teed: to logcat (via [`android_logger::AndroidLogger`]) AND into
|
||||
/// `pf_client_core::logring` — the source for the console's "Send logs to host" action
|
||||
/// ([`logs`]). The ring line mirrors the desktop `ring_layer`'s shape (wallclock, level,
|
||||
/// target, message) so a bundle reads the same on the host's Logs page whichever client
|
||||
/// sent it. Both sinks share the crate's Info ceiling — the field ring gets exactly what
|
||||
/// logcat gets, which also keeps per-frame DEBUG chatter out of it by construction.
|
||||
#[cfg(target_os = "android")]
|
||||
struct RingTee(android_logger::AndroidLogger);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl log::Log for RingTee {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
self.0.enabled(metadata)
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.0.log(record);
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
pf_client_core::logring::wallclock(),
|
||||
record.level().as_str(),
|
||||
record.target(),
|
||||
record.args()
|
||||
));
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.0.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize logging once when the JVM loads the library: logcat under the `punktfunk` tag,
|
||||
/// teed into the client log ring (see [`RingTee`]). Core `tracing` events (transport warnings:
|
||||
/// socket-buffer clamp, QoS failures) arrive here too: tracing's "log" feature — declared
|
||||
/// explicitly in Cargo.toml rather than relied on via quinn's defaults — forwards them as
|
||||
/// `log` records since no tracing subscriber is ever installed. Android-only — there is no
|
||||
/// JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
_vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut std::ffi::c_void,
|
||||
) -> jint {
|
||||
android_logger::init_once(
|
||||
let logcat = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("punktfunk"),
|
||||
);
|
||||
// `set_boxed_logger` (unlike `init_once`) does not set the max level itself.
|
||||
if log::set_boxed_logger(Box::new(RingTee(logcat))).is_ok() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
}
|
||||
log::info!(
|
||||
"punktfunk_android loaded (core ABI v{})",
|
||||
punktfunk_core::ABI_VERSION
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! JNI seam for "Send logs to host": hand Kotlin the client's recent log ring (fed by the
|
||||
//! [`crate::RingTee`] logcat tee) rendered as one text bundle. The UPLOAD stays on the
|
||||
//! Kotlin side — its mTLS OkHttp client (`mtlsHttpClient`, the library/art path) already
|
||||
//! owns HTTPS-to-the-pinned-host on this platform, and `logring::send_to_host`'s ureq
|
||||
//! agent is deliberately desktop-only. Android-gated (unlike [`crate::wol`]/[`crate::probe`])
|
||||
//! because `pf-client-core` is an Android-target dependency of this crate.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::EnvUnowned;
|
||||
|
||||
/// `NativeBridge.nativeRenderLogs(header): String` — the ring as one text bundle, oldest
|
||||
/// first, prefixed by `header` (the Kotlin side's identity line) and an eviction note when
|
||||
/// the ring wrapped. Never empty (the header line is always present); cheap enough for any
|
||||
/// thread, though the caller is about to do network anyway.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeRenderLogs<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
header: JString<'local>,
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| {
|
||||
let header: String = header.try_to_string(env)?;
|
||||
env.new_string(pf_client_core::logring::render(&header))
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
@@ -451,6 +451,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||
// The Kotlin side cancels by dropping the result (`Dial.cancelled`), not by aborting
|
||||
// the dial — its connect runs on a pool thread, so a parked one costs a thread, not a
|
||||
// stuck UI. Wire a flag through here if that ever stops being true.
|
||||
None,
|
||||
) {
|
||||
Ok(client) => {
|
||||
let handle = SessionHandle {
|
||||
|
||||
@@ -31,6 +31,10 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
|
||||
Keychain-stored identity.
|
||||
- **Tune the stream** — a fps / Mb·s / **latency** HUD (skew-corrected across machines), a bitrate
|
||||
control, a per-host **network speed test** with a recommended bitrate, and a host-compositor picker.
|
||||
- **Send logs to host** — the app keeps its recent log in a bounded in-memory ring (`ClientLog`, a
|
||||
drop-in for `os.Logger` that also writes the unified log); a host card's menu (or the gamepad
|
||||
UI's host options) posts it to the paired host's `/api/v1/client-logs`, where the web console's
|
||||
Logs page shows it next to the host's own — the same action the Gaming Mode console has.
|
||||
|
||||
Runs from one shared codebase across **macOS, iOS, iPadOS, and tvOS**.
|
||||
|
||||
|
||||
@@ -654,6 +654,7 @@ struct GamepadHomeView: View {
|
||||
guard let profile = target.profile else { return }
|
||||
store.setPinned(host.id, profileID: profile.id, pinned: false)
|
||||
},
|
||||
onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil,
|
||||
close: { if !transitioning { hostOptionsTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ struct GamepadHostOptionsView: View {
|
||||
/// Delete the saved record outright.
|
||||
let onRemove: () -> Void
|
||||
let onUnpin: () -> Void
|
||||
/// Upload this device's recent log to the host; answers with what to tell the user. nil on an
|
||||
/// unpaired host — the upload rides the pairing, so there is nothing to offer before it.
|
||||
var onSendLogs: (() async -> (ok: Bool, message: String))?
|
||||
var close: (() -> Void)?
|
||||
var controllerActive = true
|
||||
|
||||
@@ -81,13 +84,21 @@ struct GamepadHostOptionsView: View {
|
||||
/// strict as it is, and none at all to be looser.
|
||||
@State private var armed = false
|
||||
@State private var copied = false
|
||||
/// The send-logs row's own state: its label and the detail band report the outcome in place,
|
||||
/// the same way Copy link says "Copied" — this surface has no toast.
|
||||
@State private var sendLogs: SendLogsState = .idle
|
||||
@State private var focusID: String?
|
||||
|
||||
private enum SendLogsState: Equatable {
|
||||
case idle, sending, done(ok: Bool, message: String)
|
||||
}
|
||||
|
||||
private enum Action: String {
|
||||
case wake
|
||||
case copyLink
|
||||
case edit
|
||||
case forgetPairing
|
||||
case sendLogs
|
||||
case remove
|
||||
case unpin
|
||||
case cancel
|
||||
@@ -195,6 +206,15 @@ struct GamepadHostOptionsView: View {
|
||||
}
|
||||
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
|
||||
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
|
||||
if onSendLogs != nil {
|
||||
let label: String
|
||||
switch sendLogs {
|
||||
case .idle: label = "Send logs to host"
|
||||
case .sending: label = "Sending logs\u{2026}"
|
||||
case .done(let ok, _): label = ok ? "Logs sent" : "Couldn't send logs"
|
||||
}
|
||||
list.append(Row(action: .sendLogs, label: label, icon: "doc.text"))
|
||||
}
|
||||
// Only a paired host has a pairing to drop.
|
||||
if host.pinnedSHA256 != nil {
|
||||
list.append(Row(
|
||||
@@ -221,6 +241,9 @@ struct GamepadHostOptionsView: View {
|
||||
case .forgetPairing:
|
||||
return "Drop the stored fingerprint. The host stays saved and the next connect "
|
||||
+ "pairs again."
|
||||
case .sendLogs:
|
||||
if case .done(_, let message) = sendLogs { return message }
|
||||
return "Upload this device's recent log to the host, for its web console's Logs page."
|
||||
case .remove:
|
||||
return armed
|
||||
? "Press again to remove — this cannot be undone."
|
||||
@@ -263,6 +286,15 @@ struct GamepadHostOptionsView: View {
|
||||
case .forgetPairing:
|
||||
onForgetPairing()
|
||||
performClose()
|
||||
case .sendLogs:
|
||||
guard let onSendLogs, sendLogs != .sending else { return }
|
||||
withAnimation(.smooth(duration: 0.2)) { sendLogs = .sending }
|
||||
Task {
|
||||
let outcome = await onSendLogs()
|
||||
withAnimation(.smooth(duration: 0.2)) {
|
||||
sendLogs = .done(ok: outcome.ok, message: outcome.message)
|
||||
}
|
||||
}
|
||||
case .remove:
|
||||
guard armed else {
|
||||
withAnimation(.smooth(duration: 0.2)) { armed = true }
|
||||
|
||||
@@ -45,6 +45,8 @@ struct HomeView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
/// The outcome of the last "Send Logs to Host" — drives its alert.
|
||||
@State private var sendLogsResult: (ok: Bool, message: String)?
|
||||
// How this device shows its own list. `.added` is the default because it is what the grid
|
||||
// did before it could sort at all — an update should not rearrange anyone's hosts.
|
||||
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
|
||||
@@ -194,6 +196,16 @@ struct HomeView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.alert(
|
||||
sendLogsResult?.ok == true ? "Logs Sent" : "Couldn't Send Logs",
|
||||
isPresented: Binding(
|
||||
get: { sendLogsResult != nil },
|
||||
set: { if !$0 { sendLogsResult = nil } })
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(sendLogsResult?.message ?? "")
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
@@ -292,6 +304,8 @@ struct HomeView: View {
|
||||
onBrowseLibrary: onBrowseLibrary,
|
||||
onWake: { wake(host) },
|
||||
onEdit: { editTarget = host },
|
||||
onSendLogs: host.pinnedSHA256 != nil
|
||||
? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil,
|
||||
profileMenu: profileMenu(for: host),
|
||||
pinnedProfile: pinned)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,9 @@ struct HostCardView: View {
|
||||
var onWake: (() -> Void)? = nil
|
||||
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||
var onEdit: (() -> Void)? = nil
|
||||
/// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired —
|
||||
/// the upload is authenticated by the pairing, so there is nothing to offer before it.
|
||||
var onSendLogs: (() -> Void)? = nil
|
||||
/// This card's profile affordances — nil on surfaces that don't offer them.
|
||||
var profileMenu: HostProfileMenu? = nil
|
||||
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
|
||||
@@ -252,6 +255,9 @@ struct HostCardView: View {
|
||||
if let onBrowseLibrary {
|
||||
Button("Browse Library…", action: onBrowseLibrary)
|
||||
}
|
||||
if let onSendLogs {
|
||||
Button("Send Logs to Host", action: onSendLogs)
|
||||
}
|
||||
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
|
||||
Button("Wake Host", systemImage: "power", action: onWake)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -13,6 +14,9 @@ struct PunktfunkClientApp: App {
|
||||
#endif
|
||||
|
||||
init() {
|
||||
// Before anything touches the core, so its first lines (identity load, the first connect's
|
||||
// transport setup) land in the log ring "Send logs to host" uploads.
|
||||
CoreLog.install()
|
||||
#if os(iOS)
|
||||
// Put Geist on the navigation titles before any bar is built.
|
||||
BrandTheme.apply()
|
||||
|
||||
@@ -19,7 +19,11 @@ import SwiftUI
|
||||
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||||
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
|
||||
/// refresh period on the vsync-latched platforms — this is how to measure with it off.
|
||||
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
|
||||
private let statsLog = ClientLog(category: "stats")
|
||||
/// The session's lifecycle — connect asked/landed/refused, how it ended. Until this existed a
|
||||
/// client log bundle had a 1 Hz stats line and no sentence saying which host it was streaming
|
||||
/// from, with what, or why it stopped; the host's own log has always said all three.
|
||||
private let sessionLog = ClientLog(category: "session")
|
||||
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
|
||||
///
|
||||
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
|
||||
@@ -448,6 +452,11 @@ final class SessionModel: ObservableObject {
|
||||
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
|
||||
// hardware-decode probe below still gates what can actually be advertised.
|
||||
let want444 = effective.enable444
|
||||
let connectLine = "connect \(host.displayName) \(host.address):\(host.port) "
|
||||
+ "mode=\(width)x\(height)@\(hz) codec=\(effective.codec) bitrate=\(bitrateKbps)kbps "
|
||||
+ "hdr=\(hdrCapable) 444=\(want444) audio=\(audioChannels)ch/\(audioRateHz)Hz/\(audioBits)bit "
|
||||
+ "pinned=\(pin != nil) tofu=\(allowTofu) launch=\(launchID ?? "-")"
|
||||
sessionLog.info("\(connectLine, privacy: .public)")
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||
// actor. The persistent identity is presented on every connect so a paired
|
||||
@@ -530,6 +539,14 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
switch result {
|
||||
case .success(let conn):
|
||||
let landed = "connected \(host.displayName) "
|
||||
+ "mode=\(conn.width)x\(conn.height)@\(conn.refreshHz) "
|
||||
+ "codec=\(conn.videoCodec) bitrate=\(conn.resolvedBitrateKbps)kbps "
|
||||
+ "depth=\(conn.bitDepth) chroma=\(conn.isChroma444 ? "444" : "420") hdr=\(conn.isHDR) "
|
||||
+ "audio=\(conn.resolvedAudioChannels)ch/\(conn.resolvedAudioRateHz)Hz/\(conn.resolvedAudioBits)bit "
|
||||
+ "shard=\(conn.shardPayload) compositor=\(conn.resolvedCompositor.rawValue) "
|
||||
+ "gamepad=\(conn.resolvedGamepad.rawValue) mgmt=\(conn.hostMgmtPort)"
|
||||
sessionLog.info("\(landed, privacy: .public)")
|
||||
if pin != nil || autoTrust || requestAccess {
|
||||
// requestAccess: the operator approved this device on the host, so the
|
||||
// session is trusted — stream directly (the caller pins it as paired).
|
||||
@@ -553,6 +570,8 @@ final class SessionModel: ObservableObject {
|
||||
+ "Pair with its PIN before streaming."
|
||||
}
|
||||
case .failure(let error):
|
||||
sessionLog.warning(
|
||||
"connect \(host.displayName, privacy: .public) failed: \(String(describing: error), privacy: .public)")
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
SessionSettings.end() // the dial failed — back to the plain globals
|
||||
@@ -782,6 +801,10 @@ final class SessionModel: ObservableObject {
|
||||
/// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended /
|
||||
/// dropped session) passes `false` to leave the linger intact.
|
||||
func disconnect(deliberate: Bool = true) {
|
||||
if connection != nil {
|
||||
let line = "disconnect \(activeHost?.displayName ?? "-") deliberate=\(deliberate) phase=\(phase)"
|
||||
sessionLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
// Release the session's resolved settings: from here every reader falls back to the plain
|
||||
@@ -902,6 +925,9 @@ final class SessionModel: ObservableObject {
|
||||
// The shelf it came off — falling back to the host's own if a caller launched a title
|
||||
// without naming one, which is what that launch effectively browsed.
|
||||
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
|
||||
let endLine = "session ended by \(name) reason=\(reason) "
|
||||
+ "rejection=\(rejection.map { String(describing: $0) } ?? "-")"
|
||||
sessionLog.info("\(endLine, privacy: .public)")
|
||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||
if let rejection {
|
||||
// The shared typed-rejection wording ("Your access to this host has expired…").
|
||||
|
||||
@@ -522,7 +522,25 @@ extension SettingsView {
|
||||
}
|
||||
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
|
||||
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
|
||||
// Turning it ON is the moment to ask for Accessibility — never at stream start,
|
||||
// where a TCC dialog over a captured stream would be the surprise.
|
||||
.onChange(of: effective.inhibitShortcuts) { was, on in
|
||||
if on, !was, !accessibilityTrusted { InputCapture.requestSystemShortcutAccess() }
|
||||
}
|
||||
if effective.inhibitShortcuts, !accessibilityTrusted {
|
||||
Button("Allow Accessibility access…") {
|
||||
InputCapture.requestSystemShortcutAccess()
|
||||
// The prompt's own "Open System Settings" only shows the FIRST time the system
|
||||
// asks; after that the user has to find the pane themselves — open it for them.
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(
|
||||
for: NSApplication.didBecomeActiveNotification
|
||||
)) { _ in accessibilityTrusted = InputCapture.systemShortcutsAvailable }
|
||||
#endif
|
||||
described(
|
||||
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
|
||||
@@ -549,8 +567,13 @@ extension SettingsView {
|
||||
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
|
||||
return "No effect under the desktop mouse model — switch Mouse input to Capture."
|
||||
}
|
||||
return "Sends ⌘ shortcuts to the host while captured. ⌘⎋ always stays local — it "
|
||||
+ "releases capture."
|
||||
if accessibilityTrusted {
|
||||
return "Sends ⌘ shortcuts — ⌘Space, ⌘Tab and Mission Control included — to the host "
|
||||
+ "while captured. ⌘⎋ always stays local — it releases capture."
|
||||
}
|
||||
return "Sends the app's ⌘ shortcuts (⌘Q, ⌘W, ⌘H…) to the host while captured. ⌘Space, "
|
||||
+ "⌘Tab and Mission Control need Accessibility access — macOS claims them before any "
|
||||
+ "app sees them. ⌘⎋ always stays local — it releases capture."
|
||||
}
|
||||
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
|
||||
@@ -124,6 +124,10 @@ struct SettingsView: View {
|
||||
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
|
||||
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
|
||||
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
|
||||
/// Accessibility granted? Gates the system-shortcut half of `inhibit_shortcuts` (⌘Space, ⌘Tab…
|
||||
/// need the event tap). Re-read whenever the app comes back to the front — that is when the
|
||||
/// user returns from flipping the switch in System Settings.
|
||||
@State var accessibilityTrusted = InputCapture.systemShortcutsAvailable
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// "Send logs to host" — the one action behind the host card's menu item and the gamepad options
|
||||
// row. Posts `ClientLogRing` to the PAIRED host (`LibraryClient.sendLogs`), where the web
|
||||
// console's Logs page shows it next to the host's own log. The Apple port of the Gaming Mode
|
||||
// console's `ConsoleCmd::SendLogs` (clients/session/src/console.rs), same wording on success.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
|
||||
private let log = ClientLog(category: "logs")
|
||||
|
||||
enum SendLogs {
|
||||
/// Upload this device's recent log to `host`. Never throws: the caller shows `message` either
|
||||
/// way, and the outcome is itself the last line of the NEXT bundle.
|
||||
static func toHost(_ host: StoredHost) async -> (ok: Bool, message: String) {
|
||||
// The same two preconditions the library screen applies: this device's mTLS identity
|
||||
// (minted on the first connect) and the host's pinned fingerprint (pairing) — an upload
|
||||
// is an outbound write carrying the device's diagnostics, and it goes to a host the user
|
||||
// has actually paired with, not to whoever answers on that port.
|
||||
guard let identity = (try? ClientIdentityStore.shared.load())?.identity else {
|
||||
return (false, "Connect to this host once first — sending logs uses the identity "
|
||||
+ "created on the first connect.")
|
||||
}
|
||||
guard let pin = host.pinnedSHA256 else {
|
||||
return (false, "Pair with \(host.displayName) first — logs are only sent to a paired host.")
|
||||
}
|
||||
do {
|
||||
let id = try await LibraryClient.sendLogs(
|
||||
address: host.address, port: host.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin)
|
||||
log.info("client logs uploaded to \(host.displayName, privacy: .public) id=\(id, privacy: .public)")
|
||||
return (true, "Logs sent to \(host.displayName) — download them from its web console's "
|
||||
+ "Logs page.")
|
||||
} catch {
|
||||
let why = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
log.warning("client log upload to \(host.displayName, privacy: .public) failed: \(why, privacy: .public)")
|
||||
return (false, "Couldn't send logs — \(why)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import os
|
||||
import CoreAudio
|
||||
#endif
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
||||
private let log = ClientLog(category: "audio")
|
||||
|
||||
final class AudioDeviceWatcher {
|
||||
/// Why the owner is being told. Only for the log line — every reason leads to the same
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
||||
private let log = ClientLog(category: "audio")
|
||||
|
||||
/// Render-block-owned scratch storage: freed exactly when the closure (and thus the
|
||||
/// last possible render call) is released — never racing CoreAudio.
|
||||
|
||||
@@ -235,6 +235,40 @@ public enum LibraryClient {
|
||||
return status.games ?? []
|
||||
}
|
||||
|
||||
/// Upload this client's recent log (`ClientLogRing`) to the host — `POST /api/v1/client-logs`,
|
||||
/// the one WRITE a paired certificate may make (the host's `mgmt/client_logs.rs`). Same lane
|
||||
/// and identity as the library; the host files the bundle under this device and shows it on
|
||||
/// its web console's Logs page next to its own log. Returns the stored bundle id (empty for a
|
||||
/// host that predates the id in the reply).
|
||||
///
|
||||
/// Why it exists: on an Apple TV (or a phone, for anyone who is not a developer) there is no
|
||||
/// way to get the client's log off the device, so every fault report arrived with only the
|
||||
/// host's half of the story. `hostFingerprint` is required, not optional: this is an outbound
|
||||
/// write carrying the device's diagnostics, and it goes to the host the user paired with.
|
||||
public static func sendLogs(
|
||||
address: String,
|
||||
port: UInt16 = punktfunkDefaultMgmtPort,
|
||||
certPEM: String,
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data
|
||||
) async throws -> String {
|
||||
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
let body = Data(ClientLogRing.render(header: ClientLogRing.header()).utf8)
|
||||
let response = try await send(
|
||||
path: "/api/v1/client-logs", address: address, port: port,
|
||||
identity: identity, hostFingerprint: hostFingerprint,
|
||||
body: (body, "text/plain; charset=utf-8"))
|
||||
switch response.status {
|
||||
case 200, 201:
|
||||
let json = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any]
|
||||
return json?["id"] as? String ?? ""
|
||||
case 401, 403:
|
||||
throw LibraryError.unauthorized
|
||||
default:
|
||||
throw LibraryError.http(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Just the slice of `/status` this client reads. Everything else on that payload is the
|
||||
/// operator console's business, and decoding only what we use keeps an unrelated schema change
|
||||
/// on the host from breaking the library screen.
|
||||
@@ -259,12 +293,20 @@ public enum LibraryClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// One GET against the host, with transport failures mapped onto `LibraryError`.
|
||||
/// One request against the host — a GET, or a POST when `body` is given — with transport
|
||||
/// failures mapped onto `LibraryError`.
|
||||
static func send(
|
||||
path: String, address: String, port: UInt16,
|
||||
identity: SecIdentity, hostFingerprint: Data?
|
||||
identity: SecIdentity, hostFingerprint: Data?,
|
||||
body: (data: Data, contentType: String)? = nil
|
||||
) async throws -> HTTPResponse {
|
||||
do {
|
||||
if let body {
|
||||
return try await MgmtTransport.post(
|
||||
host: address, port: port, path: path, body: body.data,
|
||||
contentType: body.contentType,
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint)
|
||||
}
|
||||
return try await MgmtTransport.get(
|
||||
host: address, port: port, path: path,
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint)
|
||||
|
||||
@@ -56,6 +56,41 @@ enum MgmtTransport {
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval = 15
|
||||
) async throws -> HTTPResponse {
|
||||
try await request(
|
||||
host: host, port: port, method: "GET", path: path, body: nil, contentType: nil,
|
||||
identity: identity, pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
|
||||
}
|
||||
|
||||
/// `POST https://host:port/path` with a body — same transport, trust and retry rule as `get`.
|
||||
/// The one write a paired device may make is the client-log upload, which is idempotent in
|
||||
/// the only sense that matters (a retried bundle is a second bundle, not a corrupted one).
|
||||
static func post(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
path: String,
|
||||
body: Data,
|
||||
contentType: String,
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval = 15
|
||||
) async throws -> HTTPResponse {
|
||||
try await request(
|
||||
host: host, port: port, method: "POST", path: path, body: body,
|
||||
contentType: contentType, identity: identity,
|
||||
pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
|
||||
}
|
||||
|
||||
private static func request(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
method: String,
|
||||
path: String,
|
||||
body: Data?,
|
||||
contentType: String?,
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval
|
||||
) async throws -> HTTPResponse {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||
throw MgmtTransportError.invalidPort(port)
|
||||
@@ -70,7 +105,9 @@ enum MgmtTransport {
|
||||
}
|
||||
let wasReused = connection.hasServedRequest
|
||||
do {
|
||||
let response = try await connection.perform(path: path, timeout: timeout)
|
||||
let response = try await connection.perform(
|
||||
method: method, path: path, body: body, contentType: contentType,
|
||||
timeout: timeout)
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
return response
|
||||
} catch {
|
||||
@@ -230,7 +267,10 @@ final class MgmtConnection: @unchecked Sendable {
|
||||
private let rejection: RejectionFlag
|
||||
private final class RejectionFlag: @unchecked Sendable { var value = false }
|
||||
|
||||
func perform(path: String, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
func perform(
|
||||
method: String = "GET", path: String, body: Data? = nil, contentType: String? = nil,
|
||||
timeout: TimeInterval
|
||||
) async throws -> HTTPResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
guard self.phase != .dead else {
|
||||
@@ -240,7 +280,9 @@ final class MgmtConnection: @unchecked Sendable {
|
||||
self.operation += 1
|
||||
let op = self.operation
|
||||
self.pending = continuation
|
||||
self.pendingRequest = self.requestBytes(path: path)
|
||||
self.pendingRequest = Self.requestBytes(
|
||||
host: self.host, port: self.port,
|
||||
method: method, path: path, body: body, contentType: contentType)
|
||||
self.buffer.removeAll(keepingCapacity: true)
|
||||
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
guard let self, self.operation == op else { return }
|
||||
@@ -361,17 +403,24 @@ final class MgmtConnection: @unchecked Sendable {
|
||||
rejection.value ? .pinMismatch : .connection(String(describing: error))
|
||||
}
|
||||
|
||||
private func requestBytes(path: String) -> Data {
|
||||
/// The wire bytes of one request. Pure (and `static`) so the framing is unit-testable.
|
||||
static func requestBytes(
|
||||
host: String, port: UInt16,
|
||||
method: String, path: String, body: Data?, contentType: String?
|
||||
) -> Data {
|
||||
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
|
||||
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
|
||||
let request = """
|
||||
GET \(path) HTTP/1.1\r
|
||||
Host: \(authority)\r
|
||||
User-Agent: punktfunk-apple\r
|
||||
Accept: */*\r
|
||||
\r
|
||||
|
||||
"""
|
||||
return Data(request.utf8)
|
||||
var head = "\(method) \(path) HTTP/1.1\r\nHost: \(authority)\r\n"
|
||||
+ "User-Agent: punktfunk-apple\r\nAccept: */*\r\n"
|
||||
if let body {
|
||||
// Always framed by length — a request body has no EOF to end it on a kept-alive
|
||||
// connection, and the host's axum would otherwise wait for one.
|
||||
head += "Content-Type: \(contentType ?? "application/octet-stream")\r\n"
|
||||
head += "Content-Length: \(body.count)\r\n"
|
||||
}
|
||||
head += "\r\n"
|
||||
var request = Data(head.utf8)
|
||||
if let body { request.append(body) }
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import IOKit
|
||||
import IOKit.hid
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
private let log = ClientLog(category: "gamepad")
|
||||
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
import GameController
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
private let log = ClientLog(category: "gamepad")
|
||||
|
||||
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
|
||||
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
|
||||
|
||||
@@ -48,7 +48,7 @@ import os
|
||||
/// PUNKTFUNK_INPUT_DEBUG=1 in the environment to surface whether relative motion + buttons
|
||||
/// are actually being SENT to the host without needing host-side logs. Motion is throttled
|
||||
/// to once per second (see `motionDebugTick`); buttons log every transition.
|
||||
private let inputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
|
||||
private let inputLog = ClientLog(category: "input")
|
||||
private let inputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
|
||||
|
||||
public final class InputCapture {
|
||||
@@ -60,6 +60,10 @@ public final class InputCapture {
|
||||
private var keyboards: [GCKeyboard] = []
|
||||
#if os(macOS)
|
||||
private var keyEventMonitor: Any?
|
||||
/// The system-shortcut tap (see `installSystemKeyTap`) and its run-loop source. Live only
|
||||
/// while forwarding with `inhibit_shortcuts` on AND Accessibility granted; nil otherwise.
|
||||
private var systemKeyTap: CFMachPort?
|
||||
private var systemKeyTapSource: CFRunLoopSource?
|
||||
#endif
|
||||
|
||||
// Main-queue-only state (see header comment).
|
||||
@@ -194,7 +198,13 @@ public final class InputCapture {
|
||||
if on {
|
||||
forwarding = true
|
||||
suppressedButton = suppressClick ? 1 : nil
|
||||
#if os(macOS)
|
||||
installSystemKeyTap()
|
||||
#endif
|
||||
} else if forwarding {
|
||||
#if os(macOS)
|
||||
removeSystemKeyTap()
|
||||
#endif
|
||||
releaseAll()
|
||||
forwarding = false
|
||||
suppressedButton = nil
|
||||
@@ -369,6 +379,7 @@ public final class InputCapture {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
keyEventMonitor = nil
|
||||
}
|
||||
removeSystemKeyTap()
|
||||
#endif
|
||||
// Don't clobber the handlers if a newer capture has taken the global devices.
|
||||
if Self.activeCapture === self || Self.activeCapture == nil {
|
||||
@@ -672,6 +683,128 @@ public final class InputCapture {
|
||||
}
|
||||
commandChordVKs.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - System shortcut tap
|
||||
|
||||
/// Whether the system-shortcut tap CAN run: Accessibility granted to this process. Read live
|
||||
/// (the user flips it in System Settings while the app runs); never prompts — the prompt is the
|
||||
/// Settings toggle's job (`requestSystemShortcutAccess`), not something a stream start springs.
|
||||
public static var systemShortcutsAvailable: Bool { AXIsProcessTrusted() }
|
||||
|
||||
/// Show the one-time Accessibility prompt (a no-op once granted). Called from Settings when the
|
||||
/// user turns "Capture system shortcuts" on or presses the grant button.
|
||||
public static func requestSystemShortcutAccess() {
|
||||
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(opts)
|
||||
}
|
||||
|
||||
/// The other half of `inhibit_shortcuts` on macOS. The keyDown monitor above claims the ⌘
|
||||
/// chords that REACH the app — but ⌘Space, ⌘Tab, ⌃↑ and the rest of System Settings › Keyboard
|
||||
/// › Shortcuts never do: WindowServer hands them to Spotlight / the Dock / Mission Control before
|
||||
/// any app sees them. The SDL clients get those through a private CGS hotkey-mode call that a
|
||||
/// sandboxed app cannot make; the sandbox-legal way is a session-level event tap, which sees
|
||||
/// every key ahead of the hotkey dispatch and only exists with Accessibility granted.
|
||||
///
|
||||
/// The tap does NOT forward anything itself. It takes each keyDown/keyUp off the system and
|
||||
/// re-posts it, addressed to the key window, into THIS app's event queue (`NSApp.postEvent`), so
|
||||
/// it arrives exactly where the same key would have arrived had macOS not claimed it — the
|
||||
/// monitor first (client chords, ⌘ chords → host), then `StreamLayerView.keyDown/keyUp`
|
||||
/// (everything else → host). One key path, no second VK table, no second release bookkeeping.
|
||||
/// In-process posts don't re-enter the tap, so there is no loop. Keys the system would have
|
||||
/// delivered anyway are unaffected (we drop the original and deliver the copy) — the tap only
|
||||
/// changes what happens to the ones it wouldn't. Bonus: the keyUp of a ⌘-chord key now arrives
|
||||
/// too (the tap sees HID, which never stopped delivering it), so `flushCommandChord` has less
|
||||
/// to synthesize.
|
||||
///
|
||||
/// Gating, every event: `forwarding` (capture engaged — and capture releases on any focus loss,
|
||||
/// so this is never true with another app frontmost), `!desktopMouse` (system chords stay local
|
||||
/// under the desktop model, like every other client), `NSApp.isActive` as belt-and-braces.
|
||||
/// Anything else passes through untouched — a tap that swallows keys for the whole Mac is the
|
||||
/// failure mode to design against. Installed on the main run loop on purpose: a hung main thread
|
||||
/// trips the tap's timeout and macOS disables it, handing the keyboard back.
|
||||
private func installSystemKeyTap() {
|
||||
// `desktopMouse` is NOT an install condition: ⌃⌥⇧M flips it mid-capture, so the callback
|
||||
// reads it per event instead and the tap simply idles under the desktop model.
|
||||
guard systemKeyTap == nil, SessionSettings.current.inhibitShortcuts, AXIsProcessTrusted()
|
||||
else { return }
|
||||
let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
|
||||
let callback: CGEventTapCallBack = { _, type, event, userInfo in
|
||||
guard let userInfo else { return Unmanaged.passUnretained(event) }
|
||||
let capture = Unmanaged<InputCapture>.fromOpaque(userInfo).takeUnretainedValue()
|
||||
return capture.handleTapped(type: type, event: event)
|
||||
}
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap,
|
||||
eventsOfInterest: CGEventMask(mask), callback: callback,
|
||||
userInfo: Unmanaged.passUnretained(self).toOpaque())
|
||||
else {
|
||||
inputLog.error("system shortcut tap: tapCreate failed (Accessibility revoked?)")
|
||||
return
|
||||
}
|
||||
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
systemKeyTap = tap
|
||||
systemKeyTapSource = source
|
||||
if inputDebug { inputLog.debug("system shortcut tap installed") }
|
||||
}
|
||||
|
||||
private func removeSystemKeyTap() {
|
||||
guard let tap = systemKeyTap else { return }
|
||||
CGEvent.tapEnable(tap: tap, enable: false)
|
||||
if let source = systemKeyTapSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
|
||||
}
|
||||
CFMachPortInvalidate(tap)
|
||||
systemKeyTap = nil
|
||||
systemKeyTapSource = nil
|
||||
if inputDebug { inputLog.debug("system shortcut tap removed") }
|
||||
}
|
||||
|
||||
/// The tap callback body (main run loop). Returns the event to let it through, nil to swallow.
|
||||
private func handleTapped(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
||||
switch type {
|
||||
case .tapDisabledByTimeout, .tapDisabledByUserInput:
|
||||
// macOS switched us off (main thread stalled past the tap's deadline, or a
|
||||
// system-level interruption); re-arm if still wanted, else stay down.
|
||||
if let tap = systemKeyTap, forwarding { CGEvent.tapEnable(tap: tap, enable: true) }
|
||||
return Unmanaged.passUnretained(event)
|
||||
case .keyDown, .keyUp:
|
||||
// Stamped with the KEY window: `NSApp.sendEvent` routes a key event by `event.window`,
|
||||
// and an NSEvent wrapped straight from the CGEvent has none — it reaches the local
|
||||
// monitor but not the first responder (verified in a harness). The key window is the
|
||||
// stream window whenever `forwarding` is true (capture releases on resignKey); if there
|
||||
// somehow is none, let the key go rather than swallow it into nothing.
|
||||
guard Self.tapClaims(forwarding: forwarding, desktopMouse: desktopMouse,
|
||||
appActive: NSApp.isActive),
|
||||
let windowNumber = NSApp.keyWindow?.windowNumber,
|
||||
let copy = event.copy(), let raw = NSEvent(cgEvent: copy),
|
||||
let stamped = Self.restamp(raw, windowNumber: windowNumber)
|
||||
else { return Unmanaged.passUnretained(event) }
|
||||
NSApp.postEvent(stamped, atStart: false)
|
||||
return nil
|
||||
default:
|
||||
return Unmanaged.passUnretained(event)
|
||||
}
|
||||
}
|
||||
|
||||
/// The same key event, addressed to `windowNumber` (see `handleTapped`).
|
||||
static func restamp(_ raw: NSEvent, windowNumber: Int) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: raw.type, location: .zero, modifierFlags: raw.modifierFlags,
|
||||
timestamp: raw.timestamp, windowNumber: windowNumber, context: nil,
|
||||
characters: raw.characters ?? "",
|
||||
charactersIgnoringModifiers: raw.charactersIgnoringModifiers ?? "",
|
||||
isARepeat: raw.type == .keyDown && raw.isARepeat, keyCode: raw.keyCode)
|
||||
}
|
||||
|
||||
/// Does the system-shortcut tap take this key off macOS and hand it to the app's own key path?
|
||||
/// Pure, for the tests: only while captured, only under the capture mouse model, only with the
|
||||
/// app frontmost. The `inhibit_shortcuts` setting is checked once at install time (the tap does
|
||||
/// not exist with it off).
|
||||
static func tapClaims(forwarding: Bool, desktopMouse: Bool, appActive: Bool) -> Bool {
|
||||
forwarding && !desktopMouse && appActive
|
||||
}
|
||||
#endif
|
||||
|
||||
private func attach(mouse: GCMouse) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// The client's own recent-log ring + the drop-in logger that feeds it — the source for the
|
||||
// "Send logs to host" action (`LibraryClient.sendLogs`), the Apple port of
|
||||
// `pf_client_core::logring` + `punktfunk-session`'s `ring_layer`.
|
||||
//
|
||||
// WHY A RING AND NOT `OSLogStore`. The unified log already holds everything these loggers write,
|
||||
// and `OSLogStore(scope: .currentProcessIdentifier)` can read it back — but only the levels the
|
||||
// system PERSISTS (`.notice`/`.error`/`.fault`). `.info` is memory-only and purged under pressure,
|
||||
// and `.info` is exactly where the lines a field report needs live: the 1 Hz stats line, the
|
||||
// decoder/presenter setup, the audio underrun notes. On an Apple TV there is no Console.app to
|
||||
// read any of it on either. So every `ClientLog` call goes to os_log as before AND into this
|
||||
// process-global ring, and an explicit user action posts the ring to the PAIRED host, where the
|
||||
// web console shows it next to the host's own log.
|
||||
//
|
||||
// Bounded by lines AND bytes so a log-storm can't grow memory; the byte budget stays under the
|
||||
// host's 1 MiB upload cap so a full ring always uploads whole. `.debug` deliberately skips the
|
||||
// ring: it is per-key/per-event input chatter here, and a ring that a healthy keyboard can flush
|
||||
// in thirty seconds is worse than no ring (the session client learned this from a Steam Deck
|
||||
// bundle whose whole 27-minute session had been evicted by decoder DPB chatter).
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Process-global bounded ring of formatted log lines. `note` is cheap (one lock, one append);
|
||||
/// `render` is the upload body.
|
||||
public enum ClientLogRing {
|
||||
/// Newest lines kept — matches the host's own ring depth and the session client's.
|
||||
public static let maxLines = 4096
|
||||
/// Byte budget — under the host's 1 MiB bundle cap with headroom for the header.
|
||||
public static let maxBytes = 768 * 1024
|
||||
|
||||
private static let lock = OSAllocatedUnfairLock(initialState: State())
|
||||
|
||||
private struct State {
|
||||
var lines: [String] = []
|
||||
/// Index of the oldest live line in `lines` — popped lazily, compacted when half is dead,
|
||||
/// so eviction is O(1) amortised without a deque type.
|
||||
var head = 0
|
||||
var bytes = 0
|
||||
var dropped = 0
|
||||
}
|
||||
|
||||
/// Append one formatted log line (no trailing newline). Oversized lines are truncated to keep
|
||||
/// a single event from evicting the whole ring.
|
||||
public static func note(_ line: String) {
|
||||
// `decoding:` rather than `String(_:)`: a cut mid-scalar yields U+FFFD, not nil.
|
||||
let line = line.utf8.count > 2048
|
||||
? String(decoding: line.utf8.prefix(2048), as: UTF8.self) + "…" : line
|
||||
let size = line.utf8.count
|
||||
lock.withLock { s in
|
||||
s.lines.append(line)
|
||||
s.bytes += size
|
||||
while s.lines.count - s.head > maxLines || s.bytes > maxBytes, s.head < s.lines.count {
|
||||
s.bytes -= s.lines[s.head].utf8.count
|
||||
s.head += 1
|
||||
s.dropped += 1
|
||||
}
|
||||
if s.head > 0, s.head * 2 >= s.lines.count {
|
||||
s.lines.removeFirst(s.head)
|
||||
s.head = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the app's own
|
||||
/// identity line — name, version, platform) and an eviction note when the ring wrapped.
|
||||
public static func render(header: String) -> String {
|
||||
lock.withLock { s in
|
||||
var out = header + "\n"
|
||||
if s.dropped > 0 {
|
||||
out += "… \(s.dropped) older lines evicted from the ring …\n"
|
||||
}
|
||||
for line in s.lines[s.head...] {
|
||||
out += line
|
||||
out += "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundle's first line: `punktfunk-apple 0.31.0 (42) (iOS 26.0.0 arm64; Apple TV) — client
|
||||
/// log bundle` — the same shape as the session client's, so the host's log page reads them alike.
|
||||
public static func header() -> String {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let version = info?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
let build = info?["CFBundleVersion"] as? String
|
||||
let os = ProcessInfo.processInfo.operatingSystemVersion
|
||||
#if os(macOS)
|
||||
let platform = "macOS"
|
||||
#elseif os(tvOS)
|
||||
let platform = "tvOS"
|
||||
#elseif os(iOS)
|
||||
let platform = "iOS"
|
||||
#else
|
||||
let platform = "apple"
|
||||
#endif
|
||||
#if arch(arm64)
|
||||
let arch = "arm64"
|
||||
#else
|
||||
let arch = "x86_64"
|
||||
#endif
|
||||
let v = build.map { "\(version) (\($0))" } ?? version
|
||||
return "punktfunk-apple \(v) (\(platform) \(os.majorVersion).\(os.minorVersion).\(os.patchVersion) "
|
||||
+ "\(arch); \(DeviceName.kind)) — client log bundle"
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` — wall time, so a bundle correlates with the host log it lands
|
||||
/// next to (the session client's `wallclock`).
|
||||
static func stamp(_ date: Date = Date()) -> String {
|
||||
Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)`: the same call shape (string
|
||||
/// interpolation with `privacy:`/`format:` options), forwarded to os_log AND noted in
|
||||
/// `ClientLogRing`. Interpolated values are rendered in the clear in both places — the unified log
|
||||
/// was already being read with the app attached, and the ring only ever leaves the device by an
|
||||
/// explicit "Send logs to host" to a host the user paired with.
|
||||
public struct ClientLog: Sendable {
|
||||
public let category: String
|
||||
private let logger: Logger
|
||||
|
||||
public init(category: String) {
|
||||
self.category = category
|
||||
self.logger = Logger(subsystem: "io.unom.punktfunk", category: category)
|
||||
}
|
||||
|
||||
/// os_log only — see the file comment for why debug stays out of the ring.
|
||||
public func debug(_ message: ClientLogMessage) {
|
||||
logger.debug("\(message.text, privacy: .public)")
|
||||
}
|
||||
|
||||
public func info(_ message: ClientLogMessage) {
|
||||
logger.info("\(message.text, privacy: .public)")
|
||||
note("INFO", message.text)
|
||||
}
|
||||
|
||||
public func notice(_ message: ClientLogMessage) {
|
||||
logger.notice("\(message.text, privacy: .public)")
|
||||
note("INFO", message.text)
|
||||
}
|
||||
|
||||
public func warning(_ message: ClientLogMessage) {
|
||||
logger.warning("\(message.text, privacy: .public)")
|
||||
note("WARN", message.text)
|
||||
}
|
||||
|
||||
public func error(_ message: ClientLogMessage) {
|
||||
logger.error("\(message.text, privacy: .public)")
|
||||
note("ERROR", message.text)
|
||||
}
|
||||
|
||||
public func fault(_ message: ClientLogMessage) {
|
||||
logger.fault("\(message.text, privacy: .public)")
|
||||
note("ERROR", message.text)
|
||||
}
|
||||
|
||||
private func note(_ level: String, _ text: String) {
|
||||
ClientLogRing.note("\(ClientLogRing.stamp()) \(level.padding(toLength: 5, withPad: " ", startingAt: 0)) \(category) \(text)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The interpolated message: accepts the `OSLogMessage` options the call sites use (`privacy:`,
|
||||
/// `format:`) so swapping `Logger` for `ClientLog` touches one declaration per file, not every
|
||||
/// log line. Privacy is accepted and ignored (see `ClientLog`); `.fixed(precision:)` is honoured.
|
||||
public struct ClientLogMessage: ExpressibleByStringInterpolation, ExpressibleByStringLiteral, Sendable {
|
||||
public let text: String
|
||||
|
||||
public init(stringLiteral value: String) { text = value }
|
||||
public init(stringInterpolation: StringInterpolation) { text = stringInterpolation.out }
|
||||
|
||||
public struct StringInterpolation: StringInterpolationProtocol, Sendable {
|
||||
var out = ""
|
||||
public init(literalCapacity: Int, interpolationCount: Int) {
|
||||
out.reserveCapacity(literalCapacity + interpolationCount * 8)
|
||||
}
|
||||
public mutating func appendLiteral(_ literal: String) { out += literal }
|
||||
public mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy = .auto) {
|
||||
out += String(describing: value)
|
||||
}
|
||||
public mutating func appendInterpolation<T: BinaryFloatingPoint>(
|
||||
_ value: T, format: ClientLogFloatFormat, privacy: OSLogPrivacy = .auto
|
||||
) {
|
||||
switch format {
|
||||
case .fixed(let precision):
|
||||
out += String(format: "%.\(precision)f", Double(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one float format the call sites use. `OSLogFloatFormatting` cannot be pattern-matched, so
|
||||
/// the message type names its own — same spelling at the call site: `format: .fixed(precision: 2)`.
|
||||
public enum ClientLogFloatFormat: Sendable {
|
||||
case fixed(precision: Int)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// The Rust core's log lines, routed into `ClientLog` (os_log + the send-to-host ring).
|
||||
//
|
||||
// The core logs through `tracing`. The desktop and Android shells install a subscriber/logger and
|
||||
// see those lines; this app never did, so every transport warning (socket-buffer clamp, QoS
|
||||
// refusal), every quinn connection event and every rustls handshake note vanished, and a bundle
|
||||
// sent to the host carried the Swift half of the story only. `punktfunk_set_log_callback`
|
||||
// (ABI v25) hands them to the C callback below, which files each under a `core.<crate>` category.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
|
||||
public enum CoreLog {
|
||||
/// Install once at launch. Levels above `maxLevel` (1 = error … 5 = trace) are not even
|
||||
/// formatted on the Rust side. Info is the ceiling on purpose: quinn's debug/trace is
|
||||
/// per-packet and would churn the ring — the same gate the session client's ring applies.
|
||||
/// `PUNKTFUNK_CORE_LOG_LEVEL=4` raises it for a debugging session.
|
||||
public static func install() {
|
||||
let level = UInt8(ProcessInfo.processInfo.environment["PUNKTFUNK_CORE_LOG_LEVEL"] ?? "") ?? 3
|
||||
let status = punktfunk_set_log_callback(level, { level, target, message, _ in
|
||||
// Called from whichever Rust thread logged — copy both C strings out before anything
|
||||
// else, then hand off to ClientLog, which is cheap (one lock + os_log) and thread-safe.
|
||||
let target = target.map { String(cString: $0) } ?? "core"
|
||||
let message = message.map { String(cString: $0) } ?? ""
|
||||
// The crate (first path segment) becomes the category; the full target stays in the
|
||||
// line, so `quinn::connection` reads as `core.quinn quinn::connection …`.
|
||||
let crate_ = target.split(separator: ":", maxSplits: 1).first.map(String.init) ?? target
|
||||
let log = ClientLog(category: "core.\(crate_)")
|
||||
switch level {
|
||||
case 1: log.error("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
case 2: log.warning("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
case 3: log.info("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
default: log.debug("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
}
|
||||
}, nil)
|
||||
if status != PUNKTFUNK_STATUS_OK.rawValue {
|
||||
ClientLog(category: "core").warning("core log callback not installed: status \(status)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
private let presenterLog = ClientLog(category: "presenter")
|
||||
|
||||
#if os(macOS)
|
||||
/// HOW a windowed (composited) macOS session pushes finished frames to glass — the DCP
|
||||
|
||||
@@ -34,7 +34,7 @@ import Foundation
|
||||
import Metal
|
||||
import os
|
||||
|
||||
private let waveletLog = Logger(subsystem: "io.unom.punktfunk", category: "pyrowave")
|
||||
private let waveletLog = ClientLog(category: "pyrowave")
|
||||
|
||||
/// The per-(component, level, band) 32x32-block table — the exact Swift port of
|
||||
/// `WaveletBuffers::init_block_meta` (pyrowave_common.cpp): the walk order (level 4→0,
|
||||
|
||||
@@ -56,9 +56,9 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
|
||||
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||
private let presentLog = ClientLog(category: "present")
|
||||
/// Pump-side events (loss recovery, format seeding) — the stage-2 sibling of StreamPump's log.
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
|
||||
private let pumpLog = ClientLog(category: "pump")
|
||||
|
||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||
|
||||
@@ -8,7 +8,7 @@ import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
|
||||
private let pumpLog = ClientLog(category: "video")
|
||||
|
||||
/// One pump per instance; create a fresh StreamPump per start (the stop is permanent —
|
||||
/// a restart hands the old pump its own token, so it can never be revived by a newer start()).
|
||||
|
||||
@@ -26,7 +26,7 @@ import os
|
||||
/// Same diagnostic switch as InputCapture: PUNKTFUNK_INPUT_DEBUG=1 logs when the macOS
|
||||
/// NSEvent mouse monitor (relative motion + buttons) is installed/removed, so the user can
|
||||
/// confirm the new motion path is actually live for a session.
|
||||
private let streamInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
|
||||
private let streamInputLog = ClientLog(category: "input")
|
||||
private let streamInputDebug =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import AVKit // AVDisplayManager — the per-session display-mode (HDR10/refresh
|
||||
/// resolved pointer-lock state each time capture engages, so the user can see whether the
|
||||
/// scene actually locked (GCMouse only delivers deltas while it did) or whether we're on
|
||||
/// the touch fallback.
|
||||
private let iosInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
|
||||
private let iosInputLog = ClientLog(category: "input")
|
||||
private let iosInputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
|
||||
|
||||
public struct StreamView: UIViewControllerRepresentable {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// The client log ring behind "Send logs to host", and the POST framing that carries it.
|
||||
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class ClientLogTests: XCTestCase {
|
||||
/// The ring is process-global, so this single test owns the whole lifecycle (parallel tests
|
||||
/// over one global would interleave) — the same shape as `pf_client_core::logring`'s test.
|
||||
func testRingBoundsAndRendersWithEvictionNote() {
|
||||
let marker = "ringtest-\(ProcessInfo.processInfo.processIdentifier)"
|
||||
for i in 0..<(ClientLogRing.maxLines + 10) {
|
||||
ClientLogRing.note("\(marker) line \(i)")
|
||||
}
|
||||
let text = ClientLogRing.render(header: "punktfunk-apple test")
|
||||
XCTAssertTrue(text.hasPrefix("punktfunk-apple test\n"))
|
||||
XCTAssertTrue(text.contains("older lines evicted from the ring"))
|
||||
XCTAssertFalse(text.contains("\n\(marker) line 0\n"), "oldest line survived eviction")
|
||||
XCTAssertTrue(text.hasSuffix("\(marker) line \(ClientLogRing.maxLines + 9)\n"))
|
||||
XCTAssertLessThanOrEqual(text.utf8.count, ClientLogRing.maxBytes + 256)
|
||||
|
||||
// A pathological line is truncated, not ring-flushing — and cut safely mid-scalar.
|
||||
ClientLogRing.note(String(repeating: "é", count: 10_000))
|
||||
let after = ClientLogRing.render(header: "h")
|
||||
XCTAssertTrue(after.contains("…"))
|
||||
XCTAssertTrue(after.hasSuffix("…\n"))
|
||||
|
||||
// The drop-in logger formats `stamp LEVEL category message` and honours the OSLogMessage
|
||||
// options the call sites use; debug stays out of the ring.
|
||||
let log = ClientLog(category: "test")
|
||||
log.info("\(marker) value \(1.23456, format: .fixed(precision: 2)) \(42, privacy: .public)")
|
||||
log.debug("\(marker) debug-only")
|
||||
let lines = ClientLogRing.render(header: "h").components(separatedBy: "\n")
|
||||
let info = lines.last { $0.contains("\(marker) value") }
|
||||
XCTAssertNotNil(info)
|
||||
XCTAssertTrue(info!.contains(" INFO test \(marker) value 1.23 42"), info!)
|
||||
// `2026-08-15T12:03:47.123Z ` leads — wall time, so a bundle lines up with the host log.
|
||||
XCTAssertNotNil(
|
||||
info!.range(of: #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z INFO test "#, options: .regularExpression),
|
||||
info!)
|
||||
XCTAssertFalse(lines.contains { $0.contains("debug-only") })
|
||||
}
|
||||
|
||||
func testHeaderNamesTheAppAndPlatform() {
|
||||
let header = ClientLogRing.header()
|
||||
XCTAssertTrue(header.hasPrefix("punktfunk-apple "))
|
||||
XCTAssertTrue(header.hasSuffix(" — client log bundle"))
|
||||
#if os(macOS)
|
||||
XCTAssertTrue(header.contains("macOS"))
|
||||
#endif
|
||||
}
|
||||
|
||||
func testPostIsLengthFramedAndGetHasNoBody() {
|
||||
let body = Data("hello ring\n".utf8)
|
||||
let post = String(decoding: MgmtConnection.requestBytes(
|
||||
host: "fd00::1", port: 47990, method: "POST", path: "/api/v1/client-logs",
|
||||
body: body, contentType: "text/plain; charset=utf-8"), as: UTF8.self)
|
||||
XCTAssertTrue(post.hasPrefix("POST /api/v1/client-logs HTTP/1.1\r\nHost: [fd00::1]:47990\r\n"))
|
||||
XCTAssertTrue(post.contains("\r\nContent-Type: text/plain; charset=utf-8\r\n"))
|
||||
XCTAssertTrue(post.contains("\r\nContent-Length: \(body.count)\r\n\r\nhello ring\n"))
|
||||
XCTAssertTrue(post.hasSuffix("\r\n\r\nhello ring\n"))
|
||||
|
||||
let get = String(decoding: MgmtConnection.requestBytes(
|
||||
host: "192.168.1.2", port: 47990, method: "GET", path: "/api/v1/library",
|
||||
body: nil, contentType: nil), as: UTF8.self)
|
||||
XCTAssertTrue(get.hasPrefix("GET /api/v1/library HTTP/1.1\r\nHost: 192.168.1.2:47990\r\n"))
|
||||
XCTAssertFalse(get.contains("Content-Length"))
|
||||
XCTAssertTrue(get.hasSuffix("\r\n\r\n"))
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,25 @@ final class CommandChordTests: XCTestCase {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
|
||||
}
|
||||
|
||||
/// The system-shortcut tap (⌘Space, ⌘Tab — the keys macOS claims before the app sees them)
|
||||
/// takes keys off the system ONLY while captured, under the capture mouse model, with the app
|
||||
/// frontmost. Any other state must pass through: a tap that eats keys for the whole Mac is the
|
||||
/// failure to pin here.
|
||||
func testTheSystemShortcutTapOnlyClaimsWhileCapturedAndFrontmost() {
|
||||
XCTAssertTrue(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: true))
|
||||
XCTAssertFalse(InputCapture.tapClaims(forwarding: false, desktopMouse: false, appActive: true))
|
||||
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: true, appActive: true))
|
||||
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: false))
|
||||
}
|
||||
|
||||
/// The keys the tap exists for must have host VKs — it reposts them into the ordinary key path,
|
||||
/// which drops unmapped keyCodes on the floor.
|
||||
func testTheSystemShortcutKeysMapToHostVKs() {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[49], 0x20) // Space (⌘Space)
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[48], 0x09) // Tab (⌘Tab)
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[126], 0x26) // Up arrow (⌃↑ Mission Control)
|
||||
}
|
||||
|
||||
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
|
||||
@@ -81,6 +81,15 @@ WHY THE APP ASKS FOR WHAT IT ASKS FOR
|
||||
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
|
||||
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
|
||||
without this, no video, audio or rumble arrives.
|
||||
- Accessibility (macOS, optional, never requested unprompted): "Capture system shortcuts" in
|
||||
Settings > Input lets ⌘Space, ⌘Tab and Mission Control reach the remote desktop instead of the
|
||||
Mac while the stream has captured the keyboard -- the same thing every remote-desktop/VM app
|
||||
offers. macOS delivers those keys to Spotlight/the Dock before any app, so the only way to
|
||||
receive them is a keyboard event tap, which needs Accessibility. The prompt appears only when
|
||||
the user turns the toggle on or presses "Allow Accessibility access…"; the tap exists only while
|
||||
a stream has the keyboard captured and the app is frontmost, and it reads nothing -- keys are
|
||||
handed to the app's own stream window, never logged or stored. Without the grant, the toggle
|
||||
still works for the app's own ⌘ shortcuts and simply says the system ones need Accessibility.
|
||||
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
|
||||
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
|
||||
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
|
||||
|
||||
@@ -82,6 +82,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
.or_else(|| k.and_then(|h| h.mgmt_port))
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: false,
|
||||
clipboard_sync: k.is_some_and(|h| h.clipboard_sync),
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
os: k.map(|h| h.os.clone()).unwrap_or_default(),
|
||||
pin: None,
|
||||
@@ -336,6 +337,7 @@ fn fake_host_row() -> HostRow {
|
||||
online: true,
|
||||
mgmt_port: library::DEFAULT_MGMT_PORT,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: "linux/arch/steamos".into(),
|
||||
pin: None,
|
||||
@@ -698,6 +700,40 @@ impl ServiceState {
|
||||
// `run` refreshes the rows right after this drain, so the carousel and
|
||||
// the pin screen reflect the new card within the same service pass.
|
||||
}
|
||||
ConsoleCmd::BindProfile { key, profile_id } => {
|
||||
// The BINDING half of the profile pair — `KnownHost::profile_id`, what a
|
||||
// plain A-press on the primary tile connects with. `SetPin` above is the
|
||||
// presentation half and never touches this field; this never touches the
|
||||
// pins. Same store discipline, same refresh-after-drain.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = index_for_key(&known, &key);
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "profile bind for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if h.profile_id != profile_id {
|
||||
h.profile_id = profile_id;
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
}
|
||||
}
|
||||
ConsoleCmd::SetClipboard { key, on } => {
|
||||
// Per-host clipboard trust (`KnownHost::clipboard_sync`) — the host
|
||||
// menu's toggle. Same store discipline as the two arms above.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = index_for_key(&known, &key);
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "clipboard toggle for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if h.clipboard_sync != on {
|
||||
h.clipboard_sync = on;
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,6 +823,7 @@ impl ServiceState {
|
||||
.or(h.mgmt_port)
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: !online && !h.mac.is_empty(),
|
||||
clipboard_sync: h.clipboard_sync,
|
||||
last_used: h.last_used,
|
||||
os: advert
|
||||
.filter(|d| !d.os.is_empty())
|
||||
@@ -845,6 +882,7 @@ impl ServiceState {
|
||||
online: true,
|
||||
mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: d.os.clone(),
|
||||
pin: None,
|
||||
|
||||
@@ -68,7 +68,7 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
event.record(&mut v);
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
wallclock(),
|
||||
pf_client_core::logring::wallclock(),
|
||||
meta.level().as_str(),
|
||||
meta.target(),
|
||||
v.0
|
||||
@@ -76,34 +76,6 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
|
||||
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
|
||||
fn wallclock() -> String {
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let secs = (ms / 1000) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
|
||||
ms % 1000
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -67,6 +67,12 @@ pub enum PointerInput {
|
||||
x: f32,
|
||||
y: f32,
|
||||
button: PointerButton,
|
||||
/// A finger (or stylus) on the glass, as opposed to a mouse button. The console
|
||||
/// defers a touch press until the lift so a swipe can scroll instead of acting on
|
||||
/// whatever the finger first lands on; a mouse press keeps acting immediately.
|
||||
/// Only `Down` carries it — the console tracks the gesture it opened, so the
|
||||
/// matching `Up`/`Move`/`Cancel` need no flag of their own.
|
||||
touch: bool,
|
||||
},
|
||||
Up {
|
||||
x: f32,
|
||||
|
||||
@@ -63,7 +63,11 @@ pub mod library;
|
||||
// Per-host catalog cache, so a library screen has titles to show while a sleeping host boots.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod library_cache;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
// Android-enabled for the RING half (note/render — std only): the client's "Send logs to
|
||||
// host" needs the ring on every platform. The `send_to_host` uploader inside stays
|
||||
// desktop-gated with the rest of the ureq fetches; Android posts the rendered bundle
|
||||
// through its own mTLS OkHttp client (`SkiaConsole.sendLogs`).
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
pub mod logring;
|
||||
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
|
||||
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
|
||||
|
||||
@@ -55,6 +55,36 @@ pub fn note(mut line: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
|
||||
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
|
||||
/// Lives here (not in a shell) because every ring FEEDER wants the same stamp: the session's
|
||||
/// `ring_layer` and the Android client's logcat tee both prefix their lines with it.
|
||||
pub fn wallclock() -> String {
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let secs = (ms / 1000) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
|
||||
ms % 1000
|
||||
)
|
||||
}
|
||||
|
||||
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the shell's own
|
||||
/// identity line — binary name, version, platform) and an eviction note when the ring wrapped.
|
||||
pub fn render(header: &str) -> String {
|
||||
@@ -79,6 +109,7 @@ pub fn render(header: &str) -> String {
|
||||
/// trust as the library fetch: TLS client auth with the device identity, host pinned by
|
||||
/// fingerprint. Errors reuse the library's classification (401/403 ⇒ `NotPaired`, a pin-verifier
|
||||
/// rejection ⇒ `PinMismatch`), so the shell's existing error strings apply.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub fn send_to_host(
|
||||
addr: &str,
|
||||
mgmt_port: u16,
|
||||
|
||||
@@ -883,6 +883,12 @@ fn pump(
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
// THE session's stop flag, so the embedder's cancel reaches a dial that has not landed
|
||||
// yet. Without it this call parks the pump thread for the whole budget — 185 s on a
|
||||
// request-access connect the host holds pending approval — and the embedder's cancel
|
||||
// could not be answered until it returned: the console's takeover sat on "Canceling…"
|
||||
// with no session event to clear it.
|
||||
Some(stop.clone()),
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
|
||||
@@ -41,6 +41,11 @@ pub struct HostRow {
|
||||
pub mgmt_port: u16,
|
||||
/// Offline + a stored MAC → activating wakes first ("Wake & Connect").
|
||||
pub can_wake: bool,
|
||||
/// Share this device's clipboard with THIS host while streaming
|
||||
/// (`KnownHost::clipboard_sync`) — surfaced so the host menu can show and flip it.
|
||||
/// `serde(default)`: a producer predating the field still parses (as not-shared).
|
||||
#[serde(default)]
|
||||
pub clipboard_sync: bool,
|
||||
/// Last successful connect (UNIX seconds) — the most-recent accent.
|
||||
pub last_used: Option<u64>,
|
||||
/// The host's OS-identity chain (live advert preferred, else the stored one), for a
|
||||
@@ -220,6 +225,19 @@ pub enum ConsoleCmd {
|
||||
profile_id: String,
|
||||
pin: bool,
|
||||
},
|
||||
/// Bind (or clear) a saved host's DEFAULT profile — `KnownHost::profile_id`, the one a
|
||||
/// plain A-press on the primary tile connects with (the port design's WP5 leftover;
|
||||
/// [`ConsoleCmd::SetPin`] is presentation, this is the binding). `key` is the HOST
|
||||
/// row's key; `None` clears the binding. Idempotent like `SetPin`: re-binding the
|
||||
/// bound profile is a no-op.
|
||||
BindProfile {
|
||||
key: String,
|
||||
profile_id: Option<String>,
|
||||
},
|
||||
/// Share (or stop sharing) this device's clipboard with a saved host while streaming —
|
||||
/// `KnownHost::clipboard_sync`, the host menu's toggle. Per-host, never global:
|
||||
/// handing a host your clipboard is a trust decision about that host.
|
||||
SetClipboard { key: String, on: bool },
|
||||
/// Open a screen the PLATFORM owns over the console (design android-skia-console-port.md
|
||||
/// D7) — Android's connected-controllers view, the open-source licences. `id` is a
|
||||
/// [`crate::platform::PlatformScreen::id`]. The host draws it, holds the console's input
|
||||
@@ -276,6 +294,7 @@ mod tests {
|
||||
online: false,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! every screen animates and reads identically.
|
||||
|
||||
pub(crate) mod add_host;
|
||||
pub(crate) mod bind_profile;
|
||||
pub(crate) mod collections;
|
||||
pub(crate) mod controllers;
|
||||
pub(crate) mod home;
|
||||
@@ -180,6 +181,10 @@ pub(crate) enum Screen {
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
/// "Default for <host>": which profile the host's primary tile connects with — the
|
||||
/// binding sibling of [`Screen::PinHosts`]'s presentation cards. Raised by the host
|
||||
/// menu's "Default profile…" action.
|
||||
BindProfile(bind_profile::BindProfileScreen),
|
||||
/// "Connected controllers": the attached pads and their identity lines, plus the grants
|
||||
/// and tests only the platform can perform. Android-reachable only — the settings row
|
||||
/// that opens it is in `settings::row_on`'s Android-only list.
|
||||
@@ -205,6 +210,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
|
||||
Screen::BindProfile(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Controllers(s) => s.menu(ev, ctx, fx),
|
||||
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
|
||||
}
|
||||
@@ -224,6 +230,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Pair(s) => s.pointer(p, ctx, fx),
|
||||
Screen::PinHosts(s) => s.pointer(p, ctx, fx),
|
||||
Screen::BindProfile(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Controllers(s) => s.pointer(p, ctx, fx),
|
||||
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
|
||||
}
|
||||
@@ -274,6 +281,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.title(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
|
||||
Screen::BindProfile(s) => format!("Default for {}", s.host_name()),
|
||||
Screen::Controllers(_) => "Connected controllers".into(),
|
||||
Screen::HostOptions(s) => s.title(),
|
||||
}
|
||||
@@ -288,6 +296,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
Screen::PinHosts(s) => s.hints(ctx),
|
||||
Screen::BindProfile(s) => s.hints(ctx),
|
||||
Screen::Controllers(s) => s.hints(ctx),
|
||||
Screen::HostOptions(s) => s.hints(ctx),
|
||||
}
|
||||
@@ -313,6 +322,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::BindProfile(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Controllers(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::HostOptions(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! "Default for “Desk”" — choose the profile a plain A-press on a saved host connects
|
||||
//! with (`KnownHost::profile_id`), reached from the host tile's menu. One row per catalog
|
||||
//! profile behind a leading "No default" row; choosing rides
|
||||
//! [`ConsoleCmd::BindProfile`] to the binary, which persists the binding and refreshes
|
||||
//! the rows — the checkmark follows the model, so what the list says is always what the
|
||||
//! store holds (and what the tile's chip shows). Pinning is the sibling decision
|
||||
//! (`pin_hosts.rs`): a pin adds a CARD, this changes what the primary tile itself does.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
pub(crate) struct BindProfileScreen {
|
||||
/// The HOST row's primary key (fingerprint or `addr:port`, never a pinned card's
|
||||
/// composite) — what every [`ConsoleCmd::BindProfile`] here addresses.
|
||||
host_key: String,
|
||||
host_name: String,
|
||||
/// The catalog's `(id, name)` pairs, loaded once at construction — same stability
|
||||
/// assumption the settings screen's Profiles tab makes (the console can't create
|
||||
/// profiles, so the list can't change under this screen).
|
||||
profiles: Vec<(String, String)>,
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
impl BindProfileScreen {
|
||||
pub(crate) fn new(
|
||||
host_key: String,
|
||||
host_name: String,
|
||||
profiles: Vec<(String, String)>,
|
||||
) -> BindProfileScreen {
|
||||
BindProfileScreen {
|
||||
host_key,
|
||||
host_name,
|
||||
profiles,
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn host_name(&self) -> &str {
|
||||
&self.host_name
|
||||
}
|
||||
|
||||
/// The host's current binding, read from the model — the primary row's chip IS the
|
||||
/// state, so the checkmark can never disagree with what the carousel shows.
|
||||
fn bound(&self, ctx: &Ctx) -> Option<String> {
|
||||
ctx.hosts
|
||||
.iter()
|
||||
.find(|r| r.key == self.host_key)
|
||||
.and_then(|r| r.bound_profile.as_ref())
|
||||
.map(|p| p.id.clone())
|
||||
}
|
||||
|
||||
/// Row `i`'s meaning: 0 is "No default", the rest the catalog in order.
|
||||
fn choice(&self, i: usize) -> Option<Option<&str>> {
|
||||
if i == 0 {
|
||||
Some(None)
|
||||
} else {
|
||||
self.profiles.get(i - 1).map(|(id, _)| Some(id.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.profiles.len() + 1
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, self.len());
|
||||
self.choose(msg, pulse, ctx, fx)
|
||||
}
|
||||
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
let (msg, pulse) = self.list.pointer(p, self.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.choose(msg, pulse, ctx, fx);
|
||||
true
|
||||
}
|
||||
|
||||
/// One list message against the focused row — shared by both input paths. A choice is
|
||||
/// a radio press, not a toggle: A on the row that is already the binding is a boundary
|
||||
/// thud, and ◀/▶ adjust nothing here.
|
||||
fn choose(
|
||||
&mut self,
|
||||
msg: ListMsg,
|
||||
pulse: Option<MenuPulse>,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
let Some(choice) = self.choice(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
match msg {
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
ListMsg::Activate => {
|
||||
let current = self.bound(ctx);
|
||||
if current.as_deref() == choice {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
fx.cmds.push(ConsoleCmd::BindProfile {
|
||||
key: self.host_key.clone(),
|
||||
profile_id: choice.map(str::to_owned),
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
if self.profiles.is_empty() {
|
||||
return vec![Hint::new(HintKey::Back, "Done")];
|
||||
}
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Set default"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
if self.profiles.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"No profiles yet \u{2014} create them in the desktop app, then choose one here.",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
|
||||
f64::from(rect.width()) * 0.7,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The explainer band under the list, like the settings screen's detail text.
|
||||
let detail_h = 34.0 * k;
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let bound = self.bound(ctx);
|
||||
let rows: Vec<RowSpec> = (0..self.len())
|
||||
.map(|i| {
|
||||
let (label, id) = if i == 0 {
|
||||
("No default".to_string(), None)
|
||||
} else {
|
||||
let (id, name) = &self.profiles[i - 1];
|
||||
(name.clone(), Some(id.as_str()))
|
||||
};
|
||||
let current = bound.as_deref() == id;
|
||||
RowSpec {
|
||||
header: None,
|
||||
label,
|
||||
value: Some(if current {
|
||||
"Default".into()
|
||||
} else {
|
||||
String::new()
|
||||
}),
|
||||
value_dim: !current,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"What a plain press on this host's tile connects with. Pinned cards keep their own.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{HostRow, ProfileChip};
|
||||
use pf_client_core::menu_nav::MenuDir;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn host(bound: Option<&str>) -> HostRow {
|
||||
HostRow {
|
||||
key: "aa".into(),
|
||||
name: "Desk".into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
paired: true,
|
||||
saved: true,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: bound.map(|id| ProfileChip {
|
||||
id: id.into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn screen() -> BindProfileScreen {
|
||||
BindProfileScreen::new(
|
||||
"aa".into(),
|
||||
"Desk".into(),
|
||||
vec![("p1".into(), "Work".into()), ("p2".into(), "Game".into())],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_profile_binds_and_no_default_clears() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let hosts = [host(Some("p1"))];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = screen();
|
||||
// Row 2 = the second profile: binds it.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::BindProfile {
|
||||
key: "aa".into(),
|
||||
profile_id: Some("p2".into()),
|
||||
}]
|
||||
);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Confirm)));
|
||||
|
||||
// Row 0 clears the binding.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::BindProfile {
|
||||
key: "aa".into(),
|
||||
profile_id: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_choosing_the_current_binding_is_a_boundary_not_a_command() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let hosts = [host(Some("p1"))];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = screen();
|
||||
// Row 1 = "Work", already bound.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
|
||||
// An unbound host: "No default" is already the state.
|
||||
let hosts = [host(None)];
|
||||
let mut settings = Settings::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = screen();
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
}
|
||||
@@ -823,6 +823,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 9778,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -811,6 +811,7 @@ mod tests {
|
||||
online,
|
||||
mgmt_port: 47990,
|
||||
can_wake,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -2162,6 +2162,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 9778,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -36,6 +36,15 @@ enum Action {
|
||||
SendLogs,
|
||||
CopyLink,
|
||||
Edit,
|
||||
/// Choose the profile the host's primary tile connects with (opens the
|
||||
/// [`Screen::BindProfile`] chooser). Offered on saved primary tiles only — a pinned
|
||||
/// card's profile IS the card, and a title's menu addresses the title.
|
||||
BindProfile,
|
||||
/// Share this device's clipboard with THIS host while streaming
|
||||
/// (`KnownHost::clipboard_sync`). Per-host because it is a trust decision about that
|
||||
/// host — which is why it lives on the host and not in Settings. A toggle: the label
|
||||
/// carries the current state, activating flips it.
|
||||
Clipboard,
|
||||
Forget,
|
||||
Unpin,
|
||||
Cancel,
|
||||
@@ -115,7 +124,10 @@ impl OptionsScreen {
|
||||
key.split('\0').next().unwrap_or(key)
|
||||
}
|
||||
|
||||
fn actions(&self, platform: crate::platform::Platform) -> Vec<Action> {
|
||||
// `_platform` is the seam platform-conditional rows plug into (Send logs used it until
|
||||
// Android grew an uploader); unused today, kept so the next such row has its question
|
||||
// already answered at every call site.
|
||||
fn actions(&self, _platform: crate::platform::Platform) -> Vec<Action> {
|
||||
let host = match &self.subject {
|
||||
Subject::Host(h) => h,
|
||||
// Deliberately not [Play, …]: the host menu does not repeat its tile's own A
|
||||
@@ -137,14 +149,16 @@ impl OptionsScreen {
|
||||
// error. This is the log-escape hatch for platforms whose own filesystem the user
|
||||
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
|
||||
// its web console next to the host's own logs.
|
||||
// Only where a service exists to upload them: the Android client has no log-ring
|
||||
// uploader yet, and a row that can only toast "not available" is a promise broken.
|
||||
if host.paired && host.online && platform == crate::platform::Platform::Desktop {
|
||||
// Every platform has an uploader now (Android's rides `nativeSendLogs` over the
|
||||
// same `logring` the desktop drains), so paired-and-reachable is the whole gate.
|
||||
if host.paired && host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
a.extend([
|
||||
Action::CopyLink,
|
||||
Action::Edit,
|
||||
Action::BindProfile,
|
||||
Action::Clipboard,
|
||||
Action::Forget,
|
||||
Action::Cancel,
|
||||
]);
|
||||
@@ -159,6 +173,15 @@ impl OptionsScreen {
|
||||
Action::SendLogs => "Send logs to host".into(),
|
||||
Action::CopyLink => "Copy link".into(),
|
||||
Action::Edit => "Edit\u{2026}".into(),
|
||||
Action::BindProfile => "Default profile\u{2026}".into(),
|
||||
Action::Clipboard => format!(
|
||||
"Shared clipboard: {}",
|
||||
if self.host().clipboard_sync {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
}
|
||||
),
|
||||
Action::Forget if self.armed => "Forget \u{2014} press again".into(),
|
||||
Action::Forget => "Forget".into(),
|
||||
Action::Unpin => "Unpin card".into(),
|
||||
@@ -269,6 +292,24 @@ impl OptionsScreen {
|
||||
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
|
||||
self.host(),
|
||||
))),
|
||||
Action::BindProfile => fx.replace(Screen::BindProfile(
|
||||
super::bind_profile::BindProfileScreen::new(
|
||||
key,
|
||||
self.host().name.clone(),
|
||||
store.profiles(),
|
||||
),
|
||||
)),
|
||||
Action::Clipboard => {
|
||||
let host = self.host();
|
||||
let on = !host.clipboard_sync;
|
||||
fx.toast = Some(if on {
|
||||
format!("Clipboard shared with {}", host.name)
|
||||
} else {
|
||||
format!("Clipboard no longer shared with {}", host.name)
|
||||
});
|
||||
fx.cmds.push(ConsoleCmd::SetClipboard { key, on });
|
||||
fx.pop();
|
||||
}
|
||||
Action::Forget if !self.armed => self.armed = true,
|
||||
Action::Forget => {
|
||||
fx.cmds.push(ConsoleCmd::ForgetHost { key });
|
||||
@@ -378,6 +419,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 9778,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
@@ -438,6 +480,25 @@ mod tests {
|
||||
.contains(&Action::Wake));
|
||||
}
|
||||
|
||||
/// "Send logs" is offered wherever a paired, reachable host can receive it — on BOTH
|
||||
/// platforms since Android's uploader landed (`SkiaConsole.sendLogs` → `nativeSendLogs`
|
||||
/// over the shared `logring`); before that the row was desktop-only, because a row that
|
||||
/// can only toast "not available" is a promise broken.
|
||||
#[test]
|
||||
fn send_logs_is_offered_on_every_platform_with_an_uploader() {
|
||||
let reachable = OptionsScreen::for_host(&HostRow {
|
||||
paired: true,
|
||||
online: true,
|
||||
..host()
|
||||
});
|
||||
assert!(reachable
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::SendLogs));
|
||||
assert!(reachable
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::SendLogs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_card_cannot_forget_or_edit_the_host() {
|
||||
let s = OptionsScreen::for_host(&pinned());
|
||||
@@ -449,6 +510,57 @@ mod tests {
|
||||
assert_eq!(s.host_key(), "aa");
|
||||
}
|
||||
|
||||
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
|
||||
/// the same reason — addressed to the HOST's plain key even from rows that carry a
|
||||
/// composite one.
|
||||
#[test]
|
||||
fn default_profile_opens_the_chooser_on_the_hosts_plain_key() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::BindProfile));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
|
||||
match fx.nav {
|
||||
Some(crate::screens::Nav::Replace(screen)) => match *screen {
|
||||
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
|
||||
_ => panic!("expected the bind-profile chooser"),
|
||||
},
|
||||
_ => panic!("expected a replace"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard toggle: the label says where the host stands, activating flips it —
|
||||
/// and both address the HOST's plain key.
|
||||
#[test]
|
||||
fn the_clipboard_toggle_flips_the_stored_state() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s.label(Action::Clipboard).ends_with("Off"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
key: "aa".into(),
|
||||
on: true,
|
||||
}]
|
||||
);
|
||||
let mut s = OptionsScreen::for_host(&HostRow {
|
||||
clipboard_sync: true,
|
||||
..host()
|
||||
});
|
||||
assert!(s.label(Action::Clipboard).ends_with("On"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
key: "aa".into(),
|
||||
on: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_needs_two_presses() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
|
||||
@@ -476,6 +476,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -207,6 +207,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: pin.map(|id| ProfileChip {
|
||||
|
||||
@@ -56,6 +56,15 @@ enum RowId {
|
||||
PadType,
|
||||
SystemButtons,
|
||||
GuideGesture,
|
||||
/// The DualSense voice-coil haptics stream, rendered on the (wired) pad itself — see
|
||||
/// `trust::Settings::pad_haptics`. Negotiated: it changes nothing without a capable
|
||||
/// host and a wired DS5, which is why the row says what it is for, not what it does.
|
||||
PadHaptics,
|
||||
/// Where the pad's built-in-speaker stream renders — `trust::Settings::pad_speaker`.
|
||||
/// Offered as On (`"pad"`) / Off, exactly like the GTK switch over the same key: the
|
||||
/// third stored value (`"mix"`) is a declared TODO that renders as off, and a picker
|
||||
/// offering it would be a control that changes nothing.
|
||||
PadSpeaker,
|
||||
Touch,
|
||||
Mouse,
|
||||
InvertScroll,
|
||||
@@ -118,6 +127,14 @@ mod android_keys {
|
||||
const GAMEPAD_UI_MODES: [(&str, &str); 2] =
|
||||
[("connected", "With a controller"), ("always", "Always")];
|
||||
|
||||
/// `pad_audio::speaker_active`'s answer, restated: only `"pad"` renders today ("mix" is
|
||||
/// the declared TODO that renders as off). Local because that module owns the actual
|
||||
/// renderer and is `cfg(linux|windows)` — this row also ships on Android, where the
|
||||
/// SETTING still travels with the stream request even though no local renderer exists.
|
||||
fn pad_speaker_on(mode: &str) -> bool {
|
||||
mode == "pad"
|
||||
}
|
||||
|
||||
fn extra_bool(s: &pf_client_core::trust::Settings, key: &str, default: bool) -> bool {
|
||||
s.extra
|
||||
.get(key)
|
||||
@@ -200,6 +217,8 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
RowId::PadHaptics,
|
||||
RowId::PadSpeaker,
|
||||
RowId::PhoneRumble,
|
||||
RowId::PhoneGyro,
|
||||
RowId::Sc2Passthrough,
|
||||
@@ -390,6 +409,12 @@ impl SettingsScreen {
|
||||
self.tab
|
||||
}
|
||||
|
||||
/// Row `i`'s rect as last drawn — the shell's touch tests press real coordinates.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn row_rect_for_test(&self, i: usize) -> Option<Rect> {
|
||||
self.list.row_rect(i)
|
||||
}
|
||||
|
||||
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
|
||||
/// value cycle), keeping each tab's own cursor.
|
||||
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
@@ -608,7 +633,10 @@ impl SettingsScreen {
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = ids.get(self.list.cursor).copied().map_or("", detail);
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
.copied()
|
||||
.map_or("", |id| detail(id, ctx.platform));
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -732,9 +760,12 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// reading the same settings file on this same machine. Delete this arm when that
|
||||
// client-side filter learns the frame ladder — not before.
|
||||
RowId::AudioFormat => s.audio_channels == 2,
|
||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||
s.gamepad_forwarding
|
||||
}
|
||||
RowId::Pad
|
||||
| RowId::PadType
|
||||
| RowId::SystemButtons
|
||||
| RowId::GuideGesture
|
||||
| RowId::PadHaptics
|
||||
| RowId::PadSpeaker => s.gamepad_forwarding,
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
@@ -861,6 +892,12 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Hold Select for guide",
|
||||
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
|
||||
),
|
||||
RowId::PadHaptics => (None, "Controller haptics", on_off(s.pad_haptics).into()),
|
||||
RowId::PadSpeaker => (
|
||||
None,
|
||||
"Controller speaker",
|
||||
on_off(pad_speaker_on(&s.pad_speaker)).into(),
|
||||
),
|
||||
RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()),
|
||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
||||
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
|
||||
@@ -949,7 +986,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(id: RowId) -> &'static str {
|
||||
/// The focused row's one-line explainer. Takes the platform because two desktop rows
|
||||
/// advertise desktop-only live chords (Ctrl+Alt+Shift+…) that no Android build has — a
|
||||
/// shortcut the device cannot press must not be taught.
|
||||
fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
use crate::platform::Platform;
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
"The host creates a virtual display at exactly this size — no scaling. \
|
||||
@@ -1023,15 +1064,29 @@ fn detail(id: RowId) -> &'static str {
|
||||
the host's quick-access menu. Automatic arms it only where the real button \
|
||||
can't reach the host. A Select tap still goes through, slightly delayed."
|
||||
}
|
||||
RowId::PadHaptics => {
|
||||
"Play a DualSense's fine-grained haptics on the pad itself instead of plain \
|
||||
rumble. Negotiated — it changes nothing without a capable host and a wired pad."
|
||||
}
|
||||
RowId::PadSpeaker => {
|
||||
"Play the audio a game sends to the controller's own speaker on the pad, \
|
||||
not through this device's output."
|
||||
}
|
||||
RowId::Touch => {
|
||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||
}
|
||||
RowId::Mouse => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions. \
|
||||
Ctrl+Alt+Shift+M switches live while streaming."
|
||||
}
|
||||
RowId::Mouse => match platform {
|
||||
Platform::Desktop => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions. \
|
||||
Ctrl+Alt+Shift+M switches live while streaming."
|
||||
}
|
||||
Platform::Android => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions."
|
||||
}
|
||||
},
|
||||
RowId::InvertScroll => "Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
RowId::Shortcuts => {
|
||||
"Alt+Tab, Super and friends reach the host while input is captured. \
|
||||
@@ -1056,10 +1111,15 @@ fn detail(id: RowId) -> &'static str {
|
||||
stores as tiles — instead of the whole shelf. A library with only one \
|
||||
collection opens on the shelf as usual."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
}
|
||||
RowId::Stats => match platform {
|
||||
Platform::Desktop => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
}
|
||||
Platform::Android => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed."
|
||||
}
|
||||
},
|
||||
RowId::Fullscreen => "Streams open fullscreen instead of windowed.",
|
||||
RowId::AutoWake => {
|
||||
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
|
||||
@@ -1259,6 +1319,23 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
}
|
||||
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
|
||||
}
|
||||
RowId::PadHaptics => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
toggle(&mut s.pad_haptics, delta, wrap)
|
||||
}
|
||||
RowId::PadSpeaker => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
// On/Off over the stored string, the way the GTK switch edits the same key: a
|
||||
// stored "mix" reads as Off (it renders as off today) and any step writes the
|
||||
// two values that do something.
|
||||
let mut on = pad_speaker_on(&s.pad_speaker);
|
||||
toggle(&mut on, delta, wrap)
|
||||
.map(|()| s.pad_speaker = if on { "pad" } else { "off" }.to_string())
|
||||
}
|
||||
RowId::Touch => {
|
||||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
@@ -1559,6 +1636,44 @@ pub(super) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The controller-audio rows: they follow the forwarding switch like every other pad
|
||||
/// row, and the speaker row edits the stored STRING exactly the way the GTK switch
|
||||
/// over the same key does — a stored "mix" (the declared TODO that renders as off)
|
||||
/// reads as Off, and any step writes only the two values that do something.
|
||||
#[test]
|
||||
fn controller_audio_rows_follow_forwarding_and_speak_the_gtk_dialect() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Defaults: haptics on, speaker on the pad.
|
||||
assert!(ctx.settings.pad_haptics);
|
||||
assert_eq!(ctx.settings.pad_speaker, "pad");
|
||||
assert!(adjust(RowId::PadHaptics, 1, true, &mut ctx));
|
||||
assert!(!ctx.settings.pad_haptics);
|
||||
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.pad_speaker, "off");
|
||||
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.pad_speaker, "pad");
|
||||
// A stored "mix" reads as Off and steps onto a value that works.
|
||||
ctx.settings.pad_speaker = "mix".into();
|
||||
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.pad_speaker, "pad");
|
||||
// Forwarding off parks both, like the sibling pad rows.
|
||||
ctx.settings.gamepad_forwarding = false;
|
||||
assert!(!adjust(RowId::PadHaptics, 1, true, &mut ctx));
|
||||
assert!(!adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_clamps_and_activate_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
@@ -1871,6 +1986,7 @@ pub(super) mod tests {
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: Some(crate::model::ProfileChip {
|
||||
@@ -2047,12 +2163,14 @@ pub(super) mod tests {
|
||||
seen.push(*id);
|
||||
}
|
||||
}
|
||||
// The pre-tab flat list, plus the palette row, the lossless-audio row and the
|
||||
// reduce-motion row later passes added, minus the game-library toggle: this screen
|
||||
// never read it, and the library is offered on any paired host now.
|
||||
// 33 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// The pre-tab flat list, plus the palette row, the lossless-audio row, the
|
||||
// reduce-motion row and the two controller-audio rows (haptics + speaker — the
|
||||
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
|
||||
// game-library toggle: this screen never read it, and the library is offered on any
|
||||
// paired host now.
|
||||
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// D3): six `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 41, "{seen:?}");
|
||||
assert_eq!(seen.len(), 43, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
|
||||
@@ -58,6 +58,38 @@ const NAV_INPUT_OPENS: f64 = 0.85;
|
||||
const TOP_BAND: f64 = 64.0;
|
||||
const BOTTOM_BAND: f64 = 86.0;
|
||||
|
||||
/// How far a finger may wander (design units × the frame's `k`) and still be a tap. Past
|
||||
/// this the gesture is a drag and the lift acts on nothing. ~12dp is the classic touch
|
||||
/// slop; in device pixels it lands near Android's own ViewConfiguration figure.
|
||||
const TOUCH_SLOP_DP: f64 = 12.0;
|
||||
/// One drag step (design units × `k`): each `DRAG_TICK_DP` of dominant-axis travel emits
|
||||
/// one synthetic scroll tick. 56 is the menu list's row pitch (`widgets::ROW_H` + gap), so
|
||||
/// a list under the finger moves about as far as the finger does. The on-glass tuning knob.
|
||||
const DRAG_TICK_DP: f64 = 56.0;
|
||||
|
||||
/// The active touch gesture, tracked by [`Shell::pointer_input`] (see the `touch` flag on
|
||||
/// `PointerInput::Down`). A mouse never enters this machine — its press acts immediately,
|
||||
/// which is what a mouse means. A second finger while one gesture is live is ignored
|
||||
/// (single-tracked; multi-touch gestures are a non-goal).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum TouchGesture {
|
||||
/// Finger down, still within slop of the anchor. A lift here is a tap: the Press is
|
||||
/// delivered AT THE ANCHOR — the focused item scrolls toward the centre, so the down
|
||||
/// point is where the user aimed and the lift point is where the content dragged
|
||||
/// their eye; widgets hit-test last frame's rects and already tolerate exactly this
|
||||
/// one-frame skew.
|
||||
Armed { x: f64, y: f64 },
|
||||
/// Slop exceeded: a drag, locked to the axis it left the slop on (diagonal jitter
|
||||
/// must not alternate a carousel with a list). `last` is the dominant-axis position
|
||||
/// the previous tick was emitted at.
|
||||
Drag {
|
||||
x: f64,
|
||||
y: f64,
|
||||
horizontal: bool,
|
||||
last: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which way a transition is choreographed. The paint recipes differ (a push slides the
|
||||
/// incoming screen up out of a fade; a pop grows the revealed one back while the leaving
|
||||
/// one drops away), so the kind outlives the direction the spring happens to be heading.
|
||||
@@ -139,7 +171,6 @@ struct Toast {
|
||||
|
||||
struct Connecting {
|
||||
title: String,
|
||||
canceling: bool,
|
||||
appear: f64,
|
||||
/// A request-access wait (parked on the host until the operator approves) — the
|
||||
/// takeover reads "Waiting for approval" rather than "Connecting".
|
||||
@@ -260,6 +291,11 @@ pub(crate) struct Shell {
|
||||
/// surface pixels and are brought into the same space `hint_rects` and every screen's
|
||||
/// hit boxes were published in.
|
||||
last_insets: (f32, f32),
|
||||
/// The design-unit scale the last frame rendered at, for the touch tracker's slop and
|
||||
/// tick distances — gesture geometry must grow with the UI it drags.
|
||||
last_k: f64,
|
||||
/// The touch gesture in flight, if any (see [`TouchGesture`]).
|
||||
gesture: Option<TouchGesture>,
|
||||
/// Skia's resource-cache budget for the host that renders this shell (see
|
||||
/// [`ConsoleOptions::gpu_cache_bytes`]).
|
||||
pub(crate) gpu_cache_bytes: usize,
|
||||
@@ -332,6 +368,8 @@ impl Shell {
|
||||
pads: Vec::new(),
|
||||
hint_rects: Vec::new(),
|
||||
last_insets: (0.0, 0.0),
|
||||
last_k: 1.0,
|
||||
gesture: None,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes,
|
||||
t0: Instant::now(),
|
||||
last_frame: None,
|
||||
@@ -362,25 +400,69 @@ impl Shell {
|
||||
/// The host-facing pointer vocabulary onto the shell's own: primary press/release,
|
||||
/// secondary-down = Back (its release is dropped, or a right-click would pop two
|
||||
/// screens), wheel = discrete scroll steps, cancel.
|
||||
///
|
||||
/// A TOUCH primary down (`touch: true`) takes the gesture lane instead: the press is
|
||||
/// deferred, and the lift decides whether it was a tap (Press at the anchor) or a drag
|
||||
/// (scroll ticks were already emitted along the way, the lift acts on nothing). A press
|
||||
/// that acted on contact made every swipe across the settings list flip a value — the
|
||||
/// finger has to be allowed to mean "scroll" until it has said otherwise.
|
||||
pub(crate) fn pointer_input(&mut self, input: pf_client_core::console::PointerInput) -> bool {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (x, y, kind) = match input {
|
||||
PointerInput::Move { x, y } => (x, y, PointerKind::Move),
|
||||
PointerInput::Move { x, y } => {
|
||||
if self.gesture.is_some() {
|
||||
return self.gesture_move(f64::from(x), f64::from(y));
|
||||
}
|
||||
(x, y, PointerKind::Move)
|
||||
}
|
||||
PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
} => (x, y, PointerKind::Press),
|
||||
touch,
|
||||
} => {
|
||||
if touch {
|
||||
// A second finger while a gesture is live is ignored — single-tracked.
|
||||
if self.gesture.is_none() {
|
||||
self.gesture = Some(TouchGesture::Armed {
|
||||
x: f64::from(x),
|
||||
y: f64::from(y),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(x, y, PointerKind::Press)
|
||||
}
|
||||
PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Secondary,
|
||||
..
|
||||
} => (x, y, PointerKind::Back),
|
||||
PointerInput::Up {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
} => (x, y, PointerKind::Release),
|
||||
} => match self.gesture.take() {
|
||||
Some(TouchGesture::Armed { x, y }) => {
|
||||
// A tap: the deferred Press lands now, at the anchor, followed by the
|
||||
// Release the widgets ignore today (and a fling closes on tomorrow).
|
||||
let consumed = self.pointer(Pointer {
|
||||
x,
|
||||
y,
|
||||
kind: PointerKind::Press,
|
||||
});
|
||||
self.pointer(Pointer {
|
||||
x,
|
||||
y,
|
||||
kind: PointerKind::Release,
|
||||
});
|
||||
return consumed;
|
||||
}
|
||||
// A drag ends where its last tick left it; the lift itself does nothing.
|
||||
Some(TouchGesture::Drag { .. }) => return true,
|
||||
None => (x, y, PointerKind::Release),
|
||||
},
|
||||
PointerInput::Up { .. } => return true,
|
||||
PointerInput::Wheel { x, y, dy } => {
|
||||
if dy == 0.0 {
|
||||
@@ -388,7 +470,10 @@ impl Shell {
|
||||
}
|
||||
(x, y, PointerKind::Scroll { up: dy > 0.0 })
|
||||
}
|
||||
PointerInput::Cancel => (0.0, 0.0, PointerKind::Cancel),
|
||||
PointerInput::Cancel => {
|
||||
self.gesture = None;
|
||||
(0.0, 0.0, PointerKind::Cancel)
|
||||
}
|
||||
};
|
||||
self.pointer(Pointer {
|
||||
x: f64::from(x),
|
||||
@@ -397,6 +482,60 @@ impl Shell {
|
||||
})
|
||||
}
|
||||
|
||||
/// Advance the touch gesture by a Move. Within slop nothing happens; past it the
|
||||
/// gesture locks to its dominant axis and every [`DRAG_TICK_DP`]·k of travel becomes
|
||||
/// one synthetic scroll tick at the anchor. Direction reads as "content follows the
|
||||
/// finger": drag down/right = the previous item (a wheel-up), drag up/left = the next.
|
||||
fn gesture_move(&mut self, x: f64, y: f64) -> bool {
|
||||
let Some(gesture) = self.gesture else {
|
||||
return false;
|
||||
};
|
||||
match gesture {
|
||||
TouchGesture::Armed { x: ax, y: ay } => {
|
||||
let (dx, dy) = (x - ax, y - ay);
|
||||
if dx.hypot(dy) >= TOUCH_SLOP_DP * self.last_k {
|
||||
let horizontal = dx.abs() > dy.abs();
|
||||
self.gesture = Some(TouchGesture::Drag {
|
||||
x: ax,
|
||||
y: ay,
|
||||
horizontal,
|
||||
// Ticks count from where the slop was left, not from the anchor —
|
||||
// the slop's travel was spent proving this is a drag.
|
||||
last: if horizontal { x } else { y },
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
TouchGesture::Drag {
|
||||
x: ax,
|
||||
y: ay,
|
||||
horizontal,
|
||||
last,
|
||||
} => {
|
||||
let pos = if horizontal { x } else { y };
|
||||
let tick = DRAG_TICK_DP * self.last_k;
|
||||
let steps = ((pos - last) / tick).trunc();
|
||||
if steps != 0.0 {
|
||||
self.gesture = Some(TouchGesture::Drag {
|
||||
x: ax,
|
||||
y: ay,
|
||||
horizontal,
|
||||
last: last + steps * tick,
|
||||
});
|
||||
let up = steps > 0.0;
|
||||
for _ in 0..steps.abs() as u32 {
|
||||
self.pointer(Pointer {
|
||||
x: ax,
|
||||
y: ay,
|
||||
kind: PointerKind::Scroll { up },
|
||||
});
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host reports a session edge. `Connecting` is a no-op — the shell raised the
|
||||
/// Launch itself and is already showing the takeover.
|
||||
pub(crate) fn session_phase(&mut self, phase: pf_client_core::console::SessionPhase) {
|
||||
@@ -436,7 +575,6 @@ impl Shell {
|
||||
self.last_connect_title = Some(title.clone());
|
||||
self.connecting = Some(Connecting {
|
||||
title,
|
||||
canceling: false,
|
||||
appear: 0.0,
|
||||
request_access: false,
|
||||
})
|
||||
@@ -504,7 +642,6 @@ impl Shell {
|
||||
.last_connect_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the host".to_string()),
|
||||
canceling: false,
|
||||
appear: 1.0,
|
||||
request_access: false,
|
||||
});
|
||||
@@ -683,9 +820,19 @@ impl Shell {
|
||||
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
|
||||
self.sync();
|
||||
// Modal precedence: the connect card, then the wake card, then the screens.
|
||||
if let Some(c) = &mut self.connecting {
|
||||
if ev == MenuEvent::Back && !c.canceling {
|
||||
c.canceling = true;
|
||||
if self.connecting.is_some() {
|
||||
if ev == MenuEvent::Back {
|
||||
// The takeover comes down HERE, not when the host answers. It used to wait for
|
||||
// the next `session_phase` and show "Canceling…" until one arrived — and one is
|
||||
// not guaranteed to: the dial is a blocking call on the host's side of this
|
||||
// interface, so the wait was the whole connect budget (185 s on a request-access
|
||||
// connect the host parks pending approval), and an embedder that simply drops a
|
||||
// canceled dial never sends a phase at all. Either way the console sat on
|
||||
// "Canceling…" with no input that could reach it — only killing the app cleared
|
||||
// it. Cancel is the USER's decision and needs no confirmation from the wire; the
|
||||
// action below still goes out, and every host already handles a dial that lands
|
||||
// after it (quit-close the connector, route the end back silently).
|
||||
self.connecting = None;
|
||||
self.actions.push_back(OverlayAction::CancelConnect);
|
||||
return Some(MenuPulse::Confirm);
|
||||
}
|
||||
|
||||
@@ -68,15 +68,7 @@ impl Shell {
|
||||
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||
if c.canceling {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
"Canceling…".to_string(),
|
||||
String::new(),
|
||||
vec![],
|
||||
))
|
||||
} else if c.request_access {
|
||||
if c.request_access {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
|
||||
@@ -101,6 +101,7 @@ impl Shell {
|
||||
full_h - f64::from(ins.top) - f64::from(ins.bottom),
|
||||
);
|
||||
self.last_insets = (ins.left, ins.top);
|
||||
self.last_k = k;
|
||||
let t = self.t();
|
||||
|
||||
// Advance the transition. `None` means "settled" — which is also what makes the
|
||||
|
||||
@@ -89,6 +89,7 @@ fn hosts() -> Vec<HostRow> {
|
||||
online: false,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
@@ -178,15 +179,17 @@ fn connect_flow_raises_launch_and_cancel() {
|
||||
Some(OverlayAction::Launch { launch: None, .. })
|
||||
));
|
||||
assert!(s.connecting.is_some());
|
||||
// While connecting: B cancels exactly once.
|
||||
// While connecting: B cancels — and the takeover comes down on the spot. It must NOT wait
|
||||
// for a session phase to clear it: the dial is blocking on the host's side of this
|
||||
// interface, so that wait was the whole connect budget, and an embedder that just drops a
|
||||
// canceled dial sends no phase at all — the console stuck on "Canceling…" until the app died.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(matches!(
|
||||
s.take_action(),
|
||||
Some(OverlayAction::CancelConnect)
|
||||
));
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(s.take_action().is_none(), "cancel is idempotent");
|
||||
// The canceled dial ends silently.
|
||||
assert!(s.connecting.is_none(), "cancel drops the takeover itself");
|
||||
// A dial that resolves afterwards (or never) changes nothing.
|
||||
s.session_ended(None);
|
||||
assert!(s.connecting.is_none());
|
||||
}
|
||||
@@ -493,6 +496,164 @@ fn every_settings_tab_rasters() {
|
||||
s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads);
|
||||
}
|
||||
|
||||
/// The settings screen with one frame rendered, so its rows have real rects to press.
|
||||
fn rendered_settings() -> (Shell, skia_safe::Rect) {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((1280, 800)).unwrap();
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.handle_menu(MenuEvent::Tertiary); // X → Settings
|
||||
finish_motion(&mut s);
|
||||
s.render(surface.canvas(), 1280, 800, &fonts, None, None, &[]);
|
||||
let row = match s.stack.last() {
|
||||
Some(Screen::Settings(scr)) => scr.row_rect_for_test(0).expect("the list drew its rows"),
|
||||
_ => panic!("settings is not on top"),
|
||||
};
|
||||
(s, row)
|
||||
}
|
||||
|
||||
/// The whole point of the touch tracker: a finger swiping across the settings list is
|
||||
/// SCROLLING, and must not flip the value it happened to land on — which is exactly what
|
||||
/// the press-acts-on-contact model did to every swipe before the `touch` flag existed.
|
||||
/// The same contact lifted in place IS the tap, delivered on the lift at the anchor.
|
||||
#[test]
|
||||
fn a_touch_swipe_scrolls_settings_without_changing_a_value() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, row) = rendered_settings();
|
||||
let (cx, cy) = (row.center_x(), row.center_y());
|
||||
// The Resolution row's whole observable state: activating it steps the D1 tri-state
|
||||
// Native -> Match window, which flips the FLAG while width/height stay (0, 0).
|
||||
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
|
||||
let before = state(&s);
|
||||
|
||||
// Finger lands on the Resolution row and swipes up, well past slop and several ticks.
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: cx,
|
||||
y: cy,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
for i in 1..=6 {
|
||||
s.pointer_input(PointerInput::Move {
|
||||
x: cx,
|
||||
y: cy - (i as f32) * 40.0,
|
||||
});
|
||||
}
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: cx,
|
||||
y: cy - 240.0,
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
assert_eq!(
|
||||
state(&s),
|
||||
before,
|
||||
"a swipe across a row is a scroll, not a value change"
|
||||
);
|
||||
|
||||
// The same contact, lifted where it landed: a tap. Deferred — nothing on contact,
|
||||
// the step on the lift.
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: cx,
|
||||
y: cy,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
assert_eq!(state(&s), before, "a touch press must not act on contact");
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: cx,
|
||||
y: cy,
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
assert_ne!(
|
||||
state(&s),
|
||||
before,
|
||||
"the tap lands on the lift, at the anchor"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mouse is not a finger: its press keeps acting on contact, exactly as before the
|
||||
/// touch flag existed.
|
||||
#[test]
|
||||
fn a_mouse_press_still_acts_on_contact() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, row) = rendered_settings();
|
||||
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
|
||||
let before = state(&s);
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: row.center_x(),
|
||||
y: row.center_y(),
|
||||
button: PointerButton::Primary,
|
||||
touch: false,
|
||||
});
|
||||
assert_ne!(state(&s), before, "a mouse click acts on the press");
|
||||
}
|
||||
|
||||
/// A horizontal drag on Home steps the carousel — one tick per `DRAG_TICK_DP` of travel
|
||||
/// past the slop — and the lift after a drag presses nothing. Needs no render: ticks act
|
||||
/// on the cursor, not on drawn rects.
|
||||
#[test]
|
||||
fn a_horizontal_drag_steps_the_home_carousel() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: 640.0,
|
||||
y: 400.0,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
// First move leaves the slop (locks the horizontal axis); the second travels one full
|
||||
// tick leftward — content follows the finger, so the NEXT tile comes up.
|
||||
s.pointer_input(PointerInput::Move { x: 620.0, y: 400.0 });
|
||||
s.pointer_input(PointerInput::Move {
|
||||
x: 620.0 - DRAG_TICK_DP as f32,
|
||||
y: 400.0,
|
||||
});
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: 620.0 - DRAG_TICK_DP as f32,
|
||||
y: 400.0,
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
// The fixture's second host (Office Tower) is offline with a stored MAC: Confirm on it
|
||||
// raises the wake card. That proves the drag moved the cursor — and that the lift
|
||||
// after a drag pressed nothing (a press would have acted before Confirm ran).
|
||||
assert!(
|
||||
s.wake.is_none(),
|
||||
"the drag itself must not activate anything"
|
||||
);
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
assert!(
|
||||
s.wake.is_some(),
|
||||
"Confirm after a one-tick drag lands on the second host's wake"
|
||||
);
|
||||
}
|
||||
|
||||
/// A canceled touch (the finger left the window, the toolkit stole the gesture) is
|
||||
/// dropped whole: no press ever lands.
|
||||
#[test]
|
||||
fn a_canceled_touch_never_acts() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, row) = rendered_settings();
|
||||
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
|
||||
let before = state(&s);
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: row.center_x(),
|
||||
y: row.center_y(),
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
s.pointer_input(PointerInput::Cancel);
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: row.center_x(),
|
||||
y: row.center_y(),
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
assert_eq!(
|
||||
state(&s),
|
||||
before,
|
||||
"cancel dropped the gesture; the stray lift presses nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The work package's whole reason for existing: Back pressed mid-push is HEARD, and it
|
||||
/// turns the screen around rather than queuing a second animation behind the first.
|
||||
///
|
||||
|
||||
@@ -2723,6 +2723,11 @@ fn apply_capture(
|
||||
/// Only DIRECT touch devices are offered; an indirect trackpad already drives the mouse,
|
||||
/// and forwarding both would double every tap.
|
||||
fn overlay_pointer(event: &Event, window: &sdl3::video::Window) -> Option<PointerInput> {
|
||||
// SDL's mouse id on mouse events it SYNTHESIZED from a touch (`SDL_TOUCH_MOUSEID`,
|
||||
// not re-exported by the sdl3 crate). The finger arms below already forward the real
|
||||
// touch stream; letting the synthesized twin through would land every tap twice —
|
||||
// once deferred (touch), once immediate (mouse) — so those events are dropped here.
|
||||
const TOUCH_MOUSEID: u32 = u32::MAX;
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
let (lw, lh) = window.size();
|
||||
// Logical → physical. A zero-sized window (minimized) would divide by zero.
|
||||
@@ -2734,20 +2739,29 @@ fn overlay_pointer(event: &Event, window: &sdl3::video::Window) -> Option<Pointe
|
||||
_ => None,
|
||||
};
|
||||
Some(match event {
|
||||
Event::MouseMotion { x, y, .. } => PointerInput::Move {
|
||||
Event::MouseMotion { which, x, y, .. } if *which != TOUCH_MOUSEID => PointerInput::Move {
|
||||
x: x * sx,
|
||||
y: y * sy,
|
||||
},
|
||||
Event::MouseButtonDown {
|
||||
mouse_btn, x, y, ..
|
||||
} => PointerInput::Down {
|
||||
which,
|
||||
mouse_btn,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if *which != TOUCH_MOUSEID => PointerInput::Down {
|
||||
x: x * sx,
|
||||
y: y * sy,
|
||||
button: button(*mouse_btn)?,
|
||||
touch: false,
|
||||
},
|
||||
Event::MouseButtonUp {
|
||||
mouse_btn, x, y, ..
|
||||
} => PointerInput::Up {
|
||||
which,
|
||||
mouse_btn,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if *which != TOUCH_MOUSEID => PointerInput::Up {
|
||||
x: x * sx,
|
||||
y: y * sy,
|
||||
button: button(*mouse_btn)?,
|
||||
@@ -2767,6 +2781,7 @@ fn overlay_pointer(event: &Event, window: &sdl3::video::Window) -> Option<Pointe
|
||||
x: x * pw as f32,
|
||||
y: y * ph as f32,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
}
|
||||
}
|
||||
Event::FingerMotion { touch_id, x, y, .. } if is_direct_touch(*touch_id) => {
|
||||
|
||||
@@ -49,7 +49,12 @@ socket2 = { version = "0.6", features = [
|
||||
"all",
|
||||
] } # SO_SNDBUF/SO_RCVBUF growth (default UDP buffers too small for 4K/5K bursts) + DSCP/SO_PRIORITY media QoS
|
||||
thiserror = "2"
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
# `log`: tracing events are mirrored as `log` records when no tracing subscriber is installed —
|
||||
# what `abi::punktfunk_set_log_callback` (ABI v25) delivers to an embedder. On transitively via
|
||||
# quinn's defaults already; declared here because the ABI promise must not hinge on that.
|
||||
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
|
||||
# The backend `punktfunk_set_log_callback` installs (`log::set_logger` + `log::Log`).
|
||||
log = "0.4"
|
||||
rand = "0.9"
|
||||
zeroize = "1"
|
||||
# Interface enumeration for Wake-on-LAN: computes each NIC's subnet-directed broadcast so a
|
||||
|
||||
@@ -59,9 +59,7 @@ use std::ptr;
|
||||
/// for. The slots behind these mutexes are plain last-value caches (frame/audio/cursor/clip), so
|
||||
/// whatever a poisoned writer left behind is still structurally valid data to overwrite or hand
|
||||
/// out; recovering the guard is strictly better than aborting the embedding application.
|
||||
/// (`quic`-gated with its only callers, the `punktfunk_connection_*` entry points — a
|
||||
/// `default-features = false` consumer like the tray would otherwise see dead code.)
|
||||
#[cfg(feature = "quic")]
|
||||
/// (Ungated since v25: [`punktfunk_set_log_callback`]'s sink slot uses it on every build.)
|
||||
fn lock_recover<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
@@ -258,6 +256,117 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
|
||||
crate::ABI_VERSION
|
||||
}
|
||||
|
||||
/// A log line from the core (ABI v25, [`punktfunk_set_log_callback`]). `level` is 1 = error,
|
||||
/// 2 = warn, 3 = info, 4 = debug, 5 = trace. `target` is the Rust module path the line came from
|
||||
/// (`punktfunk_core::transport::udp`, `quinn::connection`, …) and `message` the formatted text;
|
||||
/// both are NUL-terminated UTF-8, borrowed for the duration of the call only — copy them out.
|
||||
/// Called from whichever thread logged, so the callback must be thread-safe, must not block for
|
||||
/// long (it sits on the transport and pump threads), and must not call back into the core's
|
||||
/// logging (it would be re-entered).
|
||||
pub type PunktfunkLogCb = Option<
|
||||
unsafe extern "C" fn(
|
||||
level: u8,
|
||||
target: *const c_char,
|
||||
message: *const c_char,
|
||||
user: *mut c_void,
|
||||
),
|
||||
>;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct LogSink {
|
||||
cb: unsafe extern "C" fn(u8, *const c_char, *const c_char, *mut c_void),
|
||||
user: *mut c_void,
|
||||
}
|
||||
// SAFETY: the user pointer is an opaque token handed back to the caller's own callback, which the
|
||||
// contract above requires to be thread-safe; the core never dereferences it.
|
||||
unsafe impl Send for LogSink {}
|
||||
|
||||
static LOG_SINK: std::sync::Mutex<Option<LogSink>> = std::sync::Mutex::new(None);
|
||||
static LOG_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
|
||||
/// The `log` backend behind [`punktfunk_set_log_callback`]: every record the core (and its
|
||||
/// dependencies that log through `log`, plus its own `tracing` events via tracing's `log` bridge)
|
||||
/// emits is handed to the registered sink. Installed once; the sink slot is swappable after.
|
||||
struct CallbackLogger;
|
||||
|
||||
impl log::Log for CallbackLogger {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
// Level gating is `log::set_max_level`, applied by `punktfunk_set_log_callback`.
|
||||
true
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
// Copy the sink OUT of the lock before calling it: a callback that logs (it shouldn't, but
|
||||
// an embedder's mistake must be a duplicate line, not a deadlock) re-enters `log` cleanly.
|
||||
let Some(sink) = *lock_recover(&LOG_SINK) else {
|
||||
return;
|
||||
};
|
||||
let cstr = |s: String| {
|
||||
// An interior NUL can't cross as a C string; drop the byte rather than the line.
|
||||
let mut bytes = s.into_bytes();
|
||||
bytes.retain(|&b| b != 0);
|
||||
std::ffi::CString::new(bytes).unwrap_or_default()
|
||||
};
|
||||
let target = cstr(record.target().to_string());
|
||||
let message = cstr(record.args().to_string());
|
||||
// SAFETY: the sink was registered through the ABI with exactly this signature; both
|
||||
// strings outlive the call (they are locals dropped after it) and are NUL-terminated.
|
||||
unsafe {
|
||||
(sink.cb)(
|
||||
record.level() as u8,
|
||||
target.as_ptr(),
|
||||
message.as_ptr(),
|
||||
sink.user,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Receive the core's log lines (ABI v25). The core logs through `tracing`; on the desktop and
|
||||
/// Android shells a subscriber/logger installed by the shell picks those up, but an embedder that
|
||||
/// installs none (Swift, any C host) saw NOTHING — every transport warning (socket-buffer clamp,
|
||||
/// QoS refusal), every quinn connection event and every rustls handshake note vanished, and a
|
||||
/// client log bundle carried the shell's half of the story only. This routes them to `cb`.
|
||||
///
|
||||
/// `max_level` is the most verbose level delivered (1 = error … 5 = trace; 0 = nothing) —
|
||||
/// `log::set_max_level`, so anything above it costs no formatting. 3 (info) is the right default
|
||||
/// for a field log ring; quinn's debug/trace is per-packet and would churn any bounded ring.
|
||||
/// `cb == NULL` detaches the sink (lines are dropped again). `user` is handed back on every call.
|
||||
///
|
||||
/// Returns `Ok`, or `Unsupported` when another `log` backend is already installed in this
|
||||
/// process (e.g. the Android shell's `android_logger`) — the core cannot replace it, and that
|
||||
/// backend already receives everything this one would. Idempotent: call again to change the
|
||||
/// level or the sink.
|
||||
///
|
||||
/// # Safety
|
||||
/// `cb`, if non-null, must remain a valid function for as long as it is installed (until the next
|
||||
/// call with NULL), and `user` must stay valid for every call the core may make meanwhile.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_set_log_callback(
|
||||
max_level: u8,
|
||||
cb: PunktfunkLogCb,
|
||||
user: *mut c_void,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let installed = *LOG_INSTALLED.get_or_init(|| log::set_logger(&CallbackLogger).is_ok());
|
||||
if !installed {
|
||||
return PunktfunkStatus::Unsupported;
|
||||
}
|
||||
*lock_recover(&LOG_SINK) = cb.map(|cb| LogSink { cb, user });
|
||||
log::set_max_level(match (cb.is_some(), max_level) {
|
||||
(false, _) | (_, 0) => log::LevelFilter::Off,
|
||||
(_, 1) => log::LevelFilter::Error,
|
||||
(_, 2) => log::LevelFilter::Warn,
|
||||
(_, 3) => log::LevelFilter::Info,
|
||||
(_, 4) => log::LevelFilter::Debug,
|
||||
_ => log::LevelFilter::Trace,
|
||||
});
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
|
||||
///
|
||||
/// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
|
||||
@@ -2622,6 +2731,10 @@ unsafe fn connect_ex_impl(
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
// No abort switch in the C ABI: `punktfunk_connect*` is a blocking call with
|
||||
// nothing to poll a flag from. An `ex` variant can take one when an ABI embedder
|
||||
// grows a cancelable connect screen.
|
||||
None,
|
||||
) {
|
||||
Ok(c) => {
|
||||
if !observed_sha256_out.is_null() {
|
||||
@@ -5853,6 +5966,80 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod log_sink_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// `(level, target, message, user token)` per delivered line. The collector asserts nothing
|
||||
/// itself (an `extern "C"` fn must not panic — the hygiene gate enforces it); the test body
|
||||
/// checks what landed.
|
||||
static LINES: Mutex<Vec<(u8, String, String, usize)>> = Mutex::new(Vec::new());
|
||||
|
||||
unsafe extern "C" fn collect(
|
||||
level: u8,
|
||||
target: *const c_char,
|
||||
message: *const c_char,
|
||||
user: *mut c_void,
|
||||
) {
|
||||
// SAFETY: the core hands NUL-terminated strings valid for this call, per the callback contract.
|
||||
let (t, m) = unsafe { (CStr::from_ptr(target), CStr::from_ptr(message)) };
|
||||
lock_recover(&LINES).push((
|
||||
level,
|
||||
t.to_string_lossy().into_owned(),
|
||||
m.to_string_lossy().into_owned(),
|
||||
user as usize,
|
||||
));
|
||||
}
|
||||
|
||||
/// End to end through both doors: a `log` record and a `tracing` event (via tracing's `log`
|
||||
/// feature) reach the C callback with level, real target, message and the user token; an
|
||||
/// interior NUL is dropped rather than truncating the line; the level ceiling is honoured;
|
||||
/// NULL detaches.
|
||||
#[test]
|
||||
fn callback_receives_log_and_tracing_lines() {
|
||||
// SAFETY: `collect` is a valid fn for the life of the test binary, the user token is an
|
||||
// opaque integer.
|
||||
let st = unsafe { punktfunk_set_log_callback(3, Some(collect), 0x5151 as *mut c_void) };
|
||||
assert_eq!(st, PunktfunkStatus::Ok);
|
||||
|
||||
log::warn!(target: "quinn::connection", "handshake \0 done");
|
||||
tracing::info!(target: "punktfunk_core::transport", buf = 4096, "socket buffer clamped");
|
||||
log::debug!(target: "quinn::connection", "must not arrive (above the ceiling)");
|
||||
|
||||
let lines = lock_recover(&LINES).clone();
|
||||
let warn = lines
|
||||
.iter()
|
||||
.find(|l| l.1 == "quinn::connection")
|
||||
.expect("log record delivered");
|
||||
assert_eq!(warn.0, 2);
|
||||
assert_eq!(warn.2, "handshake done", "interior NUL dropped, line kept");
|
||||
assert_eq!(warn.3, 0x5151, "the user token must come back unchanged");
|
||||
let info = lines
|
||||
.iter()
|
||||
.find(|l| l.1 == "punktfunk_core::transport")
|
||||
.expect("tracing event delivered through the log bridge");
|
||||
assert_eq!(info.0, 3);
|
||||
assert!(
|
||||
info.2.contains("socket buffer clamped") && info.2.contains("buf=4096"),
|
||||
"{}",
|
||||
info.2
|
||||
);
|
||||
assert!(!lines.iter().any(|l| l.2.contains("must not arrive")));
|
||||
|
||||
// SAFETY: NULL callback detaches; no pointer is retained.
|
||||
let detached = unsafe { punktfunk_set_log_callback(3, None, ptr::null_mut()) };
|
||||
assert_eq!(detached, PunktfunkStatus::Ok);
|
||||
let before = lock_recover(&LINES).len();
|
||||
log::error!(target: "quinn::connection", "after detach");
|
||||
assert_eq!(
|
||||
lock_recover(&LINES).len(),
|
||||
before,
|
||||
"a detached sink hears nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quic"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -750,6 +750,7 @@ impl NativeClient {
|
||||
pin,
|
||||
identity,
|
||||
timeout,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -810,6 +811,16 @@ impl NativeClient {
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
timeout: Duration,
|
||||
// The caller's abort switch, polled while this call is still blocked: setting it returns
|
||||
// [`PunktfunkError::Timeout`] straight away instead of parking the caller for the rest of
|
||||
// `timeout` — which is 185 s on a request-access dial the host has PARKED pending an
|
||||
// operator's approval, and a UI that offers Cancel cannot honour it while its dialing
|
||||
// thread is stuck in here. Taking it is the same give-up as running out of budget (quit
|
||||
// close + shutdown), so the worker stops re-dialing and the host tears down rather than
|
||||
// lingering for a reconnect nobody wants. Read ONLY here — deliberately not aliased onto
|
||||
// the client's own `shutdown`, which the pump uses to mean "this connection died" and
|
||||
// whose end reason a caller-set flag would race. `None` = a connect nobody can cancel.
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
) -> Result<NativeClient> {
|
||||
let frame_chan = Arc::new(FrameChannel::new());
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
@@ -967,18 +978,34 @@ impl NativeClient {
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
|
||||
let negotiated = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
// Polled rather than one long `recv_timeout(timeout)`: the wait has to end on the
|
||||
// caller's `cancel` as well as on the budget, and a handshake the host has PARKED
|
||||
// (request-access, pending approval) produces nothing to wake on for minutes.
|
||||
const READY_POLL: Duration = Duration::from_millis(50);
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let negotiated = loop {
|
||||
match ready_rx.recv_timeout(READY_POLL) {
|
||||
Ok(Ok(t)) => break t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
// Timed out with the worker still going: keep waiting unless the budget is
|
||||
// spent or the caller cancelled. Disconnected means the worker died without
|
||||
// reporting — the give-up path below covers it, same as it always did.
|
||||
// Both give-ups land in one arm on purpose: a cancel and an expiry owe the
|
||||
// host the same close, and the caller that cancelled is not listening to the
|
||||
// error it gets back anyway.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if std::time::Instant::now() < deadline
|
||||
&& !cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) => {}
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
*mode_slot.lock().unwrap() = negotiated.mode;
|
||||
|
||||
@@ -246,7 +246,17 @@ pub use stats::Stats;
|
||||
/// this reads and writes landed with the plane itself, appended behind the existing trailing-field
|
||||
/// discipline (old peers skip them in both directions, and a legacy request encodes byte-identical
|
||||
/// to the pre-hi-res messages), so [`WIRE_VERSION`] is still unchanged.
|
||||
pub const ABI_VERSION: u32 = 24;
|
||||
/// **v25** adds [`abi::punktfunk_set_log_callback`] — a `log` backend behind a C callback, so an
|
||||
/// embedder that installs no Rust subscriber (the Swift clients, any C host) can receive the
|
||||
/// core's own log lines: transport warnings, quinn connection events, rustls handshake notes,
|
||||
/// everything this crate and its dependencies say through `tracing`/`log`. Until now those went
|
||||
/// nowhere on Apple, and a client log bundle sent to the host carried the shell's half only.
|
||||
/// ADDED, not widened: one new function and one callback typedef; nothing existing moved, and an
|
||||
/// embedder that never calls it behaves exactly as on v24. Client-local in every sense — the host
|
||||
/// never sees it and [`WIRE_VERSION`] is unchanged. It relies on tracing's `log` feature, now
|
||||
/// declared explicitly by this crate (it was on transitively through quinn's defaults, which is
|
||||
/// not a thing an ABI promise should rest on).
|
||||
pub const ABI_VERSION: u32 = 25;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -153,10 +153,12 @@ fn percent_decode(s: &str) -> String {
|
||||
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art —
|
||||
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
|
||||
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
|
||||
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
|
||||
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
|
||||
/// operator whose library is somewhere else again.
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus the
|
||||
/// two launchers that need NOT live under the users base: the Steam install root
|
||||
/// ([`steam_art_roots`]), and every Playnite root this box can find
|
||||
/// ([`super::launch::playnite_art_roots`]) — a PORTABLE Playnite keeps its whole library, covers and
|
||||
/// all, beside the exe, wherever the operator unzipped it. `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
/// (`;`-separated) replaces the whole default for an operator whose library is somewhere else again.
|
||||
fn art_roots() -> Vec<PathBuf> {
|
||||
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
|
||||
return std::env::split_paths(&configured)
|
||||
@@ -177,6 +179,11 @@ fn art_roots() -> Vec<PathBuf> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
roots.extend(steam_art_roots());
|
||||
// Playnite, for the same reason: a portable install (`D:\Apps\Playnite`) puts `library\files\…`
|
||||
// — every cover it exports — outside every profile. An installed Playnite adds a root that is
|
||||
// already inside the users base, which costs nothing.
|
||||
#[cfg(windows)]
|
||||
roots.extend(super::launch::playnite_art_roots());
|
||||
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
|
||||
// where every launcher this host reads art from actually keeps it. Steam's
|
||||
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
|
||||
@@ -1047,6 +1054,25 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Whatever Playnite roots this box has, the confinement must be told about them with NO
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` set. That `extend` is the whole fix for the portable-install
|
||||
/// report (`D:\Apps\Playnite\library\files\…`, 70 covers dropped), and it is one line a
|
||||
/// refactor can silently drop. Vacuous on a box with no Playnite — the registry half cannot be
|
||||
/// faked from a test, so `launch::exe_from_shell_command`'s own test carries that load instead.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn playnite_roots_reach_the_art_confinement() {
|
||||
let _env = ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", None)]);
|
||||
let roots = art_roots();
|
||||
for root in crate::library::launch::playnite_art_roots() {
|
||||
assert!(root.is_dir(), "{root:?} is offered as an art root");
|
||||
assert!(
|
||||
roots.contains(&root),
|
||||
"{root:?} must be an allowed art root with no env var set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
|
||||
@@ -633,6 +633,11 @@ fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
|
||||
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
|
||||
///
|
||||
/// A **portable** Playnite is none of those: it is unzipped wherever the operator wanted it
|
||||
/// (`D:\Apps\Playnite`), registers no uninstall entry, and is not under any profile. Its one
|
||||
/// registry trace is the `playnite://` handler Playnite registers for itself
|
||||
/// ([`playnite_dir_from_uri_handler`]) — the same registration this host's own launch path follows.
|
||||
///
|
||||
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
|
||||
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
|
||||
/// a stale entry costs one `is_file` and nothing else.
|
||||
@@ -645,22 +650,33 @@ fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
|
||||
// so the WOW view is a machine-hive concern only.
|
||||
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
// Playnite's own `playnite://` registration, in both spellings: bare inside a `…_Classes` hive,
|
||||
// and via the `Software\Classes` link everywhere else.
|
||||
const URI_COMMAND: &str = r"playnite\shell\open\command";
|
||||
const CLASSES_URI_COMMAND: &str = r"Software\Classes\playnite\shell\open\command";
|
||||
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hklm, CLASSES_URI_COMMAND, &mut dirs);
|
||||
|
||||
let users = RegKey::predef(HKEY_USERS);
|
||||
for sid in users.enum_keys().flatten() {
|
||||
// The `…_Classes` companion hives carry file associations, never uninstall entries.
|
||||
let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) else {
|
||||
continue;
|
||||
};
|
||||
// The `…_Classes` companion hives carry file associations — which is exactly where the
|
||||
// `playnite://` handler lives, `HKCU\Software\Classes` BEING that hive — and never uninstall
|
||||
// entries. Both spellings are probed rather than reasoned about: the in-hive `Software\Classes`
|
||||
// link is a link, and a probe that misses costs one failed `open_subkey`.
|
||||
if sid.ends_with("_Classes") {
|
||||
playnite_dir_from_uri_handler(&hive, URI_COMMAND, &mut dirs);
|
||||
continue;
|
||||
}
|
||||
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
}
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hive, CLASSES_URI_COMMAND, &mut dirs);
|
||||
}
|
||||
|
||||
// The conventional per-user location, for every profile on the box — this is where Playnite's
|
||||
@@ -705,6 +721,80 @@ fn playnite_dirs_from_uninstall(
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the directory of Playnite's registered `playnite://` handler from `root\path`, if there is one.
|
||||
///
|
||||
/// This is what finds a **portable** Playnite. It leaves no uninstall entry and lives under no user
|
||||
/// profile, so every other probe here is blind to it — but Playnite registers its own URI scheme,
|
||||
/// and that registration is the very one `explorer.exe "playnite://…"` follows when this host starts
|
||||
/// a Playnite title. If it resolves, this box already opens games with that copy.
|
||||
#[cfg(windows)]
|
||||
fn playnite_dir_from_uri_handler(
|
||||
root: &winreg::RegKey,
|
||||
path: &str,
|
||||
out: &mut Vec<std::path::PathBuf>,
|
||||
) {
|
||||
use winreg::enums::KEY_READ;
|
||||
|
||||
let Ok(command) = root
|
||||
.open_subkey_with_flags(path, KEY_READ)
|
||||
.and_then(|k| k.get_value::<String, _>(""))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = exe_from_shell_command(&command)
|
||||
.map(std::path::Path::new)
|
||||
.and_then(std::path::Path::parent)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
{
|
||||
push_unique(out, dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// The executable out of a registered shell-open command line:
|
||||
/// `"D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1"` → `D:\Apps\Playnite\Playnite.DesktopApp.exe`.
|
||||
///
|
||||
/// Quoted form first, because that is what a registrar writes. The cut at the first `.exe` is the
|
||||
/// fallback for the unquoted spelling, whose path may itself contain spaces and so cannot be split on
|
||||
/// whitespace. `None` when neither shape matches; the result is only ever a directory to probe for an
|
||||
/// exe, so a miss costs one `is_file` and nothing else.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn exe_from_shell_command(command: &str) -> Option<&str> {
|
||||
let command = command.trim();
|
||||
if let Some(rest) = command.strip_prefix('"') {
|
||||
return rest.split('"').next().filter(|p| !p.is_empty());
|
||||
}
|
||||
let end = command.to_ascii_lowercase().find(".exe")? + ".exe".len();
|
||||
Some(&command[..end])
|
||||
}
|
||||
|
||||
/// Windows: every Playnite root on this box, as an **art** root.
|
||||
///
|
||||
/// A portable Playnite keeps its library beside the exe — covers land in
|
||||
/// `<PlayniteDir>\library\files\…` — so for that layout the install dir IS where the art lives, and
|
||||
/// the users base can never cover it: the whole point of portable is that it sits wherever the
|
||||
/// operator put it (`D:\Apps\Playnite` in the report that prompted this). Without it a portable
|
||||
/// install synced its games and had EVERY cover dropped by the confinement. An installed Playnite
|
||||
/// keeps the same tree under `%APPDATA%\Playnite`, already inside the users base; naming that
|
||||
/// directory twice costs one `canonicalize` in [`super::art::art_path_is_confined`].
|
||||
///
|
||||
/// Same shape and same reasoning as [`super::art::steam_art_roots`], and it does not widen what the
|
||||
/// host can be *tricked* into reading: every candidate comes from the host's own registry and
|
||||
/// filesystem probes, never from the plugin lane that supplies the art path, and the extension,
|
||||
/// regular-file, magic-byte and config-dir gates all still apply on top.
|
||||
///
|
||||
/// The per-user hives these candidates partly come from are writable by that user — which is a bar
|
||||
/// this host already stands on, and one rung lower here than where it already stood: the same
|
||||
/// lookup picks the `Playnite.FullscreenApp.exe` a launcher tile SPAWNS. Trusting it to name a
|
||||
/// directory whose image files may be read is strictly weaker than trusting it to name a program to
|
||||
/// run.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn playnite_art_roots() -> Vec<std::path::PathBuf> {
|
||||
playnite_install_dirs()
|
||||
.into_iter()
|
||||
.filter(|d| d.is_dir())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
|
||||
///
|
||||
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
|
||||
@@ -1086,6 +1176,36 @@ mod tests {
|
||||
assert!(!valid_aumid("Foo Bar!Game"));
|
||||
}
|
||||
|
||||
/// The portable-Playnite probe, at the only part of it that can be wrong off-Windows: pulling the
|
||||
/// exe out of the registered `playnite://` command line. A miss here is a portable install the
|
||||
/// host cannot find — no launcher tile, and (through [`playnite_art_roots`]) every cover dropped.
|
||||
#[test]
|
||||
fn exe_is_read_out_of_a_registered_shell_command() {
|
||||
// What Playnite actually registers, portable install on a second drive.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r#""D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1""#),
|
||||
Some(r"D:\Apps\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Unquoted, with a space in the path — which is why this cannot split on whitespace.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe %1"),
|
||||
Some(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Case is the registrar's business, not ours.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE"),
|
||||
Some(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE")
|
||||
);
|
||||
// Nothing exe-shaped, and the empty quoted form: no candidate beats a bogus one, because a
|
||||
// bogus one would become an allowed art root.
|
||||
assert_eq!(
|
||||
exe_from_shell_command("rundll32 shell32.dll,Control_RunDLL"),
|
||||
None
|
||||
);
|
||||
assert_eq!(exe_from_shell_command(r#""" %1"#), None);
|
||||
assert_eq!(exe_from_shell_command(""), None);
|
||||
}
|
||||
|
||||
/// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the
|
||||
/// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be
|
||||
/// used because it is registered to the desktop app (verified on .173, 2026-08-06).
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"$comment": "Single source for the install/port facts that used to drift across four surfaces (docs-and-onboarding-overhaul WP1). Consumers: docs-site install pages (<Install platform=…/> and <Ports/> in docs-site/src/components/platforms.tsx, reading the byte-identical snapshot docs-site/src/data/platforms.json that check-docs-drift.sh gates), the website download page (WP3), the guided install script (WP4). A port number, repo URL or install command lives HERE and nowhere else — pages quote it, they don't restate it. `install` is the shell snippet that puts the package on the box (lines, in order); `url` is a download/store link where there is no command. Validated by scripts/ci/check-docs-drift.sh (parse + snapshot sync; ponytail: cross-check against code literals when a consumer exists).",
|
||||
|
||||
"ports": {
|
||||
"mgmt": {
|
||||
"port": 47990,
|
||||
"proto": "tcp",
|
||||
"what": "management REST API (HTTPS + token; read-only status/library off loopback)",
|
||||
"env": "PUNKTFUNK_MGMT_BIND",
|
||||
"conflict": "Sunshine / Apollo / Vibeshine serve their web UI on 47990 too — the one port still shared with them once GameStream compat is off. Move it via PUNKTFUNK_MGMT_BIND; clients relearn it from discovery."
|
||||
},
|
||||
"web": {
|
||||
"port": 47992,
|
||||
"proto": "tcp",
|
||||
"what": "web console (HTTPS, login-gated); plugin interfaces on 47993",
|
||||
"also": [47993]
|
||||
},
|
||||
"native": {
|
||||
"port": 9777,
|
||||
"proto": "udp",
|
||||
"what": "punktfunk/1 QUIC control port",
|
||||
"env": "PUNKTFUNK_NATIVE_PORT"
|
||||
},
|
||||
"data": {
|
||||
"port": null,
|
||||
"proto": "udp",
|
||||
"what": "per-session video data plane — ephemeral port the client hole-punches; nothing fixed to open",
|
||||
"env": "PUNKTFUNK_DATA_PORT"
|
||||
},
|
||||
"mdns": {
|
||||
"port": 5353,
|
||||
"proto": "udp",
|
||||
"what": "mDNS discovery"
|
||||
},
|
||||
"gamestream": {
|
||||
"tcp": [47984, 47989, 48010],
|
||||
"udp": [47998, 47999, 48000],
|
||||
"what": "GameStream/Moonlight-compat planes (opt-in, PUNKTFUNK_GAMESTREAM=1)",
|
||||
"conflict": "Sunshine / Apollo / Vibeshine bind these same fixed ports and advertise the same mDNS name — run only one GameStream host at a time, or keep punktfunk native-only."
|
||||
}
|
||||
},
|
||||
|
||||
"firewall": {
|
||||
"$comment": "Service/profile names the Linux packages install for firewalld and ufw — the packages never open a port themselves.",
|
||||
"native": "punktfunk-native",
|
||||
"gamestream": "punktfunk-gamestream",
|
||||
"web": "punktfunk-web"
|
||||
},
|
||||
|
||||
"installer": {
|
||||
"$comment": "The guided Linux installer (WP4, preview). Canonical URL is the website's /install.sh, a redirect to the raw script on main so it's versioned with the code it installs; the docs hub and the download page quote these lines.",
|
||||
"status": "preview",
|
||||
"url": "https://punktfunk.unom.io/install.sh",
|
||||
"source": "https://git.unom.io/unom/punktfunk/raw/branch/main/scripts/install.sh",
|
||||
"oneLiner": "curl -fsSL https://punktfunk.unom.io/install.sh | sh",
|
||||
"inspectFirst": [
|
||||
"curl -fsSLO https://punktfunk.unom.io/install.sh",
|
||||
"less install.sh",
|
||||
"sh install.sh"
|
||||
],
|
||||
"docs": "/docs/install#guided-install-preview"
|
||||
},
|
||||
|
||||
"conflicts": {
|
||||
"hosts": ["Sunshine", "Apollo", "Vibeshine"],
|
||||
"detect": "punktfunk-host detect-conflicts",
|
||||
"detectExit": "1 only when a conflicting host runs or will start on its own; dormant leftovers print but exit 0",
|
||||
"docs": "/docs/switching-from-sunshine"
|
||||
},
|
||||
|
||||
"platforms": [
|
||||
{
|
||||
"id": "debian",
|
||||
"name": "Debian 13+ / Ubuntu 26.04+",
|
||||
"installs": "host",
|
||||
"packageManager": "apt",
|
||||
"docs": "/docs/debian",
|
||||
"repo": "https://git.unom.io/api/packages/unom/debian",
|
||||
"install": [
|
||||
"sudo install -d -m 0755 /etc/apt/keyrings",
|
||||
"curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key | sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null",
|
||||
"echo \"deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main\" | sudo tee /etc/apt/sources.list.d/punktfunk.list",
|
||||
"sudo apt update",
|
||||
"sudo apt install punktfunk-host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch",
|
||||
"name": "Arch Linux / CachyOS",
|
||||
"installs": "host",
|
||||
"packageManager": "pacman",
|
||||
"docs": "/docs/arch",
|
||||
"repo": "https://git.unom.io/api/packages/unom/arch",
|
||||
"install": [
|
||||
"curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -",
|
||||
"sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69",
|
||||
"grep -q '^\\[punktfunk\\]' /etc/pacman.conf || printf '\\n[punktfunk]\\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\\n' | sudo tee -a /etc/pacman.conf >/dev/null",
|
||||
"sudo pacman -Syu punktfunk-host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fedora",
|
||||
"name": "Fedora 43+",
|
||||
"installs": "host",
|
||||
"packageManager": "dnf",
|
||||
"docs": "/docs/fedora",
|
||||
"repo": "https://git.unom.io/api/packages/unom/rpm/fedora-44",
|
||||
"install": [
|
||||
"sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'",
|
||||
"[punktfunk]",
|
||||
"name=punktfunk",
|
||||
"# fedora-44 on Fedora 44; bazzite on Fedora 43 (a plain Fedora 43 build of the same package)",
|
||||
"baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44",
|
||||
"enabled=1",
|
||||
"gpgcheck=1",
|
||||
"repo_gpgcheck=1",
|
||||
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
|
||||
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
|
||||
"REPO",
|
||||
"sudo dnf install punktfunk"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "bazzite",
|
||||
"name": "Bazzite / Fedora Atomic",
|
||||
"installs": "host",
|
||||
"packageManager": "sysext",
|
||||
"docs": "/docs/bazzite",
|
||||
"install": [
|
||||
"curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh",
|
||||
"sudo bash punktfunk-sysext.sh install"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nixos",
|
||||
"name": "NixOS",
|
||||
"installs": "host",
|
||||
"packageManager": "nix",
|
||||
"docs": "/docs/nixos",
|
||||
"install": [
|
||||
"# flake input: inputs.punktfunk.url = \"git+https://git.unom.io/unom/punktfunk\";",
|
||||
"# then: imports = [ punktfunk.nixosModules.default ]; services.punktfunk.host.enable = true;"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "steamos",
|
||||
"name": "SteamOS (Steam Deck as host)",
|
||||
"installs": "host",
|
||||
"packageManager": "script",
|
||||
"docs": "/docs/steamos-host",
|
||||
"install": [
|
||||
"git clone https://git.unom.io/unom/punktfunk ~/punktfunk",
|
||||
"bash ~/punktfunk/scripts/steamdeck/install.sh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "windows",
|
||||
"name": "Windows 11 (22H2+)",
|
||||
"installs": "host",
|
||||
"packageManager": "winget",
|
||||
"docs": "/docs/windows-host",
|
||||
"install": [
|
||||
"winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest",
|
||||
"winget install unom.PunktfunkHost"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "linux-client",
|
||||
"name": "Linux client (any distro)",
|
||||
"installs": "client",
|
||||
"packageManager": "flatpak",
|
||||
"docs": "/docs/install-client#linux-desktop-flatpak",
|
||||
"install": [
|
||||
"flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "steam-deck-client",
|
||||
"name": "Steam Deck (Gaming Mode)",
|
||||
"installs": "client",
|
||||
"packageManager": "decky",
|
||||
"docs": "/docs/steam-deck"
|
||||
},
|
||||
{
|
||||
"id": "windows-client",
|
||||
"name": "Windows client",
|
||||
"installs": "client",
|
||||
"packageManager": "msix",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "macos-client",
|
||||
"name": "macOS",
|
||||
"installs": "client",
|
||||
"packageManager": "dmg",
|
||||
"docs": "/docs/install-client#macos",
|
||||
"url": "https://git.unom.io/unom/punktfunk/releases"
|
||||
},
|
||||
{
|
||||
"id": "apple-client",
|
||||
"name": "iPhone, iPad, Apple TV",
|
||||
"installs": "client",
|
||||
"packageManager": "testflight",
|
||||
"docs": "/docs/install-client#ios-ipados-apple-tv",
|
||||
"url": "https://testflight.apple.com/join/Qr7uSemk"
|
||||
},
|
||||
{
|
||||
"id": "android-client",
|
||||
"name": "Android / Android TV",
|
||||
"installs": "client",
|
||||
"packageManager": "play",
|
||||
"docs": "/docs/install-client#android",
|
||||
"url": "https://play.google.com/store/apps/details?id=io.unom.punktfunk"
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
-9
@@ -4,7 +4,12 @@ The Punktfunk documentation site: [Fumadocs](https://fumadocs.dev) on
|
||||
[TanStack Start](https://tanstack.com/start) (Vite + Nitro/bun preset).
|
||||
|
||||
Content lives in [`content/docs/`](content/docs) as `.md`/`.mdx`. This site is the source of truth
|
||||
for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo.
|
||||
for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo, and
|
||||
READMEs and the marketing site link here instead of restating anything — see "Where facts live" in
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md). Pages serve one of two audiences, not both at once: the
|
||||
**get-started track** (quickstart, install, pairing) assumes no Linux expertise — short pages, one
|
||||
task each, happy path only; the **reference track** (configuration, CLI, API, per-compositor
|
||||
pages) is allowed to be dense.
|
||||
|
||||
## API reference
|
||||
|
||||
@@ -19,17 +24,24 @@ cargo run -p punktfunk-host -- openapi > api/openapi.json
|
||||
cp api/openapi.json docs-site/public/openapi.json
|
||||
```
|
||||
|
||||
Nothing in CI diffs the two, so the snapshot goes stale silently — that manual `cp` is the only
|
||||
thing keeping them in sync. Before publishing docs, check that they match:
|
||||
CI keeps the pair honest: the `docs-drift` job fails unless the snapshot is a byte-for-byte copy
|
||||
of `api/openapi.json`, and the `rust` job regenerates the spec and diffs it against the committed
|
||||
one — so a management-API change can't publish stale API docs any more, it fails CI until you run
|
||||
the two commands above.
|
||||
|
||||
```bash
|
||||
diff <(jq -S . api/openapi.json) <(jq -S . docs-site/public/openapi.json)
|
||||
## Install commands and ports
|
||||
|
||||
`src/data/platforms.json` is a byte-identical snapshot of the repo-root
|
||||
[`data/platforms.json`](../data/platforms.json) — the single source for install commands, repo
|
||||
URLs, port facts and the Sunshine/Apollo/Vibeshine conflict facts. The `<Install platform="…" />`
|
||||
and `<Ports />` MDX components (`src/components/platforms.tsx`) render from it, so no page restates
|
||||
a command or a port. It's a snapshot for the same reason as `openapi.json` (the Docker build context
|
||||
is this directory alone), and the same `docs-drift` job fails unless it matches:
|
||||
|
||||
```sh
|
||||
cp data/platforms.json docs-site/src/data/platforms.json # from the repo root, after editing the canonical file
|
||||
```
|
||||
|
||||
That should print nothing. Right now it doesn't: the committed snapshot predates the
|
||||
`/api/v1/update/check`, `/api/v1/update/apply` and `/api/v1/update/status` endpoints, so the
|
||||
published `/api` reference is missing the host self-update surface — re-copy it.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
|
||||
@@ -3,15 +3,14 @@ title: Access levels
|
||||
description: What each paired device may do, and for how long — the three presets, the advanced toggles, temporary access that expires on its own, and what access control honestly does not cover.
|
||||
---
|
||||
|
||||
Pairing used to be all-or-nothing: a paired device had full control of the host, forever. **Access**
|
||||
changes that. Every paired device carries an **access level** — what it may send to the host — and
|
||||
optionally an expiry — how long that lasts. A friend's phone can be a second controller for the
|
||||
evening and nothing more; the living-room TV can watch and play but never type into your desktop;
|
||||
a spectator can see and hear without sending anything.
|
||||
Pairing used to be all-or-nothing: a paired device had full control of the host, forever. Now every
|
||||
paired device carries an **access level** — what it may send to the host — and optionally an
|
||||
expiry: a friend's phone as a second controller for the evening, a TV that can play but never type,
|
||||
a spectator who only watches.
|
||||
|
||||
Access is **enforced by the host**. A client's UI reflects its access as a courtesy, but the host
|
||||
drops anything a device isn't granted regardless of what the client sends — nothing a client can
|
||||
send widens its own access.
|
||||
drops anything a device isn't granted regardless of what the client sends — nothing a client sends
|
||||
can widen its own access.
|
||||
|
||||
You manage access from the host's [web console](/docs/web-console): when you
|
||||
[approve a device or arm pairing](/docs/pairing#choosing-access-when-you-admit-a-device), and any
|
||||
@@ -22,16 +21,16 @@ countdown if it expires) and an edit sheet.
|
||||
|
||||
| Access level | What the device can do |
|
||||
|---|---|
|
||||
| **Full control** | Everything — keyboard, mouse, controllers, clipboard, microphone, launching games. This is what pairing has always meant, and it stays the default: every device paired before access levels existed keeps full control, and so does a plain **Approve**. |
|
||||
| **Full control** | Everything — keyboard, mouse, controllers, clipboard, microphone, launching games. What pairing has always meant, and still the default: every device paired before access levels existed keeps full control, and so does a plain **Approve**. |
|
||||
| **Controller only** | Gamepad input only — the guest and co-play preset. The device's pads show up as additional controllers (with rumble and pad audio), but it cannot type, move the mouse, read the clipboard, use the mic, or launch anything. |
|
||||
| **View only** | See and hear the stream, send nothing. The spectator preset. |
|
||||
|
||||
The preset label is derived from the underlying toggles, so a hand-tuned combination simply shows
|
||||
as **Custom** — there is no separate thing to keep in sync.
|
||||
The preset label is derived from the underlying toggles, so a hand-tuned combination shows as
|
||||
**Custom** — there is no separate thing to keep in sync.
|
||||
|
||||
## The advanced toggles
|
||||
|
||||
Each preset is a bundle of six independent grants, exposed under **Advanced** in the edit sheet:
|
||||
Each preset is a bundle of six independent grants, under **Advanced** in the edit sheet:
|
||||
|
||||
| Toggle | Covers |
|
||||
|---|---|
|
||||
@@ -40,13 +39,13 @@ Each preset is a bundle of six independent grants, exposed under **Advanced** in
|
||||
| **Keyboard** | Key presses. |
|
||||
| **Clipboard** | The [shared clipboard](/docs/clipboard). Both switches still apply: the host operator's clipboard policy *and* this grant have to allow it — the grant can only narrow, never widen, what the operator permits. An ungranted device gets a clean "not permitted" instead of a toggle that silently does nothing. |
|
||||
| **Microphone** | Sending the client's microphone to the host. Without it, the session never attaches to the host's mic service at all. |
|
||||
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than being dropped onto the bare desktop. The library remains *visible* — this governs launching, not browsing. |
|
||||
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than dropped onto the bare desktop. The library stays *visible* — this governs launching, not browsing. |
|
||||
|
||||
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs. If
|
||||
you want a guest picking games from the couch, that's one Advanced toggle away.
|
||||
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs.
|
||||
Want a guest picking games? Turn on that one Advanced toggle.
|
||||
|
||||
A session's quality controls — resolution, bitrate, keyframe requests — are *not* governed. They
|
||||
only shape that device's own stream, so restricting them would cost usability and buy no security.
|
||||
only shape that device's own stream; restricting them would cost usability and buy no security.
|
||||
|
||||
## Temporary access
|
||||
|
||||
@@ -54,7 +53,7 @@ Any grant can carry an expiry, picked when you approve the device or set later i
|
||||
**1 h / 4 h / 8 h / custom / forever**.
|
||||
|
||||
- Expiry is **wall-clock time on the host** — "4 hours" means four hours from now by the host's
|
||||
clock, matching the mental model of "until tonight".
|
||||
clock.
|
||||
- A device streaming when its access runs out gets **warnings at 5 minutes and 1 minute** before
|
||||
the deadline, then its session ends with an explicit reason: *"Your access to this host has
|
||||
expired."* Only that device's sessions end — yours is untouched.
|
||||
@@ -65,43 +64,42 @@ Any grant can carry an expiry, picked when you approve the device or set later i
|
||||
extending re-arms the running session's deadline, and Expire now ends it with the same clean
|
||||
"access expired" message — no lingering stream.
|
||||
|
||||
Edits other than expiry are just as immediate: changing a device's access level while it streams
|
||||
takes effect within moments, and removing the device ends its sessions. Access is per *device*,
|
||||
not per session — two sessions from the same device share one grant.
|
||||
Other edits are just as immediate: changing a device's access level while it streams takes effect
|
||||
within moments, and removing the device ends its sessions. Access is per *device*, not per session
|
||||
— two sessions from the same device share one grant.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
Be honest with yourself about three limits before relying on access levels:
|
||||
Three limits before relying on access levels:
|
||||
|
||||
> **A view-only guest still sees your whole desktop.** On the shared-desktop backends every
|
||||
> session shows the *same* desktop — access levels govern what a device can send *in*, not what it
|
||||
> sees going *out*. A view-only or controller-only guest watches and hears everything you do,
|
||||
> notifications included. Don't read email with a spectator attached.
|
||||
> notifications included.
|
||||
|
||||
- **Moonlight / GameStream devices are not governed yet.** Access levels currently apply to the
|
||||
native Punktfunk protocol. A device paired via [Moonlight](/docs/moonlight) has full control and
|
||||
shows an honest **Full (ungoverned)** chip in the console — not a fake editor. When enforcement
|
||||
reaches the GameStream plane, it will be *silent* from the client's side: the GameStream
|
||||
protocol has no way to tell a Moonlight client about its access, so an ungranted keyboard will
|
||||
simply be inert, with the explanation visible only in the console.
|
||||
- **Moonlight / GameStream devices are not governed yet.** Access levels apply to the native
|
||||
Punktfunk protocol. A device paired via [Moonlight](/docs/moonlight) has full control and shows
|
||||
an honest **Full (ungoverned)** chip in the console — not a fake editor. When enforcement reaches
|
||||
the GameStream plane it will be *silent* from the client's side: the protocol has no way to tell
|
||||
a Moonlight client about its access, so an ungranted keyboard will simply be inert, with the
|
||||
explanation visible only in the console.
|
||||
- **Older Punktfunk clients are enforced, but can't explain it.** The host enforces access
|
||||
identically for every client version. A client from before this feature just lacks the chrome:
|
||||
identically for every client version; a client from before this feature just lacks the chrome:
|
||||
no "Controller only · ends in 2 h" chip, no expiry warnings, a generic disconnect instead of
|
||||
"access expired" — and an ungranted keyboard is silently inert rather than never captured in the
|
||||
first place. If a guest reports "my keyboard does nothing", check their access level in the
|
||||
console first, then whether their client is current.
|
||||
"access expired" — and an ungranted keyboard is silently inert rather than never captured. If a
|
||||
guest reports "my keyboard does nothing", check their access level in the console first, then
|
||||
whether their client is current.
|
||||
|
||||
Up-to-date native clients do get the chrome: they stop capturing what can't land (no keyboard grab
|
||||
Up-to-date native clients get the chrome: they stop capturing what can't land (no keyboard grab
|
||||
without the Keyboard grant), hide the clipboard and mic controls when ungranted, show a small
|
||||
overlay chip naming the session's access and time remaining, and surface the expiry warnings as
|
||||
toasts.
|
||||
|
||||
## Where enforcement happens
|
||||
|
||||
For the security-minded: the host checks every input event against the device's grants before
|
||||
injecting it, refuses ungranted planes at session setup (no Gamepad grant means the virtual pads
|
||||
are never created; no Microphone grant means the mic plane never attaches), and re-pairing a
|
||||
device **preserves** its existing access — the only way to widen a grant is the console's own
|
||||
dialogs, behind the console login. Dropped traffic is logged once per session and category, not
|
||||
per event, so a misbehaving client can't flood the log. See [Security & Safe
|
||||
Use](/docs/security) for the wider picture.
|
||||
The host checks every input event against the device's grants before injecting it, refuses
|
||||
ungranted planes at session setup (no Gamepad grant means the virtual pads are never created; no
|
||||
Microphone grant means the mic plane never attaches), and re-pairing a device **preserves** its
|
||||
existing access — the only way to widen a grant is the console's own dialogs, behind the console
|
||||
login. Dropped traffic is logged once per session and category, not per event, so a misbehaving
|
||||
client can't flood the log. See [Security & Safe Use](/docs/security) for the wider picture.
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
---
|
||||
title: Arch Linux
|
||||
description: Install a Punktfunk host on Arch (and Arch-derived distros) from the signed pacman binary repo.
|
||||
---
|
||||
|
||||
Set up a Punktfunk host on **Arch Linux** (or an Arch-derived distro like CachyOS/EndeavourOS). The
|
||||
host installs from a **signed pacman binary repo**, so it updates with `pacman -Syu` like the rest
|
||||
of your system — no building required. Host encode is **NVENC on NVIDIA**; on **AMD/Intel** HEVC
|
||||
and AV1 go through **Vulkan Video**, with **VAAPI** for H.264 and as the fallback
|
||||
(`PUNKTFUNK_ENCODER=auto` picks per GPU).
|
||||
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
> Prefer to build it yourself? A split `PKGBUILD` (host + client + optional web console) is in the
|
||||
> repo at `packaging/arch/` — see the [appendix](#appendix--build-from-source-pkgbuild). The binary
|
||||
> repo below is the supported path.
|
||||
|
||||
## 1. GPU prerequisites
|
||||
|
||||
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (provides NVENC + the EGL/CUDA zero-copy path).
|
||||
Arch's stock `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap like Fedora needs.
|
||||
- **AMD / Intel:** the Mesa stack. HEVC/AV1 encode goes through **Vulkan Video** by default, so
|
||||
install the Vulkan driver — `vulkan-radeon` (AMD) or `vulkan-intel` (Intel) — alongside the VAAPI
|
||||
drivers (`libva-mesa-driver` for AMD, `intel-media-driver` for Intel), which carry H.264 and the
|
||||
fallback path. Both are usually already installed on a desktop.
|
||||
|
||||
## 2. Add the signed repo
|
||||
|
||||
The registry **signs its database and every package**, so first trust its key once (after this,
|
||||
packages install signature-verified):
|
||||
|
||||
```sh
|
||||
# Trust the registry signing key.
|
||||
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
|
||||
| sudo pacman-key --add -
|
||||
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
|
||||
|
||||
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
|
||||
# verifies signed packages against the key you just trusted. (printf, not a heredoc, so this
|
||||
# works in fish too — CachyOS's default shell has no `<<EOF` support.)
|
||||
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
|
||||
| sudo tee -a /etc/pacman.conf >/dev/null
|
||||
```
|
||||
|
||||
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
|
||||
> release is cut. For the latest `main` build, use `[punktfunk-canary]` instead (same `Server` line,
|
||||
> just the repo name). Enable exactly one. See [Release Channels](/docs/channels).
|
||||
|
||||
## 3. Install the host
|
||||
|
||||
```sh
|
||||
sudo pacman -Syu punktfunk-host # the streaming host
|
||||
sudo pacman -Syu punktfunk-web # optional: the browser management console (pairing + status)
|
||||
sudo pacman -Syu punktfunk-gamescope # optional: HDR (10-bit BT.2020 PQ) off gamescope sessions
|
||||
sudo pacman -Syu punktfunk-scripting # optional: the plugin/script runner (see below)
|
||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||
```
|
||||
|
||||
Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller**
|
||||
(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts
|
||||
it), or this box autologins into Steam **Gaming Mode** and you want the host to take that session
|
||||
over at the client's resolution:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply)
|
||||
```
|
||||
|
||||
That is a second group on purpose. It grants write access to the usbip `attach` file, which
|
||||
materialises an arbitrary emulated USB device — so it stays off the `input` group everyone is
|
||||
routinely told to join. Join it only on a machine you trust. On a plain desktop host, everything
|
||||
else still works without it and the pad simply arrives as an ordinary Xbox 360 controller; on a
|
||||
Gaming Mode box the takeover silently degrades to mirroring the box's own screen — see
|
||||
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
|
||||
|
||||
Each install is a **full** `-Syu`, on purpose: our packages are built against current Arch
|
||||
sonames, and `pacman -Sy <pkg>` would drop one onto a system whose other packages are still old —
|
||||
the classic partial upgrade that breaks Arch boxes. To take several in one go, name them on a
|
||||
single line: `sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-gamescope`.
|
||||
|
||||
`punktfunk-scripting` is the runner behind [Plugins](/docs/plugins); it isn't started for you —
|
||||
`systemctl --user enable --now punktfunk-scripting` when you want it. `punktfunk-client` (the native
|
||||
GTK4 Linux client) is in the same repo if this box is also a client. The host package ships the
|
||||
systemd **user** units, the udev rule, the UDP socket-buffer sysctl tuning, and example configs.
|
||||
|
||||
Updates later are a normal `sudo pacman -Syu`, then `systemctl --user restart punktfunk-host` so the
|
||||
running host picks up the new binary. A `-Syu` moves every Punktfunk package you installed, so
|
||||
restart `punktfunk-web` the same way if you run the console. The web console can run the update for
|
||||
you — see [Updating the Host](/docs/updating); on Arch that button additionally needs
|
||||
`PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the only pacman update we will
|
||||
run is a full one.
|
||||
|
||||
## 4. Configure and run
|
||||
|
||||
The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus. Copy a
|
||||
starting config:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
|
||||
```
|
||||
|
||||
How the host creates its virtual display and injects input depends on your desktop, not your distro —
|
||||
edit `host.env` for the desktop you run, following its page for the exact settings and any quirks:
|
||||
|
||||
- [KDE Plasma (KWin)](/docs/kde)
|
||||
- [GNOME (Mutter)](/docs/gnome)
|
||||
- [Steam / gamescope](/docs/gamescope)
|
||||
- [Hyprland](/docs/hyprland)
|
||||
- [Sway / wlroots](/docs/sway)
|
||||
|
||||
Then enable the service and turn on linger so it starts at boot without a login:
|
||||
|
||||
```sh
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now punktfunk-host
|
||||
sudo loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Check it came up:
|
||||
|
||||
```sh
|
||||
systemctl --user status punktfunk-host # active
|
||||
journalctl --user -u punktfunk-host -f # watch a client connect
|
||||
```
|
||||
|
||||
Enable the browser console, find your login password, and arm PIN pairing from
|
||||
[The Web Console](/docs/web-console). For a headless KWin appliance that streams at boot with no
|
||||
graphical login, see [KDE → Headless session](/docs/kde#headless-session). Full reference:
|
||||
[Configuration](/docs/configuration) · [Running as a Service](/docs/running-as-a-service).
|
||||
|
||||
## 5. Open the firewall (if you have one)
|
||||
|
||||
**Stock Arch ships no firewall** — every port is already open, so you can skip this. But **CachyOS
|
||||
enables `ufw` by default** (firewalld is not installed), and some other spins (e.g. EndeavourOS)
|
||||
enable **`firewalld`** — an Arch package never opens ports for you, so on those the host is
|
||||
unreachable until you allow it.
|
||||
|
||||
The `punktfunk-host` package installs openers for **both**, so it's a one-liner whichever you run.
|
||||
The unit you enabled in step 4 runs `serve --gamestream` — the package installs it as it ships and
|
||||
only rewrites the binary path — so that host serves **both** the native `punktfunk/1` plane and
|
||||
stock [Moonlight](/docs/moonlight) clients, and needs **both** openers:
|
||||
|
||||
```sh
|
||||
# ufw — CachyOS (and Ubuntu, once you enable ufw):
|
||||
sudo ufw allow punktfunk-native
|
||||
|
||||
# firewalld — Fedora-like spins (EndeavourOS, …):
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Enabled **GameStream/Moonlight compat** (`PUNKTFUNK_GAMESTREAM=1` in `host.env` — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts)), or you pass
|
||||
`--gamestream` by hand? Then also open its service:
|
||||
|
||||
```sh
|
||||
sudo ufw allow punktfunk-gamestream # ufw
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-native` opens the QUIC control port (UDP 9777), mDNS discovery and the mgmt/library API
|
||||
(TCP 47990); `punktfunk-gamestream` opens the fixed Moonlight ports — TCP 47984, 47989 and 48010,
|
||||
UDP 47998–48000 — plus the same mDNS.
|
||||
The media **data plane** uses an *ephemeral* UDP port that the client opens with a hole-punch — the
|
||||
host streams back out through the path the client opened, so there's **nothing fixed to open** as
|
||||
long as the firewall allows outbound UDP (the default for both ufw and firewalld).
|
||||
|
||||
Enabled the **web console** (`punktfunk-web`, above) and want to reach it from your phone or another
|
||||
machine? It's not opened by the streaming rules — open its port too, the same one-liner way:
|
||||
|
||||
```sh
|
||||
sudo ufw allow punktfunk-web # ufw
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload # firewalld
|
||||
```
|
||||
|
||||
That opens **TCP 47992** (HTTPS, login-gated). The mgmt API (47990) is opened for paired clients by the
|
||||
`punktfunk-native` profile (game-library browsing over mTLS); off-loopback it serves only read-only
|
||||
status/library, and every admin action stays loopback-only. Full port lists (`nftables`, explicit ports) are in
|
||||
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md#firewall).
|
||||
|
||||
## 6. Connect a client
|
||||
|
||||
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
|
||||
the **PIN pairing**: arm it from [The Web Console](/docs/web-console#arm-pairing), which displays a
|
||||
4-digit PIN to type into the client. (Pairing is required by default; pass `serve --open` only if
|
||||
you deliberately want to disable it.) See [Clients](/docs/clients) for per-platform setup.
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Keep it current** — [Updating the Host](/docs/updating).
|
||||
- **Remove it again** — [Uninstalling](/docs/uninstall).
|
||||
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
|
||||
|
||||
## Appendix — build from source (PKGBUILD)
|
||||
|
||||
To build instead of using the binary repo, use the split `PKGBUILD` in `packaging/arch/` (produces
|
||||
`punktfunk-host` + `punktfunk-client`; set `PF_WITH_WEB=1` to also build `punktfunk-web` and
|
||||
`PF_WITH_SCRIPTING=1` to also build `punktfunk-scripting` — both need `bun`):
|
||||
|
||||
```sh
|
||||
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
|
||||
# Build the working tree (no git fetch):
|
||||
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
|
||||
sudo pacman -U punktfunk-host-*.pkg.tar.zst
|
||||
```
|
||||
|
||||
NVENC/EGL come from the NVIDIA driver (`nvidia-utils`); on a GPU-less builder, symlink the CUDA
|
||||
stub into the link path first (the `PKGBUILD` header documents this). Full details, the
|
||||
Fedora→Arch dependency map, and the systemd-sysext mechanism are in
|
||||
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
|
||||
(For a **SteamOS host**, use the [on-device installer](/docs/steamos-host) instead — it builds
|
||||
the host and the HDR gamescope against the running OS.)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Arch Linux
|
||||
description: Install the Punktfunk host on Arch, CachyOS or EndeavourOS from the signed pacman repo — four steps.
|
||||
---
|
||||
|
||||
For **Arch Linux** and Arch-based distros (CachyOS, EndeavourOS, …). The host comes from a signed
|
||||
binary repo and updates with `pacman -Syu` like everything else. SteamOS is different — it has
|
||||
[its own page](/docs/steamos-host).
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (NVENC and the zero-copy path; Arch's `ffmpeg`
|
||||
already has NVENC built in).
|
||||
- **AMD / Intel:** the Mesa stack you already have — `vulkan-radeon` / `vulkan-intel` for Vulkan
|
||||
Video, `libva-mesa-driver` / `intel-media-driver` for VAAPI. Usually all installed on a desktop.
|
||||
|
||||
## 2. Install the host
|
||||
|
||||
Trust the repo key once, add the repo, install. Every install and update here is a **full**
|
||||
`-Syu` on purpose — our packages are built against current Arch sonames, and `pacman -Sy <pkg>` is
|
||||
the partial upgrade that breaks Arch boxes:
|
||||
|
||||
<Install platform="arch" />
|
||||
|
||||
The browser console is **optional** on Arch, so name it yourself — same line, full upgrade:
|
||||
`sudo pacman -Syu punktfunk-web`. (Also in the repo: `punktfunk-gamescope` for HDR off gamescope,
|
||||
`punktfunk-scripting` for [plugins](/docs/plugins), `punktfunk-client` if this box is also a client.)
|
||||
|
||||
From then on a normal `sudo pacman -Syu` moves every Punktfunk package; restart the host afterwards
|
||||
(`systemctl --user restart punktfunk-host`) — or let the [console do it](/docs/updating).
|
||||
|
||||
## 3. Let it use your controllers
|
||||
|
||||
Join the `input` group (virtual gamepads go through `/dev/uinput`), then **log out and back in**:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG input "$USER"
|
||||
```
|
||||
|
||||
Want the **virtual Steam Deck controller** (paddles, trackpads, gyro)? Also join the `punktfunk`
|
||||
group — [what it gates and why it's separate](/docs/gamescope#nobara-and-other-autologin-display-managers).
|
||||
|
||||
## 4. Start it
|
||||
|
||||
From a terminal inside your desktop session:
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-host punktfunk-web
|
||||
systemctl --user enable --now punktfunk-scripting # if you installed the plugin runner — other distros start it for you, Arch doesn't
|
||||
```
|
||||
|
||||
**Firewall:** stock Arch has none. **CachyOS enables `ufw`**, EndeavourOS enables **firewalld** —
|
||||
on those the host is unreachable until you allow it (the package installed the profiles):
|
||||
|
||||
```sh
|
||||
sudo ufw allow punktfunk-native && sudo ufw allow punktfunk-web # CachyOS (ufw)
|
||||
sudo firewall-cmd --reload && sudo firewall-cmd --permanent --add-service=punktfunk-native --add-service=punktfunk-web && sudo firewall-cmd --reload # firewalld
|
||||
```
|
||||
|
||||
[Ports & firewall](/docs/ports) has every port, and the GameStream profile if you turn Moonlight
|
||||
compat on.
|
||||
|
||||
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
|
||||
— open the console, pair a client, stream.
|
||||
|
||||
## When you want more
|
||||
|
||||
- **pacman says `database already registered`?** The repo got added twice —
|
||||
[the one-line fix](/docs/troubleshooting#pacman-error-could-not-register-punktfunk-database-database-already-registered).
|
||||
**`unable to satisfy dependency 'libavcodec.so=…'`?** FFmpeg major mismatch —
|
||||
[what to do](/docs/troubleshooting#pacman-unable-to-satisfy-dependency-libavcodecso).
|
||||
- `punktfunk-host detect-conflicts` tells you if Sunshine or Apollo is also running;
|
||||
[Troubleshooting](/docs/troubleshooting) starts from the symptom.
|
||||
- Your desktop's particulars — [KDE](/docs/kde), [GNOME](/docs/gnome), [gamescope](/docs/gamescope),
|
||||
[Hyprland](/docs/hyprland), [Sway](/docs/sway).
|
||||
- Stream with nobody logged in — [Running as a service](/docs/running-as-a-service) (`sudo loginctl
|
||||
enable-linger "$USER"` is the one extra line).
|
||||
- Track `main` instead of releases (`[punktfunk-canary]`, same `Server` line — enable exactly one) —
|
||||
[Release channels](/docs/channels). Build it yourself with the split `PKGBUILD` —
|
||||
[Build from source](/docs/build-from-source#arch-pkgbuild).
|
||||
@@ -8,15 +8,15 @@ disconnects, a stream starts or stops, a pairing request arrives, a virtual disp
|
||||
the library changes, the host starts or shuts down. Two ways to consume them:
|
||||
|
||||
- **Hooks** — zero-code: entries in `~/.config/punktfunk/hooks.json` run a **command** or POST a
|
||||
**webhook** when a matching event fires. This covers the common automation: Do-Not-Disturb
|
||||
during a stream, a phone notification on a pairing request, pausing downloads while playing.
|
||||
**webhook** when a matching event fires. Covers the common automation: Do-Not-Disturb during a
|
||||
stream, a phone notification on a pairing request, pausing downloads while playing.
|
||||
- **The event stream** — code: `GET /api/v1/events` on the management API is a standard
|
||||
[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
|
||||
stream of the same events, for scripts and integrations that want to *decide* things (e.g.
|
||||
auto-approve pairing from a known subnet by calling the approve endpoint).
|
||||
|
||||
Hooks **observe** — they can never veto or delay a connection, a stream, or a pairing decision,
|
||||
and nothing you configure here runs anywhere near the streaming path.
|
||||
and nothing configured here runs anywhere near the streaming path.
|
||||
|
||||
## The events
|
||||
|
||||
@@ -38,7 +38,7 @@ and nothing you configure here runs anywhere near the streaming path.
|
||||
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
|
||||
|
||||
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
||||
version (additive-only — fields get added, never renamed), and the fields above. Example:
|
||||
version (additive-only — fields get added, never renamed), and the fields above:
|
||||
|
||||
```json
|
||||
{ "seq": 42, "ts_ms": 1784227449526, "schema": 1,
|
||||
@@ -81,13 +81,12 @@ Each entry:
|
||||
### What the host refuses
|
||||
|
||||
The document is validated as a whole, and **one bad entry disables every hook** — the host logs
|
||||
`hooks.json invalid — hooks disabled until fixed` and runs none of them until you correct it. The
|
||||
rules:
|
||||
`hooks.json invalid — hooks disabled until fixed` and runs none until you correct it. The rules:
|
||||
|
||||
- An entry needs a non-empty `on`, plus `run` and/or `webhook`.
|
||||
- `webhook` must be an `http(s)://` URL, and must **not** point at loopback, `localhost` or a
|
||||
link-local address (which is also what blocks the cloud metadata endpoint). A receiver on this
|
||||
same machine is what a `run` command is for. Ordinary LAN addresses — `192.168.x.x`, a ULA, a
|
||||
link-local address (which also blocks the cloud metadata endpoint). A receiver on this same
|
||||
machine is what a `run` command is for. Ordinary LAN addresses — `192.168.x.x`, a ULA, a
|
||||
hostname — are fine, so Home Assistant on another box on your network works as written.
|
||||
- `timeout_s` must be 1–600.
|
||||
- If `hmac_secret_file` is set but unreadable, the host **skips** that POST rather than sending it
|
||||
@@ -107,10 +106,10 @@ A `run` command's shell one-liner vocabulary — the event flattened to env, val
|
||||
[ "$PF_EVENT_KIND" = stream.started ] && makoctl mode -a do-not-disturb
|
||||
```
|
||||
|
||||
Richer payloads (and the full document) are on stdin — `jq` away. On a Windows host running as
|
||||
the service, the command runs **in your interactive session** (never as SYSTEM); that path can't
|
||||
carry per-process env or stdin, so the event JSON's path is appended as the command's last
|
||||
argument instead.
|
||||
Richer payloads (and the full document) are on stdin for `jq`. On a Windows host running as the
|
||||
service, the command runs **in your interactive session** (never as SYSTEM); that path can't carry
|
||||
per-process env or stdin, so the event JSON's path is appended as the command's last argument
|
||||
instead.
|
||||
|
||||
Verify a signed webhook (Python):
|
||||
|
||||
@@ -120,15 +119,15 @@ expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
ok = hmac.compare_digest(request.headers["X-Punktfunk-Signature"], expected)
|
||||
```
|
||||
|
||||
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra
|
||||
firings are dropped with a log line, never queued), and a command that outlives its timeout is
|
||||
killed. Hook commands run as the host user, so `hooks.json` is operator-privileged config. On
|
||||
Linux, when a command starts with an **absolute path** to a script, the host checks that file is
|
||||
owned by you (or root) and not group/world-writable, and refuses to run it — loudly, in the log —
|
||||
if it isn't. Write the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if
|
||||
you want that check: the shell expands `~` and looks up PATH names like `makoctl` only afterwards,
|
||||
so those are never checked. On Windows there is no per-script check — the ACL on the config
|
||||
directory is the boundary.
|
||||
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra firings
|
||||
are dropped with a log line, never queued), and a command that outlives its timeout is killed.
|
||||
Hook commands run as the host user, so `hooks.json` is operator-privileged config. On Linux, when a
|
||||
command starts with an **absolute path** to a script, the host checks that file is owned by you (or
|
||||
root) and not group/world-writable, and refuses to run it — loudly, in the log — if it isn't. Write
|
||||
the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if you want that
|
||||
check: the shell expands `~` and looks up PATH names like `makoctl` only afterwards, so those are
|
||||
never checked. On Windows there is no per-script check — the ACL on the config directory is the
|
||||
boundary.
|
||||
|
||||
The two simplest cases also exist as plain [host.env](/docs/configuration) settings, no
|
||||
`hooks.json` needed: `PUNKTFUNK_ON_CONNECT_CMD` and `PUNKTFUNK_ON_DISCONNECT_CMD`.
|
||||
@@ -137,9 +136,8 @@ The two simplest cases also exist as plain [host.env](/docs/configuration) setti
|
||||
|
||||
For per-title setup (HDR toggle, MangoHud, a VRR tweak), attach `prep` steps to a GameStream
|
||||
`apps.json` entry or to a [custom library entry](/docs/game-library#adding-a-game-by-hand) — each
|
||||
`do` runs **before** the title launches
|
||||
(synchronously — the launch waits), each `undo` runs at session end in **reverse order**,
|
||||
best-effort, even if the session crashed:
|
||||
`do` runs **before** the title launches (synchronously — the launch waits), each `undo` runs at
|
||||
session end in **reverse order**, best-effort, even if the session crashed:
|
||||
|
||||
```json
|
||||
{ "id": 2, "title": "Steam", "compositor": "gamescope", "cmd": "steam -gamepadui",
|
||||
@@ -153,11 +151,9 @@ A `do` that fails logs, keeps going, and its own `undo` is skipped (it never too
|
||||
|
||||
## Reacting to a game, not a stream
|
||||
|
||||
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. They are
|
||||
often the same moment, but not always — a desktop stream has no game at all, and a stream can
|
||||
outlive its game if you turned off "end the session when the game exits".
|
||||
|
||||
If you have been polling the host to work out when a game finished, you don't need to any more:
|
||||
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. Often the
|
||||
same moment, but not always — a desktop stream has no game at all, and a stream can outlive its
|
||||
game if you turned off "end the session when the game exits". No polling needed:
|
||||
|
||||
```json
|
||||
{ "hooks": [
|
||||
@@ -170,8 +166,8 @@ Both carry the title in `PF_EVENT_GAME_TITLE` / `PF_EVENT_GAME_APP`, and `game.e
|
||||
`PF_EVENT_REASON` so a script can tell "the player quit" (`exited`) from "the host closed it"
|
||||
(`terminated`) — worth checking before you, say, power the TV off.
|
||||
|
||||
Ending the session yourself when a game exits needs no script at all: it is the default behavior,
|
||||
on the console's **Virtual displays** page under
|
||||
Ending the session when a game exits needs no script: it is the default, on the console's
|
||||
**Virtual displays** page under
|
||||
[When a game or a session ends](/docs/virtual-displays#when-a-game-ends-and-when-a-session-does).
|
||||
|
||||
## The event stream (`GET /api/v1/events`)
|
||||
@@ -227,22 +223,21 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
|
||||
## Recipe: full controller passthrough (VirtualHere)
|
||||
|
||||
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
|
||||
triggers, USB rumble — or to use a device no emulation can stand in for, like a racing wheel or a
|
||||
HOTAS, hand the physical device from the couch to the host over
|
||||
triggers, USB rumble — or to use a device no emulation can stand in for (a racing wheel, a HOTAS),
|
||||
hand the physical device from the couch to the host over
|
||||
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) while you play.
|
||||
|
||||
**Use the plugin.** [VirtualHere passthrough](/docs/plugins#virtualhere-usb-passthrough) does all of
|
||||
this for you: it finds the device by name (so it survives the couch rebooting), brackets it around
|
||||
the session, gives it back if anything crashes, and tells you which half of the setup is broken when
|
||||
it isn't working. That is the supported route, and the rest of this section is only for people who
|
||||
would rather not install a plugin.
|
||||
**Use the plugin.** [VirtualHere passthrough](/docs/plugins#virtualhere-usb-passthrough) finds the
|
||||
device by name (so it survives the couch rebooting), brackets it around the session, gives it back
|
||||
if anything crashes, and tells you which half of the setup is broken. That is the supported route;
|
||||
the rest of this section is for people who would rather not install a plugin.
|
||||
|
||||
**Turn off controller forwarding on the couch.** Whatever route you take below, the client that
|
||||
hands the device over should stop *also* forwarding it: Settings → **Forward controllers**, off
|
||||
([Client settings](/docs/client-settings#input)). Otherwise the host ends up with two controllers
|
||||
for one pair of hands and games read both. On Linux and Windows it matters twice over — while the
|
||||
client has the pad open it has *claimed* the device node, and VirtualHere cannot bind a device
|
||||
somebody else is holding.
|
||||
**Turn off controller forwarding on the couch.** Whatever route you take, the client that hands the
|
||||
device over should stop *also* forwarding it: Settings → **Forward controllers**, off
|
||||
([Client settings](/docs/client-settings#input)). Otherwise the host gets two controllers for one
|
||||
pair of hands and games read both. On Linux and Windows it matters twice over — while the client
|
||||
has the pad open it has *claimed* the device node, and VirtualHere cannot bind a device somebody
|
||||
else is holding.
|
||||
|
||||
**The two sides.** VirtualHere is a server/client pair, and you run both: the **server on the couch**
|
||||
(where the device is plugged in) shares it, and the **client on the host** mounts it. The client's
|
||||
@@ -265,12 +260,11 @@ Bracket it on the stream with two [hooks](#hooks-hooksjson):
|
||||
|
||||
`couch-deck.11` is the device's address from `vhclientx86_64 -t LIST`.
|
||||
|
||||
Know what this trades away, because the plugin exists to fix exactly these: the address is
|
||||
hard-coded, so it breaks when the couch reboots or the device moves port; and if the stream ends
|
||||
abnormally the `stream.stopped` hook never fires, leaving the device stranded on the host until
|
||||
somebody notices. There is also a
|
||||
The trade-offs the plugin exists to fix: the address is hard-coded, so it breaks when the couch
|
||||
reboots or the device moves port; and if the stream ends abnormally the `stream.stopped` hook never
|
||||
fires, leaving the device stranded on the host until somebody notices. There is also a
|
||||
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
|
||||
SDK example if you want a worked script to build your own on.
|
||||
SDK example to build your own script on.
|
||||
|
||||
> VirtualHere is a commercial product, sold separately by VirtualHere Pty. Ltd. — free for one
|
||||
> shared device, licensed beyond that. Punktfunk is not affiliated with it.
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
---
|
||||
title: Bazzite
|
||||
description: Set up a Punktfunk host on Bazzite — it follows the box between Steam Gaming Mode (gamescope) and the KDE Plasma desktop automatically.
|
||||
---
|
||||
|
||||
[Bazzite](https://bazzite.gg/) already ships everything a Punktfunk host needs — the NVIDIA driver,
|
||||
NVENC, PipeWire, **gamescope**, and the **KDE Plasma desktop**. So a Bazzite host is the most
|
||||
"appliance-like" setup, and it streams **both** of Bazzite's faces:
|
||||
|
||||
- **Steam Gaming Mode** (gamescope) — the couch/handheld game UI.
|
||||
- **The KDE Plasma desktop** — the full desktop you get from "Switch to Desktop".
|
||||
|
||||
The host **auto-detects which one is live and follows the box across the switch** — including
|
||||
mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam UI /
|
||||
"Switch to Desktop"; the host just re-targets whatever's running and keeps streaming. Nothing in
|
||||
`host.env` forces a mode.
|
||||
|
||||
> Ideal for a dedicated game-streaming box that you also occasionally want as a remote desktop. For a
|
||||
> pure desktop machine, install on [Ubuntu](/docs/ubuntu) or [Fedora](/docs/fedora) and configure the
|
||||
> [KDE](/docs/kde) or [GNOME](/docs/gnome) desktop directly — simpler.
|
||||
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
## Install
|
||||
|
||||
The host installs as a **systemd system extension (sysext)** — no `rpm-ostree` layering. The
|
||||
Bazzite docs treat layering as a last resort (layered packages slow every OS update and can block
|
||||
upgrades until removed); a sysext never enters an rpm-ostree transaction: it overlays `/usr`
|
||||
read-only from `/var/lib/extensions/`, survives OS updates, installs and updates **without a
|
||||
reboot**, and is removable in one command. This is the same mechanism the Fedora Atomic
|
||||
maintainers ship via the [fedora-sysexts](https://fedora-sysexts.github.io/) project.
|
||||
|
||||
```sh
|
||||
# One-time bootstrap (afterwards the updater is on PATH as `punktfunk-sysext`):
|
||||
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
sudo bash punktfunk-sysext.sh install # add `--channel canary` for rolling builds
|
||||
```
|
||||
|
||||
That downloads the newest image — host + tray + web console + the plugin runner
|
||||
(`punktfunk-scripting`), plus the HDR `punktfunk-gamescope` build — merges it, and applies the
|
||||
udev/sysctl setup on the spot; the host is usable immediately, no reboot. The feed's checksum
|
||||
manifest is OpenPGP-signed by packages@unom.io (key `AF245C506F4E4763`, the same one that signs our
|
||||
RPMs), and `punktfunk-sysext` checks that signature against a key baked into the script before it
|
||||
trusts a single checksum — so it needs `gpg` on the box, and it refuses a feed it can't verify.
|
||||
|
||||
The plugin runner rides along in the image and is **started for you** — the image bakes in its
|
||||
`default.target.wants` symlink, because the game-library scanners ship as
|
||||
[plugins](/docs/plugins). To turn it off: `systemctl --user mask punktfunk-scripting` (`mask`, not
|
||||
`disable` — a plain disable cannot remove a symlink that lives in `/usr`).
|
||||
|
||||
From then on:
|
||||
|
||||
```sh
|
||||
sudo punktfunk-sysext update # fetch + merge the newest build
|
||||
sudo punktfunk-sysext status # channel, installed vs latest version
|
||||
sudo punktfunk-sysext remove # unmerge and delete the image (~/.config/punktfunk is kept)
|
||||
```
|
||||
|
||||
After an update, restart the host so it runs the new binary (the updater prints this reminder too).
|
||||
The image carries the console as well, so restart that first if you enabled it:
|
||||
|
||||
```sh
|
||||
systemctl --user restart punktfunk-web # only if you run the console
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
To **switch channel** later, re-run the install: `sudo punktfunk-sysext install --channel canary`
|
||||
(or `--channel stable`). `update` takes no channel flag — it follows whatever the last install wrote
|
||||
to `/etc/punktfunk-sysext.conf`. To be able to **go back** to a build that worked, keep a copy of the
|
||||
image before you update, and re-install that file afterwards:
|
||||
|
||||
```sh
|
||||
sudo cp /var/lib/extensions/punktfunk.raw ~/punktfunk-known-good.raw # before updating
|
||||
sudo punktfunk-sysext install --from-file ~/punktfunk-known-good.raw # to go back to it
|
||||
```
|
||||
|
||||
The web console can also run the update for you — see [Updating the Host](/docs/updating), which
|
||||
needs the one-time `sudo usermod -aG punktfunk-update $USER`.
|
||||
|
||||
`remove` deletes the image and the `/etc` files it seeded (the tray autostart entry, and the
|
||||
gamescope session drop-in unless you've edited it), but three things it created outside `/usr` stay
|
||||
behind. To clear those too — services first, because once the image unmerges their binaries are
|
||||
gone and the units just keep failing:
|
||||
|
||||
```sh
|
||||
systemctl --user disable --now punktfunk-host punktfunk-web
|
||||
sudo punktfunk-sysext remove
|
||||
sudo rm -f /etc/modules-load.d/punktfunk.conf /etc/udev/rules.d/60-punktfunk.rules
|
||||
sudo groupdel punktfunk-update # the (empty) group for web-console updates
|
||||
```
|
||||
|
||||
[Uninstalling](/docs/uninstall) has the same walkthrough for the other install methods, and for the
|
||||
clients.
|
||||
|
||||
Three things to know:
|
||||
|
||||
- **After a Bazzite major rebase** (Fedora 43 → 44) the old image **refuses to load** rather than
|
||||
run against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches
|
||||
the image built for the new base.
|
||||
- **Already layering Punktfunk?** Install the sysext (it shadows the layered copy immediately),
|
||||
then drop the layer so it stops slowing your updates:
|
||||
`sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
|
||||
- **If it refuses the feed.** `refusing to install from an unsigned feed` means that Fedora major's
|
||||
feed predates signing; it gets sealed on the next publish. To install from it anyway, accepting
|
||||
that the images are unauthenticated, run
|
||||
`sudo env PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 bash punktfunk-sysext.sh install`. The other message,
|
||||
`the feed's SHA256SUMS is NOT signed by packages@unom.io`, is not the same thing — don't install;
|
||||
re-download the script and try again.
|
||||
|
||||
For a fully baked appliance image there's also a **bootc** Containerfile that installs the RPMs
|
||||
from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree`
|
||||
layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too: add the
|
||||
repo exactly as on [Fedora](/docs/fedora), with the `baseurl` group matching your Fedora base, then
|
||||
`sudo rpm-ostree install punktfunk punktfunk-web` and reboot. The sysext is still the supported
|
||||
default. Building from source also works (Bazzite is Fedora Atomic underneath — same steps as
|
||||
[Fedora](/docs/fedora)).
|
||||
|
||||
## Allow controller input
|
||||
|
||||
Gamepad and DualSense input needs your user in the `input` group. On Bazzite, don't use
|
||||
`usermod` — the base is immutable and the group is managed by a recipe. Use:
|
||||
|
||||
```sh
|
||||
ujust add-user-to-input-group
|
||||
```
|
||||
|
||||
Then **log out and back in**. (A controller that's "detected but does nothing" is almost always this
|
||||
permission, not a client problem.)
|
||||
|
||||
Then join `punktfunk` — `usermod` is fine here, because unlike `input` this group is ours and the
|
||||
sysext creates it on merge:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||
```
|
||||
|
||||
This box **is** a Gaming Mode box, so that group is worth having: it gates the usbip `attach` file
|
||||
the **virtual Steam Deck controller** (paddles, trackpads, gyro) attaches through. (The Gaming Mode
|
||||
takeover itself no longer needs it — it idles the box's session with a user-level drop-in rather
|
||||
than stopping the display manager.) It is a separate group on purpose — writing that file
|
||||
can materialise arbitrary emulated USB hardware, so it is not folded into the group everyone is
|
||||
told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller — see
|
||||
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
|
||||
|
||||
## Configure
|
||||
|
||||
The RPM ships a Bazzite-tuned config you can copy as your starting point:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
|
||||
```
|
||||
|
||||
The template is deliberately minimal — it does **not** force a compositor, because the host
|
||||
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
|
||||
mid-stream. No session anchors are needed either (a user service inherits the right runtime dir).
|
||||
The only settings that matter (GPU zero-copy is on by default):
|
||||
|
||||
```sh
|
||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||
```
|
||||
|
||||
### Gaming Mode: attach vs managed
|
||||
|
||||
For Gaming Mode there are two models. The template forces **neither** — the host picks per connect,
|
||||
and on Bazzite (which ships `gamescope-session-plus`) that is **managed**:
|
||||
|
||||
- **Managed** (what you get by default here) — the host takes the box's gamescope over and
|
||||
relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the
|
||||
virtual screen — restoring the box on idle. This is the model that gives the client a display of
|
||||
its **own**, and the only one under which a game launched from a client's library gets a
|
||||
dedicated session. The takeover idles the box's own session for the length of the
|
||||
stream (a user-level drop-in — no privilege needed, and the display manager stays up, so Steam's
|
||||
"Switch to Desktop" still works mid-stream).
|
||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own
|
||||
display, and the host attaches to whatever's live without ever tearing it down (on a headless
|
||||
box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a
|
||||
display connected it streams at the box's own mode). Switching Desktop ↔ Game is rock-solid, and
|
||||
the cost is that a box with a screen attached serves the client a **mirror** of that screen
|
||||
rather than its own display. Setting it also outranks a dedicated game session.
|
||||
|
||||
`=0` turns the attach override off, the same as removing the line.
|
||||
|
||||
Full treatment: [Steam / gamescope → How the host gets a
|
||||
gamescope](/docs/gamescope#how-the-host-gets-a-gamescope).
|
||||
|
||||
Mid-stream Gaming ↔ Desktop following (`PUNKTFUNK_SESSION_WATCH`) is **on by default** on
|
||||
Bazzite/SteamOS. See [Configuration](/docs/configuration) for the full list of knobs.
|
||||
|
||||
### Streaming the KDE Plasma desktop
|
||||
|
||||
The **virtual output** (video) for the Desktop session needs no config — the host package ships an
|
||||
`io.unom.Punktfunk.Host.desktop` file whose `X-KDE-Wayland-Interfaces` grants the host KWin's
|
||||
restricted screencast protocol on a normal interactive Plasma session (background:
|
||||
[KDE Plasma](/docs/kde)). After a **fresh host install, log out and back into the Desktop session
|
||||
once** so KWin re-reads that grant.
|
||||
|
||||
The one thing a normal KDE login lacks is the RemoteDesktop grant for headless **input** injection.
|
||||
Seed it once (as the streaming user, no root) so the host auto-approves instead of popping an
|
||||
un-answerable dialog:
|
||||
|
||||
```sh
|
||||
bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
|
||||
```
|
||||
|
||||
Gaming Mode needs none of this — it auto-attaches.
|
||||
|
||||
## Run as an always-on host
|
||||
|
||||
Bazzite hosts are typically headless. Enable the host service and linger so it starts at boot — see
|
||||
[Running as a Service](/docs/running-as-a-service). One host service covers both Gaming Mode and the
|
||||
Desktop; it follows whichever the box is in.
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-host
|
||||
systemctl --user enable --now punktfunk-web # web console: pairing + status
|
||||
sudo loginctl enable-linger "$USER" # start at boot with nobody logged in
|
||||
```
|
||||
|
||||
Without that last line the `--user` units don't start until someone logs in — which on a headless box
|
||||
never happens.
|
||||
|
||||
Then open [The Web Console](/docs/web-console) for the login password and to
|
||||
[arm pairing](/docs/web-console#arm-pairing).
|
||||
|
||||
## Good to know
|
||||
|
||||
These apply to the **Gaming Mode (gamescope)** path; the KDE Desktop path is unaffected:
|
||||
|
||||
- **gamescope 3.16.22 or newer is required; 3.16.23 or newer for the Steam overlay.** Below 3.16.22
|
||||
headless capture can deadlock; between the two, capture works but the Steam overlay (Shift+Tab /
|
||||
the Quick Access Menu) is never painted into the captured node. Bazzite's current gamescope is
|
||||
past both; this only bites if you've pinned an old one.
|
||||
- **Forcing attach costs you the cursor, HDR and your own display.** The sysext ships the
|
||||
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — under
|
||||
`PUNKTFUNK_GAMESCOPE_ATTACH=1` the live session is Bazzite's own stock gamescope. The managed
|
||||
default gets you the compositor-drawn pointer, real HDR and a display of the client's own. If you
|
||||
deliberately stay on attach, also set `PUNKTFUNK_GAMESCOPE_HDR=0` and
|
||||
`PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half breaks:
|
||||
[gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
|
||||
[HDR → Linux + gamescope](/docs/hdr#linux--gamescope) for the failed connect.
|
||||
⚠ Older templates set `PUNKTFUNK_GAMESCOPE_ATTACH=1` for you — if you copied one, delete that
|
||||
line from `~/.config/punktfunk/host.env`, because an upgrade never rewrites a file you already
|
||||
have.
|
||||
|
||||
Those are the two that bite on Bazzite. The full set — touch, mouse modes, the clipboard — is on
|
||||
[gamescope → Known limits](/docs/gamescope#known-limits).
|
||||
|
||||
Then [connect a client](/docs/clients) — Moonlight works great for couch gaming, and the Apple app for
|
||||
Apple TV / iPad. Trouble? See [Troubleshooting](/docs/troubleshooting).
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Bazzite
|
||||
description: Install the Punktfunk host on Bazzite (or any Fedora Atomic spin) as a sysext — no layering, no reboot — and stream both Gaming Mode and the desktop.
|
||||
---
|
||||
|
||||
Bazzite already ships everything a host needs — the NVIDIA driver, NVENC, PipeWire, gamescope, KDE
|
||||
Plasma — so this is the most appliance-like setup. One host streams **both** of Bazzite's faces,
|
||||
Steam **Gaming Mode** and the **KDE desktop**, and follows the box when you switch, even mid-stream.
|
||||
Nothing in the config picks a mode.
|
||||
|
||||
## 1. Install the host
|
||||
|
||||
The host installs as a **systemd system extension** — it overlays `/usr` from
|
||||
`/var/lib/extensions/`, survives OS updates, and installs, updates and uninstalls **without a
|
||||
reboot** (no `rpm-ostree` layering, which Bazzite's docs treat as a last resort). The feed is
|
||||
signed; the installer refuses one it can't verify.
|
||||
|
||||
<Install platform="bazzite" />
|
||||
|
||||
That fetches the newest image — host, web console, tray, the plugin runner (started for you, because
|
||||
the game-library scanners are [plugins](/docs/plugins)) and the HDR `punktfunk-gamescope` build — and
|
||||
applies the udev/sysctl setup on the spot. From then on `sudo punktfunk-sysext update` (or the
|
||||
[console's update button](/docs/updating)) moves you to the newest build; `status` shows where you
|
||||
are. After an update restart the host: `systemctl --user restart punktfunk-web punktfunk-host`.
|
||||
|
||||
## 2. Let it use your controllers
|
||||
|
||||
On Bazzite the `input` group is managed by a recipe, so don't `usermod` it — use the helper. The
|
||||
`punktfunk` group is ours (the sysext creates it) and gates the **virtual Steam Deck controller**
|
||||
(paddles, trackpads, gyro) — without it that pad arrives as an ordinary Xbox 360 controller. Then
|
||||
**log out and back in**:
|
||||
|
||||
```sh
|
||||
ujust add-user-to-input-group
|
||||
sudo usermod -aG punktfunk "$USER"
|
||||
```
|
||||
|
||||
([Why it's a separate group](/docs/gamescope#nobara-and-other-autologin-display-managers).)
|
||||
|
||||
## 3. Start it
|
||||
|
||||
Bazzite hosts are usually headless, so enable the services **and** linger, from a terminal in the
|
||||
desktop session — without linger the `--user` units wait for a login that never comes:
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-host punktfunk-web
|
||||
sudo loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Two one-time steps for streaming the **KDE desktop** (Gaming Mode needs neither): log out and back
|
||||
into the Desktop session once so KWin re-reads the screencast grant the package installed, and seed
|
||||
the input grant so the host auto-approves instead of popping a dialog nobody can answer:
|
||||
|
||||
```sh
|
||||
bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
|
||||
```
|
||||
|
||||
## 4. Open the firewall
|
||||
|
||||
Bazzite runs **firewalld**, and a package never opens ports for you — until you allow it, no client
|
||||
can reach the host. The image installed the service definitions; enable them once:
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --reload # load the definitions the package installed
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native --add-service=punktfunk-web
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
(`punktfunk-web` is only needed to reach the console from another device; Moonlight compat needs
|
||||
`punktfunk-gamestream` too — [Ports & firewall](/docs/ports).)
|
||||
|
||||
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
|
||||
— open the console, pair a client, stream. Moonlight works great for couch gaming here, the Apple
|
||||
app for Apple TV and iPad.
|
||||
|
||||
## When you want more
|
||||
|
||||
- **Gaming Mode: what the client gets.** By default the host relaunches Gaming Mode *headless* at the
|
||||
client's exact resolution (a display of its own, real HDR, the compositor's cursor), idling the
|
||||
box's own session with a user-level drop-in for the length of the stream — no privilege needed,
|
||||
and Steam's "Switch to Desktop" keeps working — and restores the box on idle. `PUNKTFUNK_GAMESCOPE_ATTACH=1` makes it attach to the box's own session instead —
|
||||
rock-solid switching, but a box with a screen serves a *mirror* of it, and loses HDR and the cursor.
|
||||
[How the host gets a gamescope](/docs/gamescope#how-the-host-gets-a-gamescope) has the whole
|
||||
model; the Bazzite template (`/usr/share/punktfunk/host.env.bazzite`) forces neither.
|
||||
- **Stream lags, then freezes, with a DualSense-type client pad** — an SELinux storm, with a shipped
|
||||
fix: [Troubleshooting](/docs/troubleshooting#stream-lags-then-freezes-with-a-dualsense-pad-bazzite-selinux).
|
||||
- **Channels, rollback, after a Bazzite major rebase** —
|
||||
[Updating → Bazzite sysext](/docs/updating#bazzite-sysext-channels-rollback-and-rebases).
|
||||
Removing it — [Uninstall](/docs/uninstall#bazzite--fedora-atomic-systemd-sysext).
|
||||
- **Other ways in.** Plain `rpm-ostree` layering from the [RPM repo](/docs/fedora#2-install-the-host)
|
||||
(use the `bazzite` group) still works, and `packaging/bootc/` in the repo bakes an appliance image;
|
||||
the sysext is the supported default. Already layering? Install the sysext (it shadows the layer at
|
||||
once), then `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
|
||||
- Everything Gaming-Mode-specific — overlay, touch, HDR, the two gamescope version floors —
|
||||
[Steam / gamescope](/docs/gamescope); the desktop — [KDE](/docs/kde).
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Build from source
|
||||
description: Compile the Linux host yourself — on Ubuntu/Debian, Fedora, or with the Arch PKGBUILD — when no package fits your release or you want to track main.
|
||||
---
|
||||
|
||||
The package repos are the supported path ([Install the Host](/docs/install)). Build from source when
|
||||
your release is older than a package supports (Ubuntu before 26.04, Debian 12, a Fedora without a
|
||||
repo group), or to hack on it. A source build gets **no packaged units and no clean updates** — you
|
||||
wire the service up by hand ([Running as a service](/docs/running-as-a-service) shows the unit).
|
||||
|
||||
Two build features matter on every distro: `punktfunk-host/nvenc` (direct NVENC on NVIDIA) and
|
||||
`punktfunk-host/vulkan-encode` (Vulkan Video on AMD/Intel). They're what the packaged builds use;
|
||||
without them the host falls back to the slower libav backends. Rust comes from
|
||||
[rustup](https://rustup.rs) if you don't have it:
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
## Ubuntu / Debian
|
||||
|
||||
The packaged host is built against **FFmpeg 8**. Ubuntu 26.04's and Debian 13's `libavcodec-dev`
|
||||
are new enough; Ubuntu 24.04's is FFmpeg 6.1 — build FFmpeg 8 yourself first there (what
|
||||
`ci/rust-ci-noble.Dockerfile` does), or stick with the packaged host.
|
||||
|
||||
```sh
|
||||
sudo apt install build-essential pkg-config cmake clang libclang-dev nasm git curl \
|
||||
pipewire pipewire-pulse wireplumber libpipewire-0.3-dev libspa-0.2-dev \
|
||||
libwayland-dev wayland-protocols libxkbcommon-dev libopus-dev \
|
||||
libdrm-dev libgbm-dev libgl-dev libegl-dev libgles-dev mesa-common-dev libva-dev \
|
||||
ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
|
||||
libnvidia-egl-wayland1 libnvidia-egl-gbm1 libei-dev
|
||||
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
|
||||
cargo build --release --locked \
|
||||
--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host
|
||||
```
|
||||
|
||||
## Fedora
|
||||
|
||||
```sh
|
||||
sudo dnf install gcc gcc-c++ make cmake clang clang-devel nasm git pkgconf-pkg-config \
|
||||
pipewire-devel wayland-devel wayland-protocols-devel libxkbcommon-devel opus-devel \
|
||||
libdrm-devel mesa-libgbm-devel mesa-libGL-devel mesa-libEGL-devel mesa-libGLES-devel libva-devel \
|
||||
ffmpeg-devel libei-devel
|
||||
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
|
||||
cargo build --release --locked \
|
||||
--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host
|
||||
```
|
||||
|
||||
`ffmpeg-devel` must be RPM Fusion's (with NVENC), not `ffmpeg-free-devel`. `mesa-libGL-devel` isn't
|
||||
optional — the zero-copy GPU path links `libGL`, and without it the build fails at link time with
|
||||
`cannot find -lGL`. To build an RPM instead, use the same toolchain CI does:
|
||||
`docker build --build-arg FEDORA_VERSION=NN -f ci/fedora-rpm.Dockerfile -t pf-rpm ci`, then run
|
||||
`packaging/rpm/build-rpm.sh` inside it.
|
||||
|
||||
## Arch (PKGBUILD)
|
||||
|
||||
The split `PKGBUILD` in `packaging/arch/` produces `punktfunk-host` and `punktfunk-client`; set
|
||||
`PF_WITH_WEB=1` to also build `punktfunk-web` and `PF_WITH_SCRIPTING=1` for `punktfunk-scripting`
|
||||
(both need `bun`):
|
||||
|
||||
```sh
|
||||
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
|
||||
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver # builds the working tree, no git fetch
|
||||
sudo pacman -U punktfunk-host-*.pkg.tar.zst
|
||||
```
|
||||
|
||||
NVENC/EGL come from `nvidia-utils`; on a GPU-less builder, symlink the CUDA stub into the link path
|
||||
first (the `PKGBUILD` header documents this). Packager notes, the Fedora→Arch dependency map and the
|
||||
sysext mechanism: [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
|
||||
For a **SteamOS** host don't use the PKGBUILD — the [on-device installer](/docs/steamos-host) builds
|
||||
ABI-matched to the running OS.
|
||||
|
||||
## Running what you built
|
||||
|
||||
The binary lands at `target/release/punktfunk-host`. Run it from inside your desktop session — it
|
||||
auto-detects the compositor:
|
||||
|
||||
```sh
|
||||
target/release/punktfunk-host serve # secure native-only host
|
||||
target/release/punktfunk-host serve --gamestream # + Moonlight compat (trusted LAN only)
|
||||
```
|
||||
|
||||
To run it as a user service, copy `scripts/punktfunk-host.service` to
|
||||
`~/.config/systemd/user/` (it already points at `%h/punktfunk/target/release/punktfunk-host`), then
|
||||
`systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host`. The other
|
||||
workspace members (`punktfunk-web`, `punktfunk-scripting`, the client) build the same way — the
|
||||
root [README](https://git.unom.io/unom/punktfunk#build--test-from-source) covers the dev loop.
|
||||
@@ -6,21 +6,21 @@ 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, and the answer comes back in the
|
||||
handshake — so a setting the host can't honor is usually a quiet downgrade rather than an error.
|
||||
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.
|
||||
|
||||
## 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, and so 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 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).
|
||||
|
||||
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
|
||||
@@ -30,8 +30,8 @@ Changes apply to the **next** session — a running stream keeps what it started
|
||||
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, and the wording on screen varies a little between them — the
|
||||
names below are the ones the Linux app uses. The differences that matter are noted per setting.
|
||||
Not every client offers every setting; the names below are the Linux app's, and differences that
|
||||
matter are noted per setting.
|
||||
|
||||
## Video
|
||||
|
||||
@@ -73,8 +73,8 @@ link. The stops run 0.5× to 4×. The result is floored to an even size and capp
|
||||
|
||||
**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
|
||||
**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.
|
||||
|
||||
@@ -91,24 +91,23 @@ decode probe to pass). The console home offers the toggle; Android doesn't.
|
||||
|
||||
**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 carried the same setting for a while, and it is stored
|
||||
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
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
|
||||
[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** — under Lowest latency there are no held
|
||||
frames for it to count, so it isn't shown at all.
|
||||
offered, and only once you have picked **Smoothness**.
|
||||
|
||||
**V-Sync** — *default: on.* Tear-free presentation. Turning it 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 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.
|
||||
|
||||
**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
|
||||
@@ -132,21 +131,20 @@ claims a sink advertising exactly that many channels, so applications produce re
|
||||
**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.
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
**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
|
||||
**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).
|
||||
console-home clients. What it can and can't fix is in [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
|
||||
@@ -164,41 +162,39 @@ 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 simply a pad plugged into the host itself. Leaving forwarding on in that
|
||||
situation 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.
|
||||
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.
|
||||
|
||||
On Linux and Windows it does more than stay quiet. Opening a controller is what *claims* it — the
|
||||
client's SDL takes the device node — and a claimed device is one a passthrough tool cannot bind. So
|
||||
with this off the session never opens the controller at all, which is precisely what leaves it free
|
||||
for VirtualHere to hand over. The consequence to know: the
|
||||
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.
|
||||
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.
|
||||
|
||||
**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`.
|
||||
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`.
|
||||
|
||||
That degrade is the one thing worth knowing about **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 simply does nothing, which from the couch is indistinguishable from a broken
|
||||
sensor. 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.
|
||||
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
|
||||
@@ -229,9 +225,8 @@ Windows apps, *off* on Android. The two halves of [controller audio](/docs/contr
|
||||
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, so leaving haptics on is free even
|
||||
on a pad that never gets any. Turn **Controller speaker** off if you would rather all game audio came
|
||||
out of your speakers or headset.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -240,13 +235,13 @@ for why that is necessary and how to turn it off.
|
||||
|
||||
**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, for the reason the paragraph below gives: 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.
|
||||
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 — ⌘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
|
||||
@@ -270,7 +265,7 @@ learned. Turn it off for hosts you reach over a VPN, where "offline" usually mea
|
||||
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, which reads this setting like any other connect. The console home also offers wake as an
|
||||
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).
|
||||
|
||||
@@ -282,14 +277,13 @@ offered on any paired host. 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.
|
||||
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV and
|
||||
Android have no equivalent.
|
||||
|
||||
## Interface
|
||||
|
||||
These change how the client itself looks and behaves. None of them touches a stream, so none of them
|
||||
can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of
|
||||
you.
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -299,22 +293,20 @@ controller-optimized home is a separate entry point rather than a switch, so the
|
||||
turn off. An Android TV is always in this mode — its remote is the only input it has.
|
||||
|
||||
**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** is the long-standing behaviour: 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 that
|
||||
lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you
|
||||
want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
|
||||
*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.)
|
||||
|
||||
**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 rather
|
||||
than a decorative one: 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. Appearance only — nothing about a stream depends on it.
|
||||
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
|
||||
@@ -329,8 +321,7 @@ superset of the one before. This setting only picks the tier a session *starts*
|
||||
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).
|
||||
and every number in the overlay, are in [Understanding the stats overlay](/docs/stats).
|
||||
|
||||
## Settings that are facts about your device
|
||||
|
||||
@@ -352,9 +343,7 @@ stay global and **cannot be put in a settings profile**:
|
||||
- **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 and which layout it wears has nothing to do with how a host streams to it,
|
||||
so binding them to a host would only make the same device change appearance depending on what it
|
||||
connected to.
|
||||
How this client looks has nothing to do with how a host streams to it.
|
||||
|
||||
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 —
|
||||
|
||||
@@ -5,15 +5,13 @@ description: The ways to connect to a Punktfunk host — the Apple app, Moonligh
|
||||
|
||||
A Punktfunk host accepts clients over its own `punktfunk/1` protocol (the macOS, Linux, Windows, and
|
||||
Android apps) and over GameStream (Moonlight). Pick whichever fits the device you're streaming *to*.
|
||||
Ready to install?
|
||||
**[Install a Client](/docs/install-client)** has the step-by-step for every device — plus how to
|
||||
**[Install a Client](/docs/install-client)** has the step-by-step for every device, plus how to
|
||||
[update](/docs/install-client#keeping-a-client-up-to-date) and
|
||||
[remove](/docs/install-client#removing-a-client) each one.
|
||||
|
||||
Two things apply to every app, whichever you pick:
|
||||
[profiles and `punktfunk://` links](#profiles-and-links-every-app), and the keys and chords that
|
||||
work [while you're streaming](#while-youre-streaming). What each one lets you change — resolution,
|
||||
bitrate, codec, HDR, audio, controllers — is catalogued in
|
||||
Two things apply to every app: [profiles and `punktfunk://` links](#profiles-and-links-every-app),
|
||||
and the keys and chords that work [while you're streaming](#while-youre-streaming). What each app
|
||||
lets you change — resolution, bitrate, codec, HDR, audio, controllers — is catalogued in
|
||||
[Client settings](/docs/client-settings).
|
||||
|
||||
## Apple app (Mac, iPhone, iPad, Apple TV)
|
||||
@@ -29,9 +27,9 @@ protocol — the lowest-latency, most resilient path, with the full feature set:
|
||||
- A live **stats overlay** (resolution, fps, bitrate, latency) and a built-in **network speed test**
|
||||
to pick a bitrate for your link.
|
||||
- **Widgets, Live Activities and Shortcuts** — a hosts widget and a game-library widget for the
|
||||
home screen (the library one opens a host you pick straight into its library), a Live Activity
|
||||
while a session runs, and App Intents so Siri and the Shortcuts app can start a stream or jump
|
||||
into a host's game library.
|
||||
home screen (the library one opens a picked host's library), a Live Activity while a session
|
||||
runs, and App Intents so Siri and the Shortcuts app can start a stream or jump into a host's game
|
||||
library.
|
||||
|
||||
Open the app, pick your host, [pair](/docs/pairing) once, and stream. It builds from the
|
||||
`clients/apple` directory in the repo (Swift / VideoToolbox / Metal).
|
||||
@@ -40,11 +38,9 @@ Open the app, pick your host, [pair](/docs/pairing) once, and stream. It builds
|
||||
|
||||
Punktfunk also speaks the **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/)
|
||||
client — a browser, a smart TV, an old phone, a games console — connects with no punktfunk-specific
|
||||
software. (Most platforms also have a native Punktfunk app below — Moonlight is the catch-all.) See
|
||||
[Connect with Moonlight](/docs/moonlight).
|
||||
|
||||
This is the broadest-compatibility option and great for couch gaming. It doesn't use the native
|
||||
protocol's FEC/encryption extensions, but for a healthy LAN that rarely matters.
|
||||
software; it's the catch-all where no native Punktfunk app exists. See
|
||||
[Connect with Moonlight](/docs/moonlight). It doesn't use the native protocol's FEC/encryption
|
||||
extensions, but on a healthy LAN that rarely matters.
|
||||
|
||||
## Linux desktop client (GTK4)
|
||||
|
||||
@@ -52,26 +48,26 @@ protocol's FEC/encryption extensions, but for a healthy LAN that rarely matters.
|
||||
`punktfunk/1` directly, with vendor-ordered hardware decode (**Vulkan Video first on NVIDIA and
|
||||
AMD**, **VAAPI dmabuf first on Intel**; whichever isn't first is the fallback, and software decode
|
||||
is last), PipeWire audio, and SDL3 controllers (rumble, lightbar, DualSense touchpad/motion). The
|
||||
decoders are Punktfunk's own — the client links no FFmpeg at all, and talks to your GPU's Vulkan
|
||||
and VAAPI drivers directly. To force one, pick it in *Preferences → Display → Video decoder* or
|
||||
set `PUNKTFUNK_DECODER=native-vulkan|native-vaapi|software`. Like the Apple app it discovers hosts
|
||||
on your network automatically, does PIN pairing, pins reconnects, and browses the host's
|
||||
**game library** (with cover art) so you can launch a title straight into the stream.
|
||||
decoders are Punktfunk's own — the client links no FFmpeg and talks to your GPU's Vulkan and VAAPI
|
||||
drivers directly. To force one, pick it in *Preferences → Display → Video decoder* or set
|
||||
`PUNKTFUNK_DECODER=native-vulkan|native-vaapi|software`. Like the Apple app it discovers hosts
|
||||
automatically, does PIN pairing, pins reconnects, and browses the host's **game library** (with
|
||||
cover art) to launch a title straight into the stream.
|
||||
|
||||
It ships as a real package, not just a source build — full steps in
|
||||
It ships as real packages — full steps in
|
||||
[Install a Client](/docs/install-client#linux-desktop-flatpak):
|
||||
|
||||
- **Any Flatpak distro (recommended)** — one command from the hosted `flatpak.unom.io` repo; the
|
||||
guide linked above has the exact command and how updates work. It's also the client the
|
||||
- **Any Flatpak distro (recommended)** — one command from the hosted `flatpak.unom.io` repo (exact
|
||||
command and update flow in the guide above). It's also the client the
|
||||
[Decky plugin](/docs/steam-deck) uses by default, though the plugin drives a native
|
||||
`punktfunk-client` just as well.
|
||||
- **Ubuntu 26.04 or newer** — `apt install punktfunk-client` from the Punktfunk apt registry. The
|
||||
client package needs SDL3 and GTK4 ≥ 4.20, which Ubuntu 24.04 LTS doesn't ship — on 24.04 use the
|
||||
Flatpak above.
|
||||
Flatpak.
|
||||
- **Fedora** — `sudo dnf install punktfunk-client` from the Gitea RPM registry (add the repo as in
|
||||
the [Fedora guide](/docs/fedora)).
|
||||
- **Fedora Atomic / Bazzite** — use the Flatpak above. `rpm-ostree install punktfunk-client` works,
|
||||
but layering slows every OS update, so it's a last resort on an image-based system (see
|
||||
- **Fedora Atomic / Bazzite** — use the Flatpak. `rpm-ostree install punktfunk-client` works, but
|
||||
layering slows every OS update, so it's a last resort on an image-based system (see
|
||||
[Bazzite](/docs/bazzite)).
|
||||
- **Arch** — `sudo pacman -Syu punktfunk-client` from the signed binary repo (see [Arch Linux](/docs/arch)).
|
||||
|
||||
@@ -88,7 +84,7 @@ The client also updates itself (`punktfunk-client --check-update` / `--apply-upd
|
||||
|
||||
## Android app (phone + Android TV)
|
||||
|
||||
The native Android app speaks `punktfunk/1` directly, on both phones and Android TV. It does hardware
|
||||
The native Android app speaks `punktfunk/1` directly, on phones and Android TV. It does hardware
|
||||
HEVC decode (including [HDR10](/docs/hdr#per-client)), Opus audio with a mic uplink, game
|
||||
controllers with rumble and DualSense feedback, automatic host discovery, PIN pairing with pinned
|
||||
reconnects, the host's **game library** with cover art, and a live stats overlay — with D-pad and
|
||||
@@ -96,29 +92,28 @@ game-controller focus navigation for the couch. It builds from the `clients/andr
|
||||
(Kotlin + a shared Rust core).
|
||||
|
||||
**Controllers.** Plug a **DualSense**, **DualSense Edge** or **DualShock 4** into the phone or tablet
|
||||
by USB and grant the USB permission Android asks for when it attaches — Punktfunk then drives the pad
|
||||
itself instead of taking what Android's gamepad layer exposes, so the host gets rumble, adaptive
|
||||
triggers, the lightbar and gyro. The app's **Controllers** screen lists attached pads and their
|
||||
capture state, and the switch that turns this off is *DualSense / DualShock passthrough (USB)* in
|
||||
Settings. Over **Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and
|
||||
the lightbar need the USB connection.
|
||||
by USB and grant the USB permission Android asks for — Punktfunk then drives the pad itself instead
|
||||
of taking what Android's gamepad layer exposes, so the host gets rumble, adaptive triggers, the
|
||||
lightbar and gyro. The app's **Controllers** screen lists attached pads and their capture state; the
|
||||
switch that turns this off is *DualSense / DualShock passthrough (USB)* in Settings. Over
|
||||
**Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and the lightbar
|
||||
need USB.
|
||||
|
||||
The app is on **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** as a
|
||||
public listing — no invite — or you can sideload the public APK instead (see
|
||||
public listing — no invite — or sideload the public APK (see
|
||||
[Install a Client](/docs/install-client#android)); canary builds ride a separate, invite-only Play
|
||||
Internal testing track. Then open the app, pick your host, [pair](/docs/pairing) once, and stream.
|
||||
Internal testing track. Open the app, pick your host, [pair](/docs/pairing) once, and stream.
|
||||
|
||||
## Windows desktop client
|
||||
|
||||
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client for Windows — pure
|
||||
Rust, the same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host
|
||||
list, settings, PIN pairing); the stream itself runs in Punktfunk's Vulkan presenter. Its decoder
|
||||
order is per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and
|
||||
**D3D11VA first** on Intel and other GPUs (Intel's driver advertises Vulkan Video, but DXVA is the
|
||||
proven path there), with [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 both `x86_64` and `aarch64` and
|
||||
ships as a **signed MSIX**. Launch it and pick a host from the list, just like the other native apps.
|
||||
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client — pure Rust, the
|
||||
same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host list,
|
||||
settings, PIN pairing); the stream itself runs in Punktfunk's Vulkan presenter. Decoder order is
|
||||
per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and **D3D11VA first**
|
||||
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**.
|
||||
|
||||
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,
|
||||
@@ -130,7 +125,7 @@ settings and game library, all navigable with a pad) — plus the headless
|
||||
> is a proven alternative for Windows.
|
||||
|
||||
For scripting, prefer the [`punktfunk` CLI](#scripting-the-punktfunk-cli). The window binary's own
|
||||
headless flags stay supported too:
|
||||
headless flags stay supported:
|
||||
|
||||
```sh
|
||||
punktfunk-client # open the WinUI 3 window (host list / settings)
|
||||
@@ -138,12 +133,10 @@ punktfunk-client --discover # list hosts on the
|
||||
punktfunk-client --headless --speed-test --connect <host>:9777 # no window: probe the link, print measured/recommended bitrate
|
||||
```
|
||||
|
||||
Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below).
|
||||
|
||||
## webOS (LG TV) — community
|
||||
|
||||
[`pf-webos`](https://github.com/dyptan-io/pf-webos) is a native client for LG webOS TVs, built and
|
||||
maintained by the community ([dyptan-io](https://github.com/dyptan-io)) on top of Punktfunk's
|
||||
maintained by the community ([dyptan-io](https://github.com/dyptan-io)) on Punktfunk's
|
||||
`punktfunk/1` protocol and core. It's not an official Punktfunk app, but it speaks the real protocol
|
||||
directly (not Moonlight/GameStream) — LAN discovery or add-by-IP, PIN pairing with pinned reconnects,
|
||||
hardware video decode via webOS's NDL DirectMedia API, and a browsable game library with cover art,
|
||||
@@ -178,21 +171,21 @@ named, **6** it needs a person (pairing, or an unknown host).
|
||||
|
||||
Under the Flatpak, run it as `flatpak run --command=punktfunk io.unom.Punktfunk <args>`.
|
||||
|
||||
> The older headless flags stay supported for existing scripts — `punktfunk-client --connect`,
|
||||
> `--discover` and `--headless --speed-test` on both Linux and Windows. `punktfunk` is the surface
|
||||
> to build new things on: it wakes a sleeping host before connecting, which those never did.
|
||||
> The older `punktfunk-client --connect`, `--discover` and `--headless --speed-test` flags stay
|
||||
> supported on Linux and Windows for existing scripts, but build new things on `punktfunk`: it wakes
|
||||
> a sleeping host before connecting, which those never did.
|
||||
>
|
||||
> `punktfunk-probe` is a different thing again — an in-repo protocol test and latency-measurement
|
||||
> tool for development. It isn't shipped in any package; you build it from source.
|
||||
> `punktfunk-probe` is different again — an in-repo protocol test and latency-measurement tool for
|
||||
> development, not shipped in any package; you build it from source.
|
||||
|
||||
## Profiles and links (every app)
|
||||
|
||||
Two things work the same in the Apple, Linux, Windows and Android apps. **Settings profiles** are
|
||||
named sets of stream overrides — bitrate, resolution, codec, HDR and the rest — that you bind to a
|
||||
host or pick for a single connect, with every field you didn't touch still following your defaults.
|
||||
And a **`punktfunk://` link** starts a stream from a browser, a desktop shortcut, a home-automation
|
||||
rule or `punktfunk open`, carrying only *references* to things that already exist on your device —
|
||||
never a setting, and never a trust decision.
|
||||
host or pick for a single connect; every field you didn't touch still follows your defaults. A
|
||||
**`punktfunk://` link** starts a stream from a browser, a desktop shortcut, a home-automation rule
|
||||
or `punktfunk open`, carrying only *references* to things that already exist on your device — never
|
||||
a setting, never a trust decision.
|
||||
|
||||
[Profiles and links](/docs/profiles-and-links) has both in full: the link grammar, where each app
|
||||
puts **Copy link** and **Create shortcut…**, and what a link is refused for. From a script,
|
||||
@@ -203,14 +196,14 @@ puts **Copy link** and **Create shortcut…**, and what a link is refused for. F
|
||||
Click the stream and the desktop clients **capture** your keyboard and mouse — everything goes to
|
||||
the host until you let go. **Ctrl+Alt+Shift+Q** (⌃⌥⇧Q or ⌘⎋ on a Mac) gives it back.
|
||||
|
||||
That chord, the three others a stream reserves, the controller chord that works with no keyboard in
|
||||
reach, which app honours which of them, the two mouse modes, the three touch modes and stylus input
|
||||
are all on [Mouse, touch and pen](/docs/input#getting-your-input-back).
|
||||
That chord, the three others a stream reserves, the controller chord that needs no keyboard, which
|
||||
app honours which, the two mouse modes, the three touch modes and stylus input are all on
|
||||
[Mouse, touch and pen](/docs/input#getting-your-input-back).
|
||||
|
||||
Copying between the two machines is a separate opt-in: the host operator allows it in `host.env`
|
||||
and you turn it on for that one host in your client. Content crosses today from the macOS, iOS,
|
||||
iPadOS, Windows and Android apps — the Linux client has the switch but no bridge behind it yet, and
|
||||
tvOS has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
|
||||
and you turn it on per host in your client. Content crosses today from the macOS, iOS, iPadOS,
|
||||
Windows and Android apps — the Linux client has the switch but no bridge behind it yet, and tvOS
|
||||
has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
|
||||
|
||||
## Which should I use?
|
||||
|
||||
@@ -226,5 +219,4 @@ tvOS has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
|
||||
| Scripts, plugins, home automation | The headless **[`punktfunk`](#scripting-the-punktfunk-cli)** CLI |
|
||||
| Protocol development / latency measurement | **`punktfunk-probe`** (source build only) |
|
||||
|
||||
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing), and
|
||||
[Install a Client](/docs/install-client) covers installing, updating and removing it.
|
||||
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
|
||||
|
||||
@@ -4,17 +4,16 @@ description: Copy on one machine and paste on the other — the two switches tha
|
||||
---
|
||||
|
||||
Punktfunk can share the clipboard between the machine you are sitting at and the host you are
|
||||
streaming. Copy a URL on your laptop, paste it into a browser on the host. Copy an error message on
|
||||
the host, paste it into a chat app on your laptop.
|
||||
streaming, in both directions — copy a URL on your laptop, paste it on the host, and back.
|
||||
|
||||
**Two separate switches have to be on:**
|
||||
|
||||
1. The **host** operator has to allow it, with a line in `host.env` and a host restart. This one is
|
||||
off by default.
|
||||
2. **You** have to turn it on for that one host, in that host's edit sheet on your client. This one
|
||||
is off by default on the macOS, Windows and Linux clients — but **on by default on Android**.
|
||||
1. The **host** operator has to allow it, with a line in `host.env` and a host restart. Off by
|
||||
default.
|
||||
2. **You** have to turn it on for that one host, in that host's edit sheet on your client. Off by
|
||||
default on the macOS, Windows and Linux clients — **on by default on Android**.
|
||||
|
||||
Flipping one and not the other looks exactly like the feature not existing. So check both.
|
||||
Flipping one and not the other looks exactly like the feature not existing. Check both.
|
||||
|
||||
## 1. Allow it on the host
|
||||
|
||||
@@ -52,14 +51,14 @@ punktfunk-host service restart
|
||||
See [Configuration](/docs/configuration) for the rest of `host.env`.
|
||||
|
||||
> **About the file mode.** No client shipping today asks for file transfer, and no host clipboard
|
||||
> backend offers file formats yet. `on` and `text-only` therefore behave the same in practice —
|
||||
> `text-only` is how you make that explicit and keep it that way.
|
||||
> backend offers file formats yet, so `on` and `text-only` behave the same in practice — `text-only`
|
||||
> makes that explicit and keeps it that way.
|
||||
|
||||
## 2. Turn it on for that host, in your client
|
||||
|
||||
The client switch is **per saved host**, not global: handing a machine your clipboard is a decision
|
||||
about *that* machine. You set it in the host's edit sheet, and it is deliberately not something a
|
||||
[settings profile can carry](/docs/profiles-and-links#what-a-profile-cant-change).
|
||||
The client switch is **per saved host**, not global — handing a machine your clipboard is a decision
|
||||
about *that* machine — so it lives in the host's edit sheet, deliberately not in a
|
||||
[settings profile](/docs/profiles-and-links#what-a-profile-cant-change).
|
||||
|
||||
| Client | Where the switch is | Label | Default |
|
||||
|---|---|---|---|
|
||||
@@ -69,39 +68,38 @@ about *that* machine. You set it in the host's edit sheet, and it is deliberatel
|
||||
| Linux (GTK) | Host card menu → **Edit…** | **Share clipboard** | Off |
|
||||
| Android (touch) | Host card menu → **Edit…** | **Shared clipboard** | **On** |
|
||||
|
||||
On Android the switch is only in the touch edit dialog. The controller/TV interface — what you get
|
||||
on Android TV, and on a phone when a controller is attached — has its own **Edit Host** screen with
|
||||
no clipboard row, so there is nowhere to change it there. It stays on, which is the Android default.
|
||||
On Android the switch is only in the touch edit dialog. The controller/TV interface — Android TV,
|
||||
and a phone with a controller attached — has its own **Edit Host** screen with no clipboard row, so
|
||||
it stays on, the Android default.
|
||||
|
||||
The setting is read when a session starts, so if you change it while streaming, reconnect.
|
||||
|
||||
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
|
||||
Sharing Clipboard** once the host has acknowledged it. On an iPad with a hardware keyboard the same
|
||||
combo works, though there is no menu bar to show it in — and only while the pointer is released, as
|
||||
a captured session sends the keys to the host instead.
|
||||
a captured session sends the keys to the host.
|
||||
|
||||
tvOS and a Steam Deck in Gaming Mode have no clipboard switch — the Apple TV has no pasteboard to
|
||||
share at all, and neither the Decky panel nor the client's console home has a host edit sheet — see
|
||||
share, and neither the Decky panel nor the client's console home has a host edit sheet — see
|
||||
[what each client does](#which-hosts-and-clients-support-it) below.
|
||||
|
||||
## Nothing crosses until something pastes
|
||||
|
||||
A copy costs nothing. When you copy, your machine announces only the **list of formats** it now
|
||||
holds — no bytes. The bytes are pulled across on a separate transfer, and only when an application
|
||||
on the other end actually pastes. Copying a large image and never pasting it transfers nothing.
|
||||
holds — no bytes. The bytes are pulled across on a separate transfer, only when an application on
|
||||
the other end actually pastes. Copying a large image and never pasting it transfers nothing.
|
||||
|
||||
That holds for everything you copy on your own machine, and for both directions on the host. It
|
||||
does **not** hold for a host copy arriving at a Windows or Android client: those two fetch the
|
||||
content straight away and put it on your local clipboard, whether or not you ever paste. On Windows
|
||||
that is because the lazy path needs Windows delayed rendering, which the client doesn't implement
|
||||
yet; on Android there is no way to satisfy a paste from the network at all. The macOS and iOS
|
||||
clients are lazy in both directions.
|
||||
content straight away and put it on your local clipboard, whether or not you ever paste — on
|
||||
Windows because the lazy path needs Windows delayed rendering, which the client doesn't implement
|
||||
yet; on Android because there is no way to satisfy a paste from the network at all. The macOS and
|
||||
iOS clients are lazy in both directions.
|
||||
|
||||
On iOS there is one deliberate exception. Backgrounding the app ends the session, and a promise
|
||||
nobody can answer is worse than no promise at all — so if the host copied something and you have not
|
||||
pasted it yet, those bytes are pulled across as the session ends, up to 8 MiB. That is what makes
|
||||
"copy on the host, switch to Safari, paste" work on an iPad. Nothing is fetched if you never leave
|
||||
the app, or if you already pasted.
|
||||
iOS has one deliberate exception. Backgrounding the app ends the session, so if the host copied
|
||||
something you have not pasted yet, those bytes are pulled across as the session ends, up to 8 MiB. That is what makes "copy on the host,
|
||||
switch to Safari, paste" work on an iPad. Nothing is fetched if you never leave the app, or if you
|
||||
already pasted.
|
||||
|
||||
A single transfer is capped at 64 MiB. Nothing else limits size, so a very large host-side copy can
|
||||
cross to a Windows or Android client for a paste that never happens.
|
||||
@@ -119,9 +117,7 @@ from a Punktfunk client. A Moonlight client has no clipboard.
|
||||
## Which hosts and clients support it
|
||||
|
||||
**Hosts.** The host runs on Linux and Windows, and both have a clipboard backend — but on Linux it
|
||||
depends on the desktop session.
|
||||
|
||||
On Linux the host needs one of two mechanisms in the session it is streaming:
|
||||
depends on the desktop session, which needs one of two mechanisms:
|
||||
|
||||
- `ext-data-control-v1` — KWin, wlroots/Sway and Hyprland. Tried first.
|
||||
- GNOME's own `org.gnome.Mutter.RemoteDesktop.Session` clipboard, used directly. Tried second.
|
||||
@@ -154,8 +150,8 @@ registered `PNG` clipboard format. Many Windows apps publish only a bitmap, and
|
||||
announced yet. The other direction is fine: an image copied on the host reaches the Windows client
|
||||
either way.
|
||||
|
||||
The host side is richer than any client: it can offer and accept text, HTML, RTF, PNG, JPEG and
|
||||
GIF. What you get is therefore whatever your client supports.
|
||||
The host side is richer than any client: it can offer and accept text, HTML, RTF, PNG, JPEG and GIF.
|
||||
What you get is whatever your client supports.
|
||||
|
||||
## Why the toggle does nothing (or is greyed out)
|
||||
|
||||
@@ -164,18 +160,18 @@ connected host did not advertise a clipboard. On the other clients there is noth
|
||||
the per-host switch always looks available, and a host that can't do it simply does nothing. Work
|
||||
through these in order:
|
||||
|
||||
- **The host has it off.** The default. Nothing was added to `host.env`, or the value is `off`,
|
||||
`0`, `false` or empty. Fix it with step 1 above.
|
||||
- **The host has it off.** The default. Nothing was added to `host.env`, or the value is `off`, `0`,
|
||||
`false` or empty. Fix it with step 1 above.
|
||||
- **`host.env` was edited but the host wasn't restarted.** The file is read once, at startup.
|
||||
- **The switch is off for this host in your client.** It is per saved host, and off by default
|
||||
everywhere except Android. Check the host's **Edit…** sheet — step 2 above.
|
||||
- **The host's session has no supported backend.** The host allows the clipboard, so it still
|
||||
advertises the capability, but it has nothing to read the desktop's clipboard with. This is a
|
||||
gamescope session, a compositor with only the old `zwlr-data-control-unstable-v1`, or a GNOME
|
||||
session whose Mutter doesn't expose the direct RemoteDesktop clipboard. Nothing on screen tells
|
||||
you this apart — the host log does.
|
||||
advertises the capability, but has nothing to read the desktop's clipboard with: a gamescope
|
||||
session, a compositor with only the old `zwlr-data-control-unstable-v1`, or a GNOME session whose
|
||||
Mutter doesn't expose the direct RemoteDesktop clipboard. Nothing on screen tells you this apart —
|
||||
the host log does.
|
||||
- **The host is older than the feature.** A host from before clipboard sync never advertises it.
|
||||
- **Your client doesn't implement it** — Linux, Steam Deck, iOS, iPadOS or tvOS. Nothing crosses
|
||||
- **Your client doesn't implement it** — Linux (GTK), Steam Deck or tvOS. Nothing crosses
|
||||
regardless of what the host allows.
|
||||
- **You changed the switch while connected.** Reconnect, or use ⌃⌥⇧C on macOS.
|
||||
- **The copy was a secret, or a format nobody handles.** Concealed content is skipped on purpose on
|
||||
|
||||
@@ -225,7 +225,7 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console, the plugin runner (and so every library plugin) and the status tray read it from `~/.config/punktfunk/mgmt-endpoint` (`%ProgramData%\punktfunk\mgmt-endpoint` on Windows), which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
|
||||
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam and Playnite installs, wherever they are — including a portable Playnite on another drive, which keeps its covers next to the program. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
|
||||
## Updates
|
||||
|
||||
|
||||
@@ -3,50 +3,46 @@ title: Controller speaker and haptics
|
||||
description: DualSense voice-coil haptics and the pad's built-in speaker, streamed from the host to the controller in your hands — what to enable, and what "set it to Pro Audio" means on a Linux host.
|
||||
---
|
||||
|
||||
A DualSense is partly an audio device. Its little speaker and its two voice-coil motors — the
|
||||
actuators that make a PS5 pad feel like sand, rain or a bowstring instead of a buzzing phone —
|
||||
are all driven by a four-channel audio stream, not by rumble commands. Games that support them
|
||||
write PCM into "the controller's audio device".
|
||||
A DualSense is partly an audio device. Its speaker and its two voice-coil motors are driven by a
|
||||
four-channel audio stream, not by rumble commands. Games that support them write PCM into "the
|
||||
controller's audio device".
|
||||
|
||||
Punktfunk gives that device to the game on the host, captures what the game writes, and streams
|
||||
it to the controller physically in your hands, on its own low-latency plane. Channels 1–2 are the
|
||||
pad's speaker, channels 3–4 are the voice coils.
|
||||
Punktfunk gives that device to the game on the host, captures what the game writes, and streams it
|
||||
to the controller in your hands on its own low-latency plane. Channels 1–2 are the pad's speaker,
|
||||
channels 3–4 the voice coils.
|
||||
|
||||
## What you need
|
||||
|
||||
- **A DualSense or DualSense Edge plugged in over USB** on the client. Bluetooth pads expose no
|
||||
audio interface at all, so they fall back to ordinary rumble — this is a limit of the
|
||||
controller, not of Punktfunk.
|
||||
audio interface, so they fall back to ordinary rumble — a limit of the controller, not of Punktfunk.
|
||||
- On the client, **Controller haptics** is on by default. So is **Controller speaker** on the Linux
|
||||
and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you would
|
||||
rather all game audio came out of your speakers. On Android the speaker is opt-in.
|
||||
- On a **Linux host**, a game that speaks DualSense — which in practice means running it under
|
||||
**GE-Proton 11-5 or newer**. Stock Proton does not route controller audio.
|
||||
and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you'd rather
|
||||
all game audio came out of your speakers. On Android the speaker is opt-in.
|
||||
- On a **Linux host**, a game that speaks DualSense — in practice, running under **GE-Proton 11-5 or
|
||||
newer**. Stock Proton does not route controller audio.
|
||||
- On the host, controller audio is on by default (`PUNKTFUNK_PAD_AUDIO`).
|
||||
|
||||
Nothing is sent while the pad is quiet, so leaving it on costs nothing.
|
||||
|
||||
## "Set the controller audio to Pro Audio" — you don't have to
|
||||
|
||||
If you have looked into DualSense haptics on Linux before, you have probably run into this
|
||||
advice: plug the pad into the Linux box, open your sound settings, find *DualSense wireless
|
||||
controller (PS5)*, and switch its **Profile** to **Pro Audio**. That advice is real and it is
|
||||
correct — for a pad plugged directly into the host.
|
||||
The usual advice for DualSense haptics on Linux — plug the pad into the Linux box, open your sound
|
||||
settings, find *DualSense wireless controller (PS5)*, switch its **Profile** to **Pro Audio** — is
|
||||
correct for a pad plugged directly into the host.
|
||||
|
||||
The reason is channel layout. A pad's other profiles present it as a mono speaker, a stereo
|
||||
headphone jack, or a positioned four-channel "surround" device. Games write their haptics as four
|
||||
*unpositioned* channels, so on any of those profiles the audio system helpfully re-mixes them into
|
||||
the speaker pair and the voice-coil channels are folded away. You feel nothing. Pro Audio is the
|
||||
one profile that hands the four channels through untouched, in order.
|
||||
*unpositioned* channels, so on any of those profiles the audio system re-mixes them into the speaker
|
||||
pair and the voice-coil channels are folded away. Pro Audio is the one profile that hands the four
|
||||
channels through untouched, in order.
|
||||
|
||||
**Punktfunk's controller audio device is already in that shape.** It is created as four raw
|
||||
channels with no re-mixing, which is exactly what Pro Audio produces — so there is nothing to
|
||||
switch, and no switch to make.
|
||||
**Punktfunk's controller audio device is already in that shape** — four raw channels with no
|
||||
re-mixing, exactly what Pro Audio produces — so there is nothing to switch.
|
||||
|
||||
That is also why it looks different in your sound settings. A real pad is a USB sound card, so it
|
||||
gets a **Profile** dropdown; Punktfunk's is a software device, so it has no card and no dropdown.
|
||||
Seeing **Wireless Controller** with a volume slider and no profile selector is what a correctly
|
||||
minted controller-audio device looks like. It is not a sign that something is missing.
|
||||
gets a **Profile** dropdown; Punktfunk's is a software device with no card and no dropdown. Seeing
|
||||
**Wireless Controller** with a volume slider and no profile selector is what a correctly minted
|
||||
controller-audio device looks like, not a sign that something is missing.
|
||||
|
||||
## Checking it is working
|
||||
|
||||
@@ -68,9 +64,9 @@ When a game actually starts driving the actuators, the pad's own driver reports
|
||||
DS5 title asserted haptics-select (audio haptics) pad=0
|
||||
```
|
||||
|
||||
That last line is the one that matters: it means a title recognised the controller as an audio
|
||||
device and switched the pad out of plain rumble. If you see it and still feel nothing, the problem
|
||||
is downstream — on the client or the pad. If you never see it, the game never found the device.
|
||||
That last line is the one that matters: a title recognised the controller as an audio device and
|
||||
switched the pad out of plain rumble. If you see it and still feel nothing, the problem is
|
||||
downstream — on the client or the pad. If you never see it, the game never found the device.
|
||||
|
||||
You can also look at the device directly:
|
||||
|
||||
@@ -78,22 +74,22 @@ You can also look at the device directly:
|
||||
pactl list sinks | grep -A25 Speaker__sink
|
||||
```
|
||||
|
||||
The line to check is `audio.position = "AUX0,AUX1,AUX2,AUX3"` — four unpositioned channels is the
|
||||
layout that reaches the voice coils. Anything positioned (`FL,FR,RL,RR`) would not.
|
||||
Check for `audio.position = "AUX0,AUX1,AUX2,AUX3"` — four unpositioned channels is the layout that
|
||||
reaches the voice coils. Anything positioned (`FL,FR,RL,RR`) would not.
|
||||
|
||||
## If a game does not find it
|
||||
|
||||
Games identify the controller's audio device by name and by USB ids, and different titles check
|
||||
different things. GE-Proton has several routes to the pad, and a couple of them are opt-in per
|
||||
game. Add these as launch options if a title is not cooperating:
|
||||
different things. GE-Proton has several routes to the pad, a couple of them opt-in per game. Add
|
||||
these as launch options if a title is not cooperating:
|
||||
|
||||
```
|
||||
PROTON_DUALSENSE_HAPTICS_PREFER_NON_EVENT=1 %command%
|
||||
```
|
||||
|
||||
This forces GE onto its most direct route — it opens Punktfunk's controller-audio device by name
|
||||
and writes the four channels straight into it, with no re-mixing anywhere in between. It is the
|
||||
first thing to try.
|
||||
This forces GE onto its most direct route — it opens Punktfunk's controller-audio device by name and
|
||||
writes the four channels straight into it, with no re-mixing in between. It is the first thing to
|
||||
try.
|
||||
|
||||
Some titles additionally want:
|
||||
|
||||
@@ -112,55 +108,52 @@ To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for
|
||||
|
||||
## On a Linux client, the pad's own profile matters too
|
||||
|
||||
Everything above is about the host, where the controller-audio device is one Punktfunk mints. On a
|
||||
Linux **client** the pad is real, and the same channel-layout problem shows up from the other side:
|
||||
the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a
|
||||
controller almost never presents as a four-channel device on its own. Depending on your distribution
|
||||
it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair. Playing into
|
||||
any of those puts the haptics in the headphone jack and folds the coil channels away — audio that
|
||||
looks perfectly healthy, felt as nothing at all.
|
||||
On a Linux **client** the pad is real, and the same channel-layout problem shows up from the other
|
||||
side: the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a
|
||||
controller almost never presents as a four-channel device on its own. Depending on your
|
||||
distribution it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair.
|
||||
Playing into any of those puts the haptics in the headphone jack and folds the coil channels away —
|
||||
audio that looks healthy, felt as nothing.
|
||||
|
||||
**Punktfunk handles this for you.** When it needs the coils and the pad is not already presenting
|
||||
four channels, it switches the controller's card to **Pro Audio** for the length of the session and
|
||||
puts your setting back afterwards. You will see the profile change in your sound settings while you
|
||||
are streaming; that is expected. It is never saved as the card's remembered profile.
|
||||
puts your setting back afterwards. You will see the profile change in your sound settings while
|
||||
streaming; that is expected. It is never saved as the card's remembered profile.
|
||||
|
||||
If you would rather manage the card yourself, set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` on the client. Then
|
||||
Punktfunk uses a four-channel profile if you have already selected one and logs what it needs if you
|
||||
have not.
|
||||
|
||||
Many systems never reach the switch at all. On **SteamOS** a DualSense already exposes its four
|
||||
channels behind a combined speaker-and-haptics output, and Punktfunk finds them there. That is a
|
||||
Valve addition, though, not something every up-to-date system has: `alsa-ucm-conf` upstream — and
|
||||
so Fedora, Bazzite and Arch — describes the pad as a *mono speaker plus stereo headphones* and
|
||||
nothing else, which is precisely the shape that folds the coils away. The switch is the fallback
|
||||
for those. **If you run the client as a Flatpak**, your audio manager may not let a sandboxed app
|
||||
change a card's profile; if the log says so, switch the controller to Pro Audio yourself, which is
|
||||
the same fix.
|
||||
Many systems never reach the switch. On **SteamOS** a DualSense already exposes its four channels
|
||||
behind a combined speaker-and-haptics output, and Punktfunk finds them there. That is a Valve
|
||||
addition, not something every up-to-date system has: `alsa-ucm-conf` upstream — and so Fedora,
|
||||
Bazzite and Arch — describes the pad as a *mono speaker plus stereo headphones* and nothing else,
|
||||
precisely the shape that folds the coils away. The switch is the fallback for those. **If you run
|
||||
the client as a Flatpak**, your audio manager may not let a sandboxed app change a card's profile; if
|
||||
the log says so, switch the controller to Pro Audio yourself, which is the same fix.
|
||||
|
||||
Punktfunk's **host** packages (rpm, deb, Arch, and the Bazzite sysext) close that gap at the
|
||||
source: they install a small ALSA profile for the DualSense that adds the combined
|
||||
speaker-and-haptics output SteamOS has, and give it priority over the mono one. It adds files
|
||||
rather than replacing any your distribution owns, so it upgrades cleanly and can be removed by
|
||||
uninstalling Punktfunk. A pad plugged into the host then presents four channels on its own, with
|
||||
no profile switching by anyone — and, because the lone mono output stops existing, games that
|
||||
crashed when they opened it stop crashing. A card reads its profile once, when it appears, so
|
||||
replug the pad after installing (or restart PipeWire) rather than expecting a pad that was
|
||||
already plugged in to pick it up.
|
||||
Punktfunk's **host** packages (rpm, deb, Arch, and the Bazzite sysext) close that gap at the source:
|
||||
they install a small ALSA profile for the DualSense that adds the combined speaker-and-haptics
|
||||
output SteamOS has, and give it priority over the mono one. It adds files rather than replacing any
|
||||
your distribution owns, so it upgrades cleanly and can be removed by uninstalling Punktfunk. A pad
|
||||
plugged into the host then presents four channels on its own, with no profile switching by anyone —
|
||||
and, because the lone mono output stops existing, games that crashed when they opened it stop
|
||||
crashing. A card reads its profile once, when it appears, so replug the pad after installing (or
|
||||
restart PipeWire) rather than expecting an already-plugged pad to pick it up.
|
||||
|
||||
### Checking the client side without a host
|
||||
|
||||
The client can test the whole path on its own — no host, no game, no pairing. Plug in the
|
||||
DualSense and run:
|
||||
The client can test the whole path on its own — no host, no game, no pairing. Plug in the DualSense
|
||||
and run:
|
||||
|
||||
```sh
|
||||
punktfunk-session --pad-audio-test
|
||||
```
|
||||
|
||||
It prints every DualSense object it can see in your audio graph, says which one it chose, and then
|
||||
plays a tone into the voice coils for three seconds. **If the pad buzzes, the client side is
|
||||
working** and any remaining silence is coming from the host or the game. Add `--speaker` to test
|
||||
the pad's speaker instead, and `--seconds N` for a longer run.
|
||||
It prints every DualSense object it can see in your audio graph, says which one it chose, and plays
|
||||
a tone into the voice coils for three seconds. **If the pad buzzes, the client side is working** and
|
||||
any remaining silence is coming from the host or the game. Add `--speaker` to test the pad's speaker
|
||||
instead, and `--seconds N` for a longer run.
|
||||
|
||||
On the Steam Deck and other flatpak installs, run it inside the sandbox:
|
||||
|
||||
@@ -172,30 +165,29 @@ flatpak run --command=punktfunk-session io.unom.Punktfunk --pad-audio-test
|
||||
|
||||
The controller's speaker and its headphone jack **share a channel**. Channel 1 of the pad's audio
|
||||
device is the headphone jack's right channel *and* the built-in speaker, and the controller decides
|
||||
which one actually sounds. It powers up pointing at the jack — so with nothing plugged in, a
|
||||
perfectly routed speaker stream is heard by nobody.
|
||||
which one sounds. It powers up pointing at the jack — so with nothing plugged in, a perfectly routed
|
||||
speaker stream is heard by nobody.
|
||||
|
||||
Punktfunk points the pad at its own speaker when **Controller speaker** is on. The voice coils are
|
||||
different channels and are not affected by that choice, which is why haptics work as soon as the
|
||||
audio is routed correctly and the speaker needs this extra step. A game that drives the pad's audio
|
||||
settings itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH`
|
||||
and `PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild.
|
||||
different channels and unaffected by that choice. A game that drives the pad's audio settings
|
||||
itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH` and
|
||||
`PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT.
|
||||
- **Titles that match the controller by container ID** — a Windows notion of "these devices are
|
||||
the same physical thing" — will not recognise the pairing on a Linux host, because the virtual
|
||||
pad has no USB device behind it to derive one from. Titles that match by name or by USB ids are
|
||||
unaffected, which is most of them.
|
||||
- **Titles that match the controller by container ID** — a Windows notion of "these devices are the
|
||||
same physical thing" — will not recognise the pairing on a Linux host, because the virtual pad has
|
||||
no USB device behind it to derive one from. Titles that match by name or by USB ids — most of them
|
||||
— are unaffected.
|
||||
- **A pad plugged into the host itself can steal the audio.** If a real DualSense is connected to
|
||||
the host while you are streaming to a different one, some titles will find the local pad's sound
|
||||
card first. Unplug it, or stream from a host that has no pad attached.
|
||||
the host while you stream to a different one, some titles find the local pad's sound card first.
|
||||
Unplug it, or stream from a host that has no pad attached.
|
||||
- **The Pro Audio switch on a Linux client renames the pad's microphone too.** Switching a sound
|
||||
card's profile re-creates all of its inputs and outputs, so if you had picked the DualSense's own
|
||||
microphone as your [mic](/docs/client-settings#audio), that session falls back to your default
|
||||
one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a
|
||||
four-channel profile on the card yourself.
|
||||
- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a
|
||||
session ends normally and is never written to your saved settings, so anything that reloads the
|
||||
card — unplugging it, logging out, a reboot — brings your own profile back.
|
||||
one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a four-channel
|
||||
profile on the card yourself.
|
||||
- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a session
|
||||
ends normally and is never written to your saved settings, so anything that reloads the card —
|
||||
unplugging it, logging out, a reboot — brings your own profile back.
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
---
|
||||
title: Debian
|
||||
description: Install the Punktfunk host on Debian 13 with apt — including LMDE and Linux Mint.
|
||||
---
|
||||
|
||||
Install a Punktfunk host on **Debian 13 ("trixie") or newer** from the apt registry. This page
|
||||
covers the distro-level setup — GPU driver, package, gamepad access. How the host creates its
|
||||
virtual display and injects input is desktop-specific, so pick your desktop on the
|
||||
[configure pages](#configure-your-desktop) afterward rather than here.
|
||||
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
> **Which releases.** The host package needs **glibc 2.39 or newer**; Debian 13 has 2.41, so it
|
||||
> installs and runs there. **Debian 12 (bookworm) has glibc 2.36 and cannot install it** — build
|
||||
> from source ([Ubuntu appendix](/docs/ubuntu#appendix--build-from-source), which applies here too)
|
||||
> or upgrade. Check yours with `ldd --version`.
|
||||
|
||||
> **The desktop client is not packaged for Debian yet.** `punktfunk-client` is built on Ubuntu 26.04
|
||||
> and floors at `libc6 >= 2.43` (Debian 13 has 2.41), on top of needing GTK4 ≥ 4.20. On a Debian
|
||||
> box, stream *to* it with a [different client](/docs/install-client) — the Flatpak, or a build from
|
||||
> source. The **host**, the **web console** and the **plugin runner** all install normally.
|
||||
|
||||
## What works on Debian 13
|
||||
|
||||
| Package | Debian 13 | What it is |
|
||||
|---|---|---|
|
||||
| `punktfunk-host` | ✅ | The streaming host |
|
||||
| `punktfunk-web` | ✅ | The browser management console |
|
||||
| `punktfunk-scripting` | ✅ | The plugin/script runner |
|
||||
| `punktfunk-gamescope` | ✅ | The patched gamescope (HDR + cursor + real refresh) |
|
||||
| `punktfunk-client` | ❌ | Desktop client — `libc6 >= 2.43`, see above |
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
On **NVIDIA**, the driver lives in Debian's `contrib` / `non-free` / `non-free-firmware`
|
||||
components, which a default install does not enable. Debian 13 keeps its sources in the deb822
|
||||
format, so add them there and refresh:
|
||||
|
||||
```sh
|
||||
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
sudo apt update
|
||||
sudo apt install nvidia-driver firmware-misc-nonfree
|
||||
```
|
||||
|
||||
Debian 13 ships driver 550, comfortably above the [535 floor](/docs/requirements).
|
||||
|
||||
Reboot, then confirm the driver and KMS modeset — Wayland on NVIDIA needs `modeset=1`:
|
||||
|
||||
```sh
|
||||
nvidia-smi
|
||||
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
|
||||
```
|
||||
|
||||
If modeset is not `Y`:
|
||||
|
||||
```sh
|
||||
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
|
||||
sudo update-initramfs -u && sudo reboot
|
||||
```
|
||||
|
||||
> **Secure Boot:** with Secure Boot enabled, Debian's DKMS-built NVIDIA module must be signed and
|
||||
> its key enrolled before it will load. If `nvidia-smi` can't talk to the driver, enrol the MOK
|
||||
> (`sudo mokutil --import /var/lib/dkms/mok.pub`, reboot, choose **Enrol MOK**) or disable Secure
|
||||
> Boot in firmware.
|
||||
|
||||
On **AMD/Intel** none of the NVIDIA steps apply. Encode runs on the Mesa stack: **Vulkan Video** for
|
||||
HEVC and AV1 (`mesa-vulkan-drivers`), with **VAAPI** for H.264 and as the fallback —
|
||||
`mesa-va-drivers` on AMD, `intel-media-va-driver` on Intel (the latter is in `non-free`).
|
||||
|
||||
## 2. Install the host (apt)
|
||||
|
||||
The registry is public — no auth needed, just trust its signing key:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
|
||||
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
|
||||
|
||||
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/punktfunk.list
|
||||
|
||||
sudo apt update
|
||||
sudo apt install punktfunk-host
|
||||
```
|
||||
|
||||
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
|
||||
The NVIDIA driver is **not** a dependency — you installed it out of band in step 1. Later updates
|
||||
are `sudo apt update && sudo apt upgrade`; restart the running host afterwards so it picks up the
|
||||
new binary:
|
||||
|
||||
```sh
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The `stable` component above is the stable channel. To track pre-release builds instead, see
|
||||
[Release Channels](/docs/channels).
|
||||
|
||||
## 3. Grant gamepad access
|
||||
|
||||
Virtual gamepads inject through `/dev/uinput`, gated by the `input` group. Add yourself and re-login:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG input "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
Also join `punktfunk` if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro) —
|
||||
it reaches games as a real USB device over usbip, which is what makes Steam Input adopt it. Join it
|
||||
only on a machine you trust: writing the usbip `attach` file can materialise arbitrary emulated USB
|
||||
hardware.
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
## 4. Check it installed
|
||||
|
||||
```sh
|
||||
punktfunk-host --version # the binary is on PATH
|
||||
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
|
||||
```
|
||||
|
||||
Two hosts on one machine is the most common reason a clean install never streams — see
|
||||
[Troubleshooting](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
|
||||
|
||||
## 5. Open the firewall (if you have one)
|
||||
|
||||
**Debian ships no firewall enabled by default**, so out of the box there is nothing to open. If you
|
||||
run one, the package installs the openers:
|
||||
|
||||
```sh
|
||||
# ufw:
|
||||
sudo ufw allow punktfunk-native
|
||||
|
||||
# firewalld:
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Add `punktfunk-gamestream` for Moonlight compat and `punktfunk-web` (TCP 47992) to reach the console
|
||||
from another device. Full port lists are in
|
||||
[`packaging/debian/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md#firewall).
|
||||
|
||||
## Cinnamon, Linux Mint and LMDE
|
||||
|
||||
**A Cinnamon desktop cannot host a virtual display, and no setting changes that.** Punktfunk gives
|
||||
each client its own screen at that device's exact resolution by asking the compositor to create a
|
||||
virtual output. Cinnamon's compositor, **Muffin**, has no such API: it forked from Mutter 3.36, and
|
||||
its `org.cinnamon.Muffin.ScreenCast` interface offers only `RecordMonitor` and `RecordWindow` —
|
||||
never the `RecordVirtual` that Mutter gained in 42. Its portal backend
|
||||
(`xdg-desktop-portal-xapp`) implements no ScreenCast either, so the route that serves Sway and
|
||||
Hyprland is closed too. This is upstream's to fix, not a Punktfunk setting.
|
||||
|
||||
**Which Mint you run decides whether there is any route at all:**
|
||||
|
||||
| Edition | Base | Can it host? |
|
||||
|---|---|---|
|
||||
| **LMDE 7 "Gigi"** | Debian 13 | ✅ Yes — via gamescope (below) |
|
||||
| **Linux Mint 22.x** ("Wilma"…"Zena") | Ubuntu 24.04 | ❌ No — see [below](#linux-mint-22x-cannot-host-yet) |
|
||||
| **Linux Mint 23** | Ubuntu 26.04 | ✅ Expected — due December 2026 |
|
||||
|
||||
On **LMDE 7**, what works is **gamescope**: the host starts its own headless gamescope for each
|
||||
connecting client and runs the game inside it, so it needs no desktop compositor at all. Your
|
||||
Cinnamon session keeps running untouched; the stream is the game, not the desktop.
|
||||
|
||||
```sh
|
||||
sudo apt install punktfunk-gamescope # LMDE 7 / Debian 13 — not available on Mint 22.x
|
||||
echo 'PUNKTFUNK_COMPOSITOR=gamescope' >> ~/.config/punktfunk/host.env
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The pin is required: auto-detection reads the live session, finds Cinnamon, and stops with an error
|
||||
rather than guessing. Set a game to launch with
|
||||
[`PUNKTFUNK_GAMESCOPE_APP`](/docs/gamescope) or per-session launch commands, then see
|
||||
[Steam / gamescope](/docs/gamescope) for the rest.
|
||||
|
||||
> **Install `punktfunk-gamescope`, not Debian's.** Debian ships **no** `gamescope` package at all,
|
||||
> and the patched build is what gives the stream HDR, a visible cursor, and the client's real
|
||||
> refresh rate instead of a hardcoded 60 Hz.
|
||||
|
||||
If you want to stream the **desktop** from an LMDE box, the answer today is to log into a GNOME or
|
||||
Sway session instead — Debian 13 ships GNOME 48.7 and sway 1.10, both above the
|
||||
[floors](/docs/requirements). (Debian 13's KDE is KWin **6.3.6**, below the 6.5.6 floor, so Plasma
|
||||
is not an option there yet.)
|
||||
|
||||
### Linux Mint 22.x cannot host yet
|
||||
|
||||
**On Linux Mint 22.x — the current mainstream release, and every version until Mint 23 in December
|
||||
2026 — there is no working configuration.** `punktfunk-host` will install, which makes this easy to
|
||||
miss, but nothing on the box can produce a stream:
|
||||
|
||||
- **Cinnamon** cannot host a virtual display (above).
|
||||
- **gamescope is not available and cannot be made available.** Ubuntu 24.04 packages no gamescope,
|
||||
and the patched `punktfunk-gamescope` cannot run there either: 24.04 is short of what the build
|
||||
needs on *five* libraries — wayland 1.22.0 (needs ≥ 1.23.1), libinput 1.25 (≥ 1.26), libavif
|
||||
1.0.4 (≥ 1.2.1), pixman 0.42 (≥ 0.44), and no `libdisplay-info2` or `libxcb-errors0` at all.
|
||||
- **Switching desktop does not rescue it.** Ubuntu 24.04 ships KWin **5.27** (floor 6.5.6) and GNOME
|
||||
Shell **46** (floor 48). Only `sway` 1.9 is even a candidate, and that means giving up Cinnamon.
|
||||
|
||||
If you want to run a host on Mint hardware today, use **LMDE 7** — it is the same desktop on a
|
||||
Debian 13 base, where gamescope works. Otherwise wait for **Mint 23** (Ubuntu 26.04 base), where
|
||||
both the patched gamescope and the newer compositors are available.
|
||||
|
||||
## Configure your desktop
|
||||
|
||||
How the host creates its virtual display and injects input depends on your desktop, not your distro:
|
||||
|
||||
- [KDE Plasma (KWin)](/docs/kde)
|
||||
- [GNOME (Mutter)](/docs/gnome)
|
||||
- [Steam / gamescope](/docs/gamescope)
|
||||
- [Hyprland](/docs/hyprland)
|
||||
- [Sway / wlroots](/docs/sway)
|
||||
|
||||
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect your first
|
||||
[client](/docs/clients). To run the host at boot — including fully **headless** — see
|
||||
[Running as a Service](/docs/running-as-a-service).
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Keep it current** — [Updating the Host](/docs/updating).
|
||||
- **Remove it again** — [Uninstalling](/docs/uninstall).
|
||||
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
|
||||
- **Build from source** (Debian 12, or tracking `main`) — the
|
||||
[Ubuntu appendix](/docs/ubuntu#appendix--build-from-source) applies unchanged; Debian 13's
|
||||
`libavcodec-dev` is new enough to build against.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Debian
|
||||
description: Install the Punktfunk host on Debian 13 or newer with apt — including LMDE 7.
|
||||
---
|
||||
|
||||
For **Debian 13 ("trixie") or newer**, and **LMDE 7**. Debian 12 is too old (glibc 2.36, the
|
||||
package needs 2.39) — [build from source](/docs/build-from-source) there, or upgrade.
|
||||
|
||||
> **Desktop matters more than distro here.** Debian 13's GNOME (48) and Sway (1.10) can host; its
|
||||
> KDE (KWin 6.3) is below the floor, and a Cinnamon desktop (Linux Mint, LMDE) can only host through
|
||||
> gamescope. [What each desktop can do, and what Linux Mint 22 can't](/docs/requirements#cinnamon-linux-mint-and-lmde).
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** the driver lives in `non-free`, which a default install doesn't enable. Enable it,
|
||||
install, reboot:
|
||||
|
||||
```sh
|
||||
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' /etc/apt/sources.list.d/debian.sources
|
||||
sudo apt update && sudo apt install nvidia-driver firmware-misc-nonfree
|
||||
```
|
||||
|
||||
If `nvidia-smi` can't talk to the driver afterwards, Secure Boot is in the way — see
|
||||
[Troubleshooting](/docs/troubleshooting#nvidia-smi-says-it-cant-communicate-with-the-driver).
|
||||
- **AMD / Intel:** nothing to install — Mesa's Vulkan and VAAPI drivers are already there (Intel's
|
||||
`intel-media-va-driver` is in `non-free`; the host package recommends it).
|
||||
|
||||
## 2. Install the host
|
||||
|
||||
The repo is public and signed — the `debian` in the URL is the package format, it's the same repo
|
||||
Ubuntu uses. The browser console, `punktfunk-web`, comes along automatically:
|
||||
|
||||
<Install platform="debian" />
|
||||
|
||||
Updates ride along with `sudo apt upgrade`; restart the host afterwards
|
||||
(`systemctl --user restart punktfunk-host`) — or let the [console do it](/docs/updating).
|
||||
|
||||
## 3. Let it use your controllers
|
||||
|
||||
Join the `input` group (virtual gamepads go through `/dev/uinput`), then **log out and back in**:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG input "$USER"
|
||||
```
|
||||
|
||||
Want the **virtual Steam Deck controller** (paddles, trackpads, gyro)? Also join the `punktfunk`
|
||||
group — [what it gates and why it's separate](/docs/gamescope#nobara-and-other-autologin-display-managers).
|
||||
|
||||
## 4. Start it
|
||||
|
||||
From a terminal inside your desktop session:
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-host punktfunk-web
|
||||
```
|
||||
|
||||
Debian ships no firewall enabled, so there is nothing to open. If you run one, the package installed
|
||||
profiles for ufw and firewalld — [Ports & firewall](/docs/ports).
|
||||
|
||||
**Cinnamon (LMDE 7):** the host can't stream the Cinnamon desktop itself; it streams games through
|
||||
gamescope instead. Add `sudo apt install punktfunk-gamescope` and put
|
||||
`PUNKTFUNK_COMPOSITOR=gamescope` in `~/.config/punktfunk/host.env`, then restart the host —
|
||||
[Steam / gamescope](/docs/gamescope) takes it from there.
|
||||
|
||||
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
|
||||
— open the console, pair a client, stream.
|
||||
|
||||
## When you want more
|
||||
|
||||
- `punktfunk-host detect-conflicts` tells you if Sunshine or Apollo is also running;
|
||||
[Troubleshooting](/docs/troubleshooting) starts from the symptom.
|
||||
- Your desktop's particulars — [GNOME](/docs/gnome), [Sway](/docs/sway), [gamescope](/docs/gamescope).
|
||||
- Stream with nobody logged in — [Running as a service](/docs/running-as-a-service).
|
||||
- The Linux **client** isn't packaged for Debian (it needs a newer glibc) — use the
|
||||
[Flatpak](/docs/install-client#linux-desktop-flatpak) to stream *to* a Debian box.
|
||||
- Track `main` instead of releases — [Release channels](/docs/channels).
|
||||
@@ -1,240 +0,0 @@
|
||||
---
|
||||
title: Fedora
|
||||
description: Install the Punktfunk host on Fedora from the RPM registry.
|
||||
---
|
||||
|
||||
Install a Punktfunk host on **Fedora** from the self-hosted RPM registry. The host installs as an
|
||||
RPM-managed systemd **`--user`** service and updates with `dnf upgrade` like the rest of your
|
||||
system — no building required. It works with either **KDE Plasma** or **GNOME**; the
|
||||
desktop-specific setup (which compositor captures, headless sessions, quirks) lives on the
|
||||
[desktop configure pages](#5-configure-your-desktop). Host encode is **NVENC on NVIDIA**; on
|
||||
**AMD/Intel** HEVC and AV1 go through **Vulkan Video**, with **VAAPI** for H.264 and as the fallback
|
||||
(`PUNKTFUNK_ENCODER=auto` picks per GPU).
|
||||
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
Install is two parts: **GPU driver** → **host RPM**. Then open the firewall and point the host at
|
||||
your desktop from the [desktop configure pages](#5-configure-your-desktop).
|
||||
|
||||
## 1. NVIDIA driver (RPM Fusion akmod)
|
||||
|
||||
Enable RPM Fusion (free + nonfree), then install the akmod driver + CUDA. RPM Fusion's nonfree
|
||||
NVIDIA repo is sometimes pre-enabled on some spins; the full free/nonfree repos below are still
|
||||
needed (they carry the NVENC ffmpeg in the next step).
|
||||
|
||||
```sh
|
||||
sudo dnf install \
|
||||
https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \
|
||||
https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm
|
||||
sudo dnf install akmod-nvidia xorg-x11-drv-nvidia-cuda
|
||||
```
|
||||
|
||||
**NVENC ffmpeg.** Fedora ships `ffmpeg-free`, which is built **without** NVENC — the host can't
|
||||
encode with it. Swap to RPM Fusion's ffmpeg:
|
||||
|
||||
```sh
|
||||
sudo dnf install --allowerasing ffmpeg ffmpeg-libs
|
||||
ffmpeg -hide_banner -encoders | grep nvenc # expect hevc_nvenc / av1_nvenc / h264_nvenc
|
||||
```
|
||||
|
||||
**Secure Boot.** If `mokutil --sb-state` says *enabled*, the akmod module is signed with a
|
||||
locally-generated key that must be enrolled once:
|
||||
|
||||
```sh
|
||||
sudo akmods --force # build + sign the module
|
||||
sudo mokutil --import /etc/pki/akmods/certs/public_key.der # set a one-time password
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
On the next boot a blue **MOK Manager** screen appears **on the machine's console** (not over
|
||||
SSH): *Enroll MOK → Continue → Yes → (the password) → Reboot*. Then verify:
|
||||
|
||||
```sh
|
||||
nvidia-smi # driver loads
|
||||
ffmpeg -hide_banner -encoders | grep nvenc
|
||||
```
|
||||
|
||||
(Or disable Secure Boot in firmware to skip the MOK step — fine for a dedicated test box.)
|
||||
|
||||
**AMD / Intel.** No akmod needed — the Mesa stack carries both encode paths. HEVC and AV1 go through
|
||||
**Vulkan Video** by default (the Mesa Vulkan driver, present on any normal Fedora desktop), and
|
||||
**VAAPI** is the H.264 path and the fallback. Install the freeworld VAAPI drivers for full codec
|
||||
support (`mesa-va-drivers-freeworld` for AMD from RPM Fusion, `intel-media-driver` for Intel); on a
|
||||
desktop these are usually already present.
|
||||
|
||||
## 2. Install the host (RPM)
|
||||
|
||||
The host is published to the self-hosted Gitea RPM registry, in a per-release group (an RPM is
|
||||
soname-coupled to its base, so each Fedora release gets its own group). Pick the one matching your
|
||||
release — `rpm -E %fedora` prints the number you're on:
|
||||
|
||||
- **Fedora 44** → `fedora-44`
|
||||
- **Fedora 43** → `bazzite` — that group is a plain Fedora 43 build of the same `punktfunk` package,
|
||||
so it's the right one for a regular Fedora 43 box too
|
||||
|
||||
Put your group in the `baseurl` below, then add the repo and install:
|
||||
|
||||
```sh
|
||||
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
|
||||
[punktfunk]
|
||||
name=punktfunk
|
||||
# The group for your release: fedora-44 on Fedora 44, bazzite on Fedora 43.
|
||||
baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44
|
||||
enabled=1
|
||||
# Packages are GPG-signed (gpgcheck=1) AND the repo metadata is Gitea-signed (repo_gpgcheck=1).
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
|
||||
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
|
||||
REPO
|
||||
|
||||
sudo dnf install punktfunk
|
||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||
```
|
||||
|
||||
Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller**
|
||||
(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts
|
||||
it), or this box autologins into Steam **Gaming Mode** (Nobara and friends) and you want the host
|
||||
to take that session over at the client's resolution:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply)
|
||||
```
|
||||
|
||||
That is a second group on purpose: it grants write access to the usbip `attach` file, which
|
||||
materialises an arbitrary emulated USB device, so it stays off the `input` group everyone is
|
||||
routinely told to join. Join it only on a machine you trust. Skip it on a plain desktop host and
|
||||
the pad simply arrives as an ordinary Xbox 360 controller; skip it on a Gaming Mode box and the
|
||||
takeover silently degrades to mirroring the box's own screen — see
|
||||
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
|
||||
|
||||
Updates later are just `sudo dnf upgrade punktfunk`, followed by
|
||||
`systemctl --user restart punktfunk-host` so the running host picks up the new binary. The package
|
||||
ships the systemd user units, the udev rule, the UDP socket-buffer sysctl tuning, and example
|
||||
configs.
|
||||
|
||||
The group you picked above is the **stable** channel. For the latest `main` build, point `baseurl` at
|
||||
`fedora-44-canary` (or `bazzite-canary`) instead — see [Release Channels](/docs/channels). Updating
|
||||
in general, including the opt-in one-click button in the web console, is covered in
|
||||
[Updating the Host](/docs/updating).
|
||||
|
||||
> `fedora-44` and `bazzite` are the only stable groups published, so on Fedora 42 or older — or on a
|
||||
> release newer than 44 — there's nothing matching yet. Build one with the same toolchain CI uses —
|
||||
> `docker build --build-arg FEDORA_VERSION=NN -f ci/fedora-rpm.Dockerfile -t pf-rpm ci` then run
|
||||
> `packaging/rpm/build-rpm.sh` inside it — or build from source (appendix below).
|
||||
|
||||
## 3. Check it installed
|
||||
|
||||
Before moving on, confirm the binary is there and nothing else is competing for the same job:
|
||||
|
||||
```sh
|
||||
punktfunk-host --version # the binary is on PATH
|
||||
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
|
||||
```
|
||||
|
||||
If `detect-conflicts` reports another streaming host, remove it before going further — two hosts on
|
||||
one machine is the most common reason a clean install never streams. See
|
||||
[Troubleshooting → another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
|
||||
|
||||
Once you've enabled the service on your desktop page below, these are how you watch it:
|
||||
|
||||
```sh
|
||||
systemctl --user status punktfunk-host # active
|
||||
journalctl --user -u punktfunk-host -f # watch a client connect
|
||||
```
|
||||
|
||||
## 4. Open the firewall
|
||||
|
||||
Fedora runs **firewalld** by default and the package never edits your firewall, so the host stays
|
||||
unreachable until you allow it. The RPM installs the service definitions — enable them once.
|
||||
|
||||
The packaged unit runs `serve --gamestream` — the RPM installs it as it ships and only rewrites the
|
||||
binary path — so a host you enabled with `systemctl --user enable --now punktfunk-host` serves
|
||||
**both** the native `punktfunk/1` plane and stock [Moonlight](/docs/moonlight) clients, and needs
|
||||
**both** services:
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Enabled **GameStream/Moonlight compat** (`PUNKTFUNK_GAMESTREAM=1` in `host.env` — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts))? Then also:
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-native` opens UDP 9777 (QUIC control), UDP 5353 (mDNS discovery) and TCP 47990 (the
|
||||
mgmt/library API — HTTPS + mTLS, read-only off loopback). `punktfunk-gamestream` opens the fixed
|
||||
Moonlight ports — TCP 47984, 47989 and 48010, UDP 47998–48000 — plus the same mDNS. The media
|
||||
**data plane** uses an ephemeral UDP port the client opens with a hole-punch, so there is nothing
|
||||
fixed to open for video.
|
||||
|
||||
And if you want the web console reachable from another device, open **TCP 47992**:
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
## 5. Configure your desktop
|
||||
|
||||
How the host creates its virtual display and injects input depends on your desktop, not your distro.
|
||||
Continue on the page for the desktop you run — it covers your `host.env`, any compositor quirks, and
|
||||
starting the host:
|
||||
|
||||
- [KDE Plasma (KWin)](/docs/kde)
|
||||
- [GNOME (Mutter)](/docs/gnome)
|
||||
- [Steam / gamescope](/docs/gamescope)
|
||||
- [Hyprland](/docs/hyprland)
|
||||
- [Sway / wlroots](/docs/sway)
|
||||
|
||||
Enable the browser management console (status, paired devices, arm pairing) — see
|
||||
[Web Console](/docs/web-console).
|
||||
|
||||
For a headless KWin appliance that streams at boot with no graphical login, see
|
||||
[KDE → Headless session](/docs/kde#headless-session).
|
||||
|
||||
Full config reference: [Configuration](/docs/configuration). Service model:
|
||||
[Running as a Service](/docs/running-as-a-service).
|
||||
|
||||
## 6. Connect a client
|
||||
|
||||
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
|
||||
the **PIN pairing** — arm it from the host's [web console](/docs/web-console#arm-pairing), which
|
||||
displays a 4-digit PIN to type into the client. See [Clients](/docs/clients) and
|
||||
[Pairing](/docs/pairing).
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Keep it current** — [Updating the Host](/docs/updating).
|
||||
- **Remove it again** — [Uninstalling](/docs/uninstall).
|
||||
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
|
||||
|
||||
## Appendix — build from source
|
||||
|
||||
If there's no RPM for your Fedora release and you don't want to build one, compile the host directly
|
||||
(no clean updates / no packaged units — you wire those up by hand):
|
||||
|
||||
```sh
|
||||
sudo dnf install gcc gcc-c++ make cmake clang clang-devel nasm git pkgconf-pkg-config \
|
||||
pipewire-devel wayland-devel wayland-protocols-devel libxkbcommon-devel opus-devel \
|
||||
libdrm-devel mesa-libgbm-devel mesa-libGL-devel mesa-libEGL-devel mesa-libGLES-devel libva-devel \
|
||||
ffmpeg-devel libei-devel
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
|
||||
cargo build --release --locked \
|
||||
--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host
|
||||
```
|
||||
|
||||
`mesa-libGL-devel` isn't optional — the zero-copy GPU path links `libGL`, and without it the build
|
||||
fails at the link step with `cannot find -lGL`. The two `--features` are what the packaged builds
|
||||
use: leave them off and the host has no direct NVENC (NVIDIA) and no Vulkan Video encode
|
||||
(AMD/Intel), and quietly falls back to the slower libav backends.
|
||||
|
||||
Then write `~/.config/punktfunk/host.env` (as in `/usr/share/punktfunk/host.env.kde`, but the host
|
||||
binary is `target/release/punktfunk-host`) and run it inside your desktop session — for a headless
|
||||
KWin appliance see [KDE → Headless session](/docs/kde#headless-session).
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Fedora
|
||||
description: Install the Punktfunk host on Fedora 43 or newer from the RPM repo — four steps.
|
||||
---
|
||||
|
||||
For **Fedora 43 or newer** (Workstation or KDE). Bazzite and other Fedora Atomic spins have
|
||||
[their own page](/docs/bazzite).
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** the driver and an NVENC-capable FFmpeg both come from **RPM Fusion** — Fedora's own
|
||||
`ffmpeg-free` has no NVENC and the host cannot encode with it. Enable RPM Fusion, install, reboot:
|
||||
|
||||
```sh
|
||||
sudo dnf install \
|
||||
https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \
|
||||
https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm
|
||||
sudo dnf install akmod-nvidia xorg-x11-drv-nvidia-cuda
|
||||
sudo dnf install --allowerasing ffmpeg ffmpeg-libs
|
||||
```
|
||||
|
||||
With **Secure Boot** on, the module won't load until you enrol its key — `nvidia-smi` will say it
|
||||
can't talk to the driver; the fix is in
|
||||
[Troubleshooting](/docs/troubleshooting#nvidia-smi-says-it-cant-communicate-with-the-driver).
|
||||
- **AMD / Intel:** nothing to install — Mesa carries Vulkan Video and VAAPI. For full codec coverage
|
||||
add RPM Fusion's `mesa-va-drivers-freeworld` (AMD) or `intel-media-driver` (Intel).
|
||||
|
||||
## 2. Install the host
|
||||
|
||||
The RPM repo has one group per Fedora release: **`fedora-44`** on Fedora 44, **`bazzite`** on
|
||||
Fedora 43 (it's a plain Fedora 43 build of the same package). `rpm -E %fedora` prints your number —
|
||||
set `baseurl` to match, then install. The browser console, `punktfunk-web`, comes along
|
||||
automatically:
|
||||
|
||||
<Install platform="fedora" />
|
||||
|
||||
Updates ride along with `sudo dnf upgrade`; restart the host afterwards
|
||||
(`systemctl --user restart punktfunk-host`) — or let the [console do it](/docs/updating).
|
||||
|
||||
## 3. Let it use your controllers
|
||||
|
||||
Join the `input` group (virtual gamepads go through `/dev/uinput`), then **log out and back in**:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG input "$USER"
|
||||
```
|
||||
|
||||
Want the **virtual Steam Deck controller** (paddles, trackpads, gyro)? Also join the `punktfunk`
|
||||
group — [what it gates and why it's separate](/docs/gamescope#nobara-and-other-autologin-display-managers).
|
||||
|
||||
## 4. Start it, open the firewall
|
||||
|
||||
From a terminal inside your desktop session:
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-host punktfunk-web
|
||||
```
|
||||
|
||||
Fedora runs **firewalld**, and a package never opens ports for you — so until you allow it, no client
|
||||
can reach the host. The RPM installed the service definitions; enable them once:
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --reload # load the definitions the package installed
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native --add-service=punktfunk-web
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
(`punktfunk-web` is only needed to reach the console from another device. Turning on Moonlight
|
||||
compat later? Add `punktfunk-gamestream` too — [Ports & firewall](/docs/ports).)
|
||||
|
||||
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
|
||||
— open the console, pair a client, stream.
|
||||
|
||||
## When you want more
|
||||
|
||||
- **No video on NVIDIA?** `ffmpeg-libs` is only a *recommended* dependency — without RPM Fusion's
|
||||
build, NVENC fails at runtime. Re-run step 1.
|
||||
[More in Troubleshooting](/docs/troubleshooting#no-video-on-fedora-nvenc-fails-ffmpeg-libs-is-missing).
|
||||
- `punktfunk-host detect-conflicts` tells you if Sunshine or Apollo is also running;
|
||||
[Troubleshooting](/docs/troubleshooting) starts from the symptom.
|
||||
- Your desktop's particulars — [KDE](/docs/kde), [GNOME](/docs/gnome), [gamescope](/docs/gamescope).
|
||||
- Stream with nobody logged in, or a headless KDE appliance —
|
||||
[Running as a service](/docs/running-as-a-service), [KDE → Headless session](/docs/kde#headless-session).
|
||||
- Track `main` instead of releases (`fedora-44-canary` / `bazzite-canary`) —
|
||||
[Release channels](/docs/channels). No group for your release — [Build from source](/docs/build-from-source).
|
||||
@@ -3,23 +3,22 @@ title: Your game library
|
||||
description: How Punktfunk finds your installed games, how to add one by hand, and how to launch a title from a client, from Moonlight, or from the command line.
|
||||
---
|
||||
|
||||
Every Punktfunk host keeps one **game library** — a single list of titles that every surface reads
|
||||
from. It has two sources: the [plugins](/docs/plugins) you install for the launchers you actually
|
||||
use, and entries you add by hand in the [web console](/docs/web-console).
|
||||
A Punktfunk host keeps one **game library** that every surface reads from. It has two sources: the
|
||||
[plugins](/docs/plugins) you install for the launchers you use, and entries you add by hand in the
|
||||
[web console](/docs/web-console).
|
||||
|
||||
Whichever source a title came from, it looks the same everywhere: a poster, a name, and a stable id
|
||||
like `steam:570` or `custom:9f2a1c…`. Pick one on a client and the host launches it into the stream.
|
||||
Whatever its source, a title looks the same everywhere: a poster, a name, and a stable id like
|
||||
`steam:570` or `custom:9f2a1c…`. Pick one on a client and the host launches it into the stream.
|
||||
|
||||
## Where your games come from
|
||||
|
||||
**Install a plugin for each launcher you want in the library.** A fresh host holds no games until
|
||||
you do — go to the console's **Library** page, open **Game sources**, and install the ones you use.
|
||||
It takes a click each.
|
||||
you do — on the console's **Library** page, open **Game sources** and install the ones you use, a
|
||||
click each.
|
||||
|
||||
Each plugin reads that launcher's **own local files** on the host. There are no accounts to connect
|
||||
and no API keys — nothing leaves the machine to build the list. A launcher that isn't installed
|
||||
contributes nothing, so installing a plugin you turn out not to need costs you an empty source and
|
||||
nothing else.
|
||||
Each plugin reads that launcher's **own local files** on the host — no accounts to connect, no API
|
||||
keys, nothing leaves the machine to build the list. A launcher that isn't installed contributes
|
||||
nothing, so an unneeded plugin costs you an empty source and nothing else.
|
||||
|
||||
| Plugin | Linux host | Windows host | What it reads |
|
||||
|---|---|---|---|
|
||||
@@ -31,31 +30,30 @@ nothing else.
|
||||
| **Playnite** | — | ✅ | Your Playnite library, whichever stores it aggregates |
|
||||
| **ROM Manager** | ✅ | ✅ | Your ROM folders, matched against a metadata source |
|
||||
|
||||
> Through v0.27.x six of these scanners were built into the host itself and ran whether you wanted
|
||||
> them or not. From **v0.28.0** they are plugins like any other. If you were already running the
|
||||
> plugin for a launcher, nothing changes — the ids, art and app ids are identical by design. If you
|
||||
> were relying on the built-in scanner, install that launcher's plugin once and your grid comes back
|
||||
> exactly as it was, including anything you had switched off or hidden.
|
||||
> Through v0.27.x six of these scanners were built into the host and always on; from **v0.28.0**
|
||||
> they are plugins like any other. Already running the plugin for a launcher? Nothing changes — ids,
|
||||
> art and app ids are identical by design. Relied on the built-in scanner? Install that launcher's
|
||||
> plugin once and your grid comes back exactly as it was, including anything you had switched off or
|
||||
> hidden.
|
||||
|
||||
A few things are deliberately left out. Steam's tooling — Proton, the Steam Linux Runtimes, Steamworks
|
||||
Common Redistributables, SteamVR — is filtered out, so your grid holds games rather than plumbing. A
|
||||
non-Steam shortcut you have hidden inside Steam stays hidden here too.
|
||||
Deliberately left out: Steam's tooling — Proton, the Steam Linux Runtimes, Steamworks Common
|
||||
Redistributables, SteamVR — so your grid holds games rather than plumbing, and any non-Steam
|
||||
shortcut you have hidden inside Steam.
|
||||
|
||||
To see exactly what the host resolved, run [`punktfunk-host library`](/docs/host-cli) on the host: it
|
||||
prints the whole library as JSON. That answers "does the host see my games?" without involving a
|
||||
client.
|
||||
[`punktfunk-host library`](/docs/host-cli), run on the host, prints the whole library as JSON —
|
||||
"does the host see my games?" without involving a client.
|
||||
|
||||
## Turning a source off
|
||||
|
||||
The console's **Library** page has a **Game sources** card with one chip per source this host has.
|
||||
A chip is highlighted when that source is contributing titles; click it to turn the source off.
|
||||
On the console's **Library** page, the **Game sources** card shows one chip per source this host
|
||||
has; a highlighted chip is contributing titles, and clicking it turns the source off.
|
||||
|
||||
Turning a source off hides its titles from **everywhere at once** — the console grid, every native
|
||||
client, the Moonlight app list, and launching. Nothing is deleted and the change needs no restart:
|
||||
the plugin keeps its titles, they simply stop being shown, and turning the source back on brings
|
||||
them straight back on the next read. (To remove a source's titles for good, uninstall its plugin.)
|
||||
That hides its titles **everywhere at once** — the console grid, every native client, the Moonlight
|
||||
app list, and launching — with nothing deleted and no restart: the plugin keeps its titles, and
|
||||
turning the source back on brings them straight back on the next read. (To remove a source's titles
|
||||
for good, uninstall its plugin.)
|
||||
|
||||
Your hand-added entries are not a source and have no chip — they are always shown.
|
||||
Hand-added entries are not a source and have no chip — they are always shown.
|
||||
|
||||
The choice is stored per host in `library-scanners.json`, next to the rest of the host config
|
||||
(`~/.config/punktfunk/` on Linux, `%ProgramData%\punktfunk\` on Windows). Only the sources you turned
|
||||
@@ -64,10 +62,10 @@ The choice is stored per host in `library-scanners.json`, next to the rest of th
|
||||
## Adding a game by hand
|
||||
|
||||
Anything your launchers don't know about — an emulator, a ROM, a DRM-free build, a tool you want on
|
||||
the couch — goes in by hand. On the console's **Library** page, click **Add custom game**.
|
||||
the couch — goes in by hand: on the console's **Library** page, click **Add custom game**.
|
||||
|
||||
**Title** is the only required field. **Launch command** is the command the host runs for this title;
|
||||
leave it empty and the entry is a poster the host has nothing to launch from.
|
||||
**Title** is the only required field. **Launch command** is what the host runs for this title; leave
|
||||
it empty and the entry is a poster with nothing to launch.
|
||||
|
||||
Under **Details (optional)** a title can carry:
|
||||
|
||||
@@ -90,8 +88,8 @@ runs, so it is locked down to the host user (0600 on Linux, a SYSTEM+Administrat
|
||||
treat what you type there as operator-level configuration.
|
||||
|
||||
> **Editing replaces the whole entry.** The console form re-sends every field it knows about, so
|
||||
> nothing you can see is lost. Fields the form has no input for — prep/undo steps in particular — are
|
||||
> **cleared** when you save an entry through the form.
|
||||
> nothing you can see is lost — but fields the form has no input for (prep/undo steps in particular)
|
||||
> are **cleared** when you save through the form.
|
||||
|
||||
### Cover art
|
||||
|
||||
@@ -105,9 +103,9 @@ host can see is fine. A plain Linux path like `/home/me/cover.jpg` is **not** re
|
||||
|
||||
Scanned titles need no art. Steam covers come from your local Steam cache, falling back to Steam's
|
||||
public CDN. On a Windows host, GOG and Xbox covers are the one thing the library looks up over the
|
||||
network: a background pass asks GOG's and Microsoft's public catalogs for them when the host starts,
|
||||
and repeats every five minutes for any title it hasn't resolved yet. Neither needs an account or a
|
||||
key, the answer is cached on the host, and a lookup that fails just leaves a title-only tile.
|
||||
network: a background pass asks GOG's and Microsoft's public catalogs when the host starts and
|
||||
repeats every five minutes for any title still unresolved. Neither needs an account or a key, the
|
||||
answer is cached on the host, and a failed lookup just leaves a title-only tile.
|
||||
|
||||
## Games from a plugin
|
||||
|
||||
@@ -116,39 +114,40 @@ Manager and Playnite plugins get your collection into the grid, box art and all.
|
||||
|
||||
A library plugin can also publish a **launcher tile** — an entry that opens Steam Big Picture,
|
||||
Heroic, Lutris or Playnite itself rather than a game, so you can install or fix something from the
|
||||
couch. Clients group those into their own row above your titles, and each one draws its launcher's
|
||||
logo. A launcher tile you don't want is a switch in that plugin's settings.
|
||||
couch. Clients group those into their own row above your titles, each drawing its launcher's logo.
|
||||
A launcher tile you don't want is a switch in that plugin's settings.
|
||||
|
||||
Entries a plugin owns are read-only to you. The host refuses a hand edit or a delete of one, because
|
||||
Entries a plugin owns are read-only to you. The host refuses a hand edit or delete of one, because
|
||||
the next sync would overwrite it anyway — change the title at its source and let the plugin sync
|
||||
again. Only the plugin can remove its own entries, and it removes every one of them at once. Your
|
||||
hand-added entries are never touched by a sync.
|
||||
|
||||
The console grid can't tell you which entries those are: a plugin's titles carry the same **Custom**
|
||||
badge as your own and still show **Edit** and **Delete** on hover. The form and the delete
|
||||
confirmation open as usual, but the host refuses the change and the entry stays exactly as it was.
|
||||
badge as your own and still show **Edit** and **Delete** on hover — the form and the delete
|
||||
confirmation open as usual, but the host refuses the change and the entry stays as it was.
|
||||
|
||||
## Launching a game
|
||||
|
||||
Whatever the surface, the client sends only an **id**. The host looks that id up in its own library
|
||||
and runs what it already knows about the title, so a client can never hand the host a command to run.
|
||||
Whatever the surface, the client sends only an **id**. The host looks it up in its own library and
|
||||
runs what it already knows about the title, so a client can never hand the host a command to run.
|
||||
|
||||
- **Native clients** — the browser needs a **paired** host, and that is the only condition: a paired
|
||||
host's card offers **Browse library…** (**Browse Library…** on Apple) with nothing to switch on
|
||||
first. Pick a title and the stream starts with the host launching it. The Apple and Android apps
|
||||
- **Native clients** — a **paired** host's card offers **Browse library…** (**Browse Library…** on
|
||||
Apple) with nothing to switch on first; pairing is the only condition. Pick a title and the stream
|
||||
starts with the host launching it. The Apple and Android apps
|
||||
keep a **Show game library** switch, on by default, for turning it off. See
|
||||
[Client settings](/docs/client-settings).
|
||||
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
|
||||
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
|
||||
options and choose **Library**.
|
||||
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
|
||||
Punktfunk**, which opens the client's console home, and a paired host's **Library** button is
|
||||
right there — full-screen covers, gamepad-navigable, and a press starts the stream with the title
|
||||
launching. See [Steam Deck](/docs/steam-deck).
|
||||
- **Moonlight** — when the host runs with `--gamestream`, your library appears in Moonlight's app
|
||||
list beside `Desktop`, with covers served by the host. A title keeps the same app id across host
|
||||
restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are left out.
|
||||
See [Moonlight](/docs/moonlight).
|
||||
Punktfunk**, which opens the client's console home, where a paired host's **Library** button is —
|
||||
full-screen covers, gamepad-navigable, and a press starts the stream with the title launching. See
|
||||
[Steam Deck](/docs/steam-deck).
|
||||
- **Moonlight** — when the host runs GameStream compat (opt-in: `PUNKTFUNK_GAMESTREAM=1` in
|
||||
`host.env`, or `serve --gamestream` — see [Moonlight](/docs/moonlight)), your library appears in
|
||||
Moonlight's app list beside `Desktop`, with covers served by the host. A title keeps the same app id
|
||||
across host restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are
|
||||
left out.
|
||||
- **A link** — a [`punktfunk://` link](/docs/profiles-and-links) carries the id in a `launch=`
|
||||
parameter, so a desktop shortcut, a browser bookmark or a home-automation rule starts the stream
|
||||
with the title already launching: `punktfunk://connect/couch-pc?launch=steam:570`. On the Apple
|
||||
|
||||
@@ -4,23 +4,23 @@ description: How an HDR10 stream is decided end to end — the four things that
|
||||
---
|
||||
|
||||
An HDR session carries a **10-bit BT.2020 PQ (HDR10)** picture from the host's display to your
|
||||
screen. It is on by default wherever it works, and a session that can't be HDR streams 8-bit BT.709
|
||||
SDR instead. Which one you get is decided **before the first frame**: the host resolves every gate
|
||||
below, then tells the client what it is really going to send. Nothing on this page takes effect
|
||||
mid-stream, so reconnect after changing any of it.
|
||||
screen. It is on by default wherever it works; otherwise the session streams 8-bit BT.709 SDR. The
|
||||
host decides **before the first frame** — it resolves every gate below and tells the client what it
|
||||
will really send — so nothing on this page takes effect mid-stream; reconnect after changing any of
|
||||
it.
|
||||
|
||||
## The chain
|
||||
|
||||
Four things must all be true. If your stream is SDR when you expected HDR, one of these is why.
|
||||
Four things must all be true. If you got SDR when you expected HDR, one of these is why.
|
||||
|
||||
1. **The source.** What the host captures must hand it 10-bit PQ pixels. This is the link that fails
|
||||
most often, and it is entirely a host-side question — see [Per host](#per-host).
|
||||
2. **The encoder.** The host GPU must encode 10-bit for the codec the session picked. The host
|
||||
probes this by opening a tiny real encoder once per GPU and codec, and believes the answer.
|
||||
(PyroWave skips the probe — it has its own rule, below.)
|
||||
1. **The source.** What the host captures must hand it 10-bit PQ pixels. This link fails most often
|
||||
and is entirely host-side — see [Per host](#per-host).
|
||||
2. **The encoder.** The host GPU must encode 10-bit for the session's codec. The host probes by
|
||||
opening a tiny real encoder once per GPU and codec, and believes the answer. (PyroWave skips the
|
||||
probe — its own rule is below.)
|
||||
3. **The codec.** Only HEVC, AV1 and PyroWave have a 10-bit path — see [Codec rules](#codec-rules).
|
||||
4. **The client.** Your client must advertise 10-bit and HDR (its **HDR** setting), and be able to
|
||||
present or tone-map PQ.
|
||||
4. **The client.** It must advertise 10-bit and HDR (its **HDR** setting) and be able to present or
|
||||
tone-map PQ.
|
||||
|
||||
The host allows 10-bit by default (`PUNKTFUNK_10BIT`). It only ever *allows* — the client's setting
|
||||
is the real per-session switch.
|
||||
@@ -29,78 +29,75 @@ is the real per-session switch.
|
||||
|
||||
### Windows
|
||||
|
||||
The Windows host creates a virtual display for your session and **turns HDR on for that display
|
||||
itself** when the session negotiated 10-bit. You do not have to enable "Use HDR" in Windows Settings
|
||||
first: Punktfunk enables advanced colour at capture open, waits for it to settle, then composes in
|
||||
FP16 and encodes P010 BT.2020 PQ. The reverse is enforced too — a session that negotiated **SDR**
|
||||
forces advanced colour **off** on that display, so a client that asked for 8-bit is never handed PQ.
|
||||
If enabling it fails, the host logs a loud error and encodes 8-bit anyway: the client was already
|
||||
told HDR, so that is the one place a Punktfunk label can outrun the picture. The log says so.
|
||||
|
||||
Two details worth knowing:
|
||||
The Windows host **turns HDR on for the session's virtual display itself** when 10-bit was
|
||||
negotiated — you don't have to enable "Use HDR" in Windows Settings. It enables advanced colour at
|
||||
capture open, waits for it to settle, then composes in FP16 and encodes P010 BT.2020 PQ. The
|
||||
reverse holds too: an **SDR** session forces advanced colour **off** on that display, so a client
|
||||
that asked for 8-bit is never handed PQ. If enabling it fails, the host logs a loud error and encodes
|
||||
8-bit anyway — the client was already told HDR, so that is the one place a Punktfunk label can
|
||||
outrun the picture.
|
||||
|
||||
- **HDR and 4:4:4 compose on Windows, not on Linux.** A **Windows** host carries both: the capture
|
||||
path writes full-resolution 10-bit chroma and NVENC encodes HEVC Main 4:4:4 10, so
|
||||
[full chroma](/docs/client-settings) costs you nothing on an HDR desktop.
|
||||
[PyroWave](/docs/pyrowave) does the same there, in 16-bit planes. On **Linux** the 4:4:4 route is
|
||||
8-bit, so a session that negotiates both resolves back down to SDR — full chroma wins. AV1 never
|
||||
carries 4:4:4 anywhere: Range Extensions are HEVC-only.
|
||||
[full chroma](/docs/client-settings) costs nothing on an HDR desktop; [PyroWave](/docs/pyrowave)
|
||||
does the same there, in 16-bit planes. On **Linux** the 4:4:4 route is 8-bit, so a session that
|
||||
negotiates both resolves back down to SDR — full chroma wins. AV1 never carries 4:4:4 anywhere:
|
||||
Range Extensions are HEVC-only.
|
||||
- **Vulkan games need the bundled layer.** NVIDIA and AMD Vulkan drivers refuse to advertise any HDR
|
||||
colour space for a surface on an indirect (virtual) display, so Vulkan games decide the device
|
||||
"does not support HDR" — even though the driver happily presents an HDR swapchain there. The host
|
||||
"does not support HDR" — though the driver happily presents an HDR swapchain there. The host
|
||||
installer ships an implicit Vulkan layer, `VK_LAYER_PUNKTFUNK_hdr_inject`, that adds those formats
|
||||
back (installer task **Install the HDR Vulkan layer**, ticked by default). It self-gates on the
|
||||
monitor's live advanced-colour state, so it does nothing on an SDR session, and it already skips a
|
||||
built-in list of kernel-anti-cheat titles. `DISABLE_PF_VKHDR=1` in a game's environment switches it
|
||||
off for that process; `PF_VKHDR_EXCLUDE=foo.exe,bar.exe` skips further executables by name.
|
||||
D3D11/D3D12 games need none of this.
|
||||
monitor's live advanced-colour state, so it does nothing on an SDR session, and it skips a built-in
|
||||
list of kernel-anti-cheat titles. `DISABLE_PF_VKHDR=1` in a game's environment switches it off for
|
||||
that process; `PF_VKHDR_EXCLUDE=foo.exe,bar.exe` skips further executables by name. D3D11/D3D12
|
||||
games need none of this.
|
||||
|
||||
### Linux + gamescope
|
||||
|
||||
A stock gamescope tone-maps its composite down to 8 bits before handing it over, so its capture
|
||||
output is SDR no matter what the game rendered. Real HDR needs **`punktfunk-gamescope`**, a build
|
||||
carrying a patch that adds the 10-bit PQ formats to its PipeWire node. It installs beside your system
|
||||
gamescope rather than replacing it; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the
|
||||
package for each distro.
|
||||
Stock gamescope tone-maps its composite down to 8 bits before handing it over, so its capture output
|
||||
is SDR whatever the game rendered. Real HDR needs **`punktfunk-gamescope`**, a build carrying a patch
|
||||
that adds the 10-bit PQ formats to its PipeWire node. It installs beside your system gamescope rather
|
||||
than replacing it; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the package for each
|
||||
distro.
|
||||
|
||||
The host settles two facts before spawning anything: the gamescope binary it will run carries the
|
||||
Before spawning anything the host settles two facts: the gamescope binary it will run carries the
|
||||
patch (its `--version` banner contains `+pfhdr`), and this host is the one **starting** the session
|
||||
rather than attaching to a node someone else started.
|
||||
|
||||
**Attach mode is the trap.** The patched build only reaches sessions the host spawns itself —
|
||||
managed, `PUNKTFUNK_GAMESCOPE_SESSION`, or a bare spawn. A session started by your display manager
|
||||
runs the distro's own gamescope, which offers neither the 10-bit formats nor the in-node cursor. The
|
||||
host cannot tell that from the outside unless you pinned `PUNKTFUNK_GAMESCOPE_NODE`: with
|
||||
runs the distro's own gamescope, which offers neither the 10-bit formats nor the in-node cursor, and
|
||||
the host cannot tell that from the outside unless you pinned `PUNKTFUNK_GAMESCOPE_NODE`. With
|
||||
`PUNKTFUNK_GAMESCOPE_ATTACH=1` and the patched build installed it reads the binary, believes HDR is
|
||||
available, and offers it. The attached session can't answer that negotiation, so the connect fails
|
||||
available, and offers it; the attached session can't answer that negotiation, so the connect fails
|
||||
with no picture, the host latches an SDR downgrade for the rest of its life, and the next connect
|
||||
streams — in SDR.
|
||||
|
||||
That combination bites on [Bazzite](/docs/bazzite), where the sysext installs `punktfunk-gamescope`
|
||||
alongside a stock session gamescope. No template pins attach any more, so the managed default gets
|
||||
you HDR and the compositor-drawn cursor — but an older template did, and an upgrade never rewrites a
|
||||
`host.env` you already have, so check yours for `PUNKTFUNK_GAMESCOPE_ATTACH=1` and delete the line.
|
||||
If you deliberately stay on attach, set `PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never
|
||||
happens. Staying on attach also leaves the stream with no cursor;
|
||||
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
|
||||
That bites on [Bazzite](/docs/bazzite), where the sysext installs `punktfunk-gamescope` alongside a
|
||||
stock session gamescope. No template pins attach any more, so the managed default gets you HDR and
|
||||
the compositor-drawn cursor — but an older template did, and an upgrade never rewrites a `host.env`
|
||||
you already have: check yours for `PUNKTFUNK_GAMESCOPE_ATTACH=1` and delete the line. If you
|
||||
deliberately stay on attach, set `PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never happens.
|
||||
Attach also leaves the stream with no cursor; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope)
|
||||
has the fix for that half.
|
||||
|
||||
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
|
||||
`PUNKTFUNK_GAMESCOPE_SDR_NITS`, which defaults to **203 nits**. That is BT.2408 reference white, and
|
||||
it is the level our clients decode against, so the two ends agree out of the box. gamescope's own
|
||||
default is 400, nearly a stop brighter; hosts that let it float showed a glaring, over-saturated
|
||||
Steam UI and washed-out HDR game content on the same stream. Move the knob if you want a brighter or
|
||||
dimmer desktop, but be aware that moving it re-opens that gap.
|
||||
SDR content — the desktop, the Steam overlay, an SDR game — rides the same PQ container, mapped in
|
||||
at `PUNKTFUNK_GAMESCOPE_SDR_NITS`, default **203 nits**. That is BT.2408 reference white and the
|
||||
level our clients decode against, so the two ends agree out of the box. gamescope's own default is
|
||||
400, nearly a stop brighter; hosts that let it float showed a glaring, over-saturated Steam UI and
|
||||
washed-out HDR game content on the same stream. Moving the knob re-opens that gap.
|
||||
|
||||
### Linux + GNOME
|
||||
|
||||
A Punktfunk host serves [two protocols](/docs/how-it-works#two-protocols): its own `punktfunk/1`,
|
||||
which the Linux, Windows, Apple and Android apps speak, and GameStream, which
|
||||
[Moonlight](/docs/moonlight) speaks. GNOME HDR is available on the GameStream side only.
|
||||
A host serves [two protocols](/docs/how-it-works#two-protocols): its own `punktfunk/1` (the Linux,
|
||||
Windows, Apple and Android apps) and GameStream ([Moonlight](/docs/moonlight)). GNOME HDR is
|
||||
available on the GameStream side only.
|
||||
|
||||
GNOME 50 added HDR screencast for **real monitors** only, so this route mirrors a monitor instead of
|
||||
creating a virtual display: set `PUNKTFUNK_VIDEO_SOURCE=portal`, put the monitor in HDR mode in
|
||||
**Settings → Displays**, and connect an HDR-capable client. `PUNKTFUNK_CAPTURE_MONITOR=<connector>`
|
||||
pins which head, and when it is set the host checks *that* monitor's colour mode rather than asking
|
||||
pins which head; when it is set the host checks *that* monitor's colour mode rather than asking
|
||||
whether any monitor is in HDR. If none is, the session degrades to 8-bit SDR and says so in the log.
|
||||
|
||||
A Punktfunk app connecting to a GNOME host over `punktfunk/1` gets SDR. On that protocol the only
|
||||
@@ -109,10 +106,10 @@ Linux HDR source is the gamescope virtual output.
|
||||
### Linux virtual displays on KWin, Mutter and wlroots
|
||||
|
||||
**These are SDR.** Mutter's `RecordVirtual` streams and the KWin and wlroots virtual outputs are
|
||||
8-bit upstream, so there is nothing for the host to capture in 10 bits — no setting changes this.
|
||||
Streaming a *physical* monitor with the [Streamed screen](/docs/virtual-displays) setting is SDR to
|
||||
a Punktfunk app too, HDR panel or not; the GNOME/GameStream route above is the only Linux monitor
|
||||
mirror that can be HDR.
|
||||
8-bit upstream, so there is nothing to capture in 10 bits — no setting changes this. Streaming a
|
||||
*physical* monitor with the [Streamed screen](/docs/virtual-displays) setting is SDR to a Punktfunk
|
||||
app too, HDR panel or not; the GNOME/GameStream route above is the only Linux monitor mirror that can
|
||||
be HDR.
|
||||
|
||||
## Per client
|
||||
|
||||
@@ -125,12 +122,12 @@ mirror that can be HDR.
|
||||
| **Moonlight** | Its own HDR toggle, which appears only when the host advertises a 10-bit codec | — |
|
||||
|
||||
The Linux and Windows clients are deliberately looser: they advertise HDR whenever the setting is on
|
||||
and let the presenter sort out the display side — HDR10 swapchain where the compositor offers one,
|
||||
and let the presenter sort out the display — HDR10 swapchain where the compositor offers one,
|
||||
tone-mapped to SDR where it doesn't. The stats overlay says which happened: `HDR` versus `HDR→SDR`.
|
||||
|
||||
One exception: frames from **software decode** never take the HDR10 swapchain, whatever the surface
|
||||
offers. On a client with no hardware HEVC decode an HDR stream is therefore presented on the SDR
|
||||
swapchain without a tone-map, which looks washed out. Turn the client's HDR setting off there. The
|
||||
offers, so a client with no hardware HEVC decode presents an HDR stream on the SDR swapchain without
|
||||
a tone-map — washed out. Turn the client's HDR setting off there. The
|
||||
[Steam Deck plugin](/docs/steam-deck) streams through this same client.
|
||||
|
||||
## Codec rules
|
||||
@@ -139,15 +136,15 @@ swapchain without a tone-map, which looks washed out. Turn the client's HDR sett
|
||||
- **AV1** — 10-bit, where the GPU encodes it. Advertised separately from HEVC, so a box that does
|
||||
one and not the other tells the truth about each.
|
||||
- **H.264** — never. High10 is not an encode mode on the hardware Punktfunk targets, so negotiation
|
||||
never even asks. Pinning H.264 in your client settings pins the session to SDR.
|
||||
never asks. Pinning H.264 in your client settings pins the session to SDR.
|
||||
- **[PyroWave](/docs/pyrowave)** — carries HDR in 16-bit planes, but **only from a Windows host**.
|
||||
The Linux PyroWave capture path has no HDR colour conversion, so a Linux-hosted PyroWave session
|
||||
is SDR. Use HEVC or AV1 for HDR from Linux.
|
||||
|
||||
One more rule if you also use full chroma: a **Linux** host encodes 4:4:4 at 8 bits, so a session
|
||||
that negotiates both resolves back down to SDR before the stream starts — on Linux, 4:4:4 wins. A
|
||||
**Windows** host has no such trade: it carries HDR and full chroma at once. Full chroma is off until
|
||||
you turn it on, so this only bites if you did.
|
||||
With full chroma: a **Linux** host encodes 4:4:4 at 8 bits, so a session that negotiates both
|
||||
resolves back down to SDR before the stream starts — on Linux, 4:4:4 wins. A **Windows** host
|
||||
carries HDR and full chroma at once. Full chroma is off until you turn it on, so this only bites if
|
||||
you did.
|
||||
|
||||
## Check it
|
||||
|
||||
@@ -170,8 +167,8 @@ set -a; . ~/.config/punktfunk/host.env; set +a
|
||||
punktfunk-host hdr-probe
|
||||
```
|
||||
|
||||
There is no `hdr-probe` on Windows. What Windows has instead is a GPU colour self-test for the
|
||||
capture conversion, which needs no display or session:
|
||||
There is no `hdr-probe` on Windows. Windows has instead a GPU colour self-test for the capture
|
||||
conversion, which needs no display or session:
|
||||
|
||||
```powershell
|
||||
punktfunk-host hdr-p010-selftest 1920x1080 nvidia
|
||||
@@ -193,13 +190,12 @@ Host, in [`host.env`](/docs/configuration):
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_10BIT` | **on** | Allow 10-bit (HEVC Main10 / AV1 10-bit) at all. `0`, `false`, `off` or `no` forces every session to 8-bit SDR. |
|
||||
| `PUNKTFUNK_GAMESCOPE_HDR` | **on** | Allow HDR on the gamescope backend. It only decides whether HDR is *attempted* — a host without `punktfunk-gamescope` stays SDR either way. `0` is the escape hatch that puts the gamescope backend back on the old SDR path, spawn flags included. |
|
||||
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | gamescope's own (400) | How bright SDR content is inside the PQ container of an HDR gamescope session. |
|
||||
| `PUNKTFUNK_VIDEO_SOURCE=portal` | unset | Required for the GNOME 50+ monitor-mirror route. GameStream/Moonlight only — it has no effect on `punktfunk/1` sessions. |
|
||||
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | **203** | How bright SDR content is inside the PQ container of an HDR gamescope session. |
|
||||
| `PUNKTFUNK_VIDEO_SOURCE=portal` | unset | Required for the GNOME 50+ monitor-mirror route. GameStream/Moonlight only — no effect on `punktfunk/1` sessions. |
|
||||
|
||||
Client: one toggle, in Settings under **Quality** with
|
||||
[the rest of the video settings](/docs/client-settings#video) — **10-bit HDR** on the Linux, macOS,
|
||||
iOS, iPadOS and tvOS apps, **HDR (10-bit, BT.2020 PQ)** on Windows, **HDR** on Android. It is **on
|
||||
by default** on all of them. Turning it off means "never send me 10-bit", and the host then never
|
||||
upgrades the session. Like the other video settings it can be set per
|
||||
[profile](/docs/profiles-and-links), so a Work profile can prefer 4:4:4 while a Couch profile
|
||||
prefers HDR.
|
||||
by default** on all of them. Off means "never send me 10-bit", and the host then never upgrades the
|
||||
session. Like the other video settings it can be set per [profile](/docs/profiles-and-links), so a
|
||||
Work profile can prefer 4:4:4 while a Couch profile prefers HDR.
|
||||
|
||||
@@ -150,9 +150,8 @@ systemctl --user enable --now punktfunk-host
|
||||
journalctl --user -u punktfunk-host -f
|
||||
```
|
||||
|
||||
This unit runs `serve --gamestream`, so it serves stock [Moonlight](/docs/moonlight) clients as well
|
||||
as the native ones. For a native-only host, see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
|
||||
This unit runs the secure native-only host; to serve stock [Moonlight](/docs/moonlight) clients as
|
||||
well, see [What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
|
||||
|
||||
## Bring up the console and pair
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user