Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03acc9153 | ||
|
|
6b5307618f | ||
|
|
cdacd5636e | ||
|
|
47f01149bf | ||
|
|
59ef285f08 | ||
|
|
5bec450402 | ||
|
|
cd6ce34892 | ||
|
|
20568d988f | ||
|
|
2a60f94f74 | ||
|
|
60406c9d72 | ||
|
|
65f697651e | ||
|
|
a00c4d2a6a | ||
|
|
e12ef6633c | ||
|
|
38a0f54b09 | ||
|
|
c420ae4676 | ||
|
|
bfd0de8973 | ||
|
|
1dd5df0127 | ||
|
|
08c45b96eb | ||
|
|
a52d60e242 | ||
|
|
c2f5e91b3d | ||
|
|
44cb7f7815 | ||
|
|
52b89a1592 | ||
|
|
57446f9ed9 | ||
|
|
a4f6e259e3 | ||
|
|
6d82716598 | ||
|
|
f584eebb92 | ||
|
|
cd0a370229 | ||
|
|
3301f5aa60 | ||
|
|
1f0b12de6d | ||
|
|
3def50a88a | ||
|
|
8f9e451395 | ||
|
|
0d22333831 | ||
|
|
47602f7e59 | ||
|
|
0fd44d8242 | ||
|
|
6807d7951c | ||
|
|
f50721aedb | ||
|
|
5d3301ed9b |
@@ -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)
|
||||
|
||||
@@ -56,8 +56,12 @@
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
|
||||
# from one assembled layout:
|
||||
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
|
||||
# (stable path Steam can launch: overlay + Big Picture work)
|
||||
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
|
||||
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -283,6 +287,28 @@ jobs:
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
|
||||
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
|
||||
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
|
||||
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
|
||||
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
|
||||
- name: Pack + sign installer + portable zip
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-client-installer.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
@@ -301,7 +327,10 @@ jobs:
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
# The installer + portable zip (the default download; docs point at these alias URLs).
|
||||
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
|
||||
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
@@ -324,10 +353,11 @@ jobs:
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
|
||||
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
|
||||
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
|
||||
# don't collide.
|
||||
- name: Attach client artifacts to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -335,6 +365,6 @@ jobs:
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
+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": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -287,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) {
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
|
||||
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
|
||||
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
|
||||
|
||||
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
|
||||
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
|
||||
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
|
||||
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
|
||||
compatibility).
|
||||
|
||||
## Get it
|
||||
|
||||
Install the signed MSIX from the package registry — see
|
||||
Install the signed installer from the package registry — see
|
||||
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
|
||||
|
||||
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
|
||||
```
|
||||
|
||||
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
|
||||
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
|
||||
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -79,7 +82,7 @@ src/
|
||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
||||
probe.rs · wol.rs speed probe · Wake-on-LAN
|
||||
logfile.rs log tee to %LOCALAPPDATA%
|
||||
packaging/ MSIX manifest, signing, pack script
|
||||
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
# punktfunk Windows client — MSIX packaging
|
||||
# punktfunk Windows client — packaging
|
||||
|
||||
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
|
||||
The Windows client ships **three ways, packed from one assembled layout** by CI
|
||||
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels):
|
||||
|
||||
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
|
||||
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
|
||||
install shape breaks the top user-reported flows: the exe lands under the ACL'd
|
||||
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
|
||||
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
|
||||
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
|
||||
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
|
||||
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
|
||||
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
|
||||
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
|
||||
nothing registered.
|
||||
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
|
||||
compatibility. Everything below the fold documents this path.
|
||||
|
||||
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
|
||||
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
|
||||
signs its container).
|
||||
|
||||
# MSIX packaging
|
||||
|
||||
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
|
||||
portable .zip, from the layout pack-msix.ps1 already assembled.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim — one assembly,
|
||||
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
|
||||
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
|
||||
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
|
||||
|
||||
Steps:
|
||||
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
|
||||
2. sign the four exes individually (the MSIX only signs its container),
|
||||
3. zip the stage -> the portable build,
|
||||
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
|
||||
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
|
||||
|
||||
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
|
||||
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
|
||||
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust — an untrusted
|
||||
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
|
||||
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
|
||||
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
|
||||
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
|
||||
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
|
||||
[switch]$NoSign # skip signing (local debug)
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
|
||||
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
|
||||
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
|
||||
}
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$iss = Join-Path $here 'punktfunk-client.iss'
|
||||
|
||||
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
|
||||
function Find-Iscc {
|
||||
foreach ($p in @(
|
||||
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
|
||||
'C:\Program Files\Inno Setup 6\ISCC.exe')) {
|
||||
if (Test-Path $p) { return $p }
|
||||
}
|
||||
$c = Get-Command iscc -ErrorAction SilentlyContinue
|
||||
if ($c) { return $c.Source }
|
||||
throw "ISCC.exe (Inno Setup 6, any 6.x) not found - install it (choco install innosetup -y)."
|
||||
}
|
||||
function Find-SdkTool([string]$name) {
|
||||
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
|
||||
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
|
||||
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
|
||||
Select-Object -Last 1
|
||||
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$iscc = Find-Iscc
|
||||
Write-Host "ISCC: $iscc"
|
||||
|
||||
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
|
||||
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
|
||||
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
|
||||
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
$stage = Join-Path $OutDir 'portable'
|
||||
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
foreach ($f in $required) {
|
||||
$src = Join-Path $LayoutDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $src (Join-Path $stage $f) -Force
|
||||
}
|
||||
$licSrc = Join-Path $LayoutDir 'licenses'
|
||||
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
|
||||
|
||||
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
|
||||
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
if ($NoSign -and $requireCert) {
|
||||
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
|
||||
}
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'none'
|
||||
$signtool = $null
|
||||
if (-not $NoSign) {
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "signtool: $signtool"
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to " +
|
||||
"an interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
}
|
||||
elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
}
|
||||
else {
|
||||
$signMode = 'selfsigned'
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
|
||||
-KeyUsage DigitalSignature -FriendlyName 'punktfunk client installer (self-signed)' `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
|
||||
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
|
||||
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
|
||||
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
|
||||
function Sign-File([string]$Path) {
|
||||
if ($NoSign) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
|
||||
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
|
||||
"untrusted within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- sign the inner exes, zip the stage (portable build), then build + sign the installer ------
|
||||
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
|
||||
Sign-File (Join-Path $stage $f)
|
||||
}
|
||||
|
||||
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
|
||||
Write-Host "==> portable zip: $zip"
|
||||
|
||||
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
|
||||
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
|
||||
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
|
||||
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
|
||||
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
|
||||
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
|
||||
$brandStage = Join-Path $OutDir 'branding'
|
||||
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
|
||||
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
|
||||
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
|
||||
|
||||
$defines = @(
|
||||
"/DMyAppVersion=$Version",
|
||||
"/DArch=$Arch",
|
||||
"/DLayoutDir=$stage",
|
||||
"/DBrandingDir=$brandStage",
|
||||
"/DOutputDir=$OutDir"
|
||||
)
|
||||
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
|
||||
& $iscc @defines $issLocal
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
|
||||
|
||||
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
|
||||
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
|
||||
Sign-File $setup
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> installer: $setup"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA."
|
||||
}
|
||||
elseif ($signMode -ne 'none') {
|
||||
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
|
||||
}
|
||||
if ($env:GITHUB_ENV) {
|
||||
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
|
||||
;
|
||||
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
|
||||
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
|
||||
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
|
||||
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
|
||||
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
|
||||
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
|
||||
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
|
||||
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
|
||||
;
|
||||
; Built by pack-client-installer.ps1, e.g.:
|
||||
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
|
||||
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
|
||||
;
|
||||
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
|
||||
; elevation and uninstall leaves nothing behind):
|
||||
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
|
||||
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
|
||||
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
|
||||
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
|
||||
; Microsoft.WindowsAppRuntime.2 PackageDependency
|
||||
; -> download + run the runtime installer when missing ([Code])
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Arch
|
||||
#define Arch "x64"
|
||||
#endif
|
||||
#ifndef LayoutDir
|
||||
#define LayoutDir "."
|
||||
#endif
|
||||
#ifndef BrandingDir
|
||||
#define BrandingDir "..\..\..\packaging\windows\branding"
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
|
||||
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
|
||||
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
|
||||
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
|
||||
AppName=Punktfunk
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher=unom
|
||||
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
||||
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
|
||||
; see the header (Steam overlay / Big Picture).
|
||||
DefaultDirName={userpf}\Punktfunk
|
||||
PrivilegesRequired=lowest
|
||||
DisableProgramGroupPage=yes
|
||||
UsePreviousAppDir=yes
|
||||
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
|
||||
MinVersion=10.0.17763
|
||||
#if Arch == "arm64"
|
||||
ArchitecturesAllowed=arm64
|
||||
ArchitecturesInstallIn64BitMode=arm64
|
||||
#else
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
#endif
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
|
||||
#if VER >= EncodeVer(6,6,0)
|
||||
WizardStyle=modern dynamic windows11
|
||||
#else
|
||||
WizardStyle=modern
|
||||
#endif
|
||||
SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||
UninstallDisplayName=Punktfunk {#MyAppVersion}
|
||||
UninstallDisplayIcon={app}\punktfunk-client.exe
|
||||
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
|
||||
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
|
||||
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
|
||||
ChangesEnvironment=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
|
||||
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
|
||||
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
|
||||
; skipped by "the MSIX build already signed them".
|
||||
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
|
||||
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
|
||||
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
|
||||
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
|
||||
Comment: "Controller-driven couch interface for TVs and HTPCs"
|
||||
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
|
||||
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
|
||||
; command line, so this lands in the same positional URL parse in main() that the packaged
|
||||
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
|
||||
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
|
||||
ValueData: "{app}\punktfunk-client.exe,0"
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
|
||||
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
|
||||
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
|
||||
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
|
||||
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
|
||||
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
|
||||
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
|
||||
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
|
||||
|
||||
[Code]
|
||||
const
|
||||
EnvKey = 'Environment'; { the HKCU per-user environment key }
|
||||
|
||||
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
|
||||
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
|
||||
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
|
||||
retargeted from the HKLM machine key to HKCU.) }
|
||||
function PathNeedsAdd(Param: String): Boolean;
|
||||
var
|
||||
OrigPath: String;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
begin
|
||||
Result := True; { no Path value at all - the append creates it }
|
||||
exit;
|
||||
end;
|
||||
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
|
||||
end;
|
||||
|
||||
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
|
||||
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
|
||||
procedure RemoveAppFromPath;
|
||||
var
|
||||
OrigPath, NewPath, Entry: String;
|
||||
Target: String;
|
||||
P: Integer;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
exit;
|
||||
Target := Uppercase(ExpandConstant('{app}'));
|
||||
NewPath := '';
|
||||
OrigPath := OrigPath + ';';
|
||||
repeat
|
||||
P := Pos(';', OrigPath);
|
||||
Entry := Trim(Copy(OrigPath, 1, P - 1));
|
||||
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
|
||||
if (Entry <> '') and (Uppercase(Entry) <> Target) then
|
||||
begin
|
||||
if NewPath <> '' then NewPath := NewPath + ';';
|
||||
NewPath := NewPath + Entry;
|
||||
end;
|
||||
until OrigPath = '';
|
||||
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
RemoveAppFromPath;
|
||||
end;
|
||||
|
||||
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
|
||||
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
|
||||
runtime installer and run it quietly — it registers Store-signed framework packages, which
|
||||
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
|
||||
carry, because the app itself reports the missing runtime on first launch too. }
|
||||
function AppRuntimeMissing(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
|
||||
download below is idempotent and the runtime installer no-ops when it is present. }
|
||||
if not Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
Result := ResultCode <> 0;
|
||||
end;
|
||||
|
||||
procedure EnsureAppRuntime;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
Installer: String;
|
||||
begin
|
||||
if not AppRuntimeMissing() then
|
||||
exit;
|
||||
Installer := 'windowsappruntimeinstall.exe';
|
||||
try
|
||||
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
|
||||
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
|
||||
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
|
||||
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
|
||||
except
|
||||
SuppressibleMsgBox(
|
||||
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
|
||||
'Punktfunk needs it to start. Install it from ' + #13#10 +
|
||||
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
|
||||
'and then launch Punktfunk normally.',
|
||||
mbInformation, MB_OK, IDOK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
|
||||
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
|
||||
if CurStep = ssInstall then
|
||||
Exec(ExpandConstant('{sys}\taskkill.exe'),
|
||||
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
|
||||
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
|
||||
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
|
||||
if CurStep = ssPostInstall then
|
||||
EnsureAppRuntime;
|
||||
end;
|
||||
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
|
||||
INBOX.lock().unwrap().push(url);
|
||||
}
|
||||
|
||||
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
|
||||
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
|
||||
/// (`set_app_user_model_id` in main.rs).
|
||||
pub(crate) fn has_package_identity() -> bool {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
|
||||
// PROBE — it writes nothing and only reports whether this process is packaged.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
|
||||
///
|
||||
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
|
||||
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
|
||||
/// still works if it isn't, because it invokes the client directly — which is the whole point
|
||||
/// of a shortcut being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
|
||||
/// it valid across updates, since the install path changes and the alias doesn't.
|
||||
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
|
||||
/// internet shortcut. Both would work while the scheme is registered; only this one still works
|
||||
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
|
||||
/// being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
|
||||
/// installed: under MSIX the install path changes on every update but the app execution alias
|
||||
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
|
||||
/// but a stable install dir, so unpackaged runs target the absolute exe path.
|
||||
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
|
||||
use windows::core::{Interface, HSTRING};
|
||||
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
|
||||
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
|
||||
.map_err(|_| "USERPROFILE isn't set".to_string())?;
|
||||
let path = desktop.join(format!("{}.lnk", file_name(label)));
|
||||
// Alias when packaged, absolute path when not — see the doc comment above.
|
||||
let target = if has_package_identity() {
|
||||
"punktfunk-client.exe".to_string()
|
||||
} else {
|
||||
std::env::current_exe()
|
||||
.map_err(|e| format!("current exe: {e}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
|
||||
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
|
||||
// synchronous call; nothing here dereferences a pointer the caller supplied.
|
||||
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
|
||||
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| format!("shell link: {e}"))?;
|
||||
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
|
||||
link.SetPath(&HSTRING::from(target.as_str()))
|
||||
.ok()
|
||||
.map_err(|e| format!("shortcut target: {e}"))?;
|
||||
link.SetArguments(&HSTRING::from(url))
|
||||
|
||||
@@ -173,18 +173,12 @@ fn main() {
|
||||
/// processes are left alone. Must run before any window exists.
|
||||
#[cfg(windows)]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
|
||||
// documented identity PROBE — it writes nothing and only reports whether this process is
|
||||
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
if deeplink::has_package_identity() {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
// No buffer: just probe whether the process has package identity.
|
||||
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -259,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,
|
||||
@@ -331,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,
|
||||
@@ -361,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 {
|
||||
@@ -387,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),
|
||||
@@ -396,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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -495,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) => {
|
||||
|
||||
@@ -128,6 +128,11 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
|
||||
/// [`restore_takeover_on_startup`] sets it for a stranded takeover it adopts: unmasking a unit we
|
||||
/// never masked is a no-op, while missing one that IS masked leaves the box unable to enter its
|
||||
/// own Game Mode until reboot.
|
||||
///
|
||||
/// ⚠ The takeover itself no longer masks anything — it idles the autologin session instead
|
||||
/// ([`install_idle_dropin`]), because a masked unit FAILS and a failing unit is what the display
|
||||
/// manager relogin-loops against. So this is now only ever true for a takeover adopted from a
|
||||
/// host old enough to have laid one, and the lift paths stay for exactly that box.
|
||||
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
|
||||
@@ -158,6 +163,10 @@ static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::
|
||||
/// giving the DM's desktop session time to come up so re-detection follows it instead.
|
||||
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Whether [`install_idle_dropin`] has one outstanding. Process memory only — the sweep in
|
||||
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
|
||||
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
||||
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
|
||||
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
||||
@@ -373,6 +382,15 @@ pub fn restore_takeover_on_startup() {
|
||||
);
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
}
|
||||
// Same shape, same reason: a host that died mid-stream leaves the box's Game Mode replaced by
|
||||
// a session that does nothing at all, which looks exactly like broken hardware. Runtime-dir
|
||||
// state, so a reboot clears it too — this covers the restart that does not.
|
||||
if remove_idle_dropin() {
|
||||
tracing::warn!(
|
||||
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
|
||||
own Game Mode session would have started and then done nothing"
|
||||
);
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
|
||||
return; // no takeover file — clean start
|
||||
};
|
||||
@@ -1107,8 +1125,9 @@ fn discover_session_display_env() -> Option<(Option<String>, Option<String>, Opt
|
||||
/// ⚠ Only for callers whose timeout answer is the SAFE one. Both current callers time out into
|
||||
/// "assume active"/"keep looping", so a miss costs a poll tick. A caller whose timeout would invert
|
||||
/// the answer into the refusing direction must NOT use this bound — `loginctl show-user -p Linger`
|
||||
/// was given it and became a hard connect failure on a correctly-configured box (see
|
||||
/// [`linger_enabled`], now on [`UNIT_QUERY_BUDGET`]): 300 ms is an in-memory-read budget, and
|
||||
/// was given it and became a hard connect failure on a correctly-configured box (the linger probe
|
||||
/// that produced it is gone with the DM-stop path, but the lesson is not): 300 ms is an
|
||||
/// in-memory-read budget, and
|
||||
/// anything that spawns a process and makes a D-Bus round trip is not that.
|
||||
const UNIT_STATE_BUDGET: Duration = Duration::from_millis(300);
|
||||
|
||||
@@ -1287,6 +1306,74 @@ fn legacy_session_plus_dropin_path() -> std::path::PathBuf {
|
||||
.join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf")
|
||||
}
|
||||
|
||||
/// Where the takeover's IDLE drop-in lives. Same runtime-dir argument as
|
||||
/// [`session_plus_dropin_path`], and here it is the safety property the mechanism rests on rather
|
||||
/// than a tidiness one: this drop-in replaces the box's game-mode `ExecStart`, so a copy that
|
||||
/// outlived the host would leave the box unable to enter Game Mode at all. Under
|
||||
/// `$XDG_RUNTIME_DIR` it dies with the login session, and a reboot restores game mode by itself —
|
||||
/// on top of the unconditional sweep [`restore_takeover_on_startup`] does.
|
||||
fn idle_dropin_path() -> std::path::PathBuf {
|
||||
let base = crate::session::runtime_dir();
|
||||
std::path::Path::new(&base)
|
||||
.join("systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-idle.conf")
|
||||
}
|
||||
|
||||
/// `sleep`'s path on this box. The idle `ExecStart` must not be a command that can fail to
|
||||
/// EXECUTE: a unit that dies on start is precisely the relogin storm this drop-in exists to avoid
|
||||
/// ([`mask_unit`] has that chain), so resolve it instead of hardcoding one distro's layout.
|
||||
fn sleep_binary() -> &'static str {
|
||||
["/usr/bin/sleep", "/bin/sleep"]
|
||||
.into_iter()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
.unwrap_or("/usr/bin/sleep")
|
||||
}
|
||||
|
||||
/// Idle the box's autologin game session for the stream's duration: a drop-in over the
|
||||
/// `gamescope-session-plus@` TEMPLATE (so it reaches whichever instance this box autologs into)
|
||||
/// that replaces `ExecStart` with a process which merely sleeps.
|
||||
///
|
||||
/// This is what the takeover uses INSTEAD of stopping the display manager, and it satisfies all
|
||||
/// three things that path has to get right at once. Steam is freed (the session runs nothing).
|
||||
/// The DM does not storm: its autologin still SUCCEEDS, so there is no failed session to relogin
|
||||
/// against — unlike a masked unit, which fails in milliseconds and is the storm's engine. And the
|
||||
/// box keeps a live display manager, so a session switch the user asks for can still be serviced;
|
||||
/// that is the one a stopped DM could not, and it stranded `.41` on Steam's "Switch to Desktop"
|
||||
/// modal until a reboot.
|
||||
///
|
||||
/// Measured on that box: with this installed, `steam` is down, `sddm` stays active, the unit sits
|
||||
/// `active (running)` with `NRestarts=0`, and a subsequent `switch-to-desktop-mode` brings Plasma
|
||||
/// up in ~10 s.
|
||||
fn install_idle_dropin() -> Result<()> {
|
||||
let path = idle_dropin_path();
|
||||
let dir = path
|
||||
.parent()
|
||||
.context("the idle drop-in path has no parent directory")?;
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[Service]\nExecStart=\nExecStart={} infinity\n",
|
||||
sleep_binary()
|
||||
),
|
||||
)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
|
||||
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
|
||||
/// the drop-in outliving a host that died is exactly the case that has to be swept.
|
||||
fn remove_idle_dropin() -> bool {
|
||||
let removed = std::fs::remove_file(idle_dropin_path()).is_ok();
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = false;
|
||||
if removed {
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and
|
||||
/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS`
|
||||
/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60).
|
||||
@@ -2128,6 +2215,12 @@ fn kill_unit(unit: &str) {
|
||||
/// box leaves it (a mid-stream switch to a desktop session), [`lift_autologin_mask`] must lift it, or
|
||||
/// the way back is barred until reboot (`--runtime` lives in tmpfs — which is exactly why "it works
|
||||
/// again after a reboot").
|
||||
/// ⚠ Nothing in the takeover lays a mask any more — it idles the autologin session instead
|
||||
/// ([`install_idle_dropin`]), precisely because a masked unit FAILS and a failing unit is what the
|
||||
/// display manager relogin-loops against. This is kept for the test that builds the state
|
||||
/// [`lift_autologin_mask`] exists to clean up: a takeover adopted from a host old enough to have
|
||||
/// masked. That lift is still live code, so the state has to stay constructible.
|
||||
#[cfg(test)]
|
||||
fn mask_unit(unit: &str) {
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("systemctl").args(["--user", "mask", "--runtime", unit]),
|
||||
@@ -2230,16 +2323,24 @@ struct DmPlan {
|
||||
/// no Steam, masking them while a DM is up is the relogin storm ([`mask_unit`]), and stopping
|
||||
/// the DM would kill the user's live desktop for it.
|
||||
skip: bool,
|
||||
/// Stop the DM for the stream's duration (only a live instance justifies it). Masking is not
|
||||
/// a plan input: it is laid only once this stop has LANDED, so it can never substitute for it.
|
||||
stop_dm: bool,
|
||||
/// A display manager drives this LIVE gaming session, so its autologin brings the session
|
||||
/// straight back the moment we free Steam. That is what the idle drop-in answers
|
||||
/// ([`install_idle_dropin`]) — not, any longer, stopping the DM.
|
||||
///
|
||||
/// Stopping it satisfied the same requirement and broke a different one: a box with no DM has
|
||||
/// nothing that can start a desktop session, so the user's own "Switch to Desktop" hung on
|
||||
/// Steam's modal until a reboot (field report 2026-08-18). It hung UNDETECTABLY, which is why
|
||||
/// no amount of watching fixes it: on a `steamos-manager` box the switch is a D-Bus call whose
|
||||
/// every trace — the sddm state file, the session units, the login mode — is written by the
|
||||
/// display manager we had just stopped. Leave the DM up and there is nothing to detect.
|
||||
dm_relogins: bool,
|
||||
}
|
||||
|
||||
/// See [`DmPlan`].
|
||||
fn dm_plan(dm: Option<&str>, any_live: bool) -> DmPlan {
|
||||
DmPlan {
|
||||
skip: !any_live,
|
||||
stop_dm: dm.is_some() && any_live,
|
||||
dm_relogins: dm.is_some() && any_live,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2608,122 +2709,11 @@ fn systemctl_system(args: &[&str]) -> bool {
|
||||
out.status.success()
|
||||
}
|
||||
|
||||
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
|
||||
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
|
||||
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
|
||||
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
|
||||
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
|
||||
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
|
||||
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
|
||||
/// enabled there for the sessionless tests.
|
||||
///
|
||||
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
|
||||
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
|
||||
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
|
||||
/// degrades to attach, which mirrors the box's own session and never stops the DM.
|
||||
///
|
||||
/// `Err` carries **why** it could not be ensured, because the helper path is reached here first:
|
||||
/// on a sessionless host the `linger` verb goes through the same [`dm_helper`] gate the `stop`
|
||||
/// verb does, so a user outside the `punktfunk` group fails at THIS step and never reaches the
|
||||
/// DM-stop one. Dropping the reason here would just move the misdiagnosis one message earlier.
|
||||
fn ensure_host_survives_dm_stop() -> std::result::Result<(), String> {
|
||||
if !host_is_under_user_manager() {
|
||||
return Ok(()); // root / a system unit — the DM stop cannot reach us
|
||||
}
|
||||
if linger_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
|
||||
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
|
||||
// helper, whose grant is scoped to the calling uid.
|
||||
let uid = uid_string();
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("loginctl").args(["--no-ask-password", "enable-linger", &uid]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
);
|
||||
let helper = if linger_enabled() {
|
||||
Ok(()) // the plain verb was enough — the helper was never needed
|
||||
} else {
|
||||
dm_helper("linger").map_err(|e| e.to_string())
|
||||
};
|
||||
match helper {
|
||||
Ok(()) if linger_enabled() => {
|
||||
tracing::info!(
|
||||
uid,
|
||||
"enabled lingering for this user — the managed takeover stops the display manager, \
|
||||
which ends this login session, and without lingering logind would stop the host \
|
||||
along with it (`loginctl disable-linger` reverts it)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// The verb reported success and `loginctl` still says no: not a privilege problem, so say
|
||||
// that instead of blaming the grant the operator would then go and re-check.
|
||||
Ok(()) => Err(format!(
|
||||
"`loginctl enable-linger {uid}` reported success but lingering is still off"
|
||||
)),
|
||||
Err(why) => Err(why),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
|
||||
/// stop take us down)? Read from our own cgroup path.
|
||||
fn host_is_under_user_manager() -> bool {
|
||||
std::fs::read_to_string("/proc/self/cgroup")
|
||||
.as_deref()
|
||||
.map(cgroup_under_user_manager)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
|
||||
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
|
||||
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
|
||||
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
|
||||
/// reaches units the user manager owns.
|
||||
fn cgroup_under_user_manager(cgroup: &str) -> bool {
|
||||
cgroup.contains("user@")
|
||||
}
|
||||
|
||||
/// Our uid as a string — what `loginctl` wants for a user argument.
|
||||
fn uid_string() -> String {
|
||||
crate::proc::current_uid().to_string()
|
||||
}
|
||||
|
||||
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)? An
|
||||
/// unanswered one reads as "not lingering", which refuses the takeover rather than risking the DM
|
||||
/// stop taking the host down with it.
|
||||
///
|
||||
/// [`UNIT_QUERY_BUDGET`], not [`UNIT_STATE_BUDGET`], and the failure DIRECTION is why. The 300 ms
|
||||
/// bound is documented as "anything near it means the manager is wedged — the case each caller's
|
||||
/// failure path already covers", and that holds for the other two callers, whose timeout answers
|
||||
/// `true`/keep-looping (benign). Here a timeout INVERTS the answer to `false`, and `false` is the
|
||||
/// refusing direction: `ensure_host_survives_dm_stop` then reports "`enable-linger` reported success
|
||||
/// but lingering is still off" and the bare-spawn Steam path fails a connect that would have worked,
|
||||
/// blaming a lingering configuration that is in fact correct. And this is not an in-memory read the
|
||||
/// way `systemctl is-active` is: it is a process spawn plus libsystemd's dynamic link plus a logind
|
||||
/// D-Bus round trip, sampled at the busiest moment on the box (a takeover, with Steam and a
|
||||
/// compositor being torn down). Only a genuinely wedged logind exceeds 5 s.
|
||||
fn linger_enabled() -> bool {
|
||||
crate::proc::output_within(
|
||||
Command::new("loginctl").args(["show-user", &uid_string(), "-p", "Linger", "--value"]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
)
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||
/// packaged pkexec helper. The `Err` is the HELPER's reason (the plain verb's failure is expected
|
||||
/// and carries no information: an unprivileged host is meant to fail it), and the caller puts it
|
||||
/// in front of the operator instead of guessing.
|
||||
fn try_stop_display_manager(dm: &str) -> std::result::Result<(), DmHelperError> {
|
||||
if systemctl_system(&["stop", dm]) {
|
||||
return Ok(());
|
||||
}
|
||||
dm_helper("stop")
|
||||
}
|
||||
|
||||
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
|
||||
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
|
||||
@@ -2974,88 +2964,37 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
if plan.skip {
|
||||
return Ok(());
|
||||
}
|
||||
if plan.stop_dm {
|
||||
let dm = dm.expect("stop_dm ⇒ Some");
|
||||
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
|
||||
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
|
||||
// box's display manager down and nobody left to bring it back.
|
||||
//
|
||||
// BOTH arms below now BAIL, on every DM flavor. They did not always: SDDM used to degrade
|
||||
// to mask-only here, on the reasoning that the mask still protects Steam and the cost is
|
||||
// just relogin churn. It is not just churn — the mask is IN sddm's relogin path, so a
|
||||
// mask without the stop is a 4–5 logins/s fork storm that drops the pad from 250 Hz to
|
||||
// 1.4 Hz ([`mask_unit`]). Degrading to attach costs the client's mode; degrading to
|
||||
// mask-only costs the user their input plane. Attach wins.
|
||||
//
|
||||
// Both bails quote the REASON they were handed rather than describing one.
|
||||
// 0.26.0/0.27.0 described one — "the packaged pf-dm-helper polkit action is missing or was
|
||||
// denied (reinstall the punktfunk package, or install the display-manager polkit rule from
|
||||
// the docs)" — and on the box that produced it the action was installed, permissive,
|
||||
// correctly annotated, and pkexec had already RUN the helper; the helper's refusal ("user
|
||||
// 'x' is not in the 'punktfunk' group") was thrown away with its stderr. Both suggested
|
||||
// remedies were dead ends: neither a reinstall nor a polkit rule adds anyone to a group.
|
||||
if let Err(why) = ensure_host_survives_dm_stop() {
|
||||
// The reason goes LAST in both bails: the helper's own refusal ends in a command
|
||||
// to paste, and burying that mid-sentence is how it stops being read.
|
||||
bail!(
|
||||
"stopping {dm} ends this user's last login session, and without lingering \
|
||||
logind would stop the user manager — and this host with it — about 10s \
|
||||
later, leaving the box with no display manager and nothing to restore it; \
|
||||
lingering could not be enabled, so the managed takeover is unavailable. \
|
||||
Either run `sudo loginctl enable-linger $USER` once, as the setup docs ask, \
|
||||
and reconnect — or fix the privileged path: {why}"
|
||||
);
|
||||
}
|
||||
if let Err(why) = try_stop_display_manager(&dm) {
|
||||
// ERROR, not WARN, and it names the SHAPE: this is the branch whose silence cost an
|
||||
// evening on .41 — the takeover degraded, nothing failed loudly, and the storm that
|
||||
// followed read as a pad bug. The `bail!` below reaches the caller's own warn line;
|
||||
// this one exists so the shape survives into the journal even if the caller's does
|
||||
// not, because the four shapes need four different fixes.
|
||||
tracing::error!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"the managed takeover planned to stop the display manager and could not — \
|
||||
degrading to ATTACH rather than fighting its autologin: a killed session under \
|
||||
a running DM relogin-loops at 4-5/s and starves the box's input plane"
|
||||
);
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, and stopping it for the stream needs \
|
||||
privilege this host does not have; taking over without stopping it would leave \
|
||||
its autologin relogin-looping against us for the whole stream, so the managed \
|
||||
takeover is unavailable — {why}"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"freed Steam: stopped the display manager for this stream (its autologin \
|
||||
Relogin loop would otherwise churn against the takeover)"
|
||||
);
|
||||
// Baseline the switch sentinel HERE, not just at a successful launch: setting
|
||||
// STOPPED_DM is what arms the honor gate, so from this instant an unbaselined
|
||||
// sentinel would read as an in-stream "Switch to Desktop" — including the write from
|
||||
// the switch that just brought the box INTO game mode. A successful launch
|
||||
// re-baselines (tighter still).
|
||||
record_session_select_baseline();
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||
// Already idled by an earlier connect in this stream's life? Then the session listed as "live"
|
||||
// above is our own idled one — it holds no Steam and there is nothing left to free. Without
|
||||
// this, every reconnect and every in-place rebuild would kill and restart the box's session
|
||||
// again to accomplish exactly nothing.
|
||||
if *IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) {
|
||||
return Ok(());
|
||||
}
|
||||
// The display manager STAYS UP. Freeing Steam means ending the session its autologin owns, and
|
||||
// the two ways to stop that autologin fighting us are not equivalent: stopping the DM works
|
||||
// until the user asks for a desktop session, at which point nothing on the box can give them
|
||||
// one ([`DmPlan::dm_relogins`]). Idling the session instead keeps the autologin succeeding —
|
||||
// no failed unit to relogin against, no storm — while leaving the DM able to service that
|
||||
// switch.
|
||||
if plan.dm_relogins {
|
||||
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
|
||||
}
|
||||
// Reaching here means no display manager can relogin against us: either there is none
|
||||
// (`!plan.stop_dm` with `dm == None`), or the stop above LANDED — both failure arms bail. That
|
||||
// is the precondition the mask needs, and the only one under which it is a defense rather than
|
||||
// the storm's accelerator ([`mask_unit`]).
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
// Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this
|
||||
// flag, and one that ran between the mask and an unrecorded flag would leave it on forever.
|
||||
*AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
for unit in units {
|
||||
mask_unit(&unit); // belt-and-braces: no DM is up to relogin through it
|
||||
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||
if plan.dm_relogins {
|
||||
// Bring it back ourselves rather than waiting for the DM to notice: deterministic, and
|
||||
// it closes the window in which the DM sees a dead session and starts churning. The
|
||||
// drop-in above is already loaded, so what comes back runs nothing.
|
||||
systemctl_user(&["restart", &unit]);
|
||||
}
|
||||
tracing::info!(
|
||||
%unit,
|
||||
dm_stopped = plan.stop_dm,
|
||||
"freed Steam: masked and stopped the autologin gaming session for this stream"
|
||||
idled = plan.dm_relogins,
|
||||
"freed Steam: the box's autologin gaming session is idled for this stream (its \
|
||||
display manager stays up, so the box can still switch sessions)"
|
||||
);
|
||||
stopped.push(unit);
|
||||
}
|
||||
@@ -3591,6 +3530,17 @@ fn do_restore_tv_session() {
|
||||
// rests on. It used to sit after the desktop-active and DM returns, so those two paths leaked
|
||||
// it.
|
||||
disarm_session_plus_dropin();
|
||||
// The idle drop-in belongs to the same rule and leaks the same way — worse, in fact: the bind
|
||||
// one leaves the box's Game Mode running OUR gamescope, this one leaves it running NOTHING.
|
||||
// The desktop-active return below is the live case (the user switched away, so we never
|
||||
// restart the units), and a drop-in left there is a box whose Game Mode silently does nothing
|
||||
// for the rest of the login.
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
|
||||
real again"
|
||||
);
|
||||
}
|
||||
unset_forced_session_screen_env();
|
||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
|
||||
@@ -3648,12 +3598,16 @@ fn do_restore_tv_session() {
|
||||
clear_takeover();
|
||||
return;
|
||||
}
|
||||
// (The idle drop-in is already gone — removed above every early return, so the restarts
|
||||
// below bring the box's real session back rather than another idle one.)
|
||||
for unit in units {
|
||||
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
|
||||
// that logged an unconditional success over a thrown-away exit status. A `--user start`
|
||||
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
|
||||
// and the DM branch thirty lines up already shows the shape — say what happened.
|
||||
match issue_restore_verb(&["start", &unit]) {
|
||||
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
|
||||
// active unit is a no-op that would report success over a session still running nothing.
|
||||
match issue_restore_verb(&["restart", &unit]) {
|
||||
RestoreVerb::Done => tracing::info!(
|
||||
unit,
|
||||
"restored the TV's autologin gaming session (debounce elapsed, no client)"
|
||||
@@ -5278,15 +5232,14 @@ impl Drop for GamescopeProc {
|
||||
mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
cgroup_under_user_manager, classify_output_size, connected_connector_under,
|
||||
display_manager_unit_under, dm_plan, game_hz, gamescope_output_size, hdr_args,
|
||||
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script,
|
||||
our_wsi_layer_dir, plan_bind, release_autologin_mask, script_hardcodes_gamescope,
|
||||
sentinel_advanced, shape_dedicated_command, switch_ends_mask_window,
|
||||
takeover_state_is_live, unmask_unit, xwayland_refusal_marker, BindOff, BindPlan,
|
||||
BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan, AUTOLOGIN_MASKED,
|
||||
DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV,
|
||||
X11_SOCKET_DIR,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags,
|
||||
mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
|
||||
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -5461,26 +5414,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manager_lifetime_detection() {
|
||||
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
|
||||
// login session the DM stop ends — this is the case that needs lingering.
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
|
||||
));
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
|
||||
));
|
||||
// A system unit outlives every session — the DM stop cannot reach it.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/system.slice/punktfunk-host.service\n"
|
||||
));
|
||||
// Started from a login shell: owned by the session scope, not the user manager.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/session-2.scope\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_select_sentinel_needs_a_baseline() {
|
||||
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
|
||||
@@ -5583,27 +5516,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dm_plan_stops_any_dm_that_drove_a_live_session() {
|
||||
// A live gaming session behind a DM: stop the DM, whatever the flavor. The mask alone does
|
||||
// NOT stop the relogin loop — on .41 it is what makes the loop fast, because the session
|
||||
// script's last act is `systemctl --user --wait start gamescope-session-plus@…` and a
|
||||
// masked unit fails that in milliseconds (4-5 logins/s, pad at 1.4 Hz, 2026-08-18).
|
||||
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
|
||||
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
|
||||
// things that do NOT work is flavor-dependent — a mask fails the unit in milliseconds and
|
||||
// makes the relogin loop fast (4-5 logins/s, pad at 1.4 Hz, 2026-08-18), and stopping the
|
||||
// DM leaves nothing able to start a desktop session when the user asks for one.
|
||||
let p = dm_plan(Some("sddm.service"), true);
|
||||
assert!(!p.skip && p.stop_dm);
|
||||
assert!(!p.skip && p.dm_relogins);
|
||||
// Flavor is no longer an input: plasmalogin gets the same plan as sddm. It used to differ
|
||||
// only to pick a DEGRADED mode (mask-only for sddm), and that degrade is now gone —
|
||||
// `stop_autologin_sessions` bails to ATTACH instead.
|
||||
let q = dm_plan(Some("plasmalogin.service"), true);
|
||||
assert!(q.skip == p.skip && q.stop_dm == p.stop_dm);
|
||||
assert!(q.skip == p.skip && q.dm_relogins == p.dm_relogins);
|
||||
// Nothing live, DM present: hands off entirely, on EVERY flavor. Killing loaded-but-
|
||||
// inactive leftovers frees no Steam; masking them while the DM is up is the storm; and
|
||||
// stopping the DM would kill the user's live desktop for it.
|
||||
assert!(dm_plan(Some("sddm.service"), false).skip);
|
||||
assert!(dm_plan(Some("plasmalogin.service"), false).skip);
|
||||
// No DM at all (getty autologin), live: mask+kill, nothing to stop — masking is sound
|
||||
// here precisely because no relogin loop exists to run into it.
|
||||
// No DM at all (getty autologin), live: kill and leave it stopped. Nothing relogins, so
|
||||
// there is no autologin to idle — and no reason to leave a drop-in on the box.
|
||||
let p = dm_plan(None, true);
|
||||
assert!(!p.skip && !p.stop_dm);
|
||||
assert!(!p.skip && !p.dm_relogins);
|
||||
assert!(dm_plan(None, false).skip);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,16 @@ use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
|
||||
const POINTER_METADATA: u32 = 4;
|
||||
const POINTER_EMBEDDED: u32 = 2;
|
||||
|
||||
/// Marks the one KWin refusal a retry can clear: the disabled-output repair ran and changed the
|
||||
/// box between attempts ([`kwin_output_mgmt::enable_disabled_output`]).
|
||||
///
|
||||
/// It is load-bearing in TWO places and both are easy to break. The opener keys on it to skip the
|
||||
/// `KWin virtual output failed` wrapper below — and that wrapper's prefix is exactly what the
|
||||
/// host's `is_permanent_build_error` matches to short-circuit the retry loop, so a repaired
|
||||
/// refusal carrying it would be classified permanent and the retry that consumes the repair would
|
||||
/// never run. It is also the human-readable half of the message; keep it a phrase, not a code.
|
||||
const REPAIRED_HINT: &str = "enabled it over output management";
|
||||
|
||||
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
|
||||
const VOUT_NAME: &str = "punktfunk";
|
||||
|
||||
@@ -268,6 +278,10 @@ impl VirtualDisplay for KwinDisplay {
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
match setup_rx.recv_timeout(OPENER_BUDGET) {
|
||||
Ok(Ok(v)) => Ok((v, stop)),
|
||||
// Repaired: report it as-is. The wrapper below would prepend the phrase the host
|
||||
// reads as "permanent, do not retry", and this is the one refusal whose retry is
|
||||
// the entire point — the repair only fixes the NEXT request.
|
||||
Ok(Err(e)) if e.contains(REPAIRED_HINT) => bail!("{e}"),
|
||||
// KWin's reason is TRANSLATED into the session's language, so it is often
|
||||
// unsearchable for the person reading the log. Say what it means once, here.
|
||||
Ok(Err(e)) => bail!(
|
||||
@@ -1793,14 +1807,41 @@ fn run(
|
||||
);
|
||||
|
||||
// Pump events until KWin reports the node id (or an error, or the budget).
|
||||
let node_id = await_created(
|
||||
//
|
||||
// A refusal here is where the KWin >= 6.6 disabled-output trap lands, and it is repairable
|
||||
// FROM INSIDE THIS SCOPE and nowhere else: KWin destroys the output when our stream is
|
||||
// destroyed, so the connection has to stay up while we enable it (see
|
||||
// [`kwin_output_mgmt::enable_disabled_output`] for why the output is still alive at all, and
|
||||
// why enabling it fixes the NEXT request rather than this one).
|
||||
let node_id = match await_created(
|
||||
&conn,
|
||||
&mut queue,
|
||||
&mut state,
|
||||
stop,
|
||||
"stream_virtual_output",
|
||||
started,
|
||||
)?;
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
// `Virtual-<name>` is the address KWin exposes our output under (the same prefix the
|
||||
// topology path resolves against).
|
||||
match crate::kwin_output_mgmt::enable_disabled_output(&format!("Virtual-{name}")) {
|
||||
// Deliberately does NOT carry the "KWin virtual output failed" prefix: that string
|
||||
// is what marks a KWin refusal PERMANENT for the session's retry loop, and this is
|
||||
// the one refusal where something DID change between attempts. Retrying is the
|
||||
// whole point of repairing.
|
||||
Some(repaired) => bail!(
|
||||
"KWin created the virtual output disabled and refused to stream it ({e}); \
|
||||
{REPAIRED_HINT} (head {repaired}) — the retry picks up the configuration \
|
||||
KWin just persisted"
|
||||
),
|
||||
// Nothing to repair (no such head, already enabled, or the apply was refused):
|
||||
// the refusal stands, and its own prefix keeps it permanent so the session fails
|
||||
// fast instead of burning the retry budget on an unchanged box.
|
||||
None => return Err(e),
|
||||
}
|
||||
}
|
||||
};
|
||||
setup_tx
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
@@ -1274,6 +1274,76 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
|
||||
complete
|
||||
}
|
||||
|
||||
/// Enable a virtual output KWin created but left DISABLED, addressed by the `Virtual-<name>`
|
||||
/// prefix it exposes ours under. Returns the head's name when one matched, was disabled, and the
|
||||
/// enable applied.
|
||||
///
|
||||
/// This is the repair for the KWin ≥ 6.6 refusal (`"Could not find output"`, translated into the
|
||||
/// session's language). `streamVirtualOutput` there creates the output on the backend and then
|
||||
/// hands `workspace()->findOutput(output)` to the stream — and that returns null for an output the
|
||||
/// workspace does not manage, which `wantsToManage` defines as `isEnabled() && !isNonDesktop()`.
|
||||
/// KWin 6.4/6.5 passed the backend output straight through, so a disabled one streamed anyway;
|
||||
/// from 6.6 it is a hard refusal, and one that repeats forever: the host asks for a STABLE
|
||||
/// per-client name so KWin persists that client's scale and mode, and a stored setup naming it
|
||||
/// `enabled: false` is therefore reapplied to every future session.
|
||||
///
|
||||
/// Two properties of KWin make the repair possible, both verified against Plasma/6.7:
|
||||
///
|
||||
/// * `sendFailed` only sends the event — it does not emit `finished`, and `removeVirtualOutput` is
|
||||
/// wired to `finished`. So the disabled output stays alive for exactly as long as the caller
|
||||
/// holds its (failed) stream open, which is the window this runs in.
|
||||
/// * `WaylandServer::handleOutputAdded` offers EVERY backend output to the output-device registry,
|
||||
/// gating only placeholders and non-desktop ones. A disabled output has no `wl_output` — that
|
||||
/// side is gated on the workspace — but it is addressable over `kde_output_management_v2`.
|
||||
///
|
||||
/// Enabling it through output management is a user-applied configuration, so KWin persists it
|
||||
/// against that output's identity: the caller's next `stream_virtual_output` under the same name
|
||||
/// finds a stored setup that enables it. Which is why the caller must RETRY after this returns
|
||||
/// `Some` — the request that failed cannot be salvaged, only the one after it.
|
||||
pub(crate) fn enable_disabled_output(prefix: &str) -> Option<String> {
|
||||
let mut sess = Session::open("enable_disabled").ok()?;
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Newest-wins, exactly as the supersede resolve elsewhere in this file: a reconnect can leave
|
||||
// a predecessor of the same name briefly announced, and enabling THAT one repairs an output
|
||||
// that is already going away.
|
||||
let dev = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| d.name.as_deref().is_some_and(|n| n.starts_with(prefix)) && d.proxy.is_some())
|
||||
.max_by_key(|d| (d.global, d.seq))
|
||||
.cloned()?;
|
||||
let name = dev.name.clone()?;
|
||||
if dev.enabled {
|
||||
// Not the shape we repair. Say so rather than applying a no-op config that would `applied`
|
||||
// successfully and read as a fix — the caller decides whether to retry on this.
|
||||
tracing::debug!(
|
||||
%name,
|
||||
"KWin output management: our virtual output is already enabled — nothing to repair"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let proxy = dev.proxy.as_ref()?;
|
||||
let config = sess.new_config();
|
||||
config.enable(proxy, 1);
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if !ok {
|
||||
tracing::warn!(
|
||||
%name,
|
||||
reason = ?sess.state.failure_reason,
|
||||
"KWin output management: could not enable the virtual output KWin created disabled"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
tracing::info!(
|
||||
%name,
|
||||
"KWin output management: KWin created our virtual output DISABLED and refused to stream \
|
||||
it; enabled it — KWin persists that, so the retry's request comes back enabled"
|
||||
);
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
|
||||
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
|
||||
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
|
||||
|
||||
@@ -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) —
|
||||
@@ -5857,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::*;
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -5345,6 +5345,17 @@ mod tests {
|
||||
"spawn gamescope (is it installed? `apt install gamescope`)"
|
||||
));
|
||||
assert!(is_permanent_build_error("virtual displays require Linux"));
|
||||
// The ONE KWin refusal that must stay retryable: pf-vdisplay repaired the box (it enabled
|
||||
// the output KWin created disabled, which KWin persists), so the next attempt is not the
|
||||
// same attempt. That path deliberately reports WITHOUT the `KWin virtual output failed`
|
||||
// prefix above — if it ever regains it, the retry that consumes the repair stops running
|
||||
// and the repair is dead code.
|
||||
assert!(!is_permanent_build_error(
|
||||
"create virtual output: KWin created the virtual output disabled and refused to \
|
||||
stream it (stream_virtual_output failed: Não foi possível encontrar saída); enabled \
|
||||
it over output management (head Virtual-punktfunk-a1b2) — the retry picks up the \
|
||||
configuration KWin just persisted"
|
||||
));
|
||||
// Transient: negotiation/timeout races — exactly what backoff is for.
|
||||
assert!(!is_permanent_build_error(
|
||||
"first frame: no PipeWire frame within 10s (node 42): format negotiation never completed"
|
||||
|
||||
@@ -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": "installer",
|
||||
"docs": "/docs/install-client#windows",
|
||||
"install": [
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
|
||||
".\\punktfunk-client-setup_x64.exe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 not optional in practice: it authorizes the
|
||||
helper the host uses to stop the display manager when it takes the Gaming Mode session over at your
|
||||
client's resolution, and it gates the usbip `attach` file the **virtual Steam Deck controller**
|
||||
(paddles, trackpads, gyro) attaches through. 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, and the
|
||||
takeover degrades to mirroring the box's own screen — 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. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops
|
||||
the display manager for the length of the stream, and without that grant it cannot.
|
||||
- **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.
|
||||
@@ -32,7 +32,8 @@ track per machine; switching is a one-line change.
|
||||
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
|
||||
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
|
||||
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
|
||||
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
|
||||
| **Windows client** (installer) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-setup_x64.exe` | `…/latest/…` + the release page |
|
||||
| **Windows client** (MSIX / portable zip) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` (or `…_x64-portable.zip`) | `…/latest/…` + the release page |
|
||||
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
|
||||
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
|
||||
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
|
||||
|
||||
@@ -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 installer** (plus a portable zip, and an MSIX for Microsoft Store compatibility).
|
||||
|
||||
The package installs **two** Start-menu entries — **Punktfunk**, the desktop window, and
|
||||
**Punktfunk Console**, a controller-driven fullscreen interface for a TV or HTPC (host list, pairing,
|
||||
@@ -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,
|
||||
@@ -155,7 +148,7 @@ It ships as a sideloadable `.ipk` (homebrew package) rather than through the LG
|
||||
## Scripting: the `punktfunk` CLI
|
||||
|
||||
`punktfunk` is the headless client — the same core the graphical apps use, with no window. It ships
|
||||
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows MSIX**, so
|
||||
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows installer**, so
|
||||
if you have a desktop client you already have it:
|
||||
|
||||
```sh
|
||||
@@ -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?
|
||||
|
||||
@@ -220,11 +213,10 @@ tvOS has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
|
||||
| A Linux desktop or laptop | **[`punktfunk-client`](#linux-desktop-client-gtk4)** (GTK4) |
|
||||
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the [GTK4 client](#linux-desktop-client-gtk4) in Desktop Mode |
|
||||
| An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
|
||||
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed MSIX) or **[Moonlight](/docs/moonlight)** |
|
||||
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed installer) or **[Moonlight](/docs/moonlight)** |
|
||||
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **[Moonlight](/docs/moonlight)** |
|
||||
| A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
|
||||
| Scripts, plugins, home automation | The headless **[`punktfunk`](#scripting-the-punktfunk-cli)** CLI |
|
||||
| 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,12 +40,23 @@ set** — which is what every shipped template does — a box that has gamescope
|
||||
|
||||
### Nobara and other autologin display managers
|
||||
|
||||
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when that
|
||||
session is a display-manager autologin, it has to stop the **display manager** too, for the length
|
||||
of the stream. That is a privileged operation, and the privilege is granted to one group.
|
||||
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when a display
|
||||
manager autologs into that session, stopping it alone accomplishes nothing: the autologin puts it
|
||||
straight back. So the host **idles** that session for the length of the stream instead, with a
|
||||
systemd drop-in that replaces its `ExecStart` with a process that just sleeps. The autologin still
|
||||
succeeds (nothing relogin-loops), the session it logs into does nothing (Steam is free), and the
|
||||
**display manager keeps running** — which is what lets you still switch the box to Desktop Mode
|
||||
from Steam while a stream is up.
|
||||
|
||||
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover's root helper
|
||||
> runs for members of that group and for nobody else, so this one command is what authorizes it:
|
||||
That needs no privilege at all: the drop-in is a user-level unit override, written under
|
||||
`$XDG_RUNTIME_DIR` so it cannot outlive the login session, and a reboot clears it regardless.
|
||||
Versions before this one stopped the display manager for the stream's duration — which needed a
|
||||
root helper, the `punktfunk` group, and lingering, and left the box with nothing able to start a
|
||||
desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
|
||||
|
||||
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover itself no
|
||||
> longer needs it — the group now gates the usbip nodes the virtual Steam Deck pad attaches
|
||||
> through, so without it the pad arrives as an ordinary Xbox 360 controller:
|
||||
>
|
||||
> ```sh
|
||||
> sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||
@@ -60,14 +71,12 @@ of the stream. That is a privileged operation, and the privilege is granted to o
|
||||
> symptom side is [Game Mode: black screen on
|
||||
> connect](/docs/troubleshooting#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
|
||||
|
||||
How the takeover gets that privilege depends on the display manager driving the autologin:
|
||||
The display-manager flavor is no longer an input — SDDM, plasmalogin and the rest all get the
|
||||
idled session above, and none of them is stopped. The root helper described below is therefore no
|
||||
longer part of a normal takeover; it is kept for the restore path, and for a box where an older
|
||||
host left a display manager stopped:
|
||||
|
||||
- **SDDM** (Bazzite, SteamOS): SDDM survives having the session unit masked, so a box without the
|
||||
grant still streams — at the cost of SDDM relogin-looping against the takeover for the whole
|
||||
stream, which churns logind sessions and can starve the game.
|
||||
- **plasmalogin** (Nobara) and other display managers: masking is fatal there (the autologin
|
||||
start-limit-kills the display manager), so the host stops the display manager itself and
|
||||
restarts it afterwards. The packages ship that privilege: a root helper
|
||||
- The packages ship it: a root helper
|
||||
(`/usr/libexec/punktfunk/pf-dm-helper`, or `/usr/lib/punktfunk/pf-dm-helper` from the Arch
|
||||
package) behind its own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically
|
||||
when the plain `systemctl` verbs are denied. The helper only stops/restores the unit the
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ stream and links out to the detail as you need it. The rest of these are for whe
|
||||
<Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." />
|
||||
<Card title="How It Works" href="/docs/how-it-works" description="The ideas behind Punktfunk in a few minutes — virtual displays, the two protocols, pairing." />
|
||||
<Card title="Support Matrix" href="/docs/support-matrix" description="What works where — every host desktop, GPU and client app, each cell read out of the code that decides it." />
|
||||
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
|
||||
<Card title="Install the Host" href="/docs/install" description="One page per system — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows — with the install command and nothing else." />
|
||||
<Card title="Switching from Sunshine" href="/docs/switching-from-sunshine" description="Run Punktfunk next to Sunshine, Apollo or Vibeshine while you try it, then migrate — what maps to what." />
|
||||
<Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." />
|
||||
<Card title="Your Game Library" href="/docs/game-library" description="The host finds your installed games by itself — browse a paired host and launch a title straight into the stream." />
|
||||
<Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user