Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ef285f08 | ||
|
|
5bec450402 | ||
|
|
cd6ce34892 | ||
|
|
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 | ||
|
|
f415c7d090 | ||
|
|
e86c6367e1 | ||
|
|
0fd44d8242 | ||
|
|
6807d7951c | ||
|
|
f50721aedb | ||
|
|
5d3301ed9b | ||
|
|
bd140bd232 | ||
|
|
d161c12680 | ||
|
|
7537e8e6b2 | ||
|
|
5711fafa38 | ||
|
|
fa946a16b9 | ||
|
|
9a29eb4a7a | ||
|
|
1b52942bf8 | ||
|
|
6bec7c7cc6 | ||
|
|
8f4e71f8dc | ||
|
|
9ddf802665 | ||
|
|
b6ca692c13 | ||
|
|
f7eb844274 | ||
|
|
a75ed71428 | ||
|
|
b551f7dae8 | ||
|
|
e5046a2811 | ||
|
|
19d37c44b3 | ||
|
|
5be399a4f6 | ||
|
|
6c32890014 | ||
|
|
9c33bc9397 | ||
|
|
fcdb2a53de | ||
|
|
601f040ffe | ||
|
|
eac308412c | ||
|
|
65c4b4b17e | ||
|
|
329df4c1f4 | ||
|
|
5fc5da3256 | ||
|
|
a8922b454a | ||
|
|
790db5edbb | ||
|
|
82d39011ce | ||
|
|
8a4eac4c41 | ||
|
|
7e4fe80793 | ||
|
|
c4cf53c1fc | ||
|
|
d59a1a9606 | ||
|
|
7c411f7ef4 | ||
|
|
9e47f746ba | ||
|
|
13f8a1c5cd | ||
|
|
d5f2c63367 | ||
|
|
4e03dcc280 | ||
|
|
37813199b5 | ||
|
|
83f6164027 | ||
|
|
dd097d1ef2 | ||
|
|
01946aa123 | ||
|
|
c0dcac7fa2 | ||
|
|
654c09d067 | ||
|
|
ab88a8fb40 | ||
|
|
66249710b9 | ||
|
|
3717466594 | ||
|
|
c7c9500e89 |
@@ -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
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
|
||||
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
|
||||
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
|
||||
# NEITHER build: it just (re)places the compose files and pulls the already-published
|
||||
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
|
||||
# dispatch without triggering those rebuilds.
|
||||
# Deploy-only: bring up the unom-1 pieces that live in THIS repo but whose normal deploys
|
||||
# are coupled to heavy build workflows — docs to docker.yml's 5-image matrix, the flatpak
|
||||
# server to flatpak.yml's full flatpak-builder run, the nix cache to nix.yml's full Rust
|
||||
# build. This workflow does NONE of those builds: it just (re)places the compose files and
|
||||
# pulls the already-published images, so unom/infra's deploy-all can bring a fresh unom-1
|
||||
# fully up in a single dispatch without triggering those rebuilds.
|
||||
#
|
||||
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
|
||||
# brings it up on :3220.
|
||||
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
|
||||
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
|
||||
# on the next client build, or restored from the unom-1 backup
|
||||
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
|
||||
# an empty repo until then; that is expected.
|
||||
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
|
||||
# brings it up on :3220.
|
||||
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
|
||||
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
|
||||
# on the next client build, or restored from the unom-1 backup
|
||||
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
|
||||
# an empty repo until then; that is expected.
|
||||
# nix-cache -> brings up the caddy:2-alpine Nix binary cache on :3250. Same content/config
|
||||
# split: the cache CONTENT is republished by nix.yml on the next main push
|
||||
# that moves the flake. An empty cache is harmless — every path 404s and
|
||||
# users build from source, which is the pre-cache status quo.
|
||||
# winget -> brings up the winget REST source on :3240; catalogue shipped by
|
||||
# windows-host.yml on stable tags.
|
||||
#
|
||||
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
|
||||
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
|
||||
@@ -100,6 +106,46 @@ jobs:
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
|
||||
nix-cache:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync nix cache compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land all three flat in ~/unom-nix-cache/ (drop the packaging/nix/server/ prefix).
|
||||
source: "packaging/nix/server/compose.production.yml,packaging/nix/server/Caddyfile,packaging/nix/server/prune.sh"
|
||||
target: "~/unom-nix-cache"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start nix binary cache server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./site (the cache CONTENT) is NOT shipped here — nix.yml rsyncs it on each main push
|
||||
# that moves the flake, same content/config split as the flatpak repo and the winget
|
||||
# catalogue. Ensure the bind-mount source exists so caddy starts; an empty cache is
|
||||
# harmless, it just 404s every path and users build from source as they do today.
|
||||
mkdir -p ~/unom-nix-cache/site/nar
|
||||
cd ~/unom-nix-cache
|
||||
docker compose -f compose.production.yml up -d
|
||||
# A cache that 404s a miss is healthy; one that cannot answer at all is not.
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:3250/nix-cache-info \
|
||||
|| echo "NOTE: no cache content yet - push to main with the flake touched to populate it"
|
||||
|
||||
winget:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -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'
|
||||
+187
-19
@@ -4,8 +4,9 @@
|
||||
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
|
||||
# in ci.yml for that story).
|
||||
#
|
||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push:
|
||||
# Three tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push — so the two cheap tiers gate every PR and the expensive one runs
|
||||
# only where its cost buys something (a published cache):
|
||||
#
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
|
||||
# without building them. Catches the failures that actually happen to this flake — a
|
||||
@@ -32,15 +33,28 @@
|
||||
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
|
||||
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
|
||||
#
|
||||
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
|
||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||
# * cache — PUSH TO MAIN ONLY. Builds the Rust packages + gamescope for real and publishes every
|
||||
# punktfunk store path to the binary cache at https://nix.unom.io, so a NixOS user gets
|
||||
# prebuilt binaries instead of an hour of rustc and a gamescope compile. This is the
|
||||
# expensive tier and it is why the job timeout is 180 rather than 90.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
|
||||
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
|
||||
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
|
||||
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
|
||||
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
|
||||
# It needs NO extra trigger for releases: a release bumps the workspace version in
|
||||
# Cargo.toml, which is already in the path filter below, so the tag's content is
|
||||
# published by the version-bump commit on main.
|
||||
#
|
||||
# Only OUR paths are published — see the step for why that is both correct and the
|
||||
# difference between ~300 MB and several GB per publish.
|
||||
#
|
||||
# The Rust packages and punktfunk-gamescope are still not built on PRs: they are the expensive ones
|
||||
# and their inputs are already gated by the `rust` job in ci.yml. Build them on a PR by hand on a
|
||||
# Nix box, or with the `build-rust` / `build-gamescope` dispatch inputs below.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope matters more than it looks: `host.gamescopeHdr` DEFAULTS TRUE, so it is on
|
||||
# the critical path of every `services.punktfunk.host.enable = true` build. It patches whatever
|
||||
# gamescope the pinned nixpkgs carries, so a nixpkgs bump — not a change of ours — is what breaks
|
||||
# it, and the first person to find out would be an operator whose system rebuild fails. The `cache`
|
||||
# tier now compiles it on every main push, so a flake.lock bump that breaks it goes red HERE; the
|
||||
# dispatch input below is for checking it on a branch before merging.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
@@ -107,8 +121,15 @@ jobs:
|
||||
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
|
||||
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 90
|
||||
# 180, not 90: the `cache` tier on a main push compiles the whole Rust workspace AND gamescope
|
||||
# from source inside the nix sandbox, where the sccache every other Rust job leans on cannot
|
||||
# reach (no network in a derivation, and RUSTC_WRAPPER is not set inside one).
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
# Where the published cache lives on unom-1, and the URL users substitute from. Kept next to
|
||||
# the flatpak repo (3230) and winget source (3240) — see packaging/nix/server/.
|
||||
DEPLOY_DIR: unom-nix-cache
|
||||
CACHE_URL: https://nix.unom.io
|
||||
# The flake needs both experimental features. Also baked into the installer's --extra-conf
|
||||
# below; this covers any step that shells out before that config is read.
|
||||
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||
@@ -126,11 +147,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
|
||||
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
|
||||
# against a future slim-image swap, and costs one cached apt call.)
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout;
|
||||
# rsync + ssh to ship the built cache to unom-1. (node:22-bookworm is the full image and
|
||||
# already has all but rsync — this is belt-and-braces against a future slim-image swap, and
|
||||
# costs one cached apt call.)
|
||||
- name: Installer prerequisites
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git rsync openssh-client
|
||||
|
||||
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
|
||||
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
|
||||
@@ -151,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)
|
||||
@@ -183,10 +217,144 @@ jobs:
|
||||
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
|
||||
echo "bun packages OK: $web $scripting"
|
||||
|
||||
# ── binary cache (push to main only) ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Decided against a bucket on storage.unom.io even though sccache already uses it and the
|
||||
# credentials already exist: it is local RustFS on the home uplink with no CDN in front, so
|
||||
# every NixOS user's download would come off the same pipe every CI runner uses — and S3
|
||||
# answers 403, not 404, for a missing key, which nix treats as a hard error rather than a
|
||||
# cache miss (see packaging/nix/server/Caddyfile). unom-1 already serves the flatpak repo
|
||||
# this way from a cloud IP; a Nix cache is the same static-files-behind-caddy shape.
|
||||
#
|
||||
# Gitea itself cannot host this at all: it has 23 package registry types and none is Nix, and
|
||||
# the binary cache protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
|
||||
# /<hash>.narinfo, /nar/<hash>.nar.xz) that /api/packages/{owner}/generic/… cannot express.
|
||||
- name: Cache publish preflight
|
||||
id: cachecfg
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
|
||||
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
|
||||
# repo deploy does, so this workflow stays green through setup.
|
||||
run: |
|
||||
set -eu
|
||||
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
|
||||
echo "go=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "go=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
|
||||
fi
|
||||
|
||||
- name: Build the publishable packages
|
||||
if: ${{ steps.cachecfg.outputs.go == 'true' }}
|
||||
# Everything a user can install. punktfunk-gamescope earns its place here more than any
|
||||
# other: host.gamescopeHdr DEFAULTS TRUE, so without it in the cache every
|
||||
# `services.punktfunk.host.enable = true` still compiles a compositor from source.
|
||||
run: |
|
||||
"$NIX" build --print-build-logs \
|
||||
.#punktfunk-host .#punktfunk-client .#punktfunk-tray \
|
||||
.#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope
|
||||
# This is now the heaviest job on the fleet — a full workspace build plus gamescope fills
|
||||
# the store with tens of GB, and this fleet ran a runner out of disk on 2026-08-06. Record
|
||||
# the headroom AFTER the build too, or a future "no space left on device" is a guess.
|
||||
df -h / /nix /tmp || true
|
||||
|
||||
- name: Sign + publish to nix.unom.io
|
||||
if: ${{ steps.cachecfg.outputs.go == 'true' }}
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
|
||||
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
|
||||
# "Illegal option". The two places below where a pipeline's LEFT side must be able to fail
|
||||
# the step are written as redirects instead, so nothing depends on pipefail.
|
||||
set -eu
|
||||
PKGS=".#punktfunk-host .#punktfunk-client .#punktfunk-tray .#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope"
|
||||
|
||||
# 1) Pick what to publish. PUBLISH ONLY OUR OWN PATHS — this is the difference between
|
||||
# ~300 MB and several GB per run, and it is not a corner cut: a runtime closure here
|
||||
# is our binaries plus stock nixpkgs (ffmpeg, gtk4, glibc, …), and every stock path is
|
||||
# already on cache.nixos.org, served by a real CDN. Mirroring them onto unom-1 would
|
||||
# cost disk and home-to-cloud bandwidth to serve a WORSE copy of what users already
|
||||
# have. Nothing in nixpkgs is named punktfunk, so the name filter is exact.
|
||||
paths="$("$NIX" path-info -r $PKGS | grep -- '-punktfunk' || true)"
|
||||
[ -n "$paths" ] || { echo "::error::no punktfunk store paths in the closure — the name filter is broken"; exit 1; }
|
||||
echo "$paths"
|
||||
# The filter is a string match, so it would fail SILENTLY if a pname ever changed — and
|
||||
# the package most likely to drift is gamescope, the most expensive one to lose. Assert
|
||||
# every built output is actually covered rather than discovering it as a user rebuild.
|
||||
for out in $("$NIX" build --print-out-paths $PKGS); do
|
||||
printf '%s\n' "$paths" | grep -qxF "$out" \
|
||||
|| { echo "::error::$out is not matched by the '-punktfunk' filter — publish would silently omit it"; exit 1; }
|
||||
done
|
||||
|
||||
# 2) Sign into a local binary cache. The secret is the whole `name:base64` line from
|
||||
# `nix key generate-secret`; the matching public key is what users pin (README).
|
||||
KEYDIR="$(mktemp -d)"; chmod 700 "$KEYDIR"
|
||||
printf '%s' "$NIX_CACHE_SIGNING_KEY" > "$KEYDIR/key"; chmod 600 "$KEYDIR/key"
|
||||
printf '%s\n' "$paths" | xargs "$NIX" copy --to "file://$PWD/nix-cache?secret-key=$KEYDIR/key"
|
||||
# Publish the PUBLIC half beside the cache and echo it here. Users must pin this key, so
|
||||
# it needs to be fetchable from the cache itself rather than only from a doc that can
|
||||
# drift — and on the first run this log line is where the value for README.md comes from.
|
||||
# Redirect, not `| tee`: without pipefail a failing nix would be masked by tee's success
|
||||
# and publish an EMPTY public key, which every user would then pin.
|
||||
"$NIX" key convert-secret-to-public < "$KEYDIR/key" > nix-cache/punktfunk-cache.pub
|
||||
cat nix-cache/punktfunk-cache.pub
|
||||
rm -rf "$KEYDIR"
|
||||
echo "publishing $(find nix-cache -name '*.narinfo' | wc -l) paths, $(du -sh nix-cache | cut -f1)"
|
||||
|
||||
# 3) Ship it. Same deploy key and retry discipline as flatpak.yml — this runner's link to
|
||||
# unom-1 drops TCP dials under load.
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
|
||||
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
|
||||
# yet is a HARD download failure for whoever fetches it in that window; a NAR nothing
|
||||
# points at yet is simply invisible. rsync renames each file into place atomically, so a
|
||||
# cancelled run (this workflow has cancel-in-progress) can only ever under-publish.
|
||||
# No --delete: superseded paths are aged out by prune.sh below instead, so a client
|
||||
# mid-download is never pulled out from under.
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" nix-cache/nar/ "$DEST:$DEPLOY_DIR/site/nar/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" nix-cache/nix-cache-info nix-cache/punktfunk-cache.pub nix-cache/*.narinfo "$DEST:$DEPLOY_DIR/site/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" \
|
||||
packaging/nix/server/compose.production.yml packaging/nix/server/Caddyfile packaging/nix/server/prune.sh \
|
||||
"$DEST:$DEPLOY_DIR/"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
|
||||
# 4) Bound it. The flatpak repo next door reached 3.84 GB publishing this same way with
|
||||
# no sweep, on a box that has run out of disk before; this one gets the sweep from the
|
||||
# first publish. Never allowed to fail the job — the cache is already live by now, and
|
||||
# a growing disk is a slower problem than a red main.
|
||||
bash scripts/ci/retry.sh 3 $SSH "$DEST" "sh ~/$DEPLOY_DIR/prune.sh ~/$DEPLOY_DIR/site 180" \
|
||||
|| echo "::warning::cache prune failed — published cache may be growing unbounded"
|
||||
|
||||
# 5) Prove the published cache actually answers, rather than assuming the rsync landed.
|
||||
# A substituter that 200s on nix-cache-info but 403s on a miss is the failure mode that
|
||||
# breaks users' builds, so check both.
|
||||
bash scripts/ci/retry.sh 5 curl -fsS "$CACHE_URL/nix-cache-info"
|
||||
miss="$(curl -sS -o /dev/null -w '%{http_code}' "$CACHE_URL/0000000000000000000000000000000000.narinfo")"
|
||||
[ "$miss" = 404 ] || { echo "::error::cache returns $miss for an absent path; nix needs 404 or every user build fails"; exit 1; }
|
||||
echo "published → $CACHE_URL"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
# Accept BOTH shapes. A checkbox dispatched from the Gitea UI arrives as the STRING
|
||||
# "true", but an API dispatch (scripts, cross-repo automation) can deliver a real JSON
|
||||
# boolean, and `== 'true'` silently misses it — the step is skipped, the run goes green,
|
||||
# and the log looks identical to a run that genuinely had nothing to do. MEASURED
|
||||
# 2026-08-19: dispatched with build-gamescope while verifying a flake.lock bump, and this
|
||||
# step skipped while the job reported success — a green that proved nothing about the
|
||||
# very package being fixed. Still no `inputs.*`: that context is the thing Gitea's parser
|
||||
# is least reliable about, which is why this file used github.event.inputs to begin with.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
if: ${{ github.event.inputs.build-rust == 'true' || github.event.inputs.build-rust == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
|
||||
@@ -196,6 +364,6 @@ jobs:
|
||||
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||
# something actually builds it.
|
||||
- name: Build the patched gamescope (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' || github.event.inputs.build-gamescope == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
@@ -35,6 +35,7 @@ on:
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- 'scripts/alsa-ucm2/**'
|
||||
- '.gitea/workflows/rpm.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
|
||||
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
|
||||
@@ -221,6 +222,15 @@ jobs:
|
||||
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
|
||||
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
|
||||
# fail takes the job down instead of waving the release through.
|
||||
- name: The DualSense UCM drop-in must still bite
|
||||
# scripts/alsa-ucm2/ hooks into alsa-ucm-conf's own dispatcher, so an upstream rename or
|
||||
# reorder can neuter it with no error anywhere — and what comes back is the Spider-Man
|
||||
# EXCEPTION_ACCESS_VIOLATION, not a quieter pad. This is the only leg that runs on a real
|
||||
# Fedora tree, hence the two packages. Skips itself on any box without them.
|
||||
run: |
|
||||
dnf -y install alsa-ucm alsa-ucm-utils
|
||||
sh scripts/ci/check-dualsense-ucm.sh
|
||||
|
||||
- name: Assert the capability matrix (rpm)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
|
||||
+431
@@ -12,6 +12,437 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.31.0
|
||||
|
||||
90 commits since v0.30.0 (65 non-merge).
|
||||
|
||||
Nothing versioned moves. `WIRE_VERSION` stays **2**, the C ABI stays **24** — `include/punktfunk_core.h`
|
||||
is byte-identical to the v0.30.0 tag — the driver protocol, gamepad channel and plugin index schema
|
||||
are all unchanged, and no `trust::Settings` field, capability bit or control-message type byte was
|
||||
added. Every 0.30.x host, client, driver and plugin keeps interoperating in both directions, with no
|
||||
re-pairing.
|
||||
|
||||
What did move is beneath the versioned surfaces, and three parts of it are worth a packager's or
|
||||
embedder's attention: the Linux host package installs **three new system files** (a udev rule, a
|
||||
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
|
||||
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
|
||||
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
|
||||
Android app's Compose console is **deleted** — `pf-console-ui` over Skia/GL is now the console on all
|
||||
three ABIs, which removes the Compose screenshot scenes.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.30.0 | v0.31.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 24 | **24** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.30.0 tag; the only new `pub` items in `punktfunk-core` are three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`), Rust-only, no `pub const` for cbindgen to pick up |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.30.0 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
|
||||
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
|
||||
|
||||
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have
|
||||
unpublished code changes, called out in the table so they are cut deliberately rather than
|
||||
discovered.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None on any versioned surface.** No wire change, no C ABI change, no driver-protocol change, no
|
||||
plugin-contract change. Four things are worth attention anyway; none breaks a build:
|
||||
|
||||
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
|
||||
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
|
||||
the gate is simply "does the native host exist", and where it does not a controller drives the
|
||||
touch UI through focus. ~6.5 kLOC of `GamepadHome`, `GamepadSettingsScreen`,
|
||||
`GamepadAddHostScreen`, `GamepadDialogs`, `HomeTiles`, the console halves of `LibraryScreen`,
|
||||
`ConnectOverlay`/`ConnectTakeover`, the `gamepadUi` branches of `ConnectScreen`/`ConnectPrompts`/
|
||||
`AdaptiveDialogs`, `App.kt`'s `GamepadShell`/`GamepadScreen` and their tests are gone. The `!` is
|
||||
for the **store-screenshot surface**: the Compose console's marketing scenes cannot be rendered by
|
||||
Roborazzi any more (the shell draws over native GL); its shots come from the desktop screenshot dump
|
||||
or a device capture. Sysprop `debug.punktfunk.console_backend=compose` is meaningless; `=none`
|
||||
still forces the touch UI on glass.
|
||||
- **Linux desktop-audio capture topology flipped by default** — see the audio section. `=stream`
|
||||
restores 0.30 for **one release only**.
|
||||
- **Hyprland / sway: `topology: exclusive` now does what it says.** Both backends accepted it,
|
||||
echoed it as the session's effective topology, and dropped it with a warning; because `auto`
|
||||
resolves to Exclusive on any unpinned host, the *default* policy on every auto-detected Hyprland
|
||||
or sway box was an Exclusive that behaved as Extend. Operators who relied on that get their
|
||||
monitors disabled for the session now (closes #284).
|
||||
- **Three new system files in the Linux host package** — the DualSense audio path does not work
|
||||
without them. Downstream repackagers: see the packaging section.
|
||||
|
||||
### DualSense audio and haptics on Linux: five faults, and the files they needed
|
||||
|
||||
The whole in-game path — GE-Proton's haptic router → the pad's ALSA card → the voice coils — had
|
||||
never once worked against our virtual pad. In wire order:
|
||||
|
||||
- **`usbip`: the calibration feature report was 42 bytes; `hid-playstation` asks for 41.** On a USB
|
||||
backend an over-long reply is not truncated: the kernel treats it as hostile and tears down the
|
||||
connection, not the transfer — the pad vanished ~400 ms after enumerating, and the dmesg order made
|
||||
the teardown look like the cause. Three changes so the trap is not left set: the constant is 41 and
|
||||
all three feature-report sizes are pinned by test; `clamp_reply` clamps every reply to the requested
|
||||
length in the transport and drops any payload a handler returns on an OUT (the kernel never reads
|
||||
one; those bytes would misframe every following PDU); `DualSenseUsbip::open` waits for the kernel
|
||||
to actually bind a HID driver before reporting success (vhci attach succeeds immediately and
|
||||
enumerates asynchronously), so bring-up faults return `Err` and the uhid fallback catches them.
|
||||
New `PUNKTFUNK_USBIP_TRACE` (both socket directions to disk) and `scripts/usbip-trace-analyse.py`.
|
||||
- **`usbip`: every non-ISO OUT was answered with an empty buffer, i.e. `actual_length = 0`.** vhci
|
||||
copies that field verbatim into the URB's actual length; the driver returned 0 as the write's byte
|
||||
count; Wine's bus driver reads 0 as failure and prints the thread's *stale* errno — so the ENOENT /
|
||||
EINVAL / EAGAIN in the GE logs were never kernel verdicts. New
|
||||
`UsbIpResponse::usbip_ret_submit_out_success(header, accepted)`; the debug assertion now pins
|
||||
"OUT carries no buffer", not "OUT claims 0"; two wire-byte tests pin both directions. **The Steam
|
||||
Controller 2 shares this handler.** `usbip-trace-analyse.py` had flagged *any* nonzero OUT
|
||||
actual_length as a desync — the rule that would have hidden this bug — and now flags an OUT reply
|
||||
claiming more than it was sent, or 0 against a non-empty write.
|
||||
- **`usbip`: ISO completions were paced by relative sleeps**, so timer slop, socket I/O and lock waits
|
||||
accumulated per transfer: the pad's clock ran ~26 % slow (~35,700 frames/s against 48 kHz), its PCM
|
||||
backed up into dropouts, and because completion *is* the pad's audio clock, on the test box the pad
|
||||
sink became the graph driver and pulled desktop capture to 50 % delivery. Now a per-endpoint
|
||||
absolute deadline ledger (a stall > 20 ms re-anchors instead of fast-forwarding a burst); measured
|
||||
after: 48,005 frames/s. Two paused-clock tests pin the rate and the re-anchor.
|
||||
- **`usbip`: the capture forwarded the pad's hardware quad as the wire's speaker pair.** Hardware
|
||||
is HP-L, HP-R+mono-speaker, coil-L, coil-R; the wire puts the speaker pair first. Now: the speaker
|
||||
channel duplicated across the wire's speaker pair, coils passed through, HP-L dropped. The
|
||||
stream-sink (uhid) capture path already emitted the logical layout and is unchanged.
|
||||
- **`usbip`: `iSerialNumber` was the literal `"Serial"`.** A real DualSense reports none, ALSA bakes it
|
||||
into the card id (`…Wireless_Controller_Serial-00` vs `…Wireless_Controller-00`) and PipeWire
|
||||
carried it into every node name and `device.serial`. Cleared. Explicitly *not* a fix for anything
|
||||
observed broken — GE's winepulse leg matched the placeholder — and *not* a UCM-selection fix
|
||||
(alsa-ucm-conf keys on `${CardComponents}`, `USB054c:0ce6`).
|
||||
- **The pad's ALSA card was root-only.** It is created mid-session-bringup with no seat session
|
||||
active, so logind's ACL never materialises; WirePlumber's probe got EACCES and the card never
|
||||
appeared in PipeWire at all. `scripts/60-punktfunk.rules` gains two `SUBSYSTEM=="sound"` rules for
|
||||
`054c:0ce6` / `054c:0df2` (`GROUP="input" MODE="0660" TAG+="uaccess"`), matching physical pads too.
|
||||
Verified live on Bazzite f44.
|
||||
- **The DualSense's only playback route was a 1-channel `Default__Speaker__sink`**, from which
|
||||
GE-Proton mints its synthetic endpoint, and *Marvel's Spider-Man Remastered* overruns it ~74 s in
|
||||
(`EXCEPTION_ACCESS_VIOLATION`, write; the copy loop past the frame count, 5206/5207 vs 5034 — a
|
||||
game/GE bug on a code path that only exists when the mono sink does). Fix: delete the sink. New
|
||||
ALSA UCM drop-in `scripts/alsa-ucm2/USB-Audio/conf.d/{054c-0ce6,054c-0df2}.conf` +
|
||||
`scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic{,-HiFi}.conf` raises a `SpeakerHaptic`
|
||||
device at playback priority 200 against `Speaker`'s 100, so the card takes the 4-channel HiFi
|
||||
profile and the mono sink never exists. Shipped **without** replacing a file `alsa-ucm-conf` owns:
|
||||
`USB-Audio.conf` ends with an unconditional optional include of `conf.d/{vid}-{pid}.conf`
|
||||
(verified against alsa-lib source; hook and DualSense profile both since 1.2.15). New CI guard
|
||||
`scripts/ci/check-dualsense-ucm.sh` runs the chain on a real distro tree via UCM's card-less
|
||||
`conf.virt.d`, negative-tested both ways. **NixOS is not covered** (no `/usr/share/alsa/ucm2` to
|
||||
drop into).
|
||||
- **WirePlumber met every new pad card at `default-sink-volume` 0.4 — cubed, i.e. −23.88 dB — and
|
||||
both ends minted one**, so haptics reached the coils at 0.064² = −47.8 dB (field-measured −48).
|
||||
Client: `pin_sink_volume` from `correlate_pad_sink` at every pick (skipped for the `split_parent`
|
||||
pick). Host: new `audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open` succeeds
|
||||
(the host half matters because `pad_usb` captures at the ISO OUT endpoint, downstream of this
|
||||
sink), retrying 15 s because the USB device is live before its ALSA card is; only sinks of a
|
||||
DualSense **card** are touched (`device.id` keeps it off the host's own minted pad sink). Neither
|
||||
end restores on exit, deliberately. New `PUNKTFUNK_PAD_SINK_VOLUME=0` disables both ends for
|
||||
bisecting. Both pins unit-tested for one unity float per channel — PipeWire silently ignores a
|
||||
`channelVolumes` whose length mismatches the port count.
|
||||
- **`scripts/60-punktfunk-dualsense.conf`** — a new WirePlumber policy installed to
|
||||
`/usr/share/wireplumber/wireplumber.conf.d/` by rpm/deb/arch/nix: `node.always-process` + no
|
||||
suspend on the pad's `alsa_output` (GE opens the backing device raw when it is free, then hits
|
||||
"busy" against its own handle and spins a 100 Hz refresh loop — SteamOS never shows this because
|
||||
PipeWire always holds the device there), and `priority.driver = 0`. **Zero, not one**: the field is
|
||||
unsigned and a driver is skipped only when `<= 0`; at 1 the pad was merely *last*, and last is still
|
||||
elected whenever nothing above it qualifies — the ordinary in-session state on a host that has
|
||||
claimed its own sink as default and idled the real card. A second rule sets `priority.driver = 0`
|
||||
on the same cards' `alsa_input` (in the Pro Audio profile that node carries 2600 and clocked a
|
||||
reporter's whole desktop session with nothing linked to it). The rule's first landing duplicated
|
||||
its `%files` line into `%install`, which killed every RPM build on main for a few hours (fixed same
|
||||
day, no release affected).
|
||||
- **`0xD1` lane split:** speaker = Opus `Application::Audio` @ 96 kbps (~120 B / 10 ms frame),
|
||||
haptics = `Application::LowDelay` @ 64 kbps CBR, unchanged.
|
||||
- **`punktfunk-session --pad-audio-test`** now prints the effective `pad_speaker` / `pad_haptics`
|
||||
before the tone (the capability is never advertised when the toggle is off, so no later log line can
|
||||
catch it); the Android settings row states its default. Android is the one client defaulting pad
|
||||
speaker **off**; `pf_client_core`'s `default_pad_speaker` is `"pad"` and always was.
|
||||
|
||||
### The Linux desktop-audio capture drives its own graph group
|
||||
|
||||
The stream sink was a `pw_stream` wearing `media.class = Audio/Sink`. A stream is structurally a
|
||||
follower, so its group had no clock and PipeWire assigned it to the highest-priority *running*
|
||||
driver on the box. On a reporter's host that was a DualSense forwarded over VirtualHere in the Pro
|
||||
Audio profile — never suspended, nothing linked, its frame counter a kernel stub logging "not yet
|
||||
implemented" and returning 0 ~1900×/s. Not xruns: 11 errors in 15 min, wait never past 111 µs; the
|
||||
loss was *between* cycles — 3.9 delivery holes/s, worst 142 ms, **15.4 % synthesized silence** over
|
||||
a 15-minute session.
|
||||
|
||||
Now a `support.null-audio-sink` adapter created on our own connection, captured through its monitor
|
||||
(the same object `pactl load-module module-null-sink` creates). Three load-bearing properties:
|
||||
`node.passive` on the monitor tap (idle between sessions, so the null sink's timer parks — the
|
||||
objection that kept `node.always-process` off the old stream sink); `node.force-quantum`, not
|
||||
`node.latency` (a driver's quantum is the smallest follower latency rounded **down** to a power of
|
||||
two under the default `default.clock.power-of-two-quantum`, which is why the 240-frame ask has been
|
||||
served as **128** — 2.67 ms callbacks, not the 5 ms it is designed around — on every stock Linux host
|
||||
since the capture was written; force-quantum skips the rounding and forces nothing on anyone else,
|
||||
since this sink drives only its own group); and `node.dont-fallback` **with** `node.linger`, never
|
||||
one alone (WirePlumber 0.5 reads dont-fallback alone as licence to destroy the stream when its target
|
||||
is not visible). Routing claim, capture callback, stats line and everything downstream untouched.
|
||||
`PUNKTFUNK_STREAM_SINK`: unset = new topology, `stream` = 0.30's (one release), `0` = the legacy
|
||||
default-sink-monitor follower. Documented at last in `configuration.md`, with a new troubleshooting
|
||||
section on the `punktfunk-audio-…` recording stream and on another device clocking your capture.
|
||||
|
||||
Around it, from the same 2026-08-14/17 field logs:
|
||||
|
||||
- The host binds its own node and reads `node.driver-id` from its `info` event (a node-id→name map
|
||||
from the registry): on change, `audio capture graph driver` names the clocking node — WARN in the
|
||||
null-sink mode (exactly one right answer), INFO in the legacy topologies (they borrow a clock by
|
||||
design).
|
||||
- `CaptureStats::observe_gap` is now the one accounting behind both feeds (Linux callback cadence and
|
||||
the Windows discontinuity flag) and buckets holes at <20 / <50 / <100 / ≥100 ms — the client
|
||||
concealment edges. Both capture lines print `gap_hist=a/b/c/d missing_ms=`; the sum closes the
|
||||
arithmetic against `delivered_pct`. The Windows loopback **reader** thread now takes
|
||||
`boost_thread_priority(true)` like the paced sender it feeds.
|
||||
- **The pacer's schedule was wall clock; the source was not.** A missed 2.7 ms cycle is below the gap
|
||||
counter's floor and the infill threshold, so the schedule kept the debt and repaid the next ≥ 10 ms
|
||||
hole as a burst of (lag + 10)/5 silence frames (field: 33–72 % departures late, worst 99 ms,
|
||||
re-anchors 0). The infill decision now sees schedule lag; `after()` follows the real quantum
|
||||
(`InfillPolicy::note_quantum`) — one chunk plus one frame, never under two frames; a slot whose
|
||||
backlog exceeds one chunk plus one frame sends a second frame in the same slot (at most two), since
|
||||
a fast source clock could otherwise only grow the backlog — 5 ms of host latency per 50 s at
|
||||
100 ppm. Holes fade out over 1 ms (`pcm::raised_cosine_tail`) and the first real frame after fades
|
||||
in (`raised_cosine_head`).
|
||||
|
||||
### The client jitter ring can now grow without de-priming
|
||||
|
||||
`JitterStep::insert_front` mirrors `drop_front`: when the sync loop wants more than the adaptive
|
||||
target and the depth EWMA has sat > `INSERT_MARGIN_MS` below the request for `INSERT_SUSTAIN_MS` of
|
||||
consumed audio, duplicate one frame at the front, crossfaded (`crossfade_insert`, the RT-safe twin
|
||||
of `crossfade_drop`). Sync-only, primed-only, below-target-only. `hollow` is judged against the
|
||||
**adaptive** target, never the sync request — the bug was that a ≥ 10 ms sync request read as hollow
|
||||
on the next callback and the next late packet cost 15–60 ms of silence, since ~0.24/0.25. Margin is
|
||||
half the sync loop's ±10 ms deadband (a margin at or above it would leave every request it is allowed
|
||||
to make unanswered). Also fixes `crossfade_drop`'s seam: the fade-out source is now the continuation
|
||||
of the sample the device just played, not the tail of the discarded region — a hard-cap trim stepped
|
||||
2,688 samples where it now stays under 17. Wired into the PipeWire, WASAPI and AAudio rings
|
||||
(`PlaybackVitals.inserts`, `drift_inserts=` on the 10 s lines) and ported line for line to the Swift
|
||||
ring (`insertOneFrame()`, `AudioRingDriftTests` carrying the same vectors). No new `pub const`; the
|
||||
C header is unchanged.
|
||||
|
||||
Beside it: the Linux desktop client's playback stream now connects with `RT_PROCESS` (it ran on the
|
||||
main-loop thread at nice 0, and when late PipeWire rendered silence for our node and moved on — an
|
||||
underrun no counter saw); the ring is pre-reserved so `extend` never reallocates on the RT loop; new
|
||||
`audio_vitals::PlaybackVitals` printed from the decode thread on wall clock. New `audio_rt` module
|
||||
raises the decode, pad-audio, PipeWire-loop and Linux mic threads: `setpriority` where `RLIMIT_NICE`
|
||||
allows → inside a Flatpak the `org.freedesktop.portal.Realtime` portal → else rtkit
|
||||
`MakeThreadHighPriorityWithPID`. The split is `module-rt`'s and not optional: rtkit-daemon has no
|
||||
PID-namespace translation (verified on the Deck, rtkit 0.14), so a direct call from a sandbox is
|
||||
ENOENT; the portal maps pid/tid. Never setcap / `SCHED_RR`. Windows: MMCSS "Pro Audio" +
|
||||
`THREAD_PRIORITY_HIGHEST` on the render and mic loops. Acceptance on the Deck: `ps -eLo
|
||||
cls,rtprio,ni,comm` shows the decode thread at nice −10 after connect.
|
||||
|
||||
The client log ring drops DEBUG/TRACE from `cros_codecs` (its WARN+ still lands) and normalizes
|
||||
`log`-bridge events to their real target: a dozen DPB lines per frame at 120 fps last three seconds
|
||||
in a 4,096-line ring — a 2026-08-17 Deck bundle read "2,037,456 older lines evicted". `Cargo.lock`
|
||||
gains two direct deps already in the graph.
|
||||
|
||||
### Android: `pf-console-ui` is the console, presented through `ASurfaceControl`
|
||||
|
||||
- **`pf-client-core` un-gated for Android** (trust::Settings, known-hosts store, profiles model,
|
||||
deep links, the library *model*; the ureq fetches stay desktop), with `audio_format`,
|
||||
`decoder_pref`, `menu_nav` (`MenuEvent`/`MenuNav`/`PadInfo`) and `console` (`OverlayAction`,
|
||||
`PointerInput`, `SessionPhase`) split out and re-exported. `pf-console-ui`: Vulkan overlay + SDL
|
||||
event path behind the default `vulkan-overlay` feature (clients/session unchanged); a `Key` enum
|
||||
replaces SDL scancodes; a `SettingsStore` seam (desktop = the file, `SnapshotStore` across a
|
||||
language boundary); `Viewport{width,height,insets,scale}`; `Platform` filters the settings rows;
|
||||
`ConsoleOptions`; a portable `Console` driver. skia-safe features are target-specific: desktop
|
||||
`jpegd-jpege-pdf-textlayout-vulkan` (the flatpak pin), Android `gl-jpegd-jpege-pdf-textlayout`.
|
||||
Model types derive serde — the wire IS the model. `MenuNav` gains the stick hysteresis
|
||||
(`MENU_RELEASE = 0.3`) both the Apple and Android shells had grown on glass.
|
||||
- **`clients/android/native/src/console/`**: hand-declared EGL binding, Skia GL `DirectContext` over
|
||||
FBO 0, one render thread paced by `eglSwapBuffers`, ~28 `nativeConsole*` JNI seams; a run of GL
|
||||
setup failures ends the render thread through the normal release path, which raises the
|
||||
`SkiaConsole.healthy` handover to the touch UI. `SkiaConsoleShell` (SurfaceView + lifecycle,
|
||||
insets = systemBars ∪ displayCutout in surface px, system bars hidden transiently while the console
|
||||
is up, phone density floor **0.6 → 0.75**, pad probes into the shared `MenuNav`, remote D-pad,
|
||||
hardware keys, Back as B, touch as pointer). Pad-listener slot is a **stack** with removal by
|
||||
identity (a leaving Controllers/Licences page used to null the console's claim). Android-only
|
||||
settings rows ride `Settings::extra` `android.*` keys; `row_on()` keeps them off the desktop list.
|
||||
New `ConsoleCmd::PadAction { action, pad_key }` (`sc2_bluetooth`, `sc2_usb`, `ds_usb`, rumble,
|
||||
pad-audio self test); `PlatformScreen::Controllers` removed (the mechanism stays for Licences);
|
||||
`PadInfo` gains detail line / forwarded / rumble. Detail band 84 → 64 units; the grid's two-column
|
||||
minimum shrinks covers instead of clipping.
|
||||
- **Skia prebuilts** for all three ABIs come from `unom/skia-binaries` release **0.99.0** on
|
||||
git.unom.io (R2-backed), mirroring rust-skia's `{tag}/{key}` layout; the armv7 archive
|
||||
(`a25a0fdb7d90429aa2d1-armv7-linux-androideabi-gl-jpegd-jpege-pdf-textlayout`, sha256
|
||||
`4867856b…`) is built by us since rust-skia publishes none. GitHub is out of the Android build path;
|
||||
`-PskiaBinariesUrl` / `SKIA_BINARIES_URL` remain as overrides.
|
||||
- **Present path:** the codec renders into an `AImageReader`; frames are composited onto an
|
||||
`ASurfaceControl` layer via a transaction carrying a desired present time, and completion reports
|
||||
the real latch time and the previous buffer's release fence — so the panel period is learned from
|
||||
real latches (Android down-rates a game process's vsync callbacks; the old presenter could learn 60
|
||||
on a 120 Hz panel) and the frame budget is bounded by real completions. `ASurfaceControl` /
|
||||
`ASurfaceTransaction` are not in ndk-sys 0.6, so `surface_control.rs` hand-declares them and
|
||||
resolves via `dlsym` from `libandroid.so` (all API 29, above minSdk 28), same pattern as `adpf.rs` /
|
||||
`vsync.rs`. Memory safety does not rest on the fences (an `AImage` keeps its buffer alive through
|
||||
SurfaceFlinger's own reference; a mishandled fence is at worst a tear). **Default**; auto-fallback
|
||||
to the SurfaceView presenter, byte-for-byte unchanged, on API < 29 or any init failure; escape hatch
|
||||
`debug.punktfunk.present_backend=surfaceview`. The layer is sized to the view's on-screen pixels,
|
||||
not the window buffer (which is reported in a rotated/scaled space — 1260×567 for a 2800×1260
|
||||
stream, drawing into the top-left 45 %). The present-time grid uses the mode table's seed period
|
||||
for spacing and the last real latch only for phase (learning the period from latches was
|
||||
self-fulfilling and locked the panel at 60). On glass at 2800×1260@120: e2e p50 30 → ~18 ms,
|
||||
skipped 40–50/s → 0. Whether the panel *holds* 120 is the OEM's LTPO governor — measured: no
|
||||
app-side API (`preferredDisplayModeId`, `preferredRefreshRate`, the layer rate vote,
|
||||
`frameRatePowerSavingsBalanced`) raises the render-range floor — so the ineffective pins were
|
||||
removed again and `pf.present` gained the cadence loop's late-permille / jitter / cushion /
|
||||
re-anchors / qDepth.
|
||||
|
||||
### Hyprland / sway: `topology: exclusive` (closes #284)
|
||||
|
||||
`exclusive` disables the operator's outputs for the session and restores them when the display
|
||||
group's last member is torn down, through the same registry hand-off KWin uses (the compositor never
|
||||
sees zero enabled outputs; a sibling session's desk is never re-enabled under it). The disable filter
|
||||
is group-aware — enabled, not ours (`PF-<pid>-<n>` on Hyprland, the `HEADLESS-` prefix on sway), not
|
||||
managed. **The Hyprland restore is `hyprctl reload`, and that is measured, not chosen**: re-applying
|
||||
the head's own mode/position/scale does not undo a disable (probed 2026-08-18 against 0.56.2
|
||||
hyprlang and 0.55.4 Lua — every targeted form was accepted at exit 0 and changed nothing, including
|
||||
`,enable`, `preferred,auto,1`, `monitorv2 disabled=false`, `keyword unset monitor`, the Lua
|
||||
`disabled = false`, `dispatch dpms on`, `forcerendererreload`); a runtime rule is additive and the
|
||||
disable keeps winning. Disable is spelled per config era (`keyword monitor <n>,disable` under
|
||||
hyprlang; `hl.monitor{ output = "<n>", disabled = true }` under Lua) and confirmed by **read-back**,
|
||||
not exit status. `hyprctl_dispatch` now also matches "can't" (the Lua manager's "keyword can't work
|
||||
with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **The sway half is not
|
||||
exercised on a live sway** — no box in the fleet runs one; both argv shapes are pinned by tests and
|
||||
the read-back turns a wrong guess into a warning naming the outputs. Six new unit tests.
|
||||
|
||||
### Gaming Mode takeover: the mask was the relogin storm
|
||||
|
||||
On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin path, so every
|
||||
autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind sessions in 3.7 min,
|
||||
system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy crash-looping ~16
|
||||
starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per event, read the pad at
|
||||
~1.4 Hz. `dm_plan` loses its `mask` input and `dm_survives_masked_unit`; the mask is laid **only after
|
||||
the stop has landed** and every restore path unmasks before restarting; a planned DM stop that does
|
||||
not land now **fails the takeover** and the caller degrades to ATTACH. `skip` is `!any_live` on every
|
||||
flavor; `any_live` now counts `deactivating` and `reloading`. New `DmHelperError::shape()`;
|
||||
`watch_for_relogin_storm()` (two `read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s,
|
||||
detect-only); `systemctl_system` captures stderr at DEBUG (the "requires interactive authentication"
|
||||
line was going to the journal on the *successful* path). `cargo test -p pf-vdisplay --lib gamescope`
|
||||
52 passed, 1 ignored.
|
||||
|
||||
### Windows host: two session-killers
|
||||
|
||||
- **`untune_process` logged from a TLS destructor.** By then `tracing`'s own thread-local state can be
|
||||
gone; the log call panicked, and a panic escaping a TLS destructor aborts. The panic hook then hid
|
||||
the evidence — it logged through the same framework and panicked the same way, and a panic inside
|
||||
the hook is a case where std deliberately does not format the message (the field log: a location, a
|
||||
blank line, "thread panicked while processing panic. aborting."). The service manager restarted the
|
||||
host ~6 s later, so it read as a reconnect. `untune_process` no longer logs (still atomic under the
|
||||
refcount lock); the panic hook writes straight to the `LogRing` (`OnceLock` + `Mutex`, TLS-free;
|
||||
`thread::current()` and `Backtrace::force_capture()` verified safe during TLS destruction).
|
||||
Reproduced standalone on 1.96.0, byte-identical to the field log.
|
||||
- **A Windows launch is a hand-off, and 0.30 read its exit as the game's.** `explorer.exe
|
||||
"playnite://…"`, `Steam.exe "steam://…"` and shell app-folder links spawn a forwarder that quits a
|
||||
second later (launcher already running) or *becomes* the launcher (it was not); the shim window that
|
||||
guards this was skipped for hint-less titles — the one shape that needs it — so the lease reported
|
||||
running, then the forwarder's exit closed the connection. The forwarder was also a termination
|
||||
target. `WinRecipe::owns_game` records which recipe lines start the game (only `gog`, `command` and
|
||||
a plugin's own recipe) and which forward; a forwarder's pid is dropped; the shim window applies to a
|
||||
bare child or pid whatever the spec holds; giving up on tracking lands on `GameState::Untracked`
|
||||
instead of `launching` forever. Fixture in `a_pid_only_launch_reports_its_exit` widened 4 → 8 s
|
||||
(it passed only because of the bug); new ignored test drives the field report.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
|
||||
the runner's log shipper and the tray dialing a dead port (task Running, plugins never registering,
|
||||
empty library, "no logs at all"). `sdk/src/config.ts::publishedMgmtUrl` reads
|
||||
`<config_dir>/mgmt-endpoint`; `resolveConfig` uses it after `PUNKTFUNK_MGMT_URL` and before the
|
||||
default; `runner-cli.ts` exports it into `PUNKTFUNK_MGMT_URL` before any plugin loads (older
|
||||
vendored SDK copies follow too). New `pf_paths::published_mgmt_port`; `punktfunk-tray` depends on
|
||||
`pf-paths` and its `mgmt_port` is `Option<u16>` — `None` re-reads the file every poll. SDK 83 tests
|
||||
(4 new). **Unpublished — `sdk-v0.1.5` owed.**
|
||||
- **`scripts/windows/scripting-run.cmd`** redirects the runner's stdout+stderr to
|
||||
`%ProgramData%\punktfunk\plugin-state\runner.log` (previous run rotated to `.1`; writability probed
|
||||
with `copy /y nul`; no `goto`, the file is LF). Verified by reading only.
|
||||
- **`@punktfunk/plugin-kit`: `SyncSettings.minInterval`** (optional; `LibraryPluginDef.minInterval`
|
||||
overrides), default `DEFAULT_FS_CHANGE_MIN_INTERVAL` = 30 s — a floor on top of the 3 s debounce,
|
||||
which cannot bound the *rate* under sustained churn (`plugin:steam sync (fs-change)` 102× in
|
||||
27 min). Changes inside the hold coalesce into one trailing sync. **Unpublished — `plugin-kit-v0.4.3`
|
||||
owed.** Narrowing the Steam plugin's watch set lives in the steam plugin repo.
|
||||
- **Nix binary cache at `https://nix.unom.io`** (`nix.yml` third tier: build Rust packages +
|
||||
gamescope, sign, publish on every main push; a release needs no new trigger since `Cargo.toml` is
|
||||
in the path filter). Only punktfunk's own store paths (~300 MB per publish); the step asserts every
|
||||
output matches the name filter; NARs before narinfos, rsync without `--delete`. New
|
||||
`packaging/nix/server/{Caddyfile,compose.production.yml,prune.sh}` (a `caddy:2-alpine` static tree
|
||||
on unom-1 beside the flatpak repo) and `scripts/setup-nix-cache.sh` (five stages; the secret key is
|
||||
shown once and never written to disk; four stages after #318, which also made it detect an
|
||||
installed key and refuse to casually regenerate one). The signing key is generated and installed as
|
||||
the `NIX_CACHE_SIGNING_KEY` Actions secret; its public half,
|
||||
`punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=`, is pinned in `install.md` and
|
||||
`packaging/nix/README.md` and served by the cache at `/punktfunk-cache.pub` (the wizard compares the
|
||||
two and warns on mismatch). DNS for `nix.unom.io` is provisioned through `unom/infra`'s OpenTofu
|
||||
(`terraform/cloudflare/records.tf`, applied by `dns-cutover.yml`) — not a dashboard click.
|
||||
`inputs.punktfunk.inputs.nixpkgs.follows` defeats the cache entirely. Rejected: Gitea's package
|
||||
registry (no Nix type), storage.unom.io (home uplink, and S3 answers 403 not 404 for a missing key,
|
||||
which nix treats as fatal).
|
||||
- **Apple console-UI parity** (Swift, PunktfunkKit/PunktfunkShared): `LibraryCollation` ports
|
||||
`pf-console-ui`'s `collate.rs` (the desktop's eight tests by name; both read
|
||||
`clients/shared/library-collate-vectors.json`, new — desktop is the source of truth and regenerates
|
||||
it); `GameEntry.platform` (sent in `GameMeta` all along, dropped by `Codable`); `LibraryPlaceStack`,
|
||||
`CollectionsHandover.decide`, `LibraryGridCursor` (port of `GridShape`/`grid_step`/`grid_col_hint`,
|
||||
nine grid tests by name), `GridGeometry` (the grid owns its scroll offset — no trackpad wheel on the
|
||||
grid, a named trade); `ConsoleContract.swift` pins `ConsoleMotion` to the shared vectors'
|
||||
`motion_spring` (response 0.42, damping 0.88, slide 36, scales 0.985/0.96, reveal 0.4,
|
||||
interruptible; the v1 `$deprecated` note now names Android as the last v1 reader — and Android
|
||||
moved to the shared shell in this same release). Device keys `librarySort` / `libraryView` /
|
||||
`libraryCollections` / `libraryGroupBy` — presentation only, never in a profile. `PosterImage`
|
||||
decodes at the drawn size (`CGImageSourceCreateThumbnailAtIndex`). `HostCardView`'s primary action
|
||||
reverted to connect (`22fdea66` reverted; `swift test` 375/0). New dev hooks
|
||||
`PUNKTFUNK_FAKE_LIBRARY=<file.json>`, `PUNKTFUNK_SHOT_EDITING=<field>`, `PUNKTFUNK_SHOT_INTERACTIVE=1`
|
||||
(screenshot harness only).
|
||||
- **New environment variables:** `PUNKTFUNK_PAD_SINK_VOLUME` (`=0` skips both pad-sink pins),
|
||||
`PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS` (pad-arrival grace), `PUNKTFUNK_USBIP_TRACE` (byte-level
|
||||
USB/IP trace prefix, off by default), and the three Apple screenshot-harness hooks above.
|
||||
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
|
||||
- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules`
|
||||
(+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix),
|
||||
`scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs.
|
||||
- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the
|
||||
five triage labels; single-context domain docs). A host audio-source comment corrected
|
||||
(`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission).
|
||||
- **CI:** Nix publish job records `df` after the build as well as before.
|
||||
|
||||
### Verification status
|
||||
|
||||
Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`):
|
||||
`cargo fmt --all --check` clean — **after** a whitespace-only commit on the release branch: two files
|
||||
(`pf-console-ui/src/screens/controllers.rs`, `punktfunk-host/src/audio/linux/pad_card_volume.rs`)
|
||||
had landed on main formatted differently from rustfmt 1.96.0, so `ci.yml`'s Format step was red on
|
||||
the tip this is cut from; `cargo metadata --offline` ok with the `Cargo.lock` diff versions-only
|
||||
(36/36 lines); `cargo test -p punktfunk-core` **272 passed** in the unit suite; the android.yml Play
|
||||
notes gate run verbatim — 498/500 characters and not byte-identical to any prior release's; both
|
||||
openapi copies `cmp` identical and unchanged since the tag; `include/punktfunk_core.h` regenerated
|
||||
by the build and `git diff` clean against the tag.
|
||||
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**: it links the staticlib with
|
||||
`-lopus` and this machine has no libopus (`ld: library 'opus' not found`), which is an environment
|
||||
gap, not a code fault. The header it exercises is byte-identical to v0.30.0's, where the harness
|
||||
passed (261 + 1 + 8), and nothing in `punktfunk-core`'s C surface changed. The CI runner is its
|
||||
first execution for this tag.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows runner
|
||||
log redirect (`scripting-run.cmd`), the tray's `Option<u16>` port on Windows, and the sway half of
|
||||
`topology: exclusive` (no live sway in the fleet, as with #283).
|
||||
|
||||
⚠ **Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink
|
||||
capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min
|
||||
of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; and the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3) — the fallback presenter is
|
||||
byte-for-byte the 0.30 one.
|
||||
|
||||
---
|
||||
|
||||
## v0.30.0
|
||||
|
||||
175 commits since v0.29.0 (131 non-merge).
|
||||
|
||||
+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
+38
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3582,6 +3582,7 @@ dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"if-addrs",
|
||||
"libc",
|
||||
"log",
|
||||
"opus",
|
||||
"proptest",
|
||||
"quinn",
|
||||
@@ -3604,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3613,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3683,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3697,11 +3698,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"serde",
|
||||
@@ -3720,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -109,36 +109,11 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
|
||||
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
|
||||
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
|
||||
installed first:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
|
||||
# (on Bazzite take host.env.bazzite instead)
|
||||
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
|
||||
|
||||
systemctl --user enable --now punktfunk-host # the streaming host
|
||||
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
|
||||
```
|
||||
|
||||
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
|
||||
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
|
||||
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
|
||||
line before the replacement — the install guide has the snippet). Then open
|
||||
`https://<host-ip>:47992` and pair.
|
||||
|
||||
How the virtual display and input are wired up depends on your desktop — see
|
||||
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
The per-platform guide walks you through the rest — first run, the web console, pairing, and the
|
||||
desktop-specific wiring ([KDE](https://docs.punktfunk.unom.io/docs/kde) ·
|
||||
[GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway).
|
||||
|
||||
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
|
||||
nothing to run by hand — open the web console and pair. Use
|
||||
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
|
||||
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
|
||||
`setup.exe` over the old install; uninstall from Add/Remove Programs.
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway)).
|
||||
|
||||
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.29.0"
|
||||
"version": "0.31.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
|
||||
@@ -49,6 +49,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
@@ -101,6 +104,26 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// System bars have ONE owner: this effect. The stream and the console shell both want the
|
||||
// whole panel (bars hidden, a swipe shows them transiently); the touch shell wants them back.
|
||||
// It cannot live inside the screens themselves: `AnimatedContent` below keeps the outgoing
|
||||
// screen composed until its fade ends, so a per-screen `onDispose { show(...) }` fired AFTER
|
||||
// the incoming screen's hide — console → stream left the status and gesture bars parked over
|
||||
// the video. Keyed on the resolved intent, not the screens.
|
||||
val immersive = session != null || gamepadUi
|
||||
DisposableEffect(immersive) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
if (immersive) {
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
} else {
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
|
||||
|
||||
@@ -962,9 +962,14 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
// The one row here that is OFF by default (see Settings.padSpeaker for why), which
|
||||
// makes a silent pad speaker look exactly like broken hardware — the failure this
|
||||
// subtitle exists to pre-empt, after it cost a full evening of host-side measuring.
|
||||
// Say the default out loud rather than describing only what "on" does.
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker — " +
|
||||
"off by default, so the pad's speaker stays silent until you turn this on",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
|
||||
@@ -58,7 +58,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -420,10 +419,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(true)
|
||||
}
|
||||
controller?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// System bars: NOT hidden here — App.kt owns hide/show (one owner; the AnimatedContent
|
||||
// handoff broke per-screen ownership, see the `immersive` effect there).
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
@@ -817,7 +814,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(false)
|
||||
|
||||
@@ -65,6 +65,7 @@ internal object ConsoleJson {
|
||||
.put("online", online)
|
||||
.put("mgmt_port", advert?.mgmtPort ?: h.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", !online && h.mac.isNotEmpty())
|
||||
.put("clipboard_sync", h.clipboardSync)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
|
||||
.put("pin", JSONObject.NULL)
|
||||
@@ -106,6 +107,7 @@ internal object ConsoleJson {
|
||||
.put("online", true)
|
||||
.put("mgmt_port", d.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", false)
|
||||
.put("clipboard_sync", false)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", d.os)
|
||||
.put("pin", JSONObject.NULL)
|
||||
@@ -129,6 +131,7 @@ internal object ConsoleJson {
|
||||
.put("online", true)
|
||||
.put("mgmt_port", h.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", false)
|
||||
.put("clipboard_sync", h.clipboardSync)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", h.os)
|
||||
.put("pin", pin?.let(::profileChip) ?: JSONObject.NULL)
|
||||
|
||||
@@ -37,8 +37,10 @@ import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -213,6 +215,14 @@ object SkiaConsole {
|
||||
main.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (handle == 0L) return
|
||||
// Only while the console is ON SCREEN (attached): parked behind the touch UI
|
||||
// or a stream there is nobody to show the presence pips to — and mid-stream
|
||||
// the radio belongs to the session, which is exactly why discovery stops for
|
||||
// it. The timer keeps ticking so probes resume within a cadence of re-attach.
|
||||
if (onConnected == null) {
|
||||
main.postDelayed(this, 12_000)
|
||||
return
|
||||
}
|
||||
val targets = knownHostStore.all().filter { kh -> discovered.none { kh.matches(it) } }
|
||||
ioPool.execute {
|
||||
val up = targets.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
|
||||
@@ -536,12 +546,14 @@ object SkiaConsole {
|
||||
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
|
||||
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
|
||||
c.optJSONObject("Pair")?.let(::pair)
|
||||
c.optJSONObject("SendLogs")?.let { notice("Sending logs isn't available on this device yet") }
|
||||
c.optJSONObject("SendLogs")?.let(::sendLogs)
|
||||
c.optJSONObject("SaveHost")?.let(::saveHost)
|
||||
c.optJSONObject("UpdateHost")?.let(::updateHost)
|
||||
c.optJSONObject("ForgetHost")?.let(::forgetHost)
|
||||
c.optJSONObject("Wake")?.let(::wake)
|
||||
c.optJSONObject("SetPin")?.let(::setPin)
|
||||
c.optJSONObject("BindProfile")?.let(::bindProfile)
|
||||
c.optJSONObject("SetClipboard")?.let(::setClipboard)
|
||||
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
|
||||
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
|
||||
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
|
||||
@@ -582,6 +594,22 @@ object SkiaConsole {
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/** `ConsoleCmd::BindProfile` — the host's default binding (`KnownHost.profileId`); null clears. */
|
||||
private fun bindProfile(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
val pid = c.optString("profile_id")
|
||||
.takeIf { c.has("profile_id") && !c.isNull("profile_id") && it.isNotEmpty() }
|
||||
knownHostStore.save(kh.copy(profileId = pid))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/** `ConsoleCmd::SetClipboard` — the per-host clipboard trust toggle. */
|
||||
private fun setClipboard(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
knownHostStore.save(kh.copy(clipboardSync = c.optBoolean("on")))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
private fun setPin(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
val pid = c.optString("profile_id"); val pin = c.optBoolean("pin")
|
||||
@@ -591,6 +619,52 @@ object SkiaConsole {
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/**
|
||||
* `ConsoleCmd::SendLogs` — the native log ring (`nativeRenderLogs`) posted to this
|
||||
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
|
||||
* uses; the result comes back as a notice, in the desktop console's wording. The header
|
||||
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) — client
|
||||
* log bundle`).
|
||||
*/
|
||||
private fun sendLogs(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
|
||||
val hostName = c.optString("host_name").ifEmpty { addr }
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
notice("Identity not ready yet — try again in a moment")
|
||||
return
|
||||
}
|
||||
val version = appContext?.let { app ->
|
||||
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
|
||||
} ?: "?"
|
||||
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
|
||||
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
|
||||
ioPool.execute {
|
||||
val err = runCatching {
|
||||
val body = NativeBridge.nativeRenderLogs(header)
|
||||
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
|
||||
id.certPem, id.privateKeyPem, addr, fp,
|
||||
)
|
||||
val req = Request.Builder()
|
||||
.url("https://$addr:$mgmt/api/v1/client-logs")
|
||||
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
|
||||
}
|
||||
}.getOrElse { it.message ?: "upload failed" }
|
||||
main.post {
|
||||
notice(
|
||||
if (err.isEmpty()) {
|
||||
"Logs sent to $hostName — download them from its web console's Logs page"
|
||||
} else {
|
||||
"Couldn't send logs — $err"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pair(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val port = c.optInt("port")
|
||||
val pin = c.optString("pin"); val name = c.optString("device_name")
|
||||
|
||||
@@ -112,8 +112,16 @@ fun SkiaConsoleShell(
|
||||
SkiaConsole.handleDeepLink(url)
|
||||
}
|
||||
|
||||
// The console owns the whole panel while it fronts the app, exactly like the stream: the
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently). This is both the
|
||||
// space win AND the safe-area fix — hidden bars report zero insets, so the scroll clips that
|
||||
// used to end at the visible gesture-bar line now run to the panel edge. Only the display
|
||||
// cutout stays a real inset. The hide/show itself lives in App.kt (one owner; a per-screen
|
||||
// `onDispose { show }` fired after the stream's hide during the AnimatedContent cross-fade).
|
||||
|
||||
// The safe area, in surface pixels: system bars ∪ display cutout — the NP3's landscape punch
|
||||
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
|
||||
// With the bars hidden above, this is normally just the cutout.
|
||||
val density = LocalDensity.current
|
||||
val ld = LocalLayoutDirection.current
|
||||
val insets = WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
@@ -125,12 +133,14 @@ fun SkiaConsoleShell(
|
||||
// the same 800-unit field as a Deck); a phone or tablet in the hand gets a density FLOOR
|
||||
// under that formula, so type never shrinks below what the touch UI draws at the same
|
||||
// density (design D5 — a bare height/800 on a 460 dpi phone lands ~26 % smaller than a Deck).
|
||||
// The 0.6 is the on-glass tuning knob.
|
||||
// The 0.75 is the on-glass tuning knob — raised from 0.6 after a 460 dpi phone (Nothing
|
||||
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
|
||||
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
|
||||
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
|
||||
val scale = if (tv) 0f else {
|
||||
val dm = context.resources.displayMetrics
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
maxOf(couch, density.density * 0.6f).coerceIn(0.75f, 3f)
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
@@ -277,10 +287,14 @@ fun SkiaConsoleShell(
|
||||
})
|
||||
// Touch → the console's pointer (surface pixels): the escape hatch when no
|
||||
// pad is attached, and the natural way to press a legend hint on a phone.
|
||||
// A finger's down is kind 6 (the shell defers it so a swipe scrolls); a
|
||||
// mouse — which Android delivers through this same listener — keeps kind 1
|
||||
// and acts on the press, as a mouse should.
|
||||
setOnTouchListener { v, ev ->
|
||||
if (handle == 0L) return@setOnTouchListener false
|
||||
val kind = when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> 1
|
||||
MotionEvent.ACTION_DOWN ->
|
||||
if (ev.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) 1 else 6
|
||||
MotionEvent.ACTION_MOVE -> 0
|
||||
MotionEvent.ACTION_UP -> 2
|
||||
MotionEvent.ACTION_CANCEL -> 5
|
||||
|
||||
@@ -146,6 +146,14 @@ object NativeBridge {
|
||||
name: String,
|
||||
): String
|
||||
|
||||
/**
|
||||
* The native client's recent log ring rendered as one text bundle, oldest first,
|
||||
* prefixed by [header] (this app's identity line) — the body for "Send logs to host"
|
||||
* (`POST /api/v1/client-logs` over the same mTLS client the library fetch uses).
|
||||
* Never empty; cheap (string copy, no I/O).
|
||||
*/
|
||||
external fun nativeRenderLogs(header: String): String
|
||||
|
||||
/**
|
||||
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
|
||||
* (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection
|
||||
|
||||
@@ -249,6 +249,13 @@ impl ConsoleHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// No input for this long = the console is being looked at, not used — halve the redraw
|
||||
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
|
||||
/// afterglow at full smoothness and only calms a genuinely parked screen.
|
||||
const IDLE_AFTER: Duration = Duration::from_secs(60);
|
||||
/// One extra ~vsync period per frame while idle: 60 Hz → ~30, 120 Hz → ~40.
|
||||
const IDLE_FRAME_STEP: Duration = Duration::from_millis(16);
|
||||
|
||||
/// The render thread. Owns EGL + Skia + the console; runs until `Cmd::Quit`.
|
||||
fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotStore>) -> Result<()> {
|
||||
let egl = EglContext::new()?;
|
||||
@@ -267,6 +274,8 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
let mut was_editing = console.editing();
|
||||
let mut saved_gen = store.saved_gen();
|
||||
let mut menu_out: Vec<MenuEvent> = Vec::new();
|
||||
// When the last input arrived — the idle throttle's clock (see the draw site below).
|
||||
let mut last_input = Instant::now();
|
||||
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
|
||||
// torn down mid-create); a run of them is a context that is not coming back — most likely
|
||||
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
|
||||
@@ -304,21 +313,28 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
return Ok(());
|
||||
}
|
||||
Cmd::Menu(ev) => {
|
||||
last_input = Instant::now();
|
||||
if let Some(p) = console.menu(ev) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
Cmd::PadSample(s) => {
|
||||
last_input = Instant::now();
|
||||
sample = s;
|
||||
poll_now = true;
|
||||
}
|
||||
Cmd::Pointer(p) => {
|
||||
last_input = Instant::now();
|
||||
console.pointer(p);
|
||||
}
|
||||
Cmd::Key { key, shift, repeat } => {
|
||||
last_input = Instant::now();
|
||||
console.key(key, shift, repeat);
|
||||
}
|
||||
Cmd::Text(t) => console.text(&t),
|
||||
Cmd::Text(t) => {
|
||||
last_input = Instant::now();
|
||||
console.text(&t);
|
||||
}
|
||||
Cmd::Phase(ph) => {
|
||||
match &ph {
|
||||
Phase::Connecting => console.session_phase(SessionPhase::Connecting),
|
||||
@@ -413,6 +429,13 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
|
||||
// Draw, if there is somewhere to draw.
|
||||
// ponytail: half-rate after 60 s without input — one extra frame period between
|
||||
// swaps, so an idle carousel stops redrawing a phone's panel at its full rate
|
||||
// (the aurora still breathes, at half tempo). Any input restores full rate on
|
||||
// its own frame; damage-driven rendering if a TV box ever needs more.
|
||||
if last_input.elapsed() >= IDLE_AFTER {
|
||||
std::thread::sleep(IDLE_FRAME_STEP);
|
||||
}
|
||||
if let (Some(s), Some(g)) = (surface.as_mut(), gpu.as_mut()) {
|
||||
let (w, h) = (s.width, s.height);
|
||||
let need_wrap = match &skia {
|
||||
|
||||
@@ -323,8 +323,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleMenu
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsolePointer(handle, kind, x, y, dy)` — touch/mouse in surface pixels:
|
||||
/// kind 0 move, 1 primary down, 2 primary up, 3 secondary down (= Back), 4 wheel (`dy` steps,
|
||||
/// + = up), 5 cancel.
|
||||
/// kind 0 move, 1 primary down (a mouse — acts immediately), 2 primary up, 3 secondary down
|
||||
/// (= Back), 4 wheel (`dy` steps, + = up), 5 cancel, 6 primary down from a finger/stylus on
|
||||
/// the glass — the shell defers it so a swipe scrolls instead of acting on contact.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePointer(
|
||||
_env: EnvUnowned,
|
||||
@@ -341,6 +342,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
touch: false,
|
||||
},
|
||||
2 => PointerInput::Up {
|
||||
x,
|
||||
@@ -351,9 +353,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Secondary,
|
||||
touch: false,
|
||||
},
|
||||
4 => PointerInput::Wheel { x, y, dy },
|
||||
5 => PointerInput::Cancel,
|
||||
6 => PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
if let Some(h) = host(handle) {
|
||||
|
||||
@@ -115,7 +115,7 @@ pub(super) struct AscBackend {
|
||||
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
|
||||
panel_seed_ns: i64,
|
||||
last_latch_ns: i64,
|
||||
/// HDR `ADataSpace` for the transaction (`0` = SDR / leave default).
|
||||
/// `ADataSpace` for the transaction (BT709 for SDR — never untagged; see `color_dataspace`).
|
||||
dataspace: i32,
|
||||
/// Layer frame-rate vote (source Hz), applied once.
|
||||
frame_rate: f32,
|
||||
@@ -143,7 +143,7 @@ impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the HDR `ADataSpace` (`0` = SDR); `source_hz` the negotiated stream rate.
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
@@ -571,9 +571,9 @@ impl AscBackend {
|
||||
}
|
||||
|
||||
impl AscBackend {
|
||||
/// Update the HDR `ADataSpace` applied to every subsequent transaction (from the codec's
|
||||
/// output format once it is known — the analogue of the SurfaceView path's
|
||||
/// `apply_hdr_dataspace`). `0` leaves the surface SDR.
|
||||
/// Update the `ADataSpace` applied to every subsequent transaction (a refinement from the
|
||||
/// codec's output format — the analogue of the SurfaceView path's `apply_hdr_dataspace`; the
|
||||
/// negotiated colour set the initial value at create).
|
||||
pub(super) fn set_dataspace(&mut self, dataspace: i32) {
|
||||
if self.dataspace != dataspace {
|
||||
self.dataspace = dataspace;
|
||||
|
||||
@@ -15,8 +15,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use super::asc_presenter::{asc_backend_selected, AscBackend};
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, hdr_dataspace, install_render_callback, release_render_callback,
|
||||
DisplayTracker,
|
||||
apply_hdr_dataspace, color_dataspace, hdr_dataspace, install_render_callback,
|
||||
release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
@@ -192,11 +192,9 @@ pub(super) fn run_async(
|
||||
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
|
||||
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
|
||||
let mut asc = if asc_backend_selected() {
|
||||
let initial_ds = if client.color.is_hdr() {
|
||||
i32::from(ndk::data_space::DataSpace::Bt2020ItuPq)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
|
||||
// output format later corrects; many decoders never echo `color-transfer` at all.
|
||||
let initial_ds = color_dataspace(&client.color);
|
||||
AscBackend::create(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
@@ -449,7 +447,12 @@ pub(super) fn run_async(
|
||||
if fmt_dirty {
|
||||
if let Some(a) = asc.as_mut() {
|
||||
// ASC carries the HDR signal on the transaction, not the SurfaceView window.
|
||||
a.set_dataspace(hdr_dataspace(&codec).map_or(0, i32::from));
|
||||
// Refine only when the codec actually reports an HDR transfer — a `None` echo
|
||||
// (decoders commonly omit `color-transfer`) must not clobber the negotiated
|
||||
// dataspace back to SDR before the first present.
|
||||
if let Some(ds) = hdr_dataspace(&codec) {
|
||||
a.set_dataspace(i32::from(ds));
|
||||
}
|
||||
} else {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
|
||||
@@ -274,3 +274,26 @@ pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the *negotiated* session colour ([`ColorInfo`], carried on Welcome) to the `ADataSpace`
|
||||
/// the presenter should tag buffers with. This is the authoritative source — the wire contract
|
||||
/// says clients configure the presenter from these code points, not from what the decoder happens
|
||||
/// to echo back (many decoders omit `color-transfer` from the output format).
|
||||
///
|
||||
/// SDR maps to `BT709` (limited-range video), never `0`/untagged: an untagged buffer on an
|
||||
/// ASurfaceControl transaction leaves SurfaceFlinger to guess, and a full-range guess shows
|
||||
/// limited-range black (16) as gray — the elevated-blacks bug.
|
||||
// ponytail: full-range SDR would need hand-composed dataspace bits (no named constant); the host
|
||||
// only encodes limited-range SDR today (ColorInfo::SDR_BT709), so BT709 covers every SDR session.
|
||||
pub(super) fn color_dataspace(color: &punktfunk_core::quic::ColorInfo) -> i32 {
|
||||
use punktfunk_core::quic::ColorInfo;
|
||||
let full = color.full_range != 0;
|
||||
let ds = match color.transfer {
|
||||
ColorInfo::TRC_PQ if full => DataSpace::Bt2020Pq,
|
||||
ColorInfo::TRC_PQ => DataSpace::Bt2020ItuPq,
|
||||
ColorInfo::TRC_HLG if full => DataSpace::Bt2020Hlg,
|
||||
ColorInfo::TRC_HLG => DataSpace::Bt2020ItuHlg,
|
||||
_ => DataSpace::Bt709, // SDR — limited-range BT.709 video
|
||||
};
|
||||
i32::from(ds)
|
||||
}
|
||||
|
||||
@@ -333,7 +333,8 @@ impl Layer {
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
/// tagged with `seq`. `dataspace` is the HDR `ADataSpace` value (`0` = leave default/SDR).
|
||||
/// tagged with `seq`. `dataspace` is the `ADataSpace` value (`0` = leave the layer default —
|
||||
/// only the `setBufferDataSpace`-less API-29 fallback ever presents untagged).
|
||||
/// `frame_rate` votes the layer's rate once (`0.0` skips). Returns `false` if the transaction
|
||||
/// could not be created (the caller then frees the buffer itself).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -34,6 +34,9 @@ mod audio;
|
||||
// shell over EGL/GLES, on every ABI (the armv7 Skia archive is self-hosted — see Cargo.toml).
|
||||
#[cfg(target_os = "android")]
|
||||
mod console;
|
||||
// "Send logs to host": the log-ring upload (`pf-client-core` is Android-target-only here).
|
||||
#[cfg(target_os = "android")]
|
||||
mod logs;
|
||||
// The RESOLVED audio format + its ms ⇄ sample arithmetic, split out of `audio` and — unlike it —
|
||||
// ungated, because that arithmetic is what a rate the ladder does not divide gets wrong (44 100 Hz
|
||||
// used to come out 2.3 % off in every direction at once) and it must be provable without a phone.
|
||||
@@ -60,22 +63,58 @@ mod wol;
|
||||
// it off the main thread to light saved-host "online" pips independently of mDNS.
|
||||
mod probe;
|
||||
|
||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
||||
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
/// Every `log` record, teed: to logcat (via [`android_logger::AndroidLogger`]) AND into
|
||||
/// `pf_client_core::logring` — the source for the console's "Send logs to host" action
|
||||
/// ([`logs`]). The ring line mirrors the desktop `ring_layer`'s shape (wallclock, level,
|
||||
/// target, message) so a bundle reads the same on the host's Logs page whichever client
|
||||
/// sent it. Both sinks share the crate's Info ceiling — the field ring gets exactly what
|
||||
/// logcat gets, which also keeps per-frame DEBUG chatter out of it by construction.
|
||||
#[cfg(target_os = "android")]
|
||||
struct RingTee(android_logger::AndroidLogger);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl log::Log for RingTee {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
self.0.enabled(metadata)
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.0.log(record);
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
pf_client_core::logring::wallclock(),
|
||||
record.level().as_str(),
|
||||
record.target(),
|
||||
record.args()
|
||||
));
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.0.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize logging once when the JVM loads the library: logcat under the `punktfunk` tag,
|
||||
/// teed into the client log ring (see [`RingTee`]). Core `tracing` events (transport warnings:
|
||||
/// socket-buffer clamp, QoS failures) arrive here too: tracing's "log" feature — declared
|
||||
/// explicitly in Cargo.toml rather than relied on via quinn's defaults — forwards them as
|
||||
/// `log` records since no tracing subscriber is ever installed. Android-only — there is no
|
||||
/// JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
_vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut std::ffi::c_void,
|
||||
) -> jint {
|
||||
android_logger::init_once(
|
||||
let logcat = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("punktfunk"),
|
||||
);
|
||||
// `set_boxed_logger` (unlike `init_once`) does not set the max level itself.
|
||||
if log::set_boxed_logger(Box::new(RingTee(logcat))).is_ok() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
}
|
||||
log::info!(
|
||||
"punktfunk_android loaded (core ABI v{})",
|
||||
punktfunk_core::ABI_VERSION
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! JNI seam for "Send logs to host": hand Kotlin the client's recent log ring (fed by the
|
||||
//! [`crate::RingTee`] logcat tee) rendered as one text bundle. The UPLOAD stays on the
|
||||
//! Kotlin side — its mTLS OkHttp client (`mtlsHttpClient`, the library/art path) already
|
||||
//! owns HTTPS-to-the-pinned-host on this platform, and `logring::send_to_host`'s ureq
|
||||
//! agent is deliberately desktop-only. Android-gated (unlike [`crate::wol`]/[`crate::probe`])
|
||||
//! because `pf-client-core` is an Android-target dependency of this crate.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::EnvUnowned;
|
||||
|
||||
/// `NativeBridge.nativeRenderLogs(header): String` — the ring as one text bundle, oldest
|
||||
/// first, prefixed by `header` (the Kotlin side's identity line) and an eviction note when
|
||||
/// the ring wrapped. Never empty (the header line is always present); cheap enough for any
|
||||
/// thread, though the caller is about to do network anyway.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeRenderLogs<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
header: JString<'local>,
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| {
|
||||
let header: String = header.try_to_string(env)?;
|
||||
env.new_string(pf_client_core::logring::render(&header))
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
@@ -451,6 +451,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||
// The Kotlin side cancels by dropping the result (`Dial.cancelled`), not by aborting
|
||||
// the dial — its connect runs on a pool thread, so a parked one costs a thread, not a
|
||||
// stuck UI. Wire a flag through here if that ever stops being true.
|
||||
None,
|
||||
) {
|
||||
Ok(client) => {
|
||||
let handle = SessionHandle {
|
||||
|
||||
@@ -31,6 +31,10 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
|
||||
Keychain-stored identity.
|
||||
- **Tune the stream** — a fps / Mb·s / **latency** HUD (skew-corrected across machines), a bitrate
|
||||
control, a per-host **network speed test** with a recommended bitrate, and a host-compositor picker.
|
||||
- **Send logs to host** — the app keeps its recent log in a bounded in-memory ring (`ClientLog`, a
|
||||
drop-in for `os.Logger` that also writes the unified log); a host card's menu (or the gamepad
|
||||
UI's host options) posts it to the paired host's `/api/v1/client-logs`, where the web console's
|
||||
Logs page shows it next to the host's own — the same action the Gaming Mode console has.
|
||||
|
||||
Runs from one shared codebase across **macOS, iOS, iPadOS, and tvOS**.
|
||||
|
||||
|
||||
@@ -654,6 +654,7 @@ struct GamepadHomeView: View {
|
||||
guard let profile = target.profile else { return }
|
||||
store.setPinned(host.id, profileID: profile.id, pinned: false)
|
||||
},
|
||||
onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil,
|
||||
close: { if !transitioning { hostOptionsTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ struct GamepadHostOptionsView: View {
|
||||
/// Delete the saved record outright.
|
||||
let onRemove: () -> Void
|
||||
let onUnpin: () -> Void
|
||||
/// Upload this device's recent log to the host; answers with what to tell the user. nil on an
|
||||
/// unpaired host — the upload rides the pairing, so there is nothing to offer before it.
|
||||
var onSendLogs: (() async -> (ok: Bool, message: String))?
|
||||
var close: (() -> Void)?
|
||||
var controllerActive = true
|
||||
|
||||
@@ -81,13 +84,21 @@ struct GamepadHostOptionsView: View {
|
||||
/// strict as it is, and none at all to be looser.
|
||||
@State private var armed = false
|
||||
@State private var copied = false
|
||||
/// The send-logs row's own state: its label and the detail band report the outcome in place,
|
||||
/// the same way Copy link says "Copied" — this surface has no toast.
|
||||
@State private var sendLogs: SendLogsState = .idle
|
||||
@State private var focusID: String?
|
||||
|
||||
private enum SendLogsState: Equatable {
|
||||
case idle, sending, done(ok: Bool, message: String)
|
||||
}
|
||||
|
||||
private enum Action: String {
|
||||
case wake
|
||||
case copyLink
|
||||
case edit
|
||||
case forgetPairing
|
||||
case sendLogs
|
||||
case remove
|
||||
case unpin
|
||||
case cancel
|
||||
@@ -195,6 +206,15 @@ struct GamepadHostOptionsView: View {
|
||||
}
|
||||
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
|
||||
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
|
||||
if onSendLogs != nil {
|
||||
let label: String
|
||||
switch sendLogs {
|
||||
case .idle: label = "Send logs to host"
|
||||
case .sending: label = "Sending logs\u{2026}"
|
||||
case .done(let ok, _): label = ok ? "Logs sent" : "Couldn't send logs"
|
||||
}
|
||||
list.append(Row(action: .sendLogs, label: label, icon: "doc.text"))
|
||||
}
|
||||
// Only a paired host has a pairing to drop.
|
||||
if host.pinnedSHA256 != nil {
|
||||
list.append(Row(
|
||||
@@ -221,6 +241,9 @@ struct GamepadHostOptionsView: View {
|
||||
case .forgetPairing:
|
||||
return "Drop the stored fingerprint. The host stays saved and the next connect "
|
||||
+ "pairs again."
|
||||
case .sendLogs:
|
||||
if case .done(_, let message) = sendLogs { return message }
|
||||
return "Upload this device's recent log to the host, for its web console's Logs page."
|
||||
case .remove:
|
||||
return armed
|
||||
? "Press again to remove — this cannot be undone."
|
||||
@@ -263,6 +286,15 @@ struct GamepadHostOptionsView: View {
|
||||
case .forgetPairing:
|
||||
onForgetPairing()
|
||||
performClose()
|
||||
case .sendLogs:
|
||||
guard let onSendLogs, sendLogs != .sending else { return }
|
||||
withAnimation(.smooth(duration: 0.2)) { sendLogs = .sending }
|
||||
Task {
|
||||
let outcome = await onSendLogs()
|
||||
withAnimation(.smooth(duration: 0.2)) {
|
||||
sendLogs = .done(ok: outcome.ok, message: outcome.message)
|
||||
}
|
||||
}
|
||||
case .remove:
|
||||
guard armed else {
|
||||
withAnimation(.smooth(duration: 0.2)) { armed = true }
|
||||
|
||||
@@ -45,6 +45,8 @@ struct HomeView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
/// The outcome of the last "Send Logs to Host" — drives its alert.
|
||||
@State private var sendLogsResult: (ok: Bool, message: String)?
|
||||
// How this device shows its own list. `.added` is the default because it is what the grid
|
||||
// did before it could sort at all — an update should not rearrange anyone's hosts.
|
||||
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
|
||||
@@ -194,6 +196,16 @@ struct HomeView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.alert(
|
||||
sendLogsResult?.ok == true ? "Logs Sent" : "Couldn't Send Logs",
|
||||
isPresented: Binding(
|
||||
get: { sendLogsResult != nil },
|
||||
set: { if !$0 { sendLogsResult = nil } })
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(sendLogsResult?.message ?? "")
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
@@ -292,6 +304,8 @@ struct HomeView: View {
|
||||
onBrowseLibrary: onBrowseLibrary,
|
||||
onWake: { wake(host) },
|
||||
onEdit: { editTarget = host },
|
||||
onSendLogs: host.pinnedSHA256 != nil
|
||||
? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil,
|
||||
profileMenu: profileMenu(for: host),
|
||||
pinnedProfile: pinned)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,9 @@ struct HostCardView: View {
|
||||
var onWake: (() -> Void)? = nil
|
||||
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||
var onEdit: (() -> Void)? = nil
|
||||
/// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired —
|
||||
/// the upload is authenticated by the pairing, so there is nothing to offer before it.
|
||||
var onSendLogs: (() -> Void)? = nil
|
||||
/// This card's profile affordances — nil on surfaces that don't offer them.
|
||||
var profileMenu: HostProfileMenu? = nil
|
||||
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
|
||||
@@ -252,6 +255,9 @@ struct HostCardView: View {
|
||||
if let onBrowseLibrary {
|
||||
Button("Browse Library…", action: onBrowseLibrary)
|
||||
}
|
||||
if let onSendLogs {
|
||||
Button("Send Logs to Host", action: onSendLogs)
|
||||
}
|
||||
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
|
||||
Button("Wake Host", systemImage: "power", action: onWake)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -13,6 +14,9 @@ struct PunktfunkClientApp: App {
|
||||
#endif
|
||||
|
||||
init() {
|
||||
// Before anything touches the core, so its first lines (identity load, the first connect's
|
||||
// transport setup) land in the log ring "Send logs to host" uploads.
|
||||
CoreLog.install()
|
||||
#if os(iOS)
|
||||
// Put Geist on the navigation titles before any bar is built.
|
||||
BrandTheme.apply()
|
||||
|
||||
@@ -19,7 +19,11 @@ import SwiftUI
|
||||
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||||
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
|
||||
/// refresh period on the vsync-latched platforms — this is how to measure with it off.
|
||||
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
|
||||
private let statsLog = ClientLog(category: "stats")
|
||||
/// The session's lifecycle — connect asked/landed/refused, how it ended. Until this existed a
|
||||
/// client log bundle had a 1 Hz stats line and no sentence saying which host it was streaming
|
||||
/// from, with what, or why it stopped; the host's own log has always said all three.
|
||||
private let sessionLog = ClientLog(category: "session")
|
||||
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
|
||||
///
|
||||
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
|
||||
@@ -448,6 +452,11 @@ final class SessionModel: ObservableObject {
|
||||
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
|
||||
// hardware-decode probe below still gates what can actually be advertised.
|
||||
let want444 = effective.enable444
|
||||
let connectLine = "connect \(host.displayName) \(host.address):\(host.port) "
|
||||
+ "mode=\(width)x\(height)@\(hz) codec=\(effective.codec) bitrate=\(bitrateKbps)kbps "
|
||||
+ "hdr=\(hdrCapable) 444=\(want444) audio=\(audioChannels)ch/\(audioRateHz)Hz/\(audioBits)bit "
|
||||
+ "pinned=\(pin != nil) tofu=\(allowTofu) launch=\(launchID ?? "-")"
|
||||
sessionLog.info("\(connectLine, privacy: .public)")
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||
// actor. The persistent identity is presented on every connect so a paired
|
||||
@@ -530,6 +539,14 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
switch result {
|
||||
case .success(let conn):
|
||||
let landed = "connected \(host.displayName) "
|
||||
+ "mode=\(conn.width)x\(conn.height)@\(conn.refreshHz) "
|
||||
+ "codec=\(conn.videoCodec) bitrate=\(conn.resolvedBitrateKbps)kbps "
|
||||
+ "depth=\(conn.bitDepth) chroma=\(conn.isChroma444 ? "444" : "420") hdr=\(conn.isHDR) "
|
||||
+ "audio=\(conn.resolvedAudioChannels)ch/\(conn.resolvedAudioRateHz)Hz/\(conn.resolvedAudioBits)bit "
|
||||
+ "shard=\(conn.shardPayload) compositor=\(conn.resolvedCompositor.rawValue) "
|
||||
+ "gamepad=\(conn.resolvedGamepad.rawValue) mgmt=\(conn.hostMgmtPort)"
|
||||
sessionLog.info("\(landed, privacy: .public)")
|
||||
if pin != nil || autoTrust || requestAccess {
|
||||
// requestAccess: the operator approved this device on the host, so the
|
||||
// session is trusted — stream directly (the caller pins it as paired).
|
||||
@@ -553,6 +570,8 @@ final class SessionModel: ObservableObject {
|
||||
+ "Pair with its PIN before streaming."
|
||||
}
|
||||
case .failure(let error):
|
||||
sessionLog.warning(
|
||||
"connect \(host.displayName, privacy: .public) failed: \(String(describing: error), privacy: .public)")
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
SessionSettings.end() // the dial failed — back to the plain globals
|
||||
@@ -782,6 +801,10 @@ final class SessionModel: ObservableObject {
|
||||
/// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended /
|
||||
/// dropped session) passes `false` to leave the linger intact.
|
||||
func disconnect(deliberate: Bool = true) {
|
||||
if connection != nil {
|
||||
let line = "disconnect \(activeHost?.displayName ?? "-") deliberate=\(deliberate) phase=\(phase)"
|
||||
sessionLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
// Release the session's resolved settings: from here every reader falls back to the plain
|
||||
@@ -902,6 +925,9 @@ final class SessionModel: ObservableObject {
|
||||
// The shelf it came off — falling back to the host's own if a caller launched a title
|
||||
// without naming one, which is what that launch effectively browsed.
|
||||
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
|
||||
let endLine = "session ended by \(name) reason=\(reason) "
|
||||
+ "rejection=\(rejection.map { String(describing: $0) } ?? "-")"
|
||||
sessionLog.info("\(endLine, privacy: .public)")
|
||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||
if let rejection {
|
||||
// The shared typed-rejection wording ("Your access to this host has expired…").
|
||||
|
||||
@@ -522,7 +522,25 @@ extension SettingsView {
|
||||
}
|
||||
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
|
||||
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
|
||||
// Turning it ON is the moment to ask for Accessibility — never at stream start,
|
||||
// where a TCC dialog over a captured stream would be the surprise.
|
||||
.onChange(of: effective.inhibitShortcuts) { was, on in
|
||||
if on, !was, !accessibilityTrusted { InputCapture.requestSystemShortcutAccess() }
|
||||
}
|
||||
if effective.inhibitShortcuts, !accessibilityTrusted {
|
||||
Button("Allow Accessibility access…") {
|
||||
InputCapture.requestSystemShortcutAccess()
|
||||
// The prompt's own "Open System Settings" only shows the FIRST time the system
|
||||
// asks; after that the user has to find the pane themselves — open it for them.
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(
|
||||
for: NSApplication.didBecomeActiveNotification
|
||||
)) { _ in accessibilityTrusted = InputCapture.systemShortcutsAvailable }
|
||||
#endif
|
||||
described(
|
||||
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
|
||||
@@ -549,8 +567,13 @@ extension SettingsView {
|
||||
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
|
||||
return "No effect under the desktop mouse model — switch Mouse input to Capture."
|
||||
}
|
||||
return "Sends ⌘ shortcuts to the host while captured. ⌘⎋ always stays local — it "
|
||||
+ "releases capture."
|
||||
if accessibilityTrusted {
|
||||
return "Sends ⌘ shortcuts — ⌘Space, ⌘Tab and Mission Control included — to the host "
|
||||
+ "while captured. ⌘⎋ always stays local — it releases capture."
|
||||
}
|
||||
return "Sends the app's ⌘ shortcuts (⌘Q, ⌘W, ⌘H…) to the host while captured. ⌘Space, "
|
||||
+ "⌘Tab and Mission Control need Accessibility access — macOS claims them before any "
|
||||
+ "app sees them. ⌘⎋ always stays local — it releases capture."
|
||||
}
|
||||
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
|
||||
@@ -124,6 +124,10 @@ struct SettingsView: View {
|
||||
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
|
||||
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
|
||||
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
|
||||
/// Accessibility granted? Gates the system-shortcut half of `inhibit_shortcuts` (⌘Space, ⌘Tab…
|
||||
/// need the event tap). Re-read whenever the app comes back to the front — that is when the
|
||||
/// user returns from flipping the switch in System Settings.
|
||||
@State var accessibilityTrusted = InputCapture.systemShortcutsAvailable
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// "Send logs to host" — the one action behind the host card's menu item and the gamepad options
|
||||
// row. Posts `ClientLogRing` to the PAIRED host (`LibraryClient.sendLogs`), where the web
|
||||
// console's Logs page shows it next to the host's own log. The Apple port of the Gaming Mode
|
||||
// console's `ConsoleCmd::SendLogs` (clients/session/src/console.rs), same wording on success.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
|
||||
private let log = ClientLog(category: "logs")
|
||||
|
||||
enum SendLogs {
|
||||
/// Upload this device's recent log to `host`. Never throws: the caller shows `message` either
|
||||
/// way, and the outcome is itself the last line of the NEXT bundle.
|
||||
static func toHost(_ host: StoredHost) async -> (ok: Bool, message: String) {
|
||||
// The same two preconditions the library screen applies: this device's mTLS identity
|
||||
// (minted on the first connect) and the host's pinned fingerprint (pairing) — an upload
|
||||
// is an outbound write carrying the device's diagnostics, and it goes to a host the user
|
||||
// has actually paired with, not to whoever answers on that port.
|
||||
guard let identity = (try? ClientIdentityStore.shared.load())?.identity else {
|
||||
return (false, "Connect to this host once first — sending logs uses the identity "
|
||||
+ "created on the first connect.")
|
||||
}
|
||||
guard let pin = host.pinnedSHA256 else {
|
||||
return (false, "Pair with \(host.displayName) first — logs are only sent to a paired host.")
|
||||
}
|
||||
do {
|
||||
let id = try await LibraryClient.sendLogs(
|
||||
address: host.address, port: host.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin)
|
||||
log.info("client logs uploaded to \(host.displayName, privacy: .public) id=\(id, privacy: .public)")
|
||||
return (true, "Logs sent to \(host.displayName) — download them from its web console's "
|
||||
+ "Logs page.")
|
||||
} catch {
|
||||
let why = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
log.warning("client log upload to \(host.displayName, privacy: .public) failed: \(why, privacy: .public)")
|
||||
return (false, "Couldn't send logs — \(why)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import os
|
||||
import CoreAudio
|
||||
#endif
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
||||
private let log = ClientLog(category: "audio")
|
||||
|
||||
final class AudioDeviceWatcher {
|
||||
/// Why the owner is being told. Only for the log line — every reason leads to the same
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
||||
private let log = ClientLog(category: "audio")
|
||||
|
||||
/// Render-block-owned scratch storage: freed exactly when the closure (and thus the
|
||||
/// last possible render call) is released — never racing CoreAudio.
|
||||
|
||||
@@ -235,6 +235,40 @@ public enum LibraryClient {
|
||||
return status.games ?? []
|
||||
}
|
||||
|
||||
/// Upload this client's recent log (`ClientLogRing`) to the host — `POST /api/v1/client-logs`,
|
||||
/// the one WRITE a paired certificate may make (the host's `mgmt/client_logs.rs`). Same lane
|
||||
/// and identity as the library; the host files the bundle under this device and shows it on
|
||||
/// its web console's Logs page next to its own log. Returns the stored bundle id (empty for a
|
||||
/// host that predates the id in the reply).
|
||||
///
|
||||
/// Why it exists: on an Apple TV (or a phone, for anyone who is not a developer) there is no
|
||||
/// way to get the client's log off the device, so every fault report arrived with only the
|
||||
/// host's half of the story. `hostFingerprint` is required, not optional: this is an outbound
|
||||
/// write carrying the device's diagnostics, and it goes to the host the user paired with.
|
||||
public static func sendLogs(
|
||||
address: String,
|
||||
port: UInt16 = punktfunkDefaultMgmtPort,
|
||||
certPEM: String,
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data
|
||||
) async throws -> String {
|
||||
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
let body = Data(ClientLogRing.render(header: ClientLogRing.header()).utf8)
|
||||
let response = try await send(
|
||||
path: "/api/v1/client-logs", address: address, port: port,
|
||||
identity: identity, hostFingerprint: hostFingerprint,
|
||||
body: (body, "text/plain; charset=utf-8"))
|
||||
switch response.status {
|
||||
case 200, 201:
|
||||
let json = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any]
|
||||
return json?["id"] as? String ?? ""
|
||||
case 401, 403:
|
||||
throw LibraryError.unauthorized
|
||||
default:
|
||||
throw LibraryError.http(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Just the slice of `/status` this client reads. Everything else on that payload is the
|
||||
/// operator console's business, and decoding only what we use keeps an unrelated schema change
|
||||
/// on the host from breaking the library screen.
|
||||
@@ -259,12 +293,20 @@ public enum LibraryClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// One GET against the host, with transport failures mapped onto `LibraryError`.
|
||||
/// One request against the host — a GET, or a POST when `body` is given — with transport
|
||||
/// failures mapped onto `LibraryError`.
|
||||
static func send(
|
||||
path: String, address: String, port: UInt16,
|
||||
identity: SecIdentity, hostFingerprint: Data?
|
||||
identity: SecIdentity, hostFingerprint: Data?,
|
||||
body: (data: Data, contentType: String)? = nil
|
||||
) async throws -> HTTPResponse {
|
||||
do {
|
||||
if let body {
|
||||
return try await MgmtTransport.post(
|
||||
host: address, port: port, path: path, body: body.data,
|
||||
contentType: body.contentType,
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint)
|
||||
}
|
||||
return try await MgmtTransport.get(
|
||||
host: address, port: port, path: path,
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint)
|
||||
|
||||
@@ -56,6 +56,41 @@ enum MgmtTransport {
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval = 15
|
||||
) async throws -> HTTPResponse {
|
||||
try await request(
|
||||
host: host, port: port, method: "GET", path: path, body: nil, contentType: nil,
|
||||
identity: identity, pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
|
||||
}
|
||||
|
||||
/// `POST https://host:port/path` with a body — same transport, trust and retry rule as `get`.
|
||||
/// The one write a paired device may make is the client-log upload, which is idempotent in
|
||||
/// the only sense that matters (a retried bundle is a second bundle, not a corrupted one).
|
||||
static func post(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
path: String,
|
||||
body: Data,
|
||||
contentType: String,
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval = 15
|
||||
) async throws -> HTTPResponse {
|
||||
try await request(
|
||||
host: host, port: port, method: "POST", path: path, body: body,
|
||||
contentType: contentType, identity: identity,
|
||||
pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
|
||||
}
|
||||
|
||||
private static func request(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
method: String,
|
||||
path: String,
|
||||
body: Data?,
|
||||
contentType: String?,
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval
|
||||
) async throws -> HTTPResponse {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||
throw MgmtTransportError.invalidPort(port)
|
||||
@@ -70,7 +105,9 @@ enum MgmtTransport {
|
||||
}
|
||||
let wasReused = connection.hasServedRequest
|
||||
do {
|
||||
let response = try await connection.perform(path: path, timeout: timeout)
|
||||
let response = try await connection.perform(
|
||||
method: method, path: path, body: body, contentType: contentType,
|
||||
timeout: timeout)
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
return response
|
||||
} catch {
|
||||
@@ -230,7 +267,10 @@ final class MgmtConnection: @unchecked Sendable {
|
||||
private let rejection: RejectionFlag
|
||||
private final class RejectionFlag: @unchecked Sendable { var value = false }
|
||||
|
||||
func perform(path: String, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
func perform(
|
||||
method: String = "GET", path: String, body: Data? = nil, contentType: String? = nil,
|
||||
timeout: TimeInterval
|
||||
) async throws -> HTTPResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
guard self.phase != .dead else {
|
||||
@@ -240,7 +280,9 @@ final class MgmtConnection: @unchecked Sendable {
|
||||
self.operation += 1
|
||||
let op = self.operation
|
||||
self.pending = continuation
|
||||
self.pendingRequest = self.requestBytes(path: path)
|
||||
self.pendingRequest = Self.requestBytes(
|
||||
host: self.host, port: self.port,
|
||||
method: method, path: path, body: body, contentType: contentType)
|
||||
self.buffer.removeAll(keepingCapacity: true)
|
||||
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
guard let self, self.operation == op else { return }
|
||||
@@ -361,17 +403,24 @@ final class MgmtConnection: @unchecked Sendable {
|
||||
rejection.value ? .pinMismatch : .connection(String(describing: error))
|
||||
}
|
||||
|
||||
private func requestBytes(path: String) -> Data {
|
||||
/// The wire bytes of one request. Pure (and `static`) so the framing is unit-testable.
|
||||
static func requestBytes(
|
||||
host: String, port: UInt16,
|
||||
method: String, path: String, body: Data?, contentType: String?
|
||||
) -> Data {
|
||||
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
|
||||
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
|
||||
let request = """
|
||||
GET \(path) HTTP/1.1\r
|
||||
Host: \(authority)\r
|
||||
User-Agent: punktfunk-apple\r
|
||||
Accept: */*\r
|
||||
\r
|
||||
|
||||
"""
|
||||
return Data(request.utf8)
|
||||
var head = "\(method) \(path) HTTP/1.1\r\nHost: \(authority)\r\n"
|
||||
+ "User-Agent: punktfunk-apple\r\nAccept: */*\r\n"
|
||||
if let body {
|
||||
// Always framed by length — a request body has no EOF to end it on a kept-alive
|
||||
// connection, and the host's axum would otherwise wait for one.
|
||||
head += "Content-Type: \(contentType ?? "application/octet-stream")\r\n"
|
||||
head += "Content-Length: \(body.count)\r\n"
|
||||
}
|
||||
head += "\r\n"
|
||||
var request = Data(head.utf8)
|
||||
if let body { request.append(body) }
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import IOKit
|
||||
import IOKit.hid
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
private let log = ClientLog(category: "gamepad")
|
||||
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
import GameController
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
private let log = ClientLog(category: "gamepad")
|
||||
|
||||
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
|
||||
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
|
||||
|
||||
@@ -48,7 +48,7 @@ import os
|
||||
/// PUNKTFUNK_INPUT_DEBUG=1 in the environment to surface whether relative motion + buttons
|
||||
/// are actually being SENT to the host without needing host-side logs. Motion is throttled
|
||||
/// to once per second (see `motionDebugTick`); buttons log every transition.
|
||||
private let inputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
|
||||
private let inputLog = ClientLog(category: "input")
|
||||
private let inputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
|
||||
|
||||
public final class InputCapture {
|
||||
@@ -60,6 +60,10 @@ public final class InputCapture {
|
||||
private var keyboards: [GCKeyboard] = []
|
||||
#if os(macOS)
|
||||
private var keyEventMonitor: Any?
|
||||
/// The system-shortcut tap (see `installSystemKeyTap`) and its run-loop source. Live only
|
||||
/// while forwarding with `inhibit_shortcuts` on AND Accessibility granted; nil otherwise.
|
||||
private var systemKeyTap: CFMachPort?
|
||||
private var systemKeyTapSource: CFRunLoopSource?
|
||||
#endif
|
||||
|
||||
// Main-queue-only state (see header comment).
|
||||
@@ -194,7 +198,13 @@ public final class InputCapture {
|
||||
if on {
|
||||
forwarding = true
|
||||
suppressedButton = suppressClick ? 1 : nil
|
||||
#if os(macOS)
|
||||
installSystemKeyTap()
|
||||
#endif
|
||||
} else if forwarding {
|
||||
#if os(macOS)
|
||||
removeSystemKeyTap()
|
||||
#endif
|
||||
releaseAll()
|
||||
forwarding = false
|
||||
suppressedButton = nil
|
||||
@@ -369,6 +379,7 @@ public final class InputCapture {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
keyEventMonitor = nil
|
||||
}
|
||||
removeSystemKeyTap()
|
||||
#endif
|
||||
// Don't clobber the handlers if a newer capture has taken the global devices.
|
||||
if Self.activeCapture === self || Self.activeCapture == nil {
|
||||
@@ -672,6 +683,128 @@ public final class InputCapture {
|
||||
}
|
||||
commandChordVKs.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - System shortcut tap
|
||||
|
||||
/// Whether the system-shortcut tap CAN run: Accessibility granted to this process. Read live
|
||||
/// (the user flips it in System Settings while the app runs); never prompts — the prompt is the
|
||||
/// Settings toggle's job (`requestSystemShortcutAccess`), not something a stream start springs.
|
||||
public static var systemShortcutsAvailable: Bool { AXIsProcessTrusted() }
|
||||
|
||||
/// Show the one-time Accessibility prompt (a no-op once granted). Called from Settings when the
|
||||
/// user turns "Capture system shortcuts" on or presses the grant button.
|
||||
public static func requestSystemShortcutAccess() {
|
||||
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(opts)
|
||||
}
|
||||
|
||||
/// The other half of `inhibit_shortcuts` on macOS. The keyDown monitor above claims the ⌘
|
||||
/// chords that REACH the app — but ⌘Space, ⌘Tab, ⌃↑ and the rest of System Settings › Keyboard
|
||||
/// › Shortcuts never do: WindowServer hands them to Spotlight / the Dock / Mission Control before
|
||||
/// any app sees them. The SDL clients get those through a private CGS hotkey-mode call that a
|
||||
/// sandboxed app cannot make; the sandbox-legal way is a session-level event tap, which sees
|
||||
/// every key ahead of the hotkey dispatch and only exists with Accessibility granted.
|
||||
///
|
||||
/// The tap does NOT forward anything itself. It takes each keyDown/keyUp off the system and
|
||||
/// re-posts it, addressed to the key window, into THIS app's event queue (`NSApp.postEvent`), so
|
||||
/// it arrives exactly where the same key would have arrived had macOS not claimed it — the
|
||||
/// monitor first (client chords, ⌘ chords → host), then `StreamLayerView.keyDown/keyUp`
|
||||
/// (everything else → host). One key path, no second VK table, no second release bookkeeping.
|
||||
/// In-process posts don't re-enter the tap, so there is no loop. Keys the system would have
|
||||
/// delivered anyway are unaffected (we drop the original and deliver the copy) — the tap only
|
||||
/// changes what happens to the ones it wouldn't. Bonus: the keyUp of a ⌘-chord key now arrives
|
||||
/// too (the tap sees HID, which never stopped delivering it), so `flushCommandChord` has less
|
||||
/// to synthesize.
|
||||
///
|
||||
/// Gating, every event: `forwarding` (capture engaged — and capture releases on any focus loss,
|
||||
/// so this is never true with another app frontmost), `!desktopMouse` (system chords stay local
|
||||
/// under the desktop model, like every other client), `NSApp.isActive` as belt-and-braces.
|
||||
/// Anything else passes through untouched — a tap that swallows keys for the whole Mac is the
|
||||
/// failure mode to design against. Installed on the main run loop on purpose: a hung main thread
|
||||
/// trips the tap's timeout and macOS disables it, handing the keyboard back.
|
||||
private func installSystemKeyTap() {
|
||||
// `desktopMouse` is NOT an install condition: ⌃⌥⇧M flips it mid-capture, so the callback
|
||||
// reads it per event instead and the tap simply idles under the desktop model.
|
||||
guard systemKeyTap == nil, SessionSettings.current.inhibitShortcuts, AXIsProcessTrusted()
|
||||
else { return }
|
||||
let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
|
||||
let callback: CGEventTapCallBack = { _, type, event, userInfo in
|
||||
guard let userInfo else { return Unmanaged.passUnretained(event) }
|
||||
let capture = Unmanaged<InputCapture>.fromOpaque(userInfo).takeUnretainedValue()
|
||||
return capture.handleTapped(type: type, event: event)
|
||||
}
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap,
|
||||
eventsOfInterest: CGEventMask(mask), callback: callback,
|
||||
userInfo: Unmanaged.passUnretained(self).toOpaque())
|
||||
else {
|
||||
inputLog.error("system shortcut tap: tapCreate failed (Accessibility revoked?)")
|
||||
return
|
||||
}
|
||||
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
systemKeyTap = tap
|
||||
systemKeyTapSource = source
|
||||
if inputDebug { inputLog.debug("system shortcut tap installed") }
|
||||
}
|
||||
|
||||
private func removeSystemKeyTap() {
|
||||
guard let tap = systemKeyTap else { return }
|
||||
CGEvent.tapEnable(tap: tap, enable: false)
|
||||
if let source = systemKeyTapSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
|
||||
}
|
||||
CFMachPortInvalidate(tap)
|
||||
systemKeyTap = nil
|
||||
systemKeyTapSource = nil
|
||||
if inputDebug { inputLog.debug("system shortcut tap removed") }
|
||||
}
|
||||
|
||||
/// The tap callback body (main run loop). Returns the event to let it through, nil to swallow.
|
||||
private func handleTapped(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
||||
switch type {
|
||||
case .tapDisabledByTimeout, .tapDisabledByUserInput:
|
||||
// macOS switched us off (main thread stalled past the tap's deadline, or a
|
||||
// system-level interruption); re-arm if still wanted, else stay down.
|
||||
if let tap = systemKeyTap, forwarding { CGEvent.tapEnable(tap: tap, enable: true) }
|
||||
return Unmanaged.passUnretained(event)
|
||||
case .keyDown, .keyUp:
|
||||
// Stamped with the KEY window: `NSApp.sendEvent` routes a key event by `event.window`,
|
||||
// and an NSEvent wrapped straight from the CGEvent has none — it reaches the local
|
||||
// monitor but not the first responder (verified in a harness). The key window is the
|
||||
// stream window whenever `forwarding` is true (capture releases on resignKey); if there
|
||||
// somehow is none, let the key go rather than swallow it into nothing.
|
||||
guard Self.tapClaims(forwarding: forwarding, desktopMouse: desktopMouse,
|
||||
appActive: NSApp.isActive),
|
||||
let windowNumber = NSApp.keyWindow?.windowNumber,
|
||||
let copy = event.copy(), let raw = NSEvent(cgEvent: copy),
|
||||
let stamped = Self.restamp(raw, windowNumber: windowNumber)
|
||||
else { return Unmanaged.passUnretained(event) }
|
||||
NSApp.postEvent(stamped, atStart: false)
|
||||
return nil
|
||||
default:
|
||||
return Unmanaged.passUnretained(event)
|
||||
}
|
||||
}
|
||||
|
||||
/// The same key event, addressed to `windowNumber` (see `handleTapped`).
|
||||
static func restamp(_ raw: NSEvent, windowNumber: Int) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: raw.type, location: .zero, modifierFlags: raw.modifierFlags,
|
||||
timestamp: raw.timestamp, windowNumber: windowNumber, context: nil,
|
||||
characters: raw.characters ?? "",
|
||||
charactersIgnoringModifiers: raw.charactersIgnoringModifiers ?? "",
|
||||
isARepeat: raw.type == .keyDown && raw.isARepeat, keyCode: raw.keyCode)
|
||||
}
|
||||
|
||||
/// Does the system-shortcut tap take this key off macOS and hand it to the app's own key path?
|
||||
/// Pure, for the tests: only while captured, only under the capture mouse model, only with the
|
||||
/// app frontmost. The `inhibit_shortcuts` setting is checked once at install time (the tap does
|
||||
/// not exist with it off).
|
||||
static func tapClaims(forwarding: Bool, desktopMouse: Bool, appActive: Bool) -> Bool {
|
||||
forwarding && !desktopMouse && appActive
|
||||
}
|
||||
#endif
|
||||
|
||||
private func attach(mouse: GCMouse) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// The client's own recent-log ring + the drop-in logger that feeds it — the source for the
|
||||
// "Send logs to host" action (`LibraryClient.sendLogs`), the Apple port of
|
||||
// `pf_client_core::logring` + `punktfunk-session`'s `ring_layer`.
|
||||
//
|
||||
// WHY A RING AND NOT `OSLogStore`. The unified log already holds everything these loggers write,
|
||||
// and `OSLogStore(scope: .currentProcessIdentifier)` can read it back — but only the levels the
|
||||
// system PERSISTS (`.notice`/`.error`/`.fault`). `.info` is memory-only and purged under pressure,
|
||||
// and `.info` is exactly where the lines a field report needs live: the 1 Hz stats line, the
|
||||
// decoder/presenter setup, the audio underrun notes. On an Apple TV there is no Console.app to
|
||||
// read any of it on either. So every `ClientLog` call goes to os_log as before AND into this
|
||||
// process-global ring, and an explicit user action posts the ring to the PAIRED host, where the
|
||||
// web console shows it next to the host's own log.
|
||||
//
|
||||
// Bounded by lines AND bytes so a log-storm can't grow memory; the byte budget stays under the
|
||||
// host's 1 MiB upload cap so a full ring always uploads whole. `.debug` deliberately skips the
|
||||
// ring: it is per-key/per-event input chatter here, and a ring that a healthy keyboard can flush
|
||||
// in thirty seconds is worse than no ring (the session client learned this from a Steam Deck
|
||||
// bundle whose whole 27-minute session had been evicted by decoder DPB chatter).
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Process-global bounded ring of formatted log lines. `note` is cheap (one lock, one append);
|
||||
/// `render` is the upload body.
|
||||
public enum ClientLogRing {
|
||||
/// Newest lines kept — matches the host's own ring depth and the session client's.
|
||||
public static let maxLines = 4096
|
||||
/// Byte budget — under the host's 1 MiB bundle cap with headroom for the header.
|
||||
public static let maxBytes = 768 * 1024
|
||||
|
||||
private static let lock = OSAllocatedUnfairLock(initialState: State())
|
||||
|
||||
private struct State {
|
||||
var lines: [String] = []
|
||||
/// Index of the oldest live line in `lines` — popped lazily, compacted when half is dead,
|
||||
/// so eviction is O(1) amortised without a deque type.
|
||||
var head = 0
|
||||
var bytes = 0
|
||||
var dropped = 0
|
||||
}
|
||||
|
||||
/// Append one formatted log line (no trailing newline). Oversized lines are truncated to keep
|
||||
/// a single event from evicting the whole ring.
|
||||
public static func note(_ line: String) {
|
||||
// `decoding:` rather than `String(_:)`: a cut mid-scalar yields U+FFFD, not nil.
|
||||
let line = line.utf8.count > 2048
|
||||
? String(decoding: line.utf8.prefix(2048), as: UTF8.self) + "…" : line
|
||||
let size = line.utf8.count
|
||||
lock.withLock { s in
|
||||
s.lines.append(line)
|
||||
s.bytes += size
|
||||
while s.lines.count - s.head > maxLines || s.bytes > maxBytes, s.head < s.lines.count {
|
||||
s.bytes -= s.lines[s.head].utf8.count
|
||||
s.head += 1
|
||||
s.dropped += 1
|
||||
}
|
||||
if s.head > 0, s.head * 2 >= s.lines.count {
|
||||
s.lines.removeFirst(s.head)
|
||||
s.head = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the app's own
|
||||
/// identity line — name, version, platform) and an eviction note when the ring wrapped.
|
||||
public static func render(header: String) -> String {
|
||||
lock.withLock { s in
|
||||
var out = header + "\n"
|
||||
if s.dropped > 0 {
|
||||
out += "… \(s.dropped) older lines evicted from the ring …\n"
|
||||
}
|
||||
for line in s.lines[s.head...] {
|
||||
out += line
|
||||
out += "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundle's first line: `punktfunk-apple 0.31.0 (42) (iOS 26.0.0 arm64; Apple TV) — client
|
||||
/// log bundle` — the same shape as the session client's, so the host's log page reads them alike.
|
||||
public static func header() -> String {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let version = info?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
let build = info?["CFBundleVersion"] as? String
|
||||
let os = ProcessInfo.processInfo.operatingSystemVersion
|
||||
#if os(macOS)
|
||||
let platform = "macOS"
|
||||
#elseif os(tvOS)
|
||||
let platform = "tvOS"
|
||||
#elseif os(iOS)
|
||||
let platform = "iOS"
|
||||
#else
|
||||
let platform = "apple"
|
||||
#endif
|
||||
#if arch(arm64)
|
||||
let arch = "arm64"
|
||||
#else
|
||||
let arch = "x86_64"
|
||||
#endif
|
||||
let v = build.map { "\(version) (\($0))" } ?? version
|
||||
return "punktfunk-apple \(v) (\(platform) \(os.majorVersion).\(os.minorVersion).\(os.patchVersion) "
|
||||
+ "\(arch); \(DeviceName.kind)) — client log bundle"
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` — wall time, so a bundle correlates with the host log it lands
|
||||
/// next to (the session client's `wallclock`).
|
||||
static func stamp(_ date: Date = Date()) -> String {
|
||||
Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)`: the same call shape (string
|
||||
/// interpolation with `privacy:`/`format:` options), forwarded to os_log AND noted in
|
||||
/// `ClientLogRing`. Interpolated values are rendered in the clear in both places — the unified log
|
||||
/// was already being read with the app attached, and the ring only ever leaves the device by an
|
||||
/// explicit "Send logs to host" to a host the user paired with.
|
||||
public struct ClientLog: Sendable {
|
||||
public let category: String
|
||||
private let logger: Logger
|
||||
|
||||
public init(category: String) {
|
||||
self.category = category
|
||||
self.logger = Logger(subsystem: "io.unom.punktfunk", category: category)
|
||||
}
|
||||
|
||||
/// os_log only — see the file comment for why debug stays out of the ring.
|
||||
public func debug(_ message: ClientLogMessage) {
|
||||
logger.debug("\(message.text, privacy: .public)")
|
||||
}
|
||||
|
||||
public func info(_ message: ClientLogMessage) {
|
||||
logger.info("\(message.text, privacy: .public)")
|
||||
note("INFO", message.text)
|
||||
}
|
||||
|
||||
public func notice(_ message: ClientLogMessage) {
|
||||
logger.notice("\(message.text, privacy: .public)")
|
||||
note("INFO", message.text)
|
||||
}
|
||||
|
||||
public func warning(_ message: ClientLogMessage) {
|
||||
logger.warning("\(message.text, privacy: .public)")
|
||||
note("WARN", message.text)
|
||||
}
|
||||
|
||||
public func error(_ message: ClientLogMessage) {
|
||||
logger.error("\(message.text, privacy: .public)")
|
||||
note("ERROR", message.text)
|
||||
}
|
||||
|
||||
public func fault(_ message: ClientLogMessage) {
|
||||
logger.fault("\(message.text, privacy: .public)")
|
||||
note("ERROR", message.text)
|
||||
}
|
||||
|
||||
private func note(_ level: String, _ text: String) {
|
||||
ClientLogRing.note("\(ClientLogRing.stamp()) \(level.padding(toLength: 5, withPad: " ", startingAt: 0)) \(category) \(text)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The interpolated message: accepts the `OSLogMessage` options the call sites use (`privacy:`,
|
||||
/// `format:`) so swapping `Logger` for `ClientLog` touches one declaration per file, not every
|
||||
/// log line. Privacy is accepted and ignored (see `ClientLog`); `.fixed(precision:)` is honoured.
|
||||
public struct ClientLogMessage: ExpressibleByStringInterpolation, ExpressibleByStringLiteral, Sendable {
|
||||
public let text: String
|
||||
|
||||
public init(stringLiteral value: String) { text = value }
|
||||
public init(stringInterpolation: StringInterpolation) { text = stringInterpolation.out }
|
||||
|
||||
public struct StringInterpolation: StringInterpolationProtocol, Sendable {
|
||||
var out = ""
|
||||
public init(literalCapacity: Int, interpolationCount: Int) {
|
||||
out.reserveCapacity(literalCapacity + interpolationCount * 8)
|
||||
}
|
||||
public mutating func appendLiteral(_ literal: String) { out += literal }
|
||||
public mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy = .auto) {
|
||||
out += String(describing: value)
|
||||
}
|
||||
public mutating func appendInterpolation<T: BinaryFloatingPoint>(
|
||||
_ value: T, format: ClientLogFloatFormat, privacy: OSLogPrivacy = .auto
|
||||
) {
|
||||
switch format {
|
||||
case .fixed(let precision):
|
||||
out += String(format: "%.\(precision)f", Double(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one float format the call sites use. `OSLogFloatFormatting` cannot be pattern-matched, so
|
||||
/// the message type names its own — same spelling at the call site: `format: .fixed(precision: 2)`.
|
||||
public enum ClientLogFloatFormat: Sendable {
|
||||
case fixed(precision: Int)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// The Rust core's log lines, routed into `ClientLog` (os_log + the send-to-host ring).
|
||||
//
|
||||
// The core logs through `tracing`. The desktop and Android shells install a subscriber/logger and
|
||||
// see those lines; this app never did, so every transport warning (socket-buffer clamp, QoS
|
||||
// refusal), every quinn connection event and every rustls handshake note vanished, and a bundle
|
||||
// sent to the host carried the Swift half of the story only. `punktfunk_set_log_callback`
|
||||
// (ABI v25) hands them to the C callback below, which files each under a `core.<crate>` category.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
|
||||
public enum CoreLog {
|
||||
/// Install once at launch. Levels above `maxLevel` (1 = error … 5 = trace) are not even
|
||||
/// formatted on the Rust side. Info is the ceiling on purpose: quinn's debug/trace is
|
||||
/// per-packet and would churn the ring — the same gate the session client's ring applies.
|
||||
/// `PUNKTFUNK_CORE_LOG_LEVEL=4` raises it for a debugging session.
|
||||
public static func install() {
|
||||
let level = UInt8(ProcessInfo.processInfo.environment["PUNKTFUNK_CORE_LOG_LEVEL"] ?? "") ?? 3
|
||||
let status = punktfunk_set_log_callback(level, { level, target, message, _ in
|
||||
// Called from whichever Rust thread logged — copy both C strings out before anything
|
||||
// else, then hand off to ClientLog, which is cheap (one lock + os_log) and thread-safe.
|
||||
let target = target.map { String(cString: $0) } ?? "core"
|
||||
let message = message.map { String(cString: $0) } ?? ""
|
||||
// The crate (first path segment) becomes the category; the full target stays in the
|
||||
// line, so `quinn::connection` reads as `core.quinn quinn::connection …`.
|
||||
let crate_ = target.split(separator: ":", maxSplits: 1).first.map(String.init) ?? target
|
||||
let log = ClientLog(category: "core.\(crate_)")
|
||||
switch level {
|
||||
case 1: log.error("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
case 2: log.warning("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
case 3: log.info("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
default: log.debug("\(target, privacy: .public) \(message, privacy: .public)")
|
||||
}
|
||||
}, nil)
|
||||
if status != PUNKTFUNK_STATUS_OK.rawValue {
|
||||
ClientLog(category: "core").warning("core log callback not installed: status \(status)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
private let presenterLog = ClientLog(category: "presenter")
|
||||
|
||||
#if os(macOS)
|
||||
/// HOW a windowed (composited) macOS session pushes finished frames to glass — the DCP
|
||||
|
||||
@@ -34,7 +34,7 @@ import Foundation
|
||||
import Metal
|
||||
import os
|
||||
|
||||
private let waveletLog = Logger(subsystem: "io.unom.punktfunk", category: "pyrowave")
|
||||
private let waveletLog = ClientLog(category: "pyrowave")
|
||||
|
||||
/// The per-(component, level, band) 32x32-block table — the exact Swift port of
|
||||
/// `WaveletBuffers::init_block_meta` (pyrowave_common.cpp): the walk order (level 4→0,
|
||||
|
||||
@@ -56,9 +56,9 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
|
||||
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||
private let presentLog = ClientLog(category: "present")
|
||||
/// Pump-side events (loss recovery, format seeding) — the stage-2 sibling of StreamPump's log.
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
|
||||
private let pumpLog = ClientLog(category: "pump")
|
||||
|
||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||
|
||||
@@ -8,7 +8,7 @@ import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
|
||||
private let pumpLog = ClientLog(category: "video")
|
||||
|
||||
/// One pump per instance; create a fresh StreamPump per start (the stop is permanent —
|
||||
/// a restart hands the old pump its own token, so it can never be revived by a newer start()).
|
||||
|
||||
@@ -26,7 +26,7 @@ import os
|
||||
/// Same diagnostic switch as InputCapture: PUNKTFUNK_INPUT_DEBUG=1 logs when the macOS
|
||||
/// NSEvent mouse monitor (relative motion + buttons) is installed/removed, so the user can
|
||||
/// confirm the new motion path is actually live for a session.
|
||||
private let streamInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
|
||||
private let streamInputLog = ClientLog(category: "input")
|
||||
private let streamInputDebug =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import AVKit // AVDisplayManager — the per-session display-mode (HDR10/refresh
|
||||
/// resolved pointer-lock state each time capture engages, so the user can see whether the
|
||||
/// scene actually locked (GCMouse only delivers deltas while it did) or whether we're on
|
||||
/// the touch fallback.
|
||||
private let iosInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
|
||||
private let iosInputLog = ClientLog(category: "input")
|
||||
private let iosInputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
|
||||
|
||||
public struct StreamView: UIViewControllerRepresentable {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// The client log ring behind "Send logs to host", and the POST framing that carries it.
|
||||
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class ClientLogTests: XCTestCase {
|
||||
/// The ring is process-global, so this single test owns the whole lifecycle (parallel tests
|
||||
/// over one global would interleave) — the same shape as `pf_client_core::logring`'s test.
|
||||
func testRingBoundsAndRendersWithEvictionNote() {
|
||||
let marker = "ringtest-\(ProcessInfo.processInfo.processIdentifier)"
|
||||
for i in 0..<(ClientLogRing.maxLines + 10) {
|
||||
ClientLogRing.note("\(marker) line \(i)")
|
||||
}
|
||||
let text = ClientLogRing.render(header: "punktfunk-apple test")
|
||||
XCTAssertTrue(text.hasPrefix("punktfunk-apple test\n"))
|
||||
XCTAssertTrue(text.contains("older lines evicted from the ring"))
|
||||
XCTAssertFalse(text.contains("\n\(marker) line 0\n"), "oldest line survived eviction")
|
||||
XCTAssertTrue(text.hasSuffix("\(marker) line \(ClientLogRing.maxLines + 9)\n"))
|
||||
XCTAssertLessThanOrEqual(text.utf8.count, ClientLogRing.maxBytes + 256)
|
||||
|
||||
// A pathological line is truncated, not ring-flushing — and cut safely mid-scalar.
|
||||
ClientLogRing.note(String(repeating: "é", count: 10_000))
|
||||
let after = ClientLogRing.render(header: "h")
|
||||
XCTAssertTrue(after.contains("…"))
|
||||
XCTAssertTrue(after.hasSuffix("…\n"))
|
||||
|
||||
// The drop-in logger formats `stamp LEVEL category message` and honours the OSLogMessage
|
||||
// options the call sites use; debug stays out of the ring.
|
||||
let log = ClientLog(category: "test")
|
||||
log.info("\(marker) value \(1.23456, format: .fixed(precision: 2)) \(42, privacy: .public)")
|
||||
log.debug("\(marker) debug-only")
|
||||
let lines = ClientLogRing.render(header: "h").components(separatedBy: "\n")
|
||||
let info = lines.last { $0.contains("\(marker) value") }
|
||||
XCTAssertNotNil(info)
|
||||
XCTAssertTrue(info!.contains(" INFO test \(marker) value 1.23 42"), info!)
|
||||
// `2026-08-15T12:03:47.123Z ` leads — wall time, so a bundle lines up with the host log.
|
||||
XCTAssertNotNil(
|
||||
info!.range(of: #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z INFO test "#, options: .regularExpression),
|
||||
info!)
|
||||
XCTAssertFalse(lines.contains { $0.contains("debug-only") })
|
||||
}
|
||||
|
||||
func testHeaderNamesTheAppAndPlatform() {
|
||||
let header = ClientLogRing.header()
|
||||
XCTAssertTrue(header.hasPrefix("punktfunk-apple "))
|
||||
XCTAssertTrue(header.hasSuffix(" — client log bundle"))
|
||||
#if os(macOS)
|
||||
XCTAssertTrue(header.contains("macOS"))
|
||||
#endif
|
||||
}
|
||||
|
||||
func testPostIsLengthFramedAndGetHasNoBody() {
|
||||
let body = Data("hello ring\n".utf8)
|
||||
let post = String(decoding: MgmtConnection.requestBytes(
|
||||
host: "fd00::1", port: 47990, method: "POST", path: "/api/v1/client-logs",
|
||||
body: body, contentType: "text/plain; charset=utf-8"), as: UTF8.self)
|
||||
XCTAssertTrue(post.hasPrefix("POST /api/v1/client-logs HTTP/1.1\r\nHost: [fd00::1]:47990\r\n"))
|
||||
XCTAssertTrue(post.contains("\r\nContent-Type: text/plain; charset=utf-8\r\n"))
|
||||
XCTAssertTrue(post.contains("\r\nContent-Length: \(body.count)\r\n\r\nhello ring\n"))
|
||||
XCTAssertTrue(post.hasSuffix("\r\n\r\nhello ring\n"))
|
||||
|
||||
let get = String(decoding: MgmtConnection.requestBytes(
|
||||
host: "192.168.1.2", port: 47990, method: "GET", path: "/api/v1/library",
|
||||
body: nil, contentType: nil), as: UTF8.self)
|
||||
XCTAssertTrue(get.hasPrefix("GET /api/v1/library HTTP/1.1\r\nHost: 192.168.1.2:47990\r\n"))
|
||||
XCTAssertFalse(get.contains("Content-Length"))
|
||||
XCTAssertTrue(get.hasSuffix("\r\n\r\n"))
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,25 @@ final class CommandChordTests: XCTestCase {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
|
||||
}
|
||||
|
||||
/// The system-shortcut tap (⌘Space, ⌘Tab — the keys macOS claims before the app sees them)
|
||||
/// takes keys off the system ONLY while captured, under the capture mouse model, with the app
|
||||
/// frontmost. Any other state must pass through: a tap that eats keys for the whole Mac is the
|
||||
/// failure to pin here.
|
||||
func testTheSystemShortcutTapOnlyClaimsWhileCapturedAndFrontmost() {
|
||||
XCTAssertTrue(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: true))
|
||||
XCTAssertFalse(InputCapture.tapClaims(forwarding: false, desktopMouse: false, appActive: true))
|
||||
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: true, appActive: true))
|
||||
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: false))
|
||||
}
|
||||
|
||||
/// The keys the tap exists for must have host VKs — it reposts them into the ordinary key path,
|
||||
/// which drops unmapped keyCodes on the floor.
|
||||
func testTheSystemShortcutKeysMapToHostVKs() {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[49], 0x20) // Space (⌘Space)
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[48], 0x09) // Tab (⌘Tab)
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[126], 0x26) // Up arrow (⌃↑ Mission Control)
|
||||
}
|
||||
|
||||
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
|
||||
@@ -81,6 +81,15 @@ WHY THE APP ASKS FOR WHAT IT ASKS FOR
|
||||
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
|
||||
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
|
||||
without this, no video, audio or rumble arrives.
|
||||
- Accessibility (macOS, optional, never requested unprompted): "Capture system shortcuts" in
|
||||
Settings > Input lets ⌘Space, ⌘Tab and Mission Control reach the remote desktop instead of the
|
||||
Mac while the stream has captured the keyboard -- the same thing every remote-desktop/VM app
|
||||
offers. macOS delivers those keys to Spotlight/the Dock before any app, so the only way to
|
||||
receive them is a keyboard event tap, which needs Accessibility. The prompt appears only when
|
||||
the user turns the toggle on or presses "Allow Accessibility access…"; the tap exists only while
|
||||
a stream has the keyboard captured and the app is frontmost, and it reads nothing -- keys are
|
||||
handed to the app's own stream window, never logged or stored. Without the grant, the toggle
|
||||
still works for the app's own ⌘ shortcuts and simply says the system ones need Accessibility.
|
||||
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
|
||||
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
|
||||
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
|
||||
|
||||
@@ -82,6 +82,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
.or_else(|| k.and_then(|h| h.mgmt_port))
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: false,
|
||||
clipboard_sync: k.is_some_and(|h| h.clipboard_sync),
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
os: k.map(|h| h.os.clone()).unwrap_or_default(),
|
||||
pin: None,
|
||||
@@ -336,6 +337,7 @@ fn fake_host_row() -> HostRow {
|
||||
online: true,
|
||||
mgmt_port: library::DEFAULT_MGMT_PORT,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: "linux/arch/steamos".into(),
|
||||
pin: None,
|
||||
@@ -698,6 +700,40 @@ impl ServiceState {
|
||||
// `run` refreshes the rows right after this drain, so the carousel and
|
||||
// the pin screen reflect the new card within the same service pass.
|
||||
}
|
||||
ConsoleCmd::BindProfile { key, profile_id } => {
|
||||
// The BINDING half of the profile pair — `KnownHost::profile_id`, what a
|
||||
// plain A-press on the primary tile connects with. `SetPin` above is the
|
||||
// presentation half and never touches this field; this never touches the
|
||||
// pins. Same store discipline, same refresh-after-drain.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = index_for_key(&known, &key);
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "profile bind for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if h.profile_id != profile_id {
|
||||
h.profile_id = profile_id;
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
}
|
||||
}
|
||||
ConsoleCmd::SetClipboard { key, on } => {
|
||||
// Per-host clipboard trust (`KnownHost::clipboard_sync`) — the host
|
||||
// menu's toggle. Same store discipline as the two arms above.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = index_for_key(&known, &key);
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "clipboard toggle for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if h.clipboard_sync != on {
|
||||
h.clipboard_sync = on;
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,6 +823,7 @@ impl ServiceState {
|
||||
.or(h.mgmt_port)
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: !online && !h.mac.is_empty(),
|
||||
clipboard_sync: h.clipboard_sync,
|
||||
last_used: h.last_used,
|
||||
os: advert
|
||||
.filter(|d| !d.os.is_empty())
|
||||
@@ -845,6 +882,7 @@ impl ServiceState {
|
||||
online: true,
|
||||
mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: d.os.clone(),
|
||||
pin: None,
|
||||
|
||||
@@ -840,6 +840,29 @@ mod session_main {
|
||||
// speaker pair.
|
||||
let speaker = arg_flag("--speaker");
|
||||
let coils = arg_flag("--coils") || !speaker;
|
||||
// Say up front whether a real session would render what this is about to prove
|
||||
// works. The devtest drives the pad DIRECTLY, so it is deliberately blind to the
|
||||
// settings — which makes "the tone plays here but the game is silent" a genuinely
|
||||
// confusing result, and one that has cost a whole debugging evening: the toggle is
|
||||
// on the client while every instinct sends you measuring the host. The capability
|
||||
// is never advertised when the toggle is off, so no later log line can catch this.
|
||||
{
|
||||
let s = trust::Settings::load();
|
||||
if speaker && !pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
|
||||
println!(
|
||||
"note: \"Controller speaker\" is OFF in your settings (pad_speaker = \
|
||||
{:?}), so a streaming session will NOT render the pad's speaker even if \
|
||||
the tone below is audible.",
|
||||
s.pad_speaker
|
||||
);
|
||||
}
|
||||
if coils && !s.pad_haptics {
|
||||
println!(
|
||||
"note: \"Controller haptics\" is OFF in your settings, so a streaming \
|
||||
session will NOT render the voice coils even if the tone below is felt."
|
||||
);
|
||||
}
|
||||
}
|
||||
return match pf_client_core::pad_audio::pad_audio_test(seconds, coils, speaker) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
|
||||
@@ -68,7 +68,7 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
event.record(&mut v);
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
wallclock(),
|
||||
pf_client_core::logring::wallclock(),
|
||||
meta.level().as_str(),
|
||||
meta.target(),
|
||||
v.0
|
||||
@@ -76,34 +76,6 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
|
||||
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
|
||||
fn wallclock() -> String {
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let secs = (ms / 1000) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
|
||||
ms % 1000
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -67,6 +67,12 @@ pub enum PointerInput {
|
||||
x: f32,
|
||||
y: f32,
|
||||
button: PointerButton,
|
||||
/// A finger (or stylus) on the glass, as opposed to a mouse button. The console
|
||||
/// defers a touch press until the lift so a swipe can scroll instead of acting on
|
||||
/// whatever the finger first lands on; a mouse press keeps acting immediately.
|
||||
/// Only `Down` carries it — the console tracks the gesture it opened, so the
|
||||
/// matching `Up`/`Move`/`Cancel` need no flag of their own.
|
||||
touch: bool,
|
||||
},
|
||||
Up {
|
||||
x: f32,
|
||||
|
||||
@@ -63,7 +63,11 @@ pub mod library;
|
||||
// Per-host catalog cache, so a library screen has titles to show while a sleeping host boots.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod library_cache;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
// Android-enabled for the RING half (note/render — std only): the client's "Send logs to
|
||||
// host" needs the ring on every platform. The `send_to_host` uploader inside stays
|
||||
// desktop-gated with the rest of the ureq fetches; Android posts the rendered bundle
|
||||
// through its own mTLS OkHttp client (`SkiaConsole.sendLogs`).
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
pub mod logring;
|
||||
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
|
||||
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
|
||||
|
||||
@@ -55,6 +55,36 @@ pub fn note(mut line: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
|
||||
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
|
||||
/// Lives here (not in a shell) because every ring FEEDER wants the same stamp: the session's
|
||||
/// `ring_layer` and the Android client's logcat tee both prefix their lines with it.
|
||||
pub fn wallclock() -> String {
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let secs = (ms / 1000) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
|
||||
ms % 1000
|
||||
)
|
||||
}
|
||||
|
||||
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the shell's own
|
||||
/// identity line — binary name, version, platform) and an eviction note when the ring wrapped.
|
||||
pub fn render(header: &str) -> String {
|
||||
@@ -79,6 +109,7 @@ pub fn render(header: &str) -> String {
|
||||
/// trust as the library fetch: TLS client auth with the device identity, host pinned by
|
||||
/// fingerprint. Errors reuse the library's classification (401/403 ⇒ `NotPaired`, a pin-verifier
|
||||
/// rejection ⇒ `PinMismatch`), so the shell's existing error strings apply.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub fn send_to_host(
|
||||
addr: &str,
|
||||
mgmt_port: u16,
|
||||
|
||||
@@ -231,6 +231,10 @@ pub(crate) fn props_say_ds5(
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct SinkNode {
|
||||
/// The registry's global id for this node — what [`pin_sink_volume`] binds to set the
|
||||
/// node's `Props`. Zero for a node no walk produced (test fixtures, and the split parent
|
||||
/// named by a sink we can see but never shown to us as an object of its own).
|
||||
pub(crate) id: u32,
|
||||
/// `node.name` — what a stream targets via `target.object`.
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
@@ -401,6 +405,8 @@ pub(crate) fn sink_from_props(props: &pipewire::spa::utils::dict::DictRef) -> Op
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(SinkNode {
|
||||
// Filled by the caller from the node's own `info` — the proplist does not carry it.
|
||||
id: 0,
|
||||
device_id: props.get("device.id").and_then(|v| v.parse().ok()),
|
||||
channels: props
|
||||
.get("audio.channels")
|
||||
@@ -484,7 +490,8 @@ fn walk_graph() -> anyhow::Result<(Vec<SinkNode>, Vec<CardDevice>)> {
|
||||
let sinks = sinks.clone();
|
||||
move |info| {
|
||||
let Some(p) = info.props() else { return };
|
||||
if let Some(s) = sink_from_props(p) {
|
||||
if let Some(mut s) = sink_from_props(p) {
|
||||
s.id = info.id();
|
||||
let mut v = sinks.borrow_mut();
|
||||
// `info` can fire more than once per node; keep one.
|
||||
if let Some(old) = v.iter_mut().find(|o| o.name == s.name) {
|
||||
@@ -873,6 +880,157 @@ fn profile_pod(index: u32) -> anyhow::Result<Vec<u8>> {
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// The `Props` object pod that puts every channel of a sink at unity gain.
|
||||
///
|
||||
/// Unity is 1.0 in `channelVolumes`, which is NOT the "100%" a mixer shows: pulse (and every UI
|
||||
/// built on it) displays a CUBED scale, so WirePlumber's 0.4 default reads as 40% on screen and
|
||||
/// is 0.4³ = 0.064 — a hair under −24 dB — in the linear units this pod speaks. 1.0 is unity in
|
||||
/// both, which is the whole reason this pins to unity rather than to some other number.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unity_volume_pod(channels: u32) -> anyhow::Result<Vec<u8>> {
|
||||
use anyhow::Context;
|
||||
use pipewire::spa;
|
||||
use spa::pod::{Object, Property, PropertyFlags, Value, ValueArray};
|
||||
let obj = Object {
|
||||
type_: spa::utils::SpaTypes::ObjectParamProps.as_raw(),
|
||||
id: spa::param::ParamType::Props.as_raw(),
|
||||
properties: vec![
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_volume,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::Float(1.0),
|
||||
},
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_channelVolumes,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::ValueArray(ValueArray::Float(vec![1.0; channels.max(1) as usize])),
|
||||
},
|
||||
],
|
||||
};
|
||||
Ok(spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&Value::Object(obj),
|
||||
)
|
||||
.context("serialize")?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// Put the pad's sink at unity gain, because nobody chose the level it arrives at.
|
||||
///
|
||||
/// WirePlumber starts every new card's sink at `device.routes.default-sink-volume` — 0.4, which
|
||||
/// is −23.88 dB — and that setting is global: it cannot be scoped to one device in config, so
|
||||
/// there is no configuration fix to ship. It is a sane default for a laptop speaker somebody is
|
||||
/// about to turn up, and wrong for this sink twice over. The pad's is not a listening volume a
|
||||
/// user reaches for; and BOTH ends of a session mint one, so the two stack: −47.8 dB by the time
|
||||
/// a game's haptics reach a voice coil, which is felt as "the haptics are weak, maybe dead"
|
||||
/// rather than as a volume anyone would think to look at.
|
||||
///
|
||||
/// Deliberately NOT restored the way [`restore_profile`] restores a borrowed profile. A profile
|
||||
/// swap overrides a choice the user made; this overrides a default nobody made, and putting
|
||||
/// −24 dB back on the way out would be restoring the bug.
|
||||
///
|
||||
/// Best effort throughout: every failure here costs attenuation, never audio, so the caller logs
|
||||
/// and carries on. `PUNKTFUNK_PAD_SINK_VOLUME=0` leaves the sink exactly where it was found, for
|
||||
/// bisecting against a box where something else is doing the attenuating.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pin_sink_volume(node_id: u32, channels: u32) -> anyhow::Result<()> {
|
||||
use anyhow::{anyhow, Context};
|
||||
use pipewire as pw;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
||||
PW_INIT.call_once(pw::init);
|
||||
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
||||
let core = context.connect_rc(None).context("pw connect")?;
|
||||
let registry = core.get_registry_rc().context("pw registry")?;
|
||||
|
||||
let node: Rc<RefCell<Option<pw::node::Node>>> = Rc::default();
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global({
|
||||
let (registry, node) = (registry.clone(), node.clone());
|
||||
move |g| {
|
||||
if g.id != node_id || g.type_ != pw::types::ObjectType::Node {
|
||||
return;
|
||||
}
|
||||
if let Ok(n) = registry.bind::<pw::node::Node, _>(g) {
|
||||
*node.borrow_mut() = Some(n);
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let awaited: Rc<Cell<Option<pw::spa::utils::result::AsyncSeq>>> = Rc::new(Cell::new(None));
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.done({
|
||||
let (mainloop, awaited) = (mainloop.clone(), awaited.clone());
|
||||
move |_, seq| {
|
||||
if awaited.get() == Some(seq) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
let round = |issue: &dyn Fn() -> anyhow::Result<()>| -> anyhow::Result<()> {
|
||||
issue()?;
|
||||
awaited.set(Some(core.sync(0).context("pw sync")?));
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
};
|
||||
|
||||
round(&|| Ok(()))?; // 1: the registry replays its globals; our node gets bound
|
||||
let pod = unity_volume_pod(channels).context("serialize Props pod")?;
|
||||
round(&|| {
|
||||
let n = node.borrow();
|
||||
let n = n
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("sink node {node_id} is not in the PipeWire graph"))?;
|
||||
n.set_param(
|
||||
pw::spa::param::ParamType::Props,
|
||||
0,
|
||||
pw::spa::pod::Pod::from_bytes(&pod).ok_or_else(|| anyhow!("bad Props pod"))?,
|
||||
);
|
||||
Ok(())
|
||||
})?; // 2: flush the set_param before the loop and its proxies drop
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pass a picked node name through, pinning that node to unity gain on the way — see
|
||||
/// [`pin_sink_volume`] for why the level it arrives at is nobody's choice.
|
||||
///
|
||||
/// Runs on every (re)correlation rather than once, so a card that re-minted its nodes (a profile
|
||||
/// change, a replug) is pinned again without anything having to notice that it did.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pin_picked(name: String, sinks: &[SinkNode]) -> String {
|
||||
if matches!(
|
||||
std::env::var("PUNKTFUNK_PAD_SINK_VOLUME").as_deref(),
|
||||
Ok("0" | "false" | "off" | "no")
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
// Only a node the walk actually saw. The `split_parent` pick is a NAME lifted off another
|
||||
// node's proplist — there may be no object behind it we are allowed to bind, and pinning the
|
||||
// sink that named it would be pinning the wrong node.
|
||||
let Some(s) = sinks.iter().find(|s| s.name == name && s.id != 0) else {
|
||||
return name;
|
||||
};
|
||||
match pin_sink_volume(s.id, s.channels) {
|
||||
Ok(()) => tracing::debug!(node = %name, channels = s.channels, "pad sink pinned to 0 dB"),
|
||||
Err(e) => tracing::debug!(
|
||||
node = %name,
|
||||
error = %format!("{e:#}"),
|
||||
"could not pin the pad sink to 0 dB — haptics may be quiet if the session manager \
|
||||
left it at its default 40%"
|
||||
),
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Correlate: walk the graph, pick the pad's four-channel node, and move the card's profile if
|
||||
/// that is what stands between us and one. Returns the `node.name` to target.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -880,7 +1038,7 @@ pub fn correlate_pad_sink() -> anyhow::Result<String> {
|
||||
use anyhow::anyhow;
|
||||
let (sinks, cards) = walk_graph()?;
|
||||
match pick_pad_sink(&sinks, &cards) {
|
||||
Some(PadSinkPick::Node(name)) => Ok(name),
|
||||
Some(PadSinkPick::Node(name)) => Ok(pin_picked(name, &sinks)),
|
||||
Some(PadSinkPick::NeedsProfile(device_id)) => {
|
||||
if PROFILE_TRIED.lock().unwrap().contains(&device_id) {
|
||||
return Err(anyhow!(
|
||||
@@ -896,7 +1054,7 @@ pub fn correlate_pad_sink() -> anyhow::Result<String> {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let (sinks, cards) = walk_graph()?;
|
||||
if let Some(PadSinkPick::Node(name)) = pick_pad_sink(&sinks, &cards) {
|
||||
return Ok(name);
|
||||
return Ok(pin_picked(name, &sinks));
|
||||
}
|
||||
last = sinks;
|
||||
}
|
||||
@@ -1889,6 +2047,34 @@ fn pad_render_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The unity pod is what the 0 dB pin IS, so it has to be the shape PipeWire reads: one
|
||||
/// unity float per channel. PipeWire ignores a `channelVolumes` whose length does not match
|
||||
/// the port count, and an ignored pod looks exactly like the pin silently not working —
|
||||
/// which is the -23.88 dB this exists to undo, back again and just as invisible.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn unity_pod_is_one_float_per_channel() {
|
||||
use pipewire::spa::pod::{deserialize::PodDeserializer, Value, ValueArray};
|
||||
for channels in [1u32, 2, 4] {
|
||||
let bytes = unity_volume_pod(channels).expect("serialize");
|
||||
let (_, value) = PodDeserializer::deserialize_any_from(&bytes).expect("parse");
|
||||
let Value::Object(obj) = value else {
|
||||
panic!("not an object pod");
|
||||
};
|
||||
let vols = obj
|
||||
.properties
|
||||
.iter()
|
||||
.find(|p| p.key == pipewire::spa::sys::SPA_PROP_channelVolumes)
|
||||
.map(|p| p.value.clone())
|
||||
.expect("channelVolumes");
|
||||
let Value::ValueArray(ValueArray::Float(v)) = vols else {
|
||||
panic!("channelVolumes is not a float array");
|
||||
};
|
||||
assert_eq!(v.len(), channels as usize);
|
||||
assert!(v.iter().all(|&x| x == 1.0), "every channel must be unity");
|
||||
}
|
||||
}
|
||||
|
||||
/// The speaker mode gate: only `"pad"` renders today; `"mix"` is the declared TODO and
|
||||
/// reads as off; unknown values (a future store, a typo) fail safe to off.
|
||||
#[test]
|
||||
@@ -1985,6 +2171,9 @@ mod tests {
|
||||
|
||||
fn sink(name: &str, channels: u32, positions: &str, device_id: Option<u32>) -> SinkNode {
|
||||
SinkNode {
|
||||
// The picker never reads it (only the volume pin does), so these fixtures leave it
|
||||
// at the "no walk produced this" value.
|
||||
id: 0,
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
device_id,
|
||||
|
||||
@@ -883,6 +883,12 @@ fn pump(
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
// THE session's stop flag, so the embedder's cancel reaches a dial that has not landed
|
||||
// yet. Without it this call parks the pump thread for the whole budget — 185 s on a
|
||||
// request-access connect the host holds pending approval — and the embedder's cancel
|
||||
// could not be answered until it returned: the console's takeover sat on "Canceling…"
|
||||
// with no session event to clear it.
|
||||
Some(stop.clone()),
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
|
||||
@@ -41,6 +41,11 @@ pub struct HostRow {
|
||||
pub mgmt_port: u16,
|
||||
/// Offline + a stored MAC → activating wakes first ("Wake & Connect").
|
||||
pub can_wake: bool,
|
||||
/// Share this device's clipboard with THIS host while streaming
|
||||
/// (`KnownHost::clipboard_sync`) — surfaced so the host menu can show and flip it.
|
||||
/// `serde(default)`: a producer predating the field still parses (as not-shared).
|
||||
#[serde(default)]
|
||||
pub clipboard_sync: bool,
|
||||
/// Last successful connect (UNIX seconds) — the most-recent accent.
|
||||
pub last_used: Option<u64>,
|
||||
/// The host's OS-identity chain (live advert preferred, else the stored one), for a
|
||||
@@ -220,6 +225,19 @@ pub enum ConsoleCmd {
|
||||
profile_id: String,
|
||||
pin: bool,
|
||||
},
|
||||
/// Bind (or clear) a saved host's DEFAULT profile — `KnownHost::profile_id`, the one a
|
||||
/// plain A-press on the primary tile connects with (the port design's WP5 leftover;
|
||||
/// [`ConsoleCmd::SetPin`] is presentation, this is the binding). `key` is the HOST
|
||||
/// row's key; `None` clears the binding. Idempotent like `SetPin`: re-binding the
|
||||
/// bound profile is a no-op.
|
||||
BindProfile {
|
||||
key: String,
|
||||
profile_id: Option<String>,
|
||||
},
|
||||
/// Share (or stop sharing) this device's clipboard with a saved host while streaming —
|
||||
/// `KnownHost::clipboard_sync`, the host menu's toggle. Per-host, never global:
|
||||
/// handing a host your clipboard is a trust decision about that host.
|
||||
SetClipboard { key: String, on: bool },
|
||||
/// Open a screen the PLATFORM owns over the console (design android-skia-console-port.md
|
||||
/// D7) — Android's connected-controllers view, the open-source licences. `id` is a
|
||||
/// [`crate::platform::PlatformScreen::id`]. The host draws it, holds the console's input
|
||||
@@ -276,6 +294,7 @@ mod tests {
|
||||
online: false,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! every screen animates and reads identically.
|
||||
|
||||
pub(crate) mod add_host;
|
||||
pub(crate) mod bind_profile;
|
||||
pub(crate) mod collections;
|
||||
pub(crate) mod controllers;
|
||||
pub(crate) mod home;
|
||||
@@ -180,6 +181,10 @@ pub(crate) enum Screen {
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
/// "Default for <host>": which profile the host's primary tile connects with — the
|
||||
/// binding sibling of [`Screen::PinHosts`]'s presentation cards. Raised by the host
|
||||
/// menu's "Default profile…" action.
|
||||
BindProfile(bind_profile::BindProfileScreen),
|
||||
/// "Connected controllers": the attached pads and their identity lines, plus the grants
|
||||
/// and tests only the platform can perform. Android-reachable only — the settings row
|
||||
/// that opens it is in `settings::row_on`'s Android-only list.
|
||||
@@ -205,6 +210,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
|
||||
Screen::BindProfile(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Controllers(s) => s.menu(ev, ctx, fx),
|
||||
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
|
||||
}
|
||||
@@ -224,6 +230,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Pair(s) => s.pointer(p, ctx, fx),
|
||||
Screen::PinHosts(s) => s.pointer(p, ctx, fx),
|
||||
Screen::BindProfile(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Controllers(s) => s.pointer(p, ctx, fx),
|
||||
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
|
||||
}
|
||||
@@ -274,6 +281,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.title(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
|
||||
Screen::BindProfile(s) => format!("Default for {}", s.host_name()),
|
||||
Screen::Controllers(_) => "Connected controllers".into(),
|
||||
Screen::HostOptions(s) => s.title(),
|
||||
}
|
||||
@@ -288,6 +296,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
Screen::PinHosts(s) => s.hints(ctx),
|
||||
Screen::BindProfile(s) => s.hints(ctx),
|
||||
Screen::Controllers(s) => s.hints(ctx),
|
||||
Screen::HostOptions(s) => s.hints(ctx),
|
||||
}
|
||||
@@ -313,6 +322,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::BindProfile(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Controllers(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::HostOptions(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! "Default for “Desk”" — choose the profile a plain A-press on a saved host connects
|
||||
//! with (`KnownHost::profile_id`), reached from the host tile's menu. One row per catalog
|
||||
//! profile behind a leading "No default" row; choosing rides
|
||||
//! [`ConsoleCmd::BindProfile`] to the binary, which persists the binding and refreshes
|
||||
//! the rows — the checkmark follows the model, so what the list says is always what the
|
||||
//! store holds (and what the tile's chip shows). Pinning is the sibling decision
|
||||
//! (`pin_hosts.rs`): a pin adds a CARD, this changes what the primary tile itself does.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
pub(crate) struct BindProfileScreen {
|
||||
/// The HOST row's primary key (fingerprint or `addr:port`, never a pinned card's
|
||||
/// composite) — what every [`ConsoleCmd::BindProfile`] here addresses.
|
||||
host_key: String,
|
||||
host_name: String,
|
||||
/// The catalog's `(id, name)` pairs, loaded once at construction — same stability
|
||||
/// assumption the settings screen's Profiles tab makes (the console can't create
|
||||
/// profiles, so the list can't change under this screen).
|
||||
profiles: Vec<(String, String)>,
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
impl BindProfileScreen {
|
||||
pub(crate) fn new(
|
||||
host_key: String,
|
||||
host_name: String,
|
||||
profiles: Vec<(String, String)>,
|
||||
) -> BindProfileScreen {
|
||||
BindProfileScreen {
|
||||
host_key,
|
||||
host_name,
|
||||
profiles,
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn host_name(&self) -> &str {
|
||||
&self.host_name
|
||||
}
|
||||
|
||||
/// The host's current binding, read from the model — the primary row's chip IS the
|
||||
/// state, so the checkmark can never disagree with what the carousel shows.
|
||||
fn bound(&self, ctx: &Ctx) -> Option<String> {
|
||||
ctx.hosts
|
||||
.iter()
|
||||
.find(|r| r.key == self.host_key)
|
||||
.and_then(|r| r.bound_profile.as_ref())
|
||||
.map(|p| p.id.clone())
|
||||
}
|
||||
|
||||
/// Row `i`'s meaning: 0 is "No default", the rest the catalog in order.
|
||||
fn choice(&self, i: usize) -> Option<Option<&str>> {
|
||||
if i == 0 {
|
||||
Some(None)
|
||||
} else {
|
||||
self.profiles.get(i - 1).map(|(id, _)| Some(id.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.profiles.len() + 1
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, self.len());
|
||||
self.choose(msg, pulse, ctx, fx)
|
||||
}
|
||||
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
let (msg, pulse) = self.list.pointer(p, self.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.choose(msg, pulse, ctx, fx);
|
||||
true
|
||||
}
|
||||
|
||||
/// One list message against the focused row — shared by both input paths. A choice is
|
||||
/// a radio press, not a toggle: A on the row that is already the binding is a boundary
|
||||
/// thud, and ◀/▶ adjust nothing here.
|
||||
fn choose(
|
||||
&mut self,
|
||||
msg: ListMsg,
|
||||
pulse: Option<MenuPulse>,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
let Some(choice) = self.choice(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
match msg {
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
ListMsg::Activate => {
|
||||
let current = self.bound(ctx);
|
||||
if current.as_deref() == choice {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
fx.cmds.push(ConsoleCmd::BindProfile {
|
||||
key: self.host_key.clone(),
|
||||
profile_id: choice.map(str::to_owned),
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
if self.profiles.is_empty() {
|
||||
return vec![Hint::new(HintKey::Back, "Done")];
|
||||
}
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Set default"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
if self.profiles.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"No profiles yet \u{2014} create them in the desktop app, then choose one here.",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
|
||||
f64::from(rect.width()) * 0.7,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The explainer band under the list, like the settings screen's detail text.
|
||||
let detail_h = 34.0 * k;
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let bound = self.bound(ctx);
|
||||
let rows: Vec<RowSpec> = (0..self.len())
|
||||
.map(|i| {
|
||||
let (label, id) = if i == 0 {
|
||||
("No default".to_string(), None)
|
||||
} else {
|
||||
let (id, name) = &self.profiles[i - 1];
|
||||
(name.clone(), Some(id.as_str()))
|
||||
};
|
||||
let current = bound.as_deref() == id;
|
||||
RowSpec {
|
||||
header: None,
|
||||
label,
|
||||
value: Some(if current {
|
||||
"Default".into()
|
||||
} else {
|
||||
String::new()
|
||||
}),
|
||||
value_dim: !current,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"What a plain press on this host's tile connects with. Pinned cards keep their own.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{HostRow, ProfileChip};
|
||||
use pf_client_core::menu_nav::MenuDir;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn host(bound: Option<&str>) -> HostRow {
|
||||
HostRow {
|
||||
key: "aa".into(),
|
||||
name: "Desk".into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
paired: true,
|
||||
saved: true,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: bound.map(|id| ProfileChip {
|
||||
id: id.into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn screen() -> BindProfileScreen {
|
||||
BindProfileScreen::new(
|
||||
"aa".into(),
|
||||
"Desk".into(),
|
||||
vec![("p1".into(), "Work".into()), ("p2".into(), "Game".into())],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_profile_binds_and_no_default_clears() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let hosts = [host(Some("p1"))];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = screen();
|
||||
// Row 2 = the second profile: binds it.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::BindProfile {
|
||||
key: "aa".into(),
|
||||
profile_id: Some("p2".into()),
|
||||
}]
|
||||
);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Confirm)));
|
||||
|
||||
// Row 0 clears the binding.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::BindProfile {
|
||||
key: "aa".into(),
|
||||
profile_id: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_choosing_the_current_binding_is_a_boundary_not_a_command() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let hosts = [host(Some("p1"))];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = screen();
|
||||
// Row 1 = "Work", already bound.
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
|
||||
// An unbound host: "No default" is already the state.
|
||||
let hosts = [host(None)];
|
||||
let mut settings = Settings::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = screen();
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
}
|
||||
@@ -823,6 +823,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 9778,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -75,11 +75,7 @@ const PASSTHROUGH: [(PadAction, &str, &str); 4] = [
|
||||
"Grant",
|
||||
),
|
||||
(PadAction::Sc2Usb, "Steam Controller 2 over USB", "Grant"),
|
||||
(
|
||||
PadAction::DsUsb,
|
||||
"DualSense / DualShock over USB",
|
||||
"Grant",
|
||||
),
|
||||
(PadAction::DsUsb, "DualSense / DualShock over USB", "Grant"),
|
||||
(PadAction::DsHaptics, "DualSense haptics self-test", "Test"),
|
||||
];
|
||||
|
||||
@@ -258,7 +254,14 @@ fn spec(row: Row, ctx: &Ctx) -> RowSpec {
|
||||
RowSpec {
|
||||
header: (i == 0).then_some("Gamepads"),
|
||||
label: pad.name.clone(),
|
||||
value: Some(if pad.rumble { "Test rumble" } else { "No rumble" }.into()),
|
||||
value: Some(
|
||||
if pad.rumble {
|
||||
"Test rumble"
|
||||
} else {
|
||||
"No rumble"
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
value_dim: !pad.rumble,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
@@ -289,22 +292,26 @@ fn detail(row: Row, ctx: &Ctx) -> String {
|
||||
.into(),
|
||||
Row::Pad(i) => pad_detail(&ctx.pads[i]),
|
||||
Row::Passthrough(i) => match PASSTHROUGH[i].0 {
|
||||
PadAction::Sc2Bluetooth =>
|
||||
PadAction::Sc2Bluetooth => {
|
||||
"A Steam Controller 2 paired over Bluetooth cannot be detected at all without \
|
||||
Bluetooth access. Wired and Puck-dongle controllers need no permission."
|
||||
.into(),
|
||||
PadAction::Sc2Usb =>
|
||||
.into()
|
||||
}
|
||||
PadAction::Sc2Usb => {
|
||||
"A wired or Puck-dongle Steam Controller 2 needs USB access to be captured; \
|
||||
until then it stays in its built-in keyboard/mouse mode."
|
||||
.into(),
|
||||
PadAction::DsUsb =>
|
||||
.into()
|
||||
}
|
||||
PadAction::DsUsb => {
|
||||
"A wired DualSense or DualShock 4 needs USB access to be captured — with it, \
|
||||
streams drive rumble, adaptive triggers, lightbar and gyro directly."
|
||||
.into(),
|
||||
PadAction::DsHaptics =>
|
||||
.into()
|
||||
}
|
||||
PadAction::DsHaptics => {
|
||||
"Play a short tone through a wired DualSense's audio endpoint, to tell a pad \
|
||||
that cannot do haptics from a stream that is not sending them."
|
||||
.into(),
|
||||
.into()
|
||||
}
|
||||
// Not offered as a passthrough row — the pads carry it.
|
||||
PadAction::Rumble => String::new(),
|
||||
},
|
||||
@@ -408,20 +415,30 @@ mod tests {
|
||||
let pads = [pad("DualSense", true)];
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let ctx = |platform, settings: &mut Settings| Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings,
|
||||
store: crate::store::file_store(),
|
||||
platform,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert_eq!(rows_for(&ctx(Platform::Desktop, &mut settings)).len(), 1);
|
||||
fn ctx<'a>(
|
||||
platform: Platform,
|
||||
settings: &'a mut Settings,
|
||||
library: &'a crate::library::LibraryShared,
|
||||
pads: &'a [PadInfo],
|
||||
) -> Ctx<'a> {
|
||||
Ctx {
|
||||
hosts: &[],
|
||||
library,
|
||||
settings,
|
||||
store: crate::store::file_store(),
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
rows_for(&ctx(Platform::Android, &mut settings)).len(),
|
||||
rows_for(&ctx(Platform::Desktop, &mut settings, &library, &pads)).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
rows_for(&ctx(Platform::Android, &mut settings, &library, &pads)).len(),
|
||||
1 + PASSTHROUGH.len()
|
||||
);
|
||||
|
||||
|
||||
@@ -811,6 +811,7 @@ mod tests {
|
||||
online,
|
||||
mgmt_port: 47990,
|
||||
can_wake,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! on the shell's stack. B pops back to the host list; A launches the focused title in
|
||||
//! the same window. The shell owns the aurora, chrome, and the connecting overlay.
|
||||
|
||||
use crate::anim::{entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::anim::{approach, entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::{
|
||||
card_matrix, grid_col_hint, grid_step, initials, step_cursor, store_label, GridDir, GridShape,
|
||||
@@ -28,8 +28,10 @@ const GRID_MARGIN: f64 = 48.0;
|
||||
const GRID_LABEL: f64 = 10.0;
|
||||
/// The band a grid group heading occupies.
|
||||
const GRID_HEADING: f64 = 30.0;
|
||||
/// The band the focused title's name and store occupy under either arrangement.
|
||||
const DETAIL_BAND: f64 = 84.0;
|
||||
/// The band the focused title's name occupies under either arrangement. Shrunk from 84 when
|
||||
/// the store/platform subtitle left: the cover badge already carries that answer, and on a
|
||||
/// phone the 20 units bought most of a grid row back.
|
||||
const DETAIL_BAND: f64 = 64.0;
|
||||
/// The corner on the view/sort bar's own glass, once it has focus.
|
||||
const BAR_CORNER: f64 = 14.0;
|
||||
/// Air between the bar and the field under it. The shelf centres its cards and would never
|
||||
@@ -394,6 +396,13 @@ pub(super) fn strip_caption(
|
||||
/// the persisted settings are the state and these only draw it and hit-test a click.
|
||||
struct LibraryBar {
|
||||
focus: bool,
|
||||
/// How present the bar is, 0–1. The bar only APPEARS while it holds the pad (▲ from the
|
||||
/// field, "Sort & view" in the legend) — the Apple client's behaviour, adopted after the
|
||||
/// always-on band proved too expensive on a phone: it taxed every library visit a strip
|
||||
/// of field height to answer a question ("what is the sort") that only matters in the
|
||||
/// moment of changing it. Chased toward `focus` each frame; the field takes the band's
|
||||
/// room back as this falls.
|
||||
reveal: f64,
|
||||
sort_tabs: TabStrip,
|
||||
view_tabs: TabStrip,
|
||||
}
|
||||
@@ -402,6 +411,7 @@ impl LibraryBar {
|
||||
fn new() -> LibraryBar {
|
||||
LibraryBar {
|
||||
focus: false,
|
||||
reveal: 0.0,
|
||||
sort_tabs: TabStrip::new(),
|
||||
view_tabs: TabStrip::new(),
|
||||
}
|
||||
@@ -1229,7 +1239,11 @@ impl LibraryScreen {
|
||||
// reaches the bar (the shell turns that hint into a `Move(Up)`), but focus is
|
||||
// not a choice — a mouse picks a sort by pressing the pill it wants, which is
|
||||
// why both strips hit-test themselves rather than leaning on the legend.
|
||||
let (sort_hit, view_hit) = if self.bar_shown() {
|
||||
// …and only while the bar is actually PRESENT: it appears on focus now, so
|
||||
// an unfocused library has no pills on screen and none to hit. The `TabStrip`s
|
||||
// keep the geometry they last drew, and a press must not land on furniture
|
||||
// that has faded out.
|
||||
let (sort_hit, view_hit) = if self.bar_shown() && self.bar.focus {
|
||||
(
|
||||
self.bar
|
||||
.sort_tabs
|
||||
@@ -1424,9 +1438,21 @@ impl LibraryScreen {
|
||||
if self.entrance.is_some_and(|e| e.done(ctx.t)) {
|
||||
self.entrance = None;
|
||||
}
|
||||
// The bar takes its band off the TOP of the field. The detail band keeps the
|
||||
// full rect — it is anchored to the bottom — and so does the loading path
|
||||
// above, which is centred in a field the bar is not part of.
|
||||
// The bar only takes its band off the TOP of the field while it is present
|
||||
// (see [`LibraryBar::reveal`]) — hidden, the field keeps the whole rect. The
|
||||
// detail band keeps the full rect either way — it is anchored to the bottom —
|
||||
// and so does the loading path above, which is centred in a field the bar is
|
||||
// not part of.
|
||||
let bar_target = if self.bar.focus { 1.0 } else { 0.0 };
|
||||
self.bar.reveal = if crate::theme::reduce_motion() {
|
||||
bar_target
|
||||
} else {
|
||||
approach(self.bar.reveal, bar_target, dt, 0.10)
|
||||
};
|
||||
if (self.bar.reveal - bar_target).abs() < 0.005 {
|
||||
self.bar.reveal = bar_target;
|
||||
}
|
||||
let reveal = self.bar.reveal;
|
||||
let bar = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
@@ -1435,7 +1461,7 @@ impl LibraryScreen {
|
||||
);
|
||||
let field = Rect::from_ltrb(
|
||||
rect.left,
|
||||
bar.bottom + (BAR_GAP * k) as f32,
|
||||
rect.top + ((TAB_STRIP_H + BAR_GAP) * k * reveal) as f32,
|
||||
rect.right,
|
||||
rect.bottom,
|
||||
);
|
||||
@@ -1445,7 +1471,21 @@ impl LibraryScreen {
|
||||
}
|
||||
// After the cards, like the detail band: it is the screen's readout, and a
|
||||
// short window must not let an arriving cover paint over the answer.
|
||||
self.draw_bar(canvas, bar, k, fonts, dt);
|
||||
// Faded as a unit while arriving/leaving, with a small rise — the crate's
|
||||
// transition grammar. Bounded layer: unbounded would allocate a surface-sized
|
||||
// offscreen for a strip of pills (see the twin warning in home.rs).
|
||||
if reveal > 0.01 {
|
||||
let bounds = Rect::from_ltrb(
|
||||
bar.left,
|
||||
bar.top - (12.0 * k) as f32,
|
||||
bar.right,
|
||||
bar.bottom + (12.0 * k) as f32,
|
||||
);
|
||||
canvas.save_layer_alpha_f(bounds, reveal as f32);
|
||||
canvas.translate((0.0f32, (-(1.0 - reveal) * 10.0 * k) as f32));
|
||||
self.draw_bar(canvas, bar, k, fonts, dt);
|
||||
canvas.restore();
|
||||
}
|
||||
self.draw_detail_band(canvas, rect, k, fonts);
|
||||
self.evict_art();
|
||||
}
|
||||
@@ -1514,13 +1554,13 @@ impl LibraryScreen {
|
||||
/// The bar over the field: what this library is sorted by, what it is arranged as, and
|
||||
/// the control for both.
|
||||
///
|
||||
/// Drawn whether or not it has focus, because the SORT is the thing the field cannot
|
||||
/// say. A coverflow under `Platform` and one under `A–Z` are the same screen with the
|
||||
/// cards in a different order, and until this band existed the only place that answer
|
||||
/// lived was the Collections screen — which a single-store library is never offered at
|
||||
/// all ([`crate::collate::worth_browsing`]). The arrangement IS visible in the field, and
|
||||
/// is named here anyway: one strip that answers both questions the same way is a control
|
||||
/// the user finds once.
|
||||
/// Drawn only while it holds the pad ([`LibraryBar::reveal`]): the field's legend keeps
|
||||
/// "▲ Sort & view" up permanently, so the ANSWER is one press away instead of one strip
|
||||
/// of always-spent field height — the Apple client's behaviour, adopted for the small
|
||||
/// screens where that strip priced out a full grid row. A coverflow under `Platform` and
|
||||
/// one under `A–Z` are still the same screen with the cards in a different order; this
|
||||
/// band is still the only place that names it (the Collections screen is never offered
|
||||
/// to a single-store library at all — [`crate::collate::worth_browsing`]).
|
||||
fn draw_bar(&mut self, canvas: &Canvas, bar: Rect, k: f64, fonts: &Fonts, dt: f64) {
|
||||
// Focused, the WHOLE band takes an accent WASH — the two groups are one control here
|
||||
// (◀ ▶ step the sort, the shoulders pick the arrangement), so a ring around one pill
|
||||
@@ -1645,8 +1685,17 @@ impl LibraryScreen {
|
||||
// the cursor — is what put the focus ring in a different column from the cover the
|
||||
// scroll had just brought up.
|
||||
let shape = GridShape::new(self.len(), cols, self.launcher_count());
|
||||
let (cw, ch) = (GRID_W * k, GRID_H * k);
|
||||
let pitch_x = cw + GRID_GAP * k;
|
||||
// `grid_cols` clamps at two columns, so on a narrow-enough viewport (a high-density
|
||||
// phone in portrait, where the density floor raises `k` past what the panel width
|
||||
// covers) two full-size covers plus margins can overflow the rect and clip at the
|
||||
// edges. The covers shrink to fit instead — only ever downward, and only the CELLS:
|
||||
// headings and labels keep the design scale, and geometry stays self-consistent
|
||||
// because everything below draws and records from these same metrics.
|
||||
let fit = ((f64::from(rect.width()) - 2.0 * GRID_MARGIN * k)
|
||||
/ ((cols as f64 * (GRID_W + GRID_GAP) - GRID_GAP) * k))
|
||||
.clamp(0.25, 1.0);
|
||||
let (cw, ch) = (GRID_W * k * fit, GRID_H * k * fit);
|
||||
let pitch_x = cw + GRID_GAP * k * fit;
|
||||
let pitch_y = ch + GRID_GAP * k + GRID_LABEL * k;
|
||||
// The launcher prefix keeps its own band, which is how design D4 reads in two
|
||||
// dimensions: the shelf says it with a heading that changes as the cursor crosses,
|
||||
@@ -1704,7 +1753,7 @@ impl LibraryScreen {
|
||||
(bump, self.scroll.pos)
|
||||
};
|
||||
|
||||
let grid_w = cols as f64 * pitch_x - GRID_GAP * k;
|
||||
let grid_w = cols as f64 * pitch_x - GRID_GAP * k * fit;
|
||||
let x0 = f64::from(rect.left) + (f64::from(rect.width()) - grid_w) / 2.0 + bump_x;
|
||||
let y0 = f64::from(rect.top);
|
||||
let viewport = Rect::from_xywh(rect.left, rect.top, rect.width(), (view_h.max(0.0)) as f32);
|
||||
@@ -2072,7 +2121,7 @@ impl LibraryScreen {
|
||||
canvas,
|
||||
note,
|
||||
f64::from(rect.left) + EDGE_INSET * k,
|
||||
f64::from(rect.bottom) - 30.0 * k,
|
||||
f64::from(rect.bottom) - 12.0 * k,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
fg(0.55),
|
||||
@@ -2081,6 +2130,9 @@ impl LibraryScreen {
|
||||
let Some(g) = self.focused() else { return };
|
||||
let w = f64::from(rect.width());
|
||||
let cx = f64::from(rect.left) + w / 2.0;
|
||||
// The title alone. The store/platform subtitle that sat under it is gone: the cover
|
||||
// badge already names the store, so the line said everything twice and cost the band
|
||||
// 20 units of field height on every library visit.
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&g.title,
|
||||
@@ -2088,30 +2140,9 @@ impl LibraryScreen {
|
||||
27.0 * k,
|
||||
fg(1.0),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 64.0 * k,
|
||||
f64::from(rect.bottom) - 34.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
// Store, and the PLATFORM when the host named one — the reason `platform` was
|
||||
// plumbed at all is that "Shadow of the Colossus" means something rather different
|
||||
// with "PS2" under it.
|
||||
let store = store_label(&g.store).to_uppercase();
|
||||
let sub = match (&g.platform, g.launcher) {
|
||||
(_, true) => format!("{store} · LAUNCHER"),
|
||||
(Some(p), _) if !p.trim().is_empty() => format!("{store} · {}", p.to_uppercase()),
|
||||
_ => store,
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&sub,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
// The subtitle rung of the 0.55 / 0.7 / 0.85 ladder every other detail line
|
||||
// in the crate already sits on.
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 30.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2131,6 +2162,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 9778,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -36,6 +36,15 @@ enum Action {
|
||||
SendLogs,
|
||||
CopyLink,
|
||||
Edit,
|
||||
/// Choose the profile the host's primary tile connects with (opens the
|
||||
/// [`Screen::BindProfile`] chooser). Offered on saved primary tiles only — a pinned
|
||||
/// card's profile IS the card, and a title's menu addresses the title.
|
||||
BindProfile,
|
||||
/// Share this device's clipboard with THIS host while streaming
|
||||
/// (`KnownHost::clipboard_sync`). Per-host because it is a trust decision about that
|
||||
/// host — which is why it lives on the host and not in Settings. A toggle: the label
|
||||
/// carries the current state, activating flips it.
|
||||
Clipboard,
|
||||
Forget,
|
||||
Unpin,
|
||||
Cancel,
|
||||
@@ -115,7 +124,10 @@ impl OptionsScreen {
|
||||
key.split('\0').next().unwrap_or(key)
|
||||
}
|
||||
|
||||
fn actions(&self, platform: crate::platform::Platform) -> Vec<Action> {
|
||||
// `_platform` is the seam platform-conditional rows plug into (Send logs used it until
|
||||
// Android grew an uploader); unused today, kept so the next such row has its question
|
||||
// already answered at every call site.
|
||||
fn actions(&self, _platform: crate::platform::Platform) -> Vec<Action> {
|
||||
let host = match &self.subject {
|
||||
Subject::Host(h) => h,
|
||||
// Deliberately not [Play, …]: the host menu does not repeat its tile's own A
|
||||
@@ -137,14 +149,16 @@ impl OptionsScreen {
|
||||
// error. This is the log-escape hatch for platforms whose own filesystem the user
|
||||
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
|
||||
// its web console next to the host's own logs.
|
||||
// Only where a service exists to upload them: the Android client has no log-ring
|
||||
// uploader yet, and a row that can only toast "not available" is a promise broken.
|
||||
if host.paired && host.online && platform == crate::platform::Platform::Desktop {
|
||||
// Every platform has an uploader now (Android's rides `nativeSendLogs` over the
|
||||
// same `logring` the desktop drains), so paired-and-reachable is the whole gate.
|
||||
if host.paired && host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
a.extend([
|
||||
Action::CopyLink,
|
||||
Action::Edit,
|
||||
Action::BindProfile,
|
||||
Action::Clipboard,
|
||||
Action::Forget,
|
||||
Action::Cancel,
|
||||
]);
|
||||
@@ -159,6 +173,15 @@ impl OptionsScreen {
|
||||
Action::SendLogs => "Send logs to host".into(),
|
||||
Action::CopyLink => "Copy link".into(),
|
||||
Action::Edit => "Edit\u{2026}".into(),
|
||||
Action::BindProfile => "Default profile\u{2026}".into(),
|
||||
Action::Clipboard => format!(
|
||||
"Shared clipboard: {}",
|
||||
if self.host().clipboard_sync {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
}
|
||||
),
|
||||
Action::Forget if self.armed => "Forget \u{2014} press again".into(),
|
||||
Action::Forget => "Forget".into(),
|
||||
Action::Unpin => "Unpin card".into(),
|
||||
@@ -269,6 +292,24 @@ impl OptionsScreen {
|
||||
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
|
||||
self.host(),
|
||||
))),
|
||||
Action::BindProfile => fx.replace(Screen::BindProfile(
|
||||
super::bind_profile::BindProfileScreen::new(
|
||||
key,
|
||||
self.host().name.clone(),
|
||||
store.profiles(),
|
||||
),
|
||||
)),
|
||||
Action::Clipboard => {
|
||||
let host = self.host();
|
||||
let on = !host.clipboard_sync;
|
||||
fx.toast = Some(if on {
|
||||
format!("Clipboard shared with {}", host.name)
|
||||
} else {
|
||||
format!("Clipboard no longer shared with {}", host.name)
|
||||
});
|
||||
fx.cmds.push(ConsoleCmd::SetClipboard { key, on });
|
||||
fx.pop();
|
||||
}
|
||||
Action::Forget if !self.armed => self.armed = true,
|
||||
Action::Forget => {
|
||||
fx.cmds.push(ConsoleCmd::ForgetHost { key });
|
||||
@@ -378,6 +419,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 9778,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
@@ -438,6 +480,25 @@ mod tests {
|
||||
.contains(&Action::Wake));
|
||||
}
|
||||
|
||||
/// "Send logs" is offered wherever a paired, reachable host can receive it — on BOTH
|
||||
/// platforms since Android's uploader landed (`SkiaConsole.sendLogs` → `nativeSendLogs`
|
||||
/// over the shared `logring`); before that the row was desktop-only, because a row that
|
||||
/// can only toast "not available" is a promise broken.
|
||||
#[test]
|
||||
fn send_logs_is_offered_on_every_platform_with_an_uploader() {
|
||||
let reachable = OptionsScreen::for_host(&HostRow {
|
||||
paired: true,
|
||||
online: true,
|
||||
..host()
|
||||
});
|
||||
assert!(reachable
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::SendLogs));
|
||||
assert!(reachable
|
||||
.actions(crate::platform::Platform::Android)
|
||||
.contains(&Action::SendLogs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_card_cannot_forget_or_edit_the_host() {
|
||||
let s = OptionsScreen::for_host(&pinned());
|
||||
@@ -449,6 +510,57 @@ mod tests {
|
||||
assert_eq!(s.host_key(), "aa");
|
||||
}
|
||||
|
||||
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
|
||||
/// the same reason — addressed to the HOST's plain key even from rows that carry a
|
||||
/// composite one.
|
||||
#[test]
|
||||
fn default_profile_opens_the_chooser_on_the_hosts_plain_key() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s
|
||||
.actions(crate::platform::Platform::Desktop)
|
||||
.contains(&Action::BindProfile));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
|
||||
match fx.nav {
|
||||
Some(crate::screens::Nav::Replace(screen)) => match *screen {
|
||||
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
|
||||
_ => panic!("expected the bind-profile chooser"),
|
||||
},
|
||||
_ => panic!("expected a replace"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard toggle: the label says where the host stands, activating flips it —
|
||||
/// and both address the HOST's plain key.
|
||||
#[test]
|
||||
fn the_clipboard_toggle_flips_the_stored_state() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
assert!(s.label(Action::Clipboard).ends_with("Off"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
key: "aa".into(),
|
||||
on: true,
|
||||
}]
|
||||
);
|
||||
let mut s = OptionsScreen::for_host(&HostRow {
|
||||
clipboard_sync: true,
|
||||
..host()
|
||||
});
|
||||
assert!(s.label(Action::Clipboard).ends_with("On"));
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetClipboard {
|
||||
key: "aa".into(),
|
||||
on: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_needs_two_presses() {
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
|
||||
@@ -476,6 +476,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -207,6 +207,7 @@ mod tests {
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: pin.map(|id| ProfileChip {
|
||||
|
||||
@@ -56,6 +56,15 @@ enum RowId {
|
||||
PadType,
|
||||
SystemButtons,
|
||||
GuideGesture,
|
||||
/// The DualSense voice-coil haptics stream, rendered on the (wired) pad itself — see
|
||||
/// `trust::Settings::pad_haptics`. Negotiated: it changes nothing without a capable
|
||||
/// host and a wired DS5, which is why the row says what it is for, not what it does.
|
||||
PadHaptics,
|
||||
/// Where the pad's built-in-speaker stream renders — `trust::Settings::pad_speaker`.
|
||||
/// Offered as On (`"pad"`) / Off, exactly like the GTK switch over the same key: the
|
||||
/// third stored value (`"mix"`) is a declared TODO that renders as off, and a picker
|
||||
/// offering it would be a control that changes nothing.
|
||||
PadSpeaker,
|
||||
Touch,
|
||||
Mouse,
|
||||
InvertScroll,
|
||||
@@ -118,6 +127,14 @@ mod android_keys {
|
||||
const GAMEPAD_UI_MODES: [(&str, &str); 2] =
|
||||
[("connected", "With a controller"), ("always", "Always")];
|
||||
|
||||
/// `pad_audio::speaker_active`'s answer, restated: only `"pad"` renders today ("mix" is
|
||||
/// the declared TODO that renders as off). Local because that module owns the actual
|
||||
/// renderer and is `cfg(linux|windows)` — this row also ships on Android, where the
|
||||
/// SETTING still travels with the stream request even though no local renderer exists.
|
||||
fn pad_speaker_on(mode: &str) -> bool {
|
||||
mode == "pad"
|
||||
}
|
||||
|
||||
fn extra_bool(s: &pf_client_core::trust::Settings, key: &str, default: bool) -> bool {
|
||||
s.extra
|
||||
.get(key)
|
||||
@@ -200,6 +217,8 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
RowId::PadHaptics,
|
||||
RowId::PadSpeaker,
|
||||
RowId::PhoneRumble,
|
||||
RowId::PhoneGyro,
|
||||
RowId::Sc2Passthrough,
|
||||
@@ -390,6 +409,12 @@ impl SettingsScreen {
|
||||
self.tab
|
||||
}
|
||||
|
||||
/// Row `i`'s rect as last drawn — the shell's touch tests press real coordinates.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn row_rect_for_test(&self, i: usize) -> Option<Rect> {
|
||||
self.list.row_rect(i)
|
||||
}
|
||||
|
||||
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
|
||||
/// value cycle), keeping each tab's own cursor.
|
||||
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
@@ -608,7 +633,10 @@ impl SettingsScreen {
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = ids.get(self.list.cursor).copied().map_or("", detail);
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
.copied()
|
||||
.map_or("", |id| detail(id, ctx.platform));
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -732,9 +760,12 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// reading the same settings file on this same machine. Delete this arm when that
|
||||
// client-side filter learns the frame ladder — not before.
|
||||
RowId::AudioFormat => s.audio_channels == 2,
|
||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||
s.gamepad_forwarding
|
||||
}
|
||||
RowId::Pad
|
||||
| RowId::PadType
|
||||
| RowId::SystemButtons
|
||||
| RowId::GuideGesture
|
||||
| RowId::PadHaptics
|
||||
| RowId::PadSpeaker => s.gamepad_forwarding,
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
@@ -861,6 +892,12 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Hold Select for guide",
|
||||
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
|
||||
),
|
||||
RowId::PadHaptics => (None, "Controller haptics", on_off(s.pad_haptics).into()),
|
||||
RowId::PadSpeaker => (
|
||||
None,
|
||||
"Controller speaker",
|
||||
on_off(pad_speaker_on(&s.pad_speaker)).into(),
|
||||
),
|
||||
RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()),
|
||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
||||
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
|
||||
@@ -949,7 +986,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(id: RowId) -> &'static str {
|
||||
/// The focused row's one-line explainer. Takes the platform because two desktop rows
|
||||
/// advertise desktop-only live chords (Ctrl+Alt+Shift+…) that no Android build has — a
|
||||
/// shortcut the device cannot press must not be taught.
|
||||
fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
use crate::platform::Platform;
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
"The host creates a virtual display at exactly this size — no scaling. \
|
||||
@@ -1023,15 +1064,29 @@ fn detail(id: RowId) -> &'static str {
|
||||
the host's quick-access menu. Automatic arms it only where the real button \
|
||||
can't reach the host. A Select tap still goes through, slightly delayed."
|
||||
}
|
||||
RowId::PadHaptics => {
|
||||
"Play a DualSense's fine-grained haptics on the pad itself instead of plain \
|
||||
rumble. Negotiated — it changes nothing without a capable host and a wired pad."
|
||||
}
|
||||
RowId::PadSpeaker => {
|
||||
"Play the audio a game sends to the controller's own speaker on the pad, \
|
||||
not through this device's output."
|
||||
}
|
||||
RowId::Touch => {
|
||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||
}
|
||||
RowId::Mouse => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions. \
|
||||
Ctrl+Alt+Shift+M switches live while streaming."
|
||||
}
|
||||
RowId::Mouse => match platform {
|
||||
Platform::Desktop => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions. \
|
||||
Ctrl+Alt+Shift+M switches live while streaming."
|
||||
}
|
||||
Platform::Android => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions."
|
||||
}
|
||||
},
|
||||
RowId::InvertScroll => "Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
RowId::Shortcuts => {
|
||||
"Alt+Tab, Super and friends reach the host while input is captured. \
|
||||
@@ -1056,10 +1111,15 @@ fn detail(id: RowId) -> &'static str {
|
||||
stores as tiles — instead of the whole shelf. A library with only one \
|
||||
collection opens on the shelf as usual."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
}
|
||||
RowId::Stats => match platform {
|
||||
Platform::Desktop => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
}
|
||||
Platform::Android => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed."
|
||||
}
|
||||
},
|
||||
RowId::Fullscreen => "Streams open fullscreen instead of windowed.",
|
||||
RowId::AutoWake => {
|
||||
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
|
||||
@@ -1259,6 +1319,23 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
}
|
||||
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
|
||||
}
|
||||
RowId::PadHaptics => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
toggle(&mut s.pad_haptics, delta, wrap)
|
||||
}
|
||||
RowId::PadSpeaker => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
// On/Off over the stored string, the way the GTK switch edits the same key: a
|
||||
// stored "mix" reads as Off (it renders as off today) and any step writes the
|
||||
// two values that do something.
|
||||
let mut on = pad_speaker_on(&s.pad_speaker);
|
||||
toggle(&mut on, delta, wrap)
|
||||
.map(|()| s.pad_speaker = if on { "pad" } else { "off" }.to_string())
|
||||
}
|
||||
RowId::Touch => {
|
||||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
@@ -1559,6 +1636,44 @@ pub(super) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The controller-audio rows: they follow the forwarding switch like every other pad
|
||||
/// row, and the speaker row edits the stored STRING exactly the way the GTK switch
|
||||
/// over the same key does — a stored "mix" (the declared TODO that renders as off)
|
||||
/// reads as Off, and any step writes only the two values that do something.
|
||||
#[test]
|
||||
fn controller_audio_rows_follow_forwarding_and_speak_the_gtk_dialect() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Defaults: haptics on, speaker on the pad.
|
||||
assert!(ctx.settings.pad_haptics);
|
||||
assert_eq!(ctx.settings.pad_speaker, "pad");
|
||||
assert!(adjust(RowId::PadHaptics, 1, true, &mut ctx));
|
||||
assert!(!ctx.settings.pad_haptics);
|
||||
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.pad_speaker, "off");
|
||||
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.pad_speaker, "pad");
|
||||
// A stored "mix" reads as Off and steps onto a value that works.
|
||||
ctx.settings.pad_speaker = "mix".into();
|
||||
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.pad_speaker, "pad");
|
||||
// Forwarding off parks both, like the sibling pad rows.
|
||||
ctx.settings.gamepad_forwarding = false;
|
||||
assert!(!adjust(RowId::PadHaptics, 1, true, &mut ctx));
|
||||
assert!(!adjust(RowId::PadSpeaker, 1, true, &mut ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_clamps_and_activate_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
@@ -1871,6 +1986,7 @@ pub(super) mod tests {
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: Some(crate::model::ProfileChip {
|
||||
@@ -2047,12 +2163,14 @@ pub(super) mod tests {
|
||||
seen.push(*id);
|
||||
}
|
||||
}
|
||||
// The pre-tab flat list, plus the palette row, the lossless-audio row and the
|
||||
// reduce-motion row later passes added, minus the game-library toggle: this screen
|
||||
// never read it, and the library is offered on any paired host now.
|
||||
// 33 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// The pre-tab flat list, plus the palette row, the lossless-audio row, the
|
||||
// reduce-motion row and the two controller-audio rows (haptics + speaker — the
|
||||
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
|
||||
// game-library toggle: this screen never read it, and the library is offered on any
|
||||
// paired host now.
|
||||
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
|
||||
// D3): six `extra`-backed settings and two platform-screen action rows.
|
||||
assert_eq!(seen.len(), 41, "{seen:?}");
|
||||
assert_eq!(seen.len(), 43, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
|
||||
@@ -58,6 +58,38 @@ const NAV_INPUT_OPENS: f64 = 0.85;
|
||||
const TOP_BAND: f64 = 64.0;
|
||||
const BOTTOM_BAND: f64 = 86.0;
|
||||
|
||||
/// How far a finger may wander (design units × the frame's `k`) and still be a tap. Past
|
||||
/// this the gesture is a drag and the lift acts on nothing. ~12dp is the classic touch
|
||||
/// slop; in device pixels it lands near Android's own ViewConfiguration figure.
|
||||
const TOUCH_SLOP_DP: f64 = 12.0;
|
||||
/// One drag step (design units × `k`): each `DRAG_TICK_DP` of dominant-axis travel emits
|
||||
/// one synthetic scroll tick. 56 is the menu list's row pitch (`widgets::ROW_H` + gap), so
|
||||
/// a list under the finger moves about as far as the finger does. The on-glass tuning knob.
|
||||
const DRAG_TICK_DP: f64 = 56.0;
|
||||
|
||||
/// The active touch gesture, tracked by [`Shell::pointer_input`] (see the `touch` flag on
|
||||
/// `PointerInput::Down`). A mouse never enters this machine — its press acts immediately,
|
||||
/// which is what a mouse means. A second finger while one gesture is live is ignored
|
||||
/// (single-tracked; multi-touch gestures are a non-goal).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum TouchGesture {
|
||||
/// Finger down, still within slop of the anchor. A lift here is a tap: the Press is
|
||||
/// delivered AT THE ANCHOR — the focused item scrolls toward the centre, so the down
|
||||
/// point is where the user aimed and the lift point is where the content dragged
|
||||
/// their eye; widgets hit-test last frame's rects and already tolerate exactly this
|
||||
/// one-frame skew.
|
||||
Armed { x: f64, y: f64 },
|
||||
/// Slop exceeded: a drag, locked to the axis it left the slop on (diagonal jitter
|
||||
/// must not alternate a carousel with a list). `last` is the dominant-axis position
|
||||
/// the previous tick was emitted at.
|
||||
Drag {
|
||||
x: f64,
|
||||
y: f64,
|
||||
horizontal: bool,
|
||||
last: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which way a transition is choreographed. The paint recipes differ (a push slides the
|
||||
/// incoming screen up out of a fade; a pop grows the revealed one back while the leaving
|
||||
/// one drops away), so the kind outlives the direction the spring happens to be heading.
|
||||
@@ -139,7 +171,6 @@ struct Toast {
|
||||
|
||||
struct Connecting {
|
||||
title: String,
|
||||
canceling: bool,
|
||||
appear: f64,
|
||||
/// A request-access wait (parked on the host until the operator approves) — the
|
||||
/// takeover reads "Waiting for approval" rather than "Connecting".
|
||||
@@ -260,6 +291,11 @@ pub(crate) struct Shell {
|
||||
/// surface pixels and are brought into the same space `hint_rects` and every screen's
|
||||
/// hit boxes were published in.
|
||||
last_insets: (f32, f32),
|
||||
/// The design-unit scale the last frame rendered at, for the touch tracker's slop and
|
||||
/// tick distances — gesture geometry must grow with the UI it drags.
|
||||
last_k: f64,
|
||||
/// The touch gesture in flight, if any (see [`TouchGesture`]).
|
||||
gesture: Option<TouchGesture>,
|
||||
/// Skia's resource-cache budget for the host that renders this shell (see
|
||||
/// [`ConsoleOptions::gpu_cache_bytes`]).
|
||||
pub(crate) gpu_cache_bytes: usize,
|
||||
@@ -332,6 +368,8 @@ impl Shell {
|
||||
pads: Vec::new(),
|
||||
hint_rects: Vec::new(),
|
||||
last_insets: (0.0, 0.0),
|
||||
last_k: 1.0,
|
||||
gesture: None,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes,
|
||||
t0: Instant::now(),
|
||||
last_frame: None,
|
||||
@@ -362,25 +400,69 @@ impl Shell {
|
||||
/// The host-facing pointer vocabulary onto the shell's own: primary press/release,
|
||||
/// secondary-down = Back (its release is dropped, or a right-click would pop two
|
||||
/// screens), wheel = discrete scroll steps, cancel.
|
||||
///
|
||||
/// A TOUCH primary down (`touch: true`) takes the gesture lane instead: the press is
|
||||
/// deferred, and the lift decides whether it was a tap (Press at the anchor) or a drag
|
||||
/// (scroll ticks were already emitted along the way, the lift acts on nothing). A press
|
||||
/// that acted on contact made every swipe across the settings list flip a value — the
|
||||
/// finger has to be allowed to mean "scroll" until it has said otherwise.
|
||||
pub(crate) fn pointer_input(&mut self, input: pf_client_core::console::PointerInput) -> bool {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (x, y, kind) = match input {
|
||||
PointerInput::Move { x, y } => (x, y, PointerKind::Move),
|
||||
PointerInput::Move { x, y } => {
|
||||
if self.gesture.is_some() {
|
||||
return self.gesture_move(f64::from(x), f64::from(y));
|
||||
}
|
||||
(x, y, PointerKind::Move)
|
||||
}
|
||||
PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
} => (x, y, PointerKind::Press),
|
||||
touch,
|
||||
} => {
|
||||
if touch {
|
||||
// A second finger while a gesture is live is ignored — single-tracked.
|
||||
if self.gesture.is_none() {
|
||||
self.gesture = Some(TouchGesture::Armed {
|
||||
x: f64::from(x),
|
||||
y: f64::from(y),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(x, y, PointerKind::Press)
|
||||
}
|
||||
PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Secondary,
|
||||
..
|
||||
} => (x, y, PointerKind::Back),
|
||||
PointerInput::Up {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
} => (x, y, PointerKind::Release),
|
||||
} => match self.gesture.take() {
|
||||
Some(TouchGesture::Armed { x, y }) => {
|
||||
// A tap: the deferred Press lands now, at the anchor, followed by the
|
||||
// Release the widgets ignore today (and a fling closes on tomorrow).
|
||||
let consumed = self.pointer(Pointer {
|
||||
x,
|
||||
y,
|
||||
kind: PointerKind::Press,
|
||||
});
|
||||
self.pointer(Pointer {
|
||||
x,
|
||||
y,
|
||||
kind: PointerKind::Release,
|
||||
});
|
||||
return consumed;
|
||||
}
|
||||
// A drag ends where its last tick left it; the lift itself does nothing.
|
||||
Some(TouchGesture::Drag { .. }) => return true,
|
||||
None => (x, y, PointerKind::Release),
|
||||
},
|
||||
PointerInput::Up { .. } => return true,
|
||||
PointerInput::Wheel { x, y, dy } => {
|
||||
if dy == 0.0 {
|
||||
@@ -388,7 +470,10 @@ impl Shell {
|
||||
}
|
||||
(x, y, PointerKind::Scroll { up: dy > 0.0 })
|
||||
}
|
||||
PointerInput::Cancel => (0.0, 0.0, PointerKind::Cancel),
|
||||
PointerInput::Cancel => {
|
||||
self.gesture = None;
|
||||
(0.0, 0.0, PointerKind::Cancel)
|
||||
}
|
||||
};
|
||||
self.pointer(Pointer {
|
||||
x: f64::from(x),
|
||||
@@ -397,6 +482,60 @@ impl Shell {
|
||||
})
|
||||
}
|
||||
|
||||
/// Advance the touch gesture by a Move. Within slop nothing happens; past it the
|
||||
/// gesture locks to its dominant axis and every [`DRAG_TICK_DP`]·k of travel becomes
|
||||
/// one synthetic scroll tick at the anchor. Direction reads as "content follows the
|
||||
/// finger": drag down/right = the previous item (a wheel-up), drag up/left = the next.
|
||||
fn gesture_move(&mut self, x: f64, y: f64) -> bool {
|
||||
let Some(gesture) = self.gesture else {
|
||||
return false;
|
||||
};
|
||||
match gesture {
|
||||
TouchGesture::Armed { x: ax, y: ay } => {
|
||||
let (dx, dy) = (x - ax, y - ay);
|
||||
if dx.hypot(dy) >= TOUCH_SLOP_DP * self.last_k {
|
||||
let horizontal = dx.abs() > dy.abs();
|
||||
self.gesture = Some(TouchGesture::Drag {
|
||||
x: ax,
|
||||
y: ay,
|
||||
horizontal,
|
||||
// Ticks count from where the slop was left, not from the anchor —
|
||||
// the slop's travel was spent proving this is a drag.
|
||||
last: if horizontal { x } else { y },
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
TouchGesture::Drag {
|
||||
x: ax,
|
||||
y: ay,
|
||||
horizontal,
|
||||
last,
|
||||
} => {
|
||||
let pos = if horizontal { x } else { y };
|
||||
let tick = DRAG_TICK_DP * self.last_k;
|
||||
let steps = ((pos - last) / tick).trunc();
|
||||
if steps != 0.0 {
|
||||
self.gesture = Some(TouchGesture::Drag {
|
||||
x: ax,
|
||||
y: ay,
|
||||
horizontal,
|
||||
last: last + steps * tick,
|
||||
});
|
||||
let up = steps > 0.0;
|
||||
for _ in 0..steps.abs() as u32 {
|
||||
self.pointer(Pointer {
|
||||
x: ax,
|
||||
y: ay,
|
||||
kind: PointerKind::Scroll { up },
|
||||
});
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host reports a session edge. `Connecting` is a no-op — the shell raised the
|
||||
/// Launch itself and is already showing the takeover.
|
||||
pub(crate) fn session_phase(&mut self, phase: pf_client_core::console::SessionPhase) {
|
||||
@@ -436,7 +575,6 @@ impl Shell {
|
||||
self.last_connect_title = Some(title.clone());
|
||||
self.connecting = Some(Connecting {
|
||||
title,
|
||||
canceling: false,
|
||||
appear: 0.0,
|
||||
request_access: false,
|
||||
})
|
||||
@@ -504,7 +642,6 @@ impl Shell {
|
||||
.last_connect_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the host".to_string()),
|
||||
canceling: false,
|
||||
appear: 1.0,
|
||||
request_access: false,
|
||||
});
|
||||
@@ -683,9 +820,19 @@ impl Shell {
|
||||
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
|
||||
self.sync();
|
||||
// Modal precedence: the connect card, then the wake card, then the screens.
|
||||
if let Some(c) = &mut self.connecting {
|
||||
if ev == MenuEvent::Back && !c.canceling {
|
||||
c.canceling = true;
|
||||
if self.connecting.is_some() {
|
||||
if ev == MenuEvent::Back {
|
||||
// The takeover comes down HERE, not when the host answers. It used to wait for
|
||||
// the next `session_phase` and show "Canceling…" until one arrived — and one is
|
||||
// not guaranteed to: the dial is a blocking call on the host's side of this
|
||||
// interface, so the wait was the whole connect budget (185 s on a request-access
|
||||
// connect the host parks pending approval), and an embedder that simply drops a
|
||||
// canceled dial never sends a phase at all. Either way the console sat on
|
||||
// "Canceling…" with no input that could reach it — only killing the app cleared
|
||||
// it. Cancel is the USER's decision and needs no confirmation from the wire; the
|
||||
// action below still goes out, and every host already handles a dial that lands
|
||||
// after it (quit-close the connector, route the end back silently).
|
||||
self.connecting = None;
|
||||
self.actions.push_back(OverlayAction::CancelConnect);
|
||||
return Some(MenuPulse::Confirm);
|
||||
}
|
||||
|
||||
@@ -68,15 +68,7 @@ impl Shell {
|
||||
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||
if c.canceling {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
"Canceling…".to_string(),
|
||||
String::new(),
|
||||
vec![],
|
||||
))
|
||||
} else if c.request_access {
|
||||
if c.request_access {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
|
||||
@@ -101,6 +101,7 @@ impl Shell {
|
||||
full_h - f64::from(ins.top) - f64::from(ins.bottom),
|
||||
);
|
||||
self.last_insets = (ins.left, ins.top);
|
||||
self.last_k = k;
|
||||
let t = self.t();
|
||||
|
||||
// Advance the transition. `None` means "settled" — which is also what makes the
|
||||
|
||||
@@ -89,6 +89,7 @@ fn hosts() -> Vec<HostRow> {
|
||||
online: false,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
@@ -178,15 +179,17 @@ fn connect_flow_raises_launch_and_cancel() {
|
||||
Some(OverlayAction::Launch { launch: None, .. })
|
||||
));
|
||||
assert!(s.connecting.is_some());
|
||||
// While connecting: B cancels exactly once.
|
||||
// While connecting: B cancels — and the takeover comes down on the spot. It must NOT wait
|
||||
// for a session phase to clear it: the dial is blocking on the host's side of this
|
||||
// interface, so that wait was the whole connect budget, and an embedder that just drops a
|
||||
// canceled dial sends no phase at all — the console stuck on "Canceling…" until the app died.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(matches!(
|
||||
s.take_action(),
|
||||
Some(OverlayAction::CancelConnect)
|
||||
));
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(s.take_action().is_none(), "cancel is idempotent");
|
||||
// The canceled dial ends silently.
|
||||
assert!(s.connecting.is_none(), "cancel drops the takeover itself");
|
||||
// A dial that resolves afterwards (or never) changes nothing.
|
||||
s.session_ended(None);
|
||||
assert!(s.connecting.is_none());
|
||||
}
|
||||
@@ -493,6 +496,164 @@ fn every_settings_tab_rasters() {
|
||||
s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads);
|
||||
}
|
||||
|
||||
/// The settings screen with one frame rendered, so its rows have real rects to press.
|
||||
fn rendered_settings() -> (Shell, skia_safe::Rect) {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((1280, 800)).unwrap();
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.handle_menu(MenuEvent::Tertiary); // X → Settings
|
||||
finish_motion(&mut s);
|
||||
s.render(surface.canvas(), 1280, 800, &fonts, None, None, &[]);
|
||||
let row = match s.stack.last() {
|
||||
Some(Screen::Settings(scr)) => scr.row_rect_for_test(0).expect("the list drew its rows"),
|
||||
_ => panic!("settings is not on top"),
|
||||
};
|
||||
(s, row)
|
||||
}
|
||||
|
||||
/// The whole point of the touch tracker: a finger swiping across the settings list is
|
||||
/// SCROLLING, and must not flip the value it happened to land on — which is exactly what
|
||||
/// the press-acts-on-contact model did to every swipe before the `touch` flag existed.
|
||||
/// The same contact lifted in place IS the tap, delivered on the lift at the anchor.
|
||||
#[test]
|
||||
fn a_touch_swipe_scrolls_settings_without_changing_a_value() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, row) = rendered_settings();
|
||||
let (cx, cy) = (row.center_x(), row.center_y());
|
||||
// The Resolution row's whole observable state: activating it steps the D1 tri-state
|
||||
// Native -> Match window, which flips the FLAG while width/height stay (0, 0).
|
||||
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
|
||||
let before = state(&s);
|
||||
|
||||
// Finger lands on the Resolution row and swipes up, well past slop and several ticks.
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: cx,
|
||||
y: cy,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
for i in 1..=6 {
|
||||
s.pointer_input(PointerInput::Move {
|
||||
x: cx,
|
||||
y: cy - (i as f32) * 40.0,
|
||||
});
|
||||
}
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: cx,
|
||||
y: cy - 240.0,
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
assert_eq!(
|
||||
state(&s),
|
||||
before,
|
||||
"a swipe across a row is a scroll, not a value change"
|
||||
);
|
||||
|
||||
// The same contact, lifted where it landed: a tap. Deferred — nothing on contact,
|
||||
// the step on the lift.
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: cx,
|
||||
y: cy,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
assert_eq!(state(&s), before, "a touch press must not act on contact");
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: cx,
|
||||
y: cy,
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
assert_ne!(
|
||||
state(&s),
|
||||
before,
|
||||
"the tap lands on the lift, at the anchor"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mouse is not a finger: its press keeps acting on contact, exactly as before the
|
||||
/// touch flag existed.
|
||||
#[test]
|
||||
fn a_mouse_press_still_acts_on_contact() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, row) = rendered_settings();
|
||||
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
|
||||
let before = state(&s);
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: row.center_x(),
|
||||
y: row.center_y(),
|
||||
button: PointerButton::Primary,
|
||||
touch: false,
|
||||
});
|
||||
assert_ne!(state(&s), before, "a mouse click acts on the press");
|
||||
}
|
||||
|
||||
/// A horizontal drag on Home steps the carousel — one tick per `DRAG_TICK_DP` of travel
|
||||
/// past the slop — and the lift after a drag presses nothing. Needs no render: ticks act
|
||||
/// on the cursor, not on drawn rects.
|
||||
#[test]
|
||||
fn a_horizontal_drag_steps_the_home_carousel() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: 640.0,
|
||||
y: 400.0,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
// First move leaves the slop (locks the horizontal axis); the second travels one full
|
||||
// tick leftward — content follows the finger, so the NEXT tile comes up.
|
||||
s.pointer_input(PointerInput::Move { x: 620.0, y: 400.0 });
|
||||
s.pointer_input(PointerInput::Move {
|
||||
x: 620.0 - DRAG_TICK_DP as f32,
|
||||
y: 400.0,
|
||||
});
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: 620.0 - DRAG_TICK_DP as f32,
|
||||
y: 400.0,
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
// The fixture's second host (Office Tower) is offline with a stored MAC: Confirm on it
|
||||
// raises the wake card. That proves the drag moved the cursor — and that the lift
|
||||
// after a drag pressed nothing (a press would have acted before Confirm ran).
|
||||
assert!(
|
||||
s.wake.is_none(),
|
||||
"the drag itself must not activate anything"
|
||||
);
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
assert!(
|
||||
s.wake.is_some(),
|
||||
"Confirm after a one-tick drag lands on the second host's wake"
|
||||
);
|
||||
}
|
||||
|
||||
/// A canceled touch (the finger left the window, the toolkit stole the gesture) is
|
||||
/// dropped whole: no press ever lands.
|
||||
#[test]
|
||||
fn a_canceled_touch_never_acts() {
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
let (mut s, row) = rendered_settings();
|
||||
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
|
||||
let before = state(&s);
|
||||
s.pointer_input(PointerInput::Down {
|
||||
x: row.center_x(),
|
||||
y: row.center_y(),
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
});
|
||||
s.pointer_input(PointerInput::Cancel);
|
||||
s.pointer_input(PointerInput::Up {
|
||||
x: row.center_x(),
|
||||
y: row.center_y(),
|
||||
button: PointerButton::Primary,
|
||||
});
|
||||
assert_eq!(
|
||||
state(&s),
|
||||
before,
|
||||
"cancel dropped the gesture; the stray lift presses nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The work package's whole reason for existing: Back pressed mid-push is HEARD, and it
|
||||
/// turns the screen around rather than queuing a second animation behind the first.
|
||||
///
|
||||
|
||||
@@ -47,6 +47,30 @@ pub fn config_dir() -> PathBuf {
|
||||
base.join("punktfunk")
|
||||
}
|
||||
|
||||
/// The mgmt port the host actually bound, from `<config_dir>/mgmt-endpoint` — the one
|
||||
/// `PUNKTFUNK_MGMT_URL=https://127.0.0.1:<port>` line `punktfunk-host serve` publishes on every
|
||||
/// start (`mgmt::publish_endpoint`). This is how a `PUNKTFUNK_MGMT_BIND` move reaches a loopback
|
||||
/// consumer that inherits nothing from `host.env` — the tray, which on Windows cannot even read
|
||||
/// `host.env` (DACL-locked to SYSTEM/Administrators) while this file is deliberately Users-readable.
|
||||
/// `None` when the file is absent (an older host, or no host on this box) or unparsable; callers
|
||||
/// fall back to 47990, which is strictly what they did before.
|
||||
pub fn published_mgmt_port() -> Option<u16> {
|
||||
published_mgmt_port_in(&config_dir())
|
||||
}
|
||||
|
||||
/// The IO half of [`published_mgmt_port`], taking the directory so it is testable without touching
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (this crate forbids the `unsafe` that `set_var` now needs).
|
||||
pub fn published_mgmt_port_in(dir: &std::path::Path) -> Option<u16> {
|
||||
let raw = std::fs::read_to_string(dir.join("mgmt-endpoint")).ok()?;
|
||||
let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
let value = line.split_once('=').map_or(line, |(_, v)| v).trim();
|
||||
// `https://127.0.0.1:47995` → the last `:`-separated field, tolerating a trailing `/`.
|
||||
value
|
||||
.trim_end_matches('/')
|
||||
.rsplit_once(':')
|
||||
.and_then(|(_, port)| port.parse().ok())
|
||||
}
|
||||
|
||||
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
||||
/// by other local users via a traversable config path). On Windows, applies a restrictive DACL
|
||||
/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in
|
||||
@@ -260,3 +284,41 @@ fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn published_mgmt_port_follows_the_endpoint_file_and_is_absent_without_it() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-paths-endpoint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
published_mgmt_port_in(&dir),
|
||||
None,
|
||||
"no file → fall back to the default"
|
||||
);
|
||||
|
||||
// exactly what `mgmt::endpoint_line` writes
|
||||
std::fs::write(
|
||||
dir.join("mgmt-endpoint"),
|
||||
"PUNKTFUNK_MGMT_URL=https://127.0.0.1:47995\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(published_mgmt_port_in(&dir), Some(47995));
|
||||
|
||||
std::fs::write(dir.join("mgmt-endpoint"), "\n").unwrap();
|
||||
assert_eq!(
|
||||
published_mgmt_port_in(&dir),
|
||||
None,
|
||||
"blank reads as unset, not port 0"
|
||||
);
|
||||
|
||||
std::fs::write(dir.join("mgmt-endpoint"), "PUNKTFUNK_MGMT_URL=\n").unwrap();
|
||||
assert_eq!(published_mgmt_port_in(&dir), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -2094,23 +2181,46 @@ fn kill_unit(unit: &str) {
|
||||
/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies
|
||||
/// without restoring (the same semantics as the persisted takeover file).
|
||||
///
|
||||
/// ⚠ The mask stops the UNIT from starting — it does NOT stop the relogin loop that keeps trying.
|
||||
/// On images whose SDDM session helper execs the session script directly (`/etc/sddm/wayland-session
|
||||
/// gamescope-session-plus steam`, f43 bazzite-deck — live-diagnosed on the .41 VM 2026-07-31) SDDM
|
||||
/// relogins ~3×/s regardless, each a full `bash --login` session start — 328 forks/s, load 6+, 1481
|
||||
/// logind sessions in 8 minutes, the journal flooded past its own rotation. The stream itself
|
||||
/// survives, but the storm starves the game and the encoder ("atrocious, unplayable 240fps"). The
|
||||
/// real defense against the storm is stopping the DM ([`dm_plan`]); the mask stays as belt-and-braces
|
||||
/// for the window before the stop lands, and as the degraded takeover when the stop is impossible.
|
||||
/// ⚠⚠⚠ **A mask laid while the display manager is still RUNNING is the relogin storm, not a defense
|
||||
/// against it** — the single most expensive misreading in this file's history, and the reason
|
||||
/// [`dm_plan`] no longer has a `mask` input. Measured end to end on `.41` (Bazzite `.41`, host
|
||||
/// `0.31.0`, 2026-08-18): `/usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop` runs
|
||||
/// `Exec=gamescope-session-plus ogui-steam`, and that script's last act is
|
||||
/// `systemctl --user --wait start gamescope-session-plus@ogui-steam.service`. So the mask sits
|
||||
/// **directly in SDDM's relogin path**: every autologin fails in milliseconds instead of taking the
|
||||
/// seconds a real gamescope + Steam start costs, and SDDM's `Relogin=true` has no backoff at all.
|
||||
/// That converts a slow, survivable relogin loop into a **4–5 logins/s fork storm**: 962 logind
|
||||
/// sessions in 3.7 min, `Watching system buttons` re-scanned 5,688 times, a box-wide udev `change`
|
||||
/// storm at ~20/s, `iio-sensor-proxy` crash-looping at ~16 starts/s, load 26 on 12 cores. What it
|
||||
/// breaks is not the display: `winebus` re-enumerates udev on every event instead of reading
|
||||
/// `hidraw`, so **the pad delivers input at ~1.4 Hz instead of 250 Hz** — "my DualSense is not
|
||||
/// detected in the game" (see `design/sddm-relogin-storm-starves-input-handoff.md`). The earlier
|
||||
/// reading of the same box (2026-07-31) recorded the storm but concluded the sddm helper "execs the
|
||||
/// session script directly, so the masked unit never enters the picture" — it does, one `systemctl`
|
||||
/// call further down, which is why masking looked inert and was left as the degraded takeover.
|
||||
///
|
||||
/// ⚠⚠ The mask DOES bite on that image, which is easy to miss and was the 2026-08-10 field bug: the
|
||||
/// session script's last act is `systemctl --user --wait start gamescope-session-plus@$1.service`, so
|
||||
/// a masked unit makes every entry into game mode — including the user's own deliberate "Return to
|
||||
/// Gaming Mode" — fail instantly, with Steam left sitting on its "Switch to Desktop…" modal forever.
|
||||
/// The mask is therefore only sound while our managed session actually holds the box: the moment the
|
||||
/// The rule that follows, enforced by [`stop_autologin_sessions`]: **the mask is laid only once the
|
||||
/// DM stop has landed, and is never a substitute for it.** With the DM down there is no relogin
|
||||
/// loop for the mask to accelerate, and it is pure belt-and-braces against a supervisor-side
|
||||
/// restart. It is also what keeps a mask-fragile flavor safe — Nobara's `plasmalogin` (KDE's SDDM
|
||||
/// successor) start-limit-kills ITSELF against a masked unit within ~1 s, leaving a permanent black
|
||||
/// screen that only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
|
||||
/// 2026-07-24) — because a stopped DM cannot trip its own start limit, and every restore path
|
||||
/// unmasks BEFORE restarting the DM ([`do_restore_tv_session`]).
|
||||
///
|
||||
/// ⚠⚠ The mask DOES bite the user's own way back, which is easy to miss and was the 2026-08-10 field
|
||||
/// bug: a masked unit makes every entry into game mode — including a deliberate "Return to Gaming
|
||||
/// Mode" — fail instantly, with Steam left sitting on its "Switch to Desktop…" modal forever. The
|
||||
/// mask is therefore only sound while our managed session actually holds the box: the moment the
|
||||
/// 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]),
|
||||
@@ -2193,55 +2303,44 @@ fn display_manager_unit_under(base: &std::path::Path) -> Option<String> {
|
||||
target.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Does this display manager's autologin loop SURVIVE the gamescope unit being masked? This does
|
||||
/// NOT decide whether the DM keeps running — any DM relogin-loops against a killed live gaming
|
||||
/// session, so [`dm_plan`] stops the DM on every flavor — it decides whether masking is safe at
|
||||
/// all, and with it the DEGRADED takeover when the DM can't be stopped (no lingering / no
|
||||
/// privilege):
|
||||
/// * **SDDM** survives (a failing autologin leaves sddm itself running — .181 2026-07-07), so the
|
||||
/// degraded takeover is mask-only: Steam stays protected, and the cost is SDDM's relogin churn
|
||||
/// for the stream's duration — anything from logind/ACL flapping (.181, the audio-flap
|
||||
/// pathology) to a full fork storm on images whose sddm helper bypasses the unit (.41
|
||||
/// 2026-07-31, see [`mask_unit`]).
|
||||
/// * Nobara's `plasmalogin` (KDE's SDDM successor) is proven FATAL: against a masked unit
|
||||
/// its session Exec fails instantly, `Relogin=true` retries, and `plasmalogin.service` trips
|
||||
/// systemd's start limit within ~1 s — the DM dies and the box is a permanent black screen that
|
||||
/// only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
|
||||
/// 2026-07-24). Unknown DMs are treated as fragile: the fragile path degrades gracefully, a wrong
|
||||
/// "safe" kills the seat.
|
||||
fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||
dm == "sddm.service"
|
||||
}
|
||||
|
||||
/// The takeover's display-manager decision, derived purely from the DM flavor and whether any
|
||||
/// autologin gaming instance is LIVE (unit-tested; the runtime guards — lingering, privilege —
|
||||
/// stay with [`stop_autologin_sessions`]).
|
||||
/// The takeover's display-manager decision, derived purely from whether a display manager exists
|
||||
/// and whether any autologin gaming instance is LIVE (unit-tested; the runtime guards — lingering,
|
||||
/// privilege — stay with [`stop_autologin_sessions`]).
|
||||
///
|
||||
/// Killing a live autologin session starts its DM's `Relogin=true` loop, and no flavor tolerates
|
||||
/// that loop well: SDDM's churns logind sessions up to a fork storm ([`mask_unit`]), plasmalogin's
|
||||
/// start-limit-kills the DM. So whenever a DM drove a LIVE gaming session, the DM itself is
|
||||
/// stopped for the stream's duration; the restore ([`do_restore_tv_session`]) brings it back and
|
||||
/// its autologin restores gaming mode. The flavors differ only in masking and in the degraded
|
||||
/// mode ([`dm_survives_masked_unit`]).
|
||||
/// its autologin restores gaming mode.
|
||||
///
|
||||
/// There is no flavor-dependent degraded mode any more, and the DM flavor is no longer an input.
|
||||
/// It used to be: SDDM was classified as surviving a masked unit, so a failed DM stop degraded to
|
||||
/// **mask-only** there. That degrade is what starved the .41 box's input plane on 2026-08-18 —
|
||||
/// masking without the stop is not a weaker defense, it is the storm's engine ([`mask_unit`]). A
|
||||
/// planned DM stop that does not land now fails the takeover and the caller degrades to ATTACH.
|
||||
struct DmPlan {
|
||||
/// Touch nothing at all: a mask-fragile DM with no live gaming instance — killing
|
||||
/// loaded-but-inactive leftovers frees nothing, and stopping the DM would kill the user's
|
||||
/// live desktop for it.
|
||||
/// Touch nothing at all: no live gaming instance. Killing loaded-but-inactive leftovers frees
|
||||
/// 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,
|
||||
/// Mask the units before killing them (safe only where the DM survives a masked unit; also
|
||||
/// the whole of the degraded takeover when the DM can't be stopped).
|
||||
mask: bool,
|
||||
/// Stop the DM for the stream's duration (only a live instance justifies 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 {
|
||||
let mask = dm.is_none_or(dm_survives_masked_unit);
|
||||
DmPlan {
|
||||
skip: !mask && !any_live,
|
||||
mask,
|
||||
stop_dm: dm.is_some() && any_live,
|
||||
skip: !any_live,
|
||||
dm_relogins: dm.is_some() && any_live,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2311,6 +2410,23 @@ enum DmHelperError {
|
||||
},
|
||||
}
|
||||
|
||||
impl DmHelperError {
|
||||
/// The variant as a stable one-word tag, for the `shape` log field. The [`Display`] text is
|
||||
/// prose aimed at whoever reads the line; this is what makes the four cases greppable and
|
||||
/// countable across boxes, since each needs a different fix (package it / install polkit /
|
||||
/// fix the action / join the group).
|
||||
///
|
||||
/// [`Display`]: std::fmt::Display
|
||||
fn shape(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotInstalled => "not-installed",
|
||||
Self::NotExecutable { .. } => "not-executable",
|
||||
Self::Denied { .. } => "denied",
|
||||
Self::Refused { .. } => "refused",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DmHelperError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -2568,87 +2684,29 @@ fn user_in_group(user: &str, group: &str) -> bool {
|
||||
/// wait — a system manager mid-shutdown can still take the request and never answer. A timeout
|
||||
/// reads as `false`, which is the same answer an unauthorized call already gives, so every caller
|
||||
/// falls through to the pkexec helper exactly as it does today.
|
||||
///
|
||||
/// Its stderr is **captured and logged at DEBUG**, not inherited. On an unprivileged host this verb
|
||||
/// is EXPECTED to fail — it is the cheap probe that runs before the pkexec helper — so systemctl's
|
||||
/// own "Access denied … requires interactive authentication" went to the journal on the normal,
|
||||
/// successful path: two of them immediately before `INFO restored the display manager`. That shape
|
||||
/// cost two debugging sessions on its own (2026-08-18), each spent explaining a failure that had
|
||||
/// already succeeded one line later. A `--no-ask-password` refusal is not news; it is the design.
|
||||
fn systemctl_system(args: &[&str]) -> bool {
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--no-ask-password").args(args);
|
||||
crate::proc::status_within(&mut cmd, DM_VERB_BUDGET)
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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())
|
||||
let Ok(out) = crate::proc::output_within(&mut cmd, DM_VERB_BUDGET) else {
|
||||
return false; // timed out / could not spawn — the helper path is next either way
|
||||
};
|
||||
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),
|
||||
if !out.status.success() {
|
||||
tracing::debug!(
|
||||
?args,
|
||||
status = ?out.status.code(),
|
||||
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
|
||||
"systemctl on the system bus was refused — falling through to the packaged pkexec \
|
||||
helper (expected on an unprivileged host)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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@")
|
||||
out.status.success()
|
||||
}
|
||||
|
||||
/// Our uid as a string — what `loginctl` wants for a user argument.
|
||||
@@ -2656,42 +2714,6 @@ 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
|
||||
@@ -2878,22 +2900,24 @@ fn honor_session_select_switch(dm: String) {
|
||||
///
|
||||
/// When a display manager drove a LIVE gaming session, it is **stopped for the stream** on every
|
||||
/// flavor ([`dm_plan`]): killing the session otherwise starts the DM's `Relogin=true` loop, which
|
||||
/// at best churns logind sessions/ACLs and at worst is a full fork storm — f43 bazzite-deck's sddm
|
||||
/// helper execs the session script directly, so the masked unit never enters the picture (328
|
||||
/// forks/s, load 6+, live-diagnosed on the .41 VM 2026-07-31 — see [`mask_unit`]). The units
|
||||
/// themselves are torn down with **SIGKILL** ([`kill_unit`]) to avoid the F44 GPU-context leak
|
||||
/// that the autologin's SIGTERM stop triggers. The flavors differ in masking and in the degraded
|
||||
/// mode when the DM can't be stopped (no lingering / no privilege):
|
||||
/// * **SDDM / no DM**: each unit is **masked first** ([`mask_unit`] — belt-and-braces under a
|
||||
/// stopped DM, and the whole defense on images that DO route the relogin through the unit).
|
||||
/// Matches every loaded instance, not just `running` ones — under a relogin churn the unit
|
||||
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters
|
||||
/// the fight the moment the supervisor restarts it. A failed DM stop **degrades to mask-only**
|
||||
/// with a warning, never to attach: the mask still protects Steam, at the storm-tax price.
|
||||
/// * **Mask-fragile DM** (Nobara's `plasmalogin`, unknown DMs): masking start-limit-kills the DM
|
||||
/// itself (permanent black screen), so the units are killed unmasked, and a failed DM stop
|
||||
/// **fails the takeover** — the error tells the caller to degrade to ATTACH (mirror the box's
|
||||
/// own session) rather than destabilize the seat.
|
||||
/// at best churns logind sessions/ACLs and at worst is a full fork storm. The units themselves are
|
||||
/// torn down with **SIGKILL** ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's
|
||||
/// SIGTERM stop triggers, and each is **masked first** ([`mask_unit`]) so the supervisor cannot
|
||||
/// restart it underneath us. Masking matches every loaded instance, not just `running` ones — under
|
||||
/// a relogin churn the unit flaps through `activating`/`failed` between cycles, and an unmasked
|
||||
/// flapping unit re-enters the fight the moment the supervisor restarts it.
|
||||
///
|
||||
/// **A planned DM stop that does not land fails the takeover, on every flavor** — the `Err` tells
|
||||
/// the caller to degrade to ATTACH (mirror the box's own session) instead. There is deliberately no
|
||||
/// mask-only degrade any more. SDDM used to get one, on the reasoning that "the mask still protects
|
||||
/// Steam, at the storm-tax price"; the storm tax was then measured on `.41` (2026-08-18) and it is
|
||||
/// not a tax, it is a **4–5 logins/s fork storm that costs the user their input plane** — the pad
|
||||
/// reads at 1.4 Hz instead of 250 Hz, because the mask sits inside SDDM's relogin path and makes
|
||||
/// every retry fail instantly ([`mask_unit`] has the full chain). Fighting an autologin we cannot
|
||||
/// stop is strictly worse than not taking over at all, and attach is a fully working stream.
|
||||
///
|
||||
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
|
||||
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
|
||||
fn stop_autologin_sessions() -> Result<()> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
@@ -2927,110 +2951,133 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||
// leftover (the box switched back to the desktop earlier) must not stop the DM — that
|
||||
// would kill the user's live desktop to free nothing.
|
||||
// Stated as the NEGATIVE — systemd has exactly two not-running ACTIVE states, and the other
|
||||
// four all mean the unit still owns Steam and the GPU. Listing the live ones instead missed
|
||||
// `deactivating` (and `reloading`): a unit caught mid-teardown read as a dead leftover, so a
|
||||
// box that IS in gaming mode could be sampled as idle and skipped, leaving the autologin's
|
||||
// Steam holding the single instance our own launch then collides with. The window is small on
|
||||
// an idle box and wide open on a churning one — which is exactly when this is sampled.
|
||||
let any_live = listed
|
||||
.iter()
|
||||
.any(|(_, active)| matches!(active.as_str(), "active" | "activating"));
|
||||
.any(|(_, active)| !matches!(active.as_str(), "inactive" | "failed"));
|
||||
let plan = dm_plan(dm.as_deref(), any_live);
|
||||
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. On a mask-fragile flavor,
|
||||
// degrading to attach is strictly better than a black screen that needs a VT to recover;
|
||||
// where masking is safe, mask-only (the storm tax) is strictly better than attach.
|
||||
//
|
||||
// Both failure arms below 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.
|
||||
let dm_stopped = if let Err(why) = ensure_host_survives_dm_stop() {
|
||||
if !plan.mask {
|
||||
// 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}"
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
reason = %why,
|
||||
"cannot stop the display manager for this stream (lingering could not be \
|
||||
enabled, and without it the DM stop would take this host down ~10s later) — \
|
||||
leaving it running: its autologin Relogin loop will churn logind sessions for \
|
||||
the whole stream, up to a fork storm that starves the game and encoder; run \
|
||||
`sudo loginctl enable-linger $USER` once, as the setup docs ask"
|
||||
);
|
||||
false
|
||||
} else if let Err(why) = try_stop_display_manager(&dm) {
|
||||
if !plan.mask {
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||
session unit, and stopping it needs privilege, so the managed takeover is \
|
||||
unavailable — {why}"
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
reason = %why,
|
||||
"stopping the display manager for this stream needs privilege and the privileged \
|
||||
path failed — leaving it running: its autologin Relogin loop will churn logind \
|
||||
sessions for the whole stream, up to a fork storm that starves the game and \
|
||||
encoder"
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if dm_stopped {
|
||||
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")?;
|
||||
}
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
if plan.mask {
|
||||
// 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 {
|
||||
if plan.mask {
|
||||
mask_unit(&unit); // belt-and-braces under a stopped DM; the whole defense otherwise
|
||||
}
|
||||
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,
|
||||
masked = plan.mask,
|
||||
"freed Steam: 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);
|
||||
}
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||
persist_takeover(); // A3: survive a host crash mid-stream
|
||||
watch_for_relogin_storm(); // §5.4: no measurement taken during a storm is valid — say so
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long the post-takeover storm probe samples logind's session counter for. Long enough that
|
||||
/// one legitimate login racing our teardown cannot reach the threshold, short enough that the line
|
||||
/// lands in the journal while the operator is still looking at the connect that produced it.
|
||||
const STORM_PROBE_WINDOW: Duration = Duration::from_secs(5);
|
||||
|
||||
/// New logind sessions per second above which the box is relogin-storming rather than merely busy.
|
||||
/// A healthy takeover creates **zero** (the display manager is stopped for the stream); the
|
||||
/// measured storm ran at 4–5/s. An order of magnitude clear of both.
|
||||
const STORM_LOGINS_PER_SEC: f64 = 1.0;
|
||||
|
||||
/// The highest logind session id on the box right now. logind names its per-session state files
|
||||
/// after the id in `/run/systemd/sessions/` and hands ids out monotonically, so the maximum is a
|
||||
/// free monotonic LOGIN COUNTER — no `journalctl` grep, no D-Bus, just a `read_dir`. `None` on a
|
||||
/// box with no logind at all.
|
||||
fn max_logind_session_id() -> Option<u64> {
|
||||
std::fs::read_dir("/run/systemd/sessions")
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_name().to_str().and_then(|n| n.parse::<u64>().ok()))
|
||||
.max()
|
||||
}
|
||||
|
||||
/// Watch for a display-manager relogin storm just after a takeover, and say so at ERROR if one is
|
||||
/// running.
|
||||
///
|
||||
/// This exists because of what a storm costs to DIAGNOSE, not what it costs to run. A box relogging
|
||||
/// at 4–5/s re-fires logind's seat scan on every cycle, which re-fires udev `uaccess` across every
|
||||
/// subsystem at ~20/s; `winebus` then re-enumerates udev instead of reading `hidraw` and the pad
|
||||
/// delivers **~1.4 Hz instead of 250 Hz**, WirePlumber re-enumerates at 72 % CPU, and
|
||||
/// `iio-sensor-proxy` crash-loops at ~16 starts/s as a udev-activated amplifier. None of that names
|
||||
/// the display manager. It presents as "my controller is not detected in the game", and an evening
|
||||
/// was spent on 2026-08-18 disproving the pad stack, the ALSA UCM, PipeWire and GE-Proton before
|
||||
/// the DM was suspected at all. **Every audio, input and PipeWire measurement taken during a storm
|
||||
/// is invalid**, and that is worth one loud line before anyone starts measuring.
|
||||
///
|
||||
/// ponytail: detect-and-report only, no self-mitigation. The mitigation would be tearing our own
|
||||
/// session down mid-stream and re-connecting in attach mode, which is a worse failure than the one
|
||||
/// it fixes if the detector is ever wrong. Now that the mask can no longer outlive the DM stop
|
||||
/// ([`stop_autologin_sessions`]) this host does not create storms, so what is left to catch is
|
||||
/// somebody else's — a hand-masked unit, a third-party session switcher, a distro change. If one of
|
||||
/// those turns up in the field with a reliable signature, self-mitigate then.
|
||||
fn watch_for_relogin_storm() {
|
||||
let Some(before) = max_logind_session_id() else {
|
||||
return; // no logind — nothing relogins here
|
||||
};
|
||||
// Detached: the takeover path is already the slowest part of a connect and the answer is worth
|
||||
// nothing to it (it only ever logs). Dies with the process, which is fine — a storm outlives
|
||||
// any single 5 s window and the next connect probes again.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(STORM_PROBE_WINDOW);
|
||||
let Some(after) = max_logind_session_id() else {
|
||||
return;
|
||||
};
|
||||
let logins = after.saturating_sub(before);
|
||||
let per_sec = logins as f64 / STORM_PROBE_WINDOW.as_secs_f64();
|
||||
if per_sec < STORM_LOGINS_PER_SEC {
|
||||
return;
|
||||
}
|
||||
tracing::error!(
|
||||
logins,
|
||||
window_s = STORM_PROBE_WINDOW.as_secs(),
|
||||
rate = %format!("{per_sec:.1}/s"),
|
||||
"this box is in a display-manager RELOGIN STORM — logind is opening sessions faster \
|
||||
than once a second. Every udev consumer on the box is drowning in the fallout: \
|
||||
expect the gamepad to read at a few Hz instead of 250, WirePlumber to burn CPU \
|
||||
re-enumerating, and iio-sensor-proxy to crash-loop. NO audio, input or PipeWire \
|
||||
measurement taken now is valid — find what is relogging first. Usual cause: a \
|
||||
gamescope session unit left masked while the display manager is running, so every \
|
||||
autologin fails instantly (`systemctl --user list-unit-files 'gamescope-session*'`); \
|
||||
`systemctl --user unmask --runtime <unit>` clears it, a reboot clears it too"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
||||
/// down a running game (Proton/wineserver included) on the way out, so this is generous.
|
||||
const STEAM_SHUTDOWN_WAIT: Duration = Duration::from_secs(20);
|
||||
@@ -3483,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
|
||||
@@ -3515,6 +3573,7 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
Err(why) if crate::try_recover_session() => tracing::warn!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \
|
||||
to bring the session back"
|
||||
@@ -3524,6 +3583,7 @@ fn do_restore_tv_session() {
|
||||
// symptom once and the reason is what stops it happening again.
|
||||
Err(why) => tracing::error!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \
|
||||
configured — the box has no graphical session until someone runs \
|
||||
@@ -3538,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)"
|
||||
@@ -5168,10 +5232,9 @@ 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, dm_survives_masked_unit, 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,
|
||||
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,
|
||||
@@ -5351,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);
|
||||
@@ -5423,11 +5466,6 @@ mod tests {
|
||||
display_manager_unit_under(&base).as_deref(),
|
||||
Some("plasmalogin.service")
|
||||
);
|
||||
// Only SDDM is proven to survive a masked session unit; plasmalogin start-limit-kills
|
||||
// itself (live-proven), and unknown DMs default to fragile.
|
||||
assert!(dm_survives_masked_unit("sddm.service"));
|
||||
assert!(!dm_survives_masked_unit("plasmalogin.service"));
|
||||
assert!(!dm_survives_masked_unit("gdm.service"));
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
@@ -5478,24 +5516,56 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dm_plan_stops_any_dm_that_drove_a_live_session() {
|
||||
// SDDM, live gaming session: mask (belt-and-braces) AND stop the DM — the mask alone
|
||||
// does not stop the relogin loop on images whose sddm helper execs the session script
|
||||
// directly, bypassing the unit (fork storm, .41 VM 2026-07-31).
|
||||
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.mask && p.stop_dm);
|
||||
// SDDM, only inactive leftovers: nothing live justifies touching the DM — mask+kill only.
|
||||
let p = dm_plan(Some("sddm.service"), false);
|
||||
assert!(!p.skip && p.mask && !p.stop_dm);
|
||||
// Mask-fragile flavor, live: stop the DM, never mask (masking start-limit-kills the DM).
|
||||
let p = dm_plan(Some("plasmalogin.service"), true);
|
||||
assert!(!p.skip && !p.mask && p.stop_dm);
|
||||
// Mask-fragile flavor, nothing live: hands off entirely — stopping the DM here would
|
||||
// kill the user's live desktop to free nothing.
|
||||
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.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): mask+kill, nothing to stop.
|
||||
// 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.mask && !p.stop_dm);
|
||||
assert!(!p.skip && !p.dm_relogins);
|
||||
assert!(dm_plan(None, false).skip);
|
||||
}
|
||||
|
||||
/// The four [`DmHelperError`] shapes need four different fixes, so the `shape` field must keep
|
||||
/// them apart — a helper that could not be EXECUTED must never read as one that ran and refused.
|
||||
#[test]
|
||||
fn dm_helper_error_shapes_stay_distinct() {
|
||||
let shapes = [
|
||||
DmHelperError::NotInstalled.shape(),
|
||||
DmHelperError::NotExecutable {
|
||||
helper: "h",
|
||||
io: String::new(),
|
||||
}
|
||||
.shape(),
|
||||
DmHelperError::Denied {
|
||||
helper: "h",
|
||||
code: 127,
|
||||
stderr: String::new(),
|
||||
}
|
||||
.shape(),
|
||||
DmHelperError::Refused {
|
||||
helper: "h",
|
||||
code: Some(1),
|
||||
stderr: String::new(),
|
||||
}
|
||||
.shape(),
|
||||
];
|
||||
let unique: std::collections::HashSet<_> = shapes.iter().collect();
|
||||
assert_eq!(unique.len(), shapes.len(), "shapes collided: {shapes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -268,7 +268,16 @@ impl VirtualDisplay for KwinDisplay {
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
match setup_rx.recv_timeout(OPENER_BUDGET) {
|
||||
Ok(Ok(v)) => Ok((v, stop)),
|
||||
Ok(Err(e)) => bail!("KWin virtual output failed: {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!(
|
||||
"KWin virtual output failed: {e} — KWin declined to create the output. It \
|
||||
needs a Plasma WAYLAND session on KWin's DRM backend; a nested or \
|
||||
`kwin_wayland --virtual` KWin can only do this since 6.5.6, and on KWin 6.6+ \
|
||||
an output KWin creates but leaves DISABLED (stored \
|
||||
~/.config/kwinoutputconfig.json, or a display config it refused to apply) \
|
||||
reports the same. kwin_wayland's own journal says which"
|
||||
),
|
||||
Err(_) => {
|
||||
// Nothing else will ever flip this `stop`: it is dropped with the error, and
|
||||
// the `StopGuard` that normally owns it is only built on the success path. So
|
||||
|
||||
@@ -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) —
|
||||
@@ -2326,12 +2435,15 @@ pub unsafe extern "C" fn punktfunk_connect_ex10(
|
||||
/// `audio_rate_hz` — `48000`, `96000`, or the 44.1 kHz family `44100` / `88200` / `176400` — and
|
||||
/// `audio_bits` (`16` or `24`).
|
||||
///
|
||||
/// Passing anything other than `48000`/`16` sets `CLIENT_CAP_AUDIO_HIRES` in the `Hello` and asks
|
||||
/// the host for the LOSSLESS `0xD3` plane — bit-exact PCM instead of Opus. That is an opt-in on
|
||||
/// both ends, and it is meant to be: it costs **1.5–4.6 Mbps** taken off the top of the link
|
||||
/// (audio rides QUIC datagrams outside the ABR loop, so ABR can neither see it nor reclaim it),
|
||||
/// against the ~256 kbps Opus this replaces. Only call it with a non-default format when the
|
||||
/// user turned the feature on AND this embedder can genuinely open an output device at it.
|
||||
/// Passing a format AT ALL — any non-zero `audio_rate_hz`/`audio_bits`, `48000`/`16` included —
|
||||
/// sets `CLIENT_CAP_AUDIO_HIRES` in the `Hello` and asks the host for the LOSSLESS `0xD3` plane,
|
||||
/// bit-exact PCM instead of Opus. (This line once said "anything other than `48000`/`16`", which
|
||||
/// was the rule until the cheapest rung turned out to be the one nobody could ask for; the ⚠ below
|
||||
/// is the whole story.) That is an opt-in on both ends, and it is meant to be: it costs
|
||||
/// **1.5–4.6 Mbps** taken off the top of the link (audio rides QUIC datagrams outside the ABR
|
||||
/// loop, so ABR can neither see it nor reclaim it), against the ~256 kbps Opus this replaces. Only
|
||||
/// pass a format when the user turned the feature on AND this embedder can genuinely open an
|
||||
/// output device at it.
|
||||
///
|
||||
/// **The request is not the answer.** The host runs a five-condition gate
|
||||
/// (`design/hi-res-audio.md` §8.4 — client asked, operator policy allows, stereo, the capture
|
||||
@@ -2619,6 +2731,10 @@ unsafe fn connect_ex_impl(
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
// No abort switch in the C ABI: `punktfunk_connect*` is a blocking call with
|
||||
// nothing to poll a flag from. An `ex` variant can take one when an ABI embedder
|
||||
// grows a cancelable connect screen.
|
||||
None,
|
||||
) {
|
||||
Ok(c) => {
|
||||
if !observed_sha256_out.is_null() {
|
||||
@@ -5850,6 +5966,80 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod log_sink_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// `(level, target, message, user token)` per delivered line. The collector asserts nothing
|
||||
/// itself (an `extern "C"` fn must not panic — the hygiene gate enforces it); the test body
|
||||
/// checks what landed.
|
||||
static LINES: Mutex<Vec<(u8, String, String, usize)>> = Mutex::new(Vec::new());
|
||||
|
||||
unsafe extern "C" fn collect(
|
||||
level: u8,
|
||||
target: *const c_char,
|
||||
message: *const c_char,
|
||||
user: *mut c_void,
|
||||
) {
|
||||
// SAFETY: the core hands NUL-terminated strings valid for this call, per the callback contract.
|
||||
let (t, m) = unsafe { (CStr::from_ptr(target), CStr::from_ptr(message)) };
|
||||
lock_recover(&LINES).push((
|
||||
level,
|
||||
t.to_string_lossy().into_owned(),
|
||||
m.to_string_lossy().into_owned(),
|
||||
user as usize,
|
||||
));
|
||||
}
|
||||
|
||||
/// End to end through both doors: a `log` record and a `tracing` event (via tracing's `log`
|
||||
/// feature) reach the C callback with level, real target, message and the user token; an
|
||||
/// interior NUL is dropped rather than truncating the line; the level ceiling is honoured;
|
||||
/// NULL detaches.
|
||||
#[test]
|
||||
fn callback_receives_log_and_tracing_lines() {
|
||||
// SAFETY: `collect` is a valid fn for the life of the test binary, the user token is an
|
||||
// opaque integer.
|
||||
let st = unsafe { punktfunk_set_log_callback(3, Some(collect), 0x5151 as *mut c_void) };
|
||||
assert_eq!(st, PunktfunkStatus::Ok);
|
||||
|
||||
log::warn!(target: "quinn::connection", "handshake \0 done");
|
||||
tracing::info!(target: "punktfunk_core::transport", buf = 4096, "socket buffer clamped");
|
||||
log::debug!(target: "quinn::connection", "must not arrive (above the ceiling)");
|
||||
|
||||
let lines = lock_recover(&LINES).clone();
|
||||
let warn = lines
|
||||
.iter()
|
||||
.find(|l| l.1 == "quinn::connection")
|
||||
.expect("log record delivered");
|
||||
assert_eq!(warn.0, 2);
|
||||
assert_eq!(warn.2, "handshake done", "interior NUL dropped, line kept");
|
||||
assert_eq!(warn.3, 0x5151, "the user token must come back unchanged");
|
||||
let info = lines
|
||||
.iter()
|
||||
.find(|l| l.1 == "punktfunk_core::transport")
|
||||
.expect("tracing event delivered through the log bridge");
|
||||
assert_eq!(info.0, 3);
|
||||
assert!(
|
||||
info.2.contains("socket buffer clamped") && info.2.contains("buf=4096"),
|
||||
"{}",
|
||||
info.2
|
||||
);
|
||||
assert!(!lines.iter().any(|l| l.2.contains("must not arrive")));
|
||||
|
||||
// SAFETY: NULL callback detaches; no pointer is retained.
|
||||
let detached = unsafe { punktfunk_set_log_callback(3, None, ptr::null_mut()) };
|
||||
assert_eq!(detached, PunktfunkStatus::Ok);
|
||||
let before = lock_recover(&LINES).len();
|
||||
log::error!(target: "quinn::connection", "after detach");
|
||||
assert_eq!(
|
||||
lock_recover(&LINES).len(),
|
||||
before,
|
||||
"a detached sink hears nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quic"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -750,6 +750,7 @@ impl NativeClient {
|
||||
pin,
|
||||
identity,
|
||||
timeout,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -810,6 +811,16 @@ impl NativeClient {
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
timeout: Duration,
|
||||
// The caller's abort switch, polled while this call is still blocked: setting it returns
|
||||
// [`PunktfunkError::Timeout`] straight away instead of parking the caller for the rest of
|
||||
// `timeout` — which is 185 s on a request-access dial the host has PARKED pending an
|
||||
// operator's approval, and a UI that offers Cancel cannot honour it while its dialing
|
||||
// thread is stuck in here. Taking it is the same give-up as running out of budget (quit
|
||||
// close + shutdown), so the worker stops re-dialing and the host tears down rather than
|
||||
// lingering for a reconnect nobody wants. Read ONLY here — deliberately not aliased onto
|
||||
// the client's own `shutdown`, which the pump uses to mean "this connection died" and
|
||||
// whose end reason a caller-set flag would race. `None` = a connect nobody can cancel.
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
) -> Result<NativeClient> {
|
||||
let frame_chan = Arc::new(FrameChannel::new());
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
@@ -967,18 +978,34 @@ impl NativeClient {
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
|
||||
let negotiated = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
// Polled rather than one long `recv_timeout(timeout)`: the wait has to end on the
|
||||
// caller's `cancel` as well as on the budget, and a handshake the host has PARKED
|
||||
// (request-access, pending approval) produces nothing to wake on for minutes.
|
||||
const READY_POLL: Duration = Duration::from_millis(50);
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let negotiated = loop {
|
||||
match ready_rx.recv_timeout(READY_POLL) {
|
||||
Ok(Ok(t)) => break t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
// Timed out with the worker still going: keep waiting unless the budget is
|
||||
// spent or the caller cancelled. Disconnected means the worker died without
|
||||
// reporting — the give-up path below covers it, same as it always did.
|
||||
// Both give-ups land in one arm on purpose: a cancel and an expiry owe the
|
||||
// host the same close, and the caller that cancelled is not listening to the
|
||||
// error it gets back anyway.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if std::time::Instant::now() < deadline
|
||||
&& !cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) => {}
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
*mode_slot.lock().unwrap() = negotiated.mode;
|
||||
|
||||
@@ -246,7 +246,17 @@ pub use stats::Stats;
|
||||
/// this reads and writes landed with the plane itself, appended behind the existing trailing-field
|
||||
/// discipline (old peers skip them in both directions, and a legacy request encodes byte-identical
|
||||
/// to the pre-hi-res messages), so [`WIRE_VERSION`] is still unchanged.
|
||||
pub const ABI_VERSION: u32 = 24;
|
||||
/// **v25** adds [`abi::punktfunk_set_log_callback`] — a `log` backend behind a C callback, so an
|
||||
/// embedder that installs no Rust subscriber (the Swift clients, any C host) can receive the
|
||||
/// core's own log lines: transport warnings, quinn connection events, rustls handshake notes,
|
||||
/// everything this crate and its dependencies say through `tracing`/`log`. Until now those went
|
||||
/// nowhere on Apple, and a client log bundle sent to the host carried the shell's half only.
|
||||
/// ADDED, not widened: one new function and one callback typedef; nothing existing moved, and an
|
||||
/// embedder that never calls it behaves exactly as on v24. Client-local in every sense — the host
|
||||
/// never sees it and [`WIRE_VERSION`] is unchanged. It relies on tracing's `log` feature, now
|
||||
/// declared explicitly by this crate (it was on transitively through quinn's defaults, which is
|
||||
/// not a thing an ABI promise should rest on).
|
||||
pub const ABI_VERSION: u32 = 25;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
//! link head-blocks the daemon).
|
||||
|
||||
mod monitor_rate;
|
||||
mod pad_card_volume;
|
||||
pub(crate) mod pad_sink;
|
||||
pub(crate) mod pad_usb;
|
||||
mod stream_sink;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Put the usbip pad's REAL sound card at unity gain — the host half of the attenuation the
|
||||
//! client fixes in `pf_client_core::pad_audio::pin_sink_volume`.
|
||||
//!
|
||||
//! ## The defect
|
||||
//!
|
||||
//! WirePlumber starts every new card's sink at `device.routes.default-sink-volume`. That is 0.4,
|
||||
//! and 0.4 is a *cubed* number: what a mixer shows as 40 % is 0.4³ = 0.064 of linear amplitude,
|
||||
//! −23.88 dB. The setting is global — it cannot be scoped to one device from configuration — so
|
||||
//! there is no config file we could ship to exempt the pad, and it fires again on every fresh
|
||||
//! card, which for a usbip pad means every single attach.
|
||||
//!
|
||||
//! It is a reasonable default for a laptop speaker somebody is about to turn up. It is wrong
|
||||
//! here twice:
|
||||
//!
|
||||
//! - nobody chose it, and nobody would think to look for it: the pad's sink is not a listening
|
||||
//! volume anyone reaches for, so it reads as weak hardware rather than as a slider; and
|
||||
//! - **both ends of a session mint one.** The game's samples cross this sink on the host and the
|
||||
//! pad's own sink on the client, so the two multiply: 0.064² = −47.8 dB by the time a game's
|
||||
//! haptics reach a voice coil. That is the difference between "the haptics are subtle" and
|
||||
//! "I'm not sure the haptics are connected".
|
||||
//!
|
||||
//! ## Why it lands on the host at all
|
||||
//!
|
||||
//! [`super::pad_usb`] captures at the pad's isochronous OUT endpoint, which is DOWNSTREAM of
|
||||
//! this sink: PipeWire applies the sink's volume when it mixes into the ALSA device, and what
|
||||
//! reaches the wire — and therefore what we encode and send — is already attenuated. Fixing it
|
||||
//! on the client cannot recover what the host threw away before the encoder saw it.
|
||||
//!
|
||||
//! Nothing here is restored on the way out, deliberately. The client restores a *profile* it
|
||||
//! borrowed, because that overrides a choice the user made; this overrides a default nobody
|
||||
//! made, and putting −24 dB back would be restoring the bug.
|
||||
//!
|
||||
//! Best effort throughout: this is a volume, and every failure costs loudness rather than audio.
|
||||
//! `PUNKTFUNK_PAD_SINK_VOLUME=0` skips it entirely, for bisecting a box where something else is
|
||||
//! doing the attenuating.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The pad's USB identity, as its ALSA card publishes it.
|
||||
const DS5_VENDOR: u32 = 0x054c;
|
||||
const DS5_PRODUCTS: [u32; 2] = [0x0ce6, 0x0df2];
|
||||
|
||||
/// How long to keep looking for the card after the pad attaches, and how often.
|
||||
///
|
||||
/// The USB device is live well before its sink is: `snd-usb-audio` has to probe it, PipeWire has
|
||||
/// to build the device, and WirePlumber has to apply the very default we are here to undo — and
|
||||
/// pinning BEFORE that lands would simply be overwritten. So this retries rather than firing
|
||||
/// once, and gives up quietly: a pad whose card never appears is a pad with no sink to pin.
|
||||
const ATTEMPTS: u32 = 15;
|
||||
const INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Pin every DualSense card sink in the graph to unity, in the background.
|
||||
///
|
||||
/// Detached on purpose. The caller is the pad-audio capture thread's open path, and a second of
|
||||
/// waiting for a card to appear there is a second of missing pad audio.
|
||||
pub(crate) fn spawn_pin(pad: u8) {
|
||||
if matches!(
|
||||
std::env::var("PUNKTFUNK_PAD_SINK_VOLUME").as_deref(),
|
||||
Ok("0" | "false" | "off" | "no")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-padvol{pad}"))
|
||||
.spawn(move || {
|
||||
// An error retries like an absent card does: the pad attaching is exactly the moment
|
||||
// the graph is busy, and giving up on one transient connect failure would leave the
|
||||
// attenuation in place for the whole session. Only the last one is reported.
|
||||
let mut last_err = None;
|
||||
for _ in 0..ATTEMPTS {
|
||||
match pin_pad_sinks() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
tracing::info!(
|
||||
pad,
|
||||
sinks = n,
|
||||
"pad card sink pinned to 0 dB (WirePlumber starts every new card at \
|
||||
40% = -23.88 dB, and host+client stack)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => last_err = Some(format!("{e:#}")),
|
||||
}
|
||||
std::thread::sleep(INTERVAL);
|
||||
}
|
||||
tracing::debug!(
|
||||
pad,
|
||||
error = last_err.unwrap_or_else(|| "no DualSense card sink in the graph".into()),
|
||||
"pad sink volume not pinned — pad audio may be quiet if this box attenuates it"
|
||||
);
|
||||
})
|
||||
{
|
||||
tracing::debug!(pad, error = %e, "pad sink volume thread not spawned");
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass: walk the graph, and set every DualSense CARD sink to unity. Returns how many were
|
||||
/// pinned, so the caller can tell "the card is not here yet" from "done".
|
||||
fn pin_pad_sinks() -> Result<usize> {
|
||||
use pipewire as pw;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
||||
PW_INIT.call_once(pw::init);
|
||||
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
||||
let core = context.connect_rc(None).context("pw connect")?;
|
||||
let registry = core.get_registry_rc().context("pw registry")?;
|
||||
|
||||
/// A bound sink and what the set_param needs to know about it.
|
||||
struct Sink {
|
||||
node: pw::node::Node,
|
||||
_listener: pw::node::NodeListener,
|
||||
/// `device.id` from the announce props — `None` for a node that belongs to no card.
|
||||
card: Option<u32>,
|
||||
/// `audio.channels`, which arrives only with the bound node's `info`. Zero until then.
|
||||
channels: Rc<Cell<u32>>,
|
||||
}
|
||||
|
||||
let sinks: Rc<RefCell<Vec<Sink>>> = Rc::default();
|
||||
let ds5_cards: Rc<RefCell<Vec<u32>>> = Rc::default();
|
||||
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global({
|
||||
let (registry, sinks, ds5_cards) = (registry.clone(), sinks.clone(), ds5_cards.clone());
|
||||
move |g| {
|
||||
let Some(props) = g.props else { return };
|
||||
let usb_id = |k: &str| {
|
||||
props.get(k).and_then(|v| {
|
||||
let v = v.trim();
|
||||
// The specimen publishes `0x054c`; a bare `054c` read with base 0
|
||||
// is octal and yields nonsense, so the radix is chosen explicitly.
|
||||
v.strip_prefix("0x")
|
||||
.or_else(|| v.strip_prefix("0X"))
|
||||
.map(|h| u32::from_str_radix(h, 16))
|
||||
.unwrap_or_else(|| u32::from_str_radix(v, 16))
|
||||
.ok()
|
||||
})
|
||||
};
|
||||
match g.type_ {
|
||||
// Cards announce their identity keys, so no second round is needed for them.
|
||||
pw::types::ObjectType::Device => {
|
||||
let vendor = usb_id("device.vendor.id");
|
||||
let product = usb_id("device.product.id");
|
||||
if vendor == Some(DS5_VENDOR)
|
||||
&& product.is_some_and(|p| DS5_PRODUCTS.contains(&p))
|
||||
{
|
||||
ds5_cards.borrow_mut().push(g.id);
|
||||
}
|
||||
}
|
||||
pw::types::ObjectType::Node => {
|
||||
if !props
|
||||
.get("media.class")
|
||||
.is_some_and(|c| c.starts_with("Audio/Sink"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(node) = registry.bind::<pw::node::Node, _>(g) else {
|
||||
return;
|
||||
};
|
||||
// `audio.channels` is NOT in the announce subset — reading it there looks
|
||||
// like it works and returns zero on every real machine (the same trap
|
||||
// `pf_client_core::pad_audio::walk_graph` documents). Bind for it.
|
||||
let channels = Rc::new(Cell::new(0u32));
|
||||
let listener = node
|
||||
.add_listener_local()
|
||||
.info({
|
||||
let channels = channels.clone();
|
||||
move |info| {
|
||||
let Some(p) = info.props() else { return };
|
||||
if let Some(c) =
|
||||
p.get("audio.channels").and_then(|v| v.parse().ok())
|
||||
{
|
||||
channels.set(c);
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
sinks.borrow_mut().push(Sink {
|
||||
node,
|
||||
_listener: listener,
|
||||
card: props.get("device.id").and_then(|v| v.parse().ok()),
|
||||
channels,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let awaited: Rc<Cell<Option<pw::spa::utils::result::AsyncSeq>>> = Rc::new(Cell::new(None));
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.done({
|
||||
let (mainloop, awaited) = (mainloop.clone(), awaited.clone());
|
||||
move |_, seq| {
|
||||
if awaited.get() == Some(seq) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
let round = |issue: &dyn Fn() -> Result<()>| -> Result<()> {
|
||||
issue()?;
|
||||
awaited.set(Some(core.sync(0).context("pw sync")?));
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
};
|
||||
|
||||
round(&|| Ok(()))?; // 1: globals replay; sinks get bound
|
||||
round(&|| Ok(()))?; // 2: the binds' `info` events land, carrying audio.channels
|
||||
|
||||
// A `Cell` because `round` takes an `Fn` — the set_params have to be issued from inside it,
|
||||
// and a closure that incremented a plain counter would be `FnMut`.
|
||||
let pinned = Cell::new(0usize);
|
||||
round(&|| {
|
||||
let cards = ds5_cards.borrow();
|
||||
for s in sinks.borrow().iter() {
|
||||
// A CARD's sink only. A Punktfunk host minting its own pad sink on this same box
|
||||
// publishes the full DualSense identity on purpose (that is how Proton finds it) and
|
||||
// is not a thing to set a hardware volume on; `device.id` is what tells them apart.
|
||||
let Some(card) = s.card else { continue };
|
||||
if !cards.contains(&card) {
|
||||
continue;
|
||||
}
|
||||
let channels = s.channels.get();
|
||||
if channels == 0 {
|
||||
continue;
|
||||
}
|
||||
let pod = unity_volume_pod(channels)?;
|
||||
let Some(pod) = pw::spa::pod::Pod::from_bytes(&pod) else {
|
||||
continue;
|
||||
};
|
||||
s.node.set_param(pw::spa::param::ParamType::Props, 0, pod);
|
||||
pinned.set(pinned.get() + 1);
|
||||
}
|
||||
Ok(())
|
||||
})?; // 3: flush the set_params before the loop and its proxies drop
|
||||
Ok(pinned.get())
|
||||
}
|
||||
|
||||
/// The `Props` object pod that puts every channel of a sink at unity gain (1.0 linear = 0 dB;
|
||||
/// see the module docs for why that is not the same number a mixer would call 100 %).
|
||||
fn unity_volume_pod(channels: u32) -> Result<Vec<u8>> {
|
||||
use pipewire::spa;
|
||||
use spa::pod::{Object, Property, PropertyFlags, Value, ValueArray};
|
||||
let obj = Object {
|
||||
type_: spa::utils::SpaTypes::ObjectParamProps.as_raw(),
|
||||
id: spa::param::ParamType::Props.as_raw(),
|
||||
properties: vec![
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_volume,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::Float(1.0),
|
||||
},
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_channelVolumes,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::ValueArray(ValueArray::Float(vec![1.0; channels.max(1) as usize])),
|
||||
},
|
||||
],
|
||||
};
|
||||
Ok(spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&Value::Object(obj),
|
||||
)
|
||||
.context("serialize Props pod")?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The pod is what the fix IS, so it has to be the shape PipeWire reads: a `Props` object
|
||||
/// carrying one unity float per channel. A pod whose array is the wrong length is the
|
||||
/// failure this guards — PipeWire ignores a `channelVolumes` that does not match the port
|
||||
/// count, which would look exactly like the pin silently not working.
|
||||
#[test]
|
||||
fn unity_pod_is_one_float_per_channel() {
|
||||
use pipewire::spa::pod::{deserialize::PodDeserializer, Value, ValueArray};
|
||||
for channels in [1u32, 2, 4] {
|
||||
let bytes = unity_volume_pod(channels).expect("serialize");
|
||||
let (_, value) = PodDeserializer::deserialize_any_from(&bytes).expect("parse");
|
||||
let Value::Object(obj) = value else {
|
||||
panic!("not an object pod");
|
||||
};
|
||||
let vols = obj
|
||||
.properties
|
||||
.iter()
|
||||
.find(|p| p.key == pipewire::spa::sys::SPA_PROP_channelVolumes)
|
||||
.map(|p| p.value.clone())
|
||||
.expect("channelVolumes");
|
||||
let Value::ValueArray(ValueArray::Float(v)) = vols else {
|
||||
panic!("channelVolumes is not a float array");
|
||||
};
|
||||
assert_eq!(v.len(), channels as usize);
|
||||
assert!(v.iter().all(|&x| x == 1.0), "every channel must be unity");
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero channels must not serialize an empty array — an empty `channelVolumes` is not
|
||||
/// "leave it alone", it is a pod PipeWire may take literally.
|
||||
#[test]
|
||||
fn unity_pod_never_empty() {
|
||||
let bytes = unity_volume_pod(0).expect("serialize");
|
||||
assert!(!bytes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,11 @@ impl PadUsbCapturer {
|
||||
let rx = pf_inject::dualsense_usbip::take_audio_rx(pad)
|
||||
.ok_or_else(|| anyhow!("no usbip pad audio published for pad {pad} (not attached?)"))?;
|
||||
tracing::info!(pad, "pad audio capturing from the USB isochronous endpoint");
|
||||
// The pad's ALSA card is real, so WirePlumber greets it with its global 40 % default —
|
||||
// which is -23.88 dB applied BEFORE the isochronous endpoint we capture from, and which
|
||||
// stacks with the same default on the client. Undo it once the card shows up; see
|
||||
// [`super::pad_card_volume`]. Best effort, off-thread, never fatal.
|
||||
super::pad_card_volume::spawn_pin(pad);
|
||||
Ok(PadUsbCapturer { rx, pad })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +153,12 @@ fn percent_decode(s: &str) -> String {
|
||||
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art —
|
||||
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
|
||||
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
|
||||
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
|
||||
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
|
||||
/// operator whose library is somewhere else again.
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus the
|
||||
/// two launchers that need NOT live under the users base: the Steam install root
|
||||
/// ([`steam_art_roots`]), and every Playnite root this box can find
|
||||
/// ([`super::launch::playnite_art_roots`]) — a PORTABLE Playnite keeps its whole library, covers and
|
||||
/// all, beside the exe, wherever the operator unzipped it. `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
/// (`;`-separated) replaces the whole default for an operator whose library is somewhere else again.
|
||||
fn art_roots() -> Vec<PathBuf> {
|
||||
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
|
||||
return std::env::split_paths(&configured)
|
||||
@@ -177,6 +179,11 @@ fn art_roots() -> Vec<PathBuf> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
roots.extend(steam_art_roots());
|
||||
// Playnite, for the same reason: a portable install (`D:\Apps\Playnite`) puts `library\files\…`
|
||||
// — every cover it exports — outside every profile. An installed Playnite adds a root that is
|
||||
// already inside the users base, which costs nothing.
|
||||
#[cfg(windows)]
|
||||
roots.extend(super::launch::playnite_art_roots());
|
||||
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
|
||||
// where every launcher this host reads art from actually keeps it. Steam's
|
||||
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
|
||||
@@ -1047,6 +1054,25 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Whatever Playnite roots this box has, the confinement must be told about them with NO
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` set. That `extend` is the whole fix for the portable-install
|
||||
/// report (`D:\Apps\Playnite\library\files\…`, 70 covers dropped), and it is one line a
|
||||
/// refactor can silently drop. Vacuous on a box with no Playnite — the registry half cannot be
|
||||
/// faked from a test, so `launch::exe_from_shell_command`'s own test carries that load instead.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn playnite_roots_reach_the_art_confinement() {
|
||||
let _env = ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", None)]);
|
||||
let roots = art_roots();
|
||||
for root in crate::library::launch::playnite_art_roots() {
|
||||
assert!(root.is_dir(), "{root:?} is offered as an art root");
|
||||
assert!(
|
||||
roots.contains(&root),
|
||||
"{root:?} must be an allowed art root with no env var set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
|
||||
@@ -633,6 +633,11 @@ fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
|
||||
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
|
||||
///
|
||||
/// A **portable** Playnite is none of those: it is unzipped wherever the operator wanted it
|
||||
/// (`D:\Apps\Playnite`), registers no uninstall entry, and is not under any profile. Its one
|
||||
/// registry trace is the `playnite://` handler Playnite registers for itself
|
||||
/// ([`playnite_dir_from_uri_handler`]) — the same registration this host's own launch path follows.
|
||||
///
|
||||
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
|
||||
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
|
||||
/// a stale entry costs one `is_file` and nothing else.
|
||||
@@ -645,22 +650,33 @@ fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
|
||||
// so the WOW view is a machine-hive concern only.
|
||||
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
// Playnite's own `playnite://` registration, in both spellings: bare inside a `…_Classes` hive,
|
||||
// and via the `Software\Classes` link everywhere else.
|
||||
const URI_COMMAND: &str = r"playnite\shell\open\command";
|
||||
const CLASSES_URI_COMMAND: &str = r"Software\Classes\playnite\shell\open\command";
|
||||
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hklm, CLASSES_URI_COMMAND, &mut dirs);
|
||||
|
||||
let users = RegKey::predef(HKEY_USERS);
|
||||
for sid in users.enum_keys().flatten() {
|
||||
// The `…_Classes` companion hives carry file associations, never uninstall entries.
|
||||
let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) else {
|
||||
continue;
|
||||
};
|
||||
// The `…_Classes` companion hives carry file associations — which is exactly where the
|
||||
// `playnite://` handler lives, `HKCU\Software\Classes` BEING that hive — and never uninstall
|
||||
// entries. Both spellings are probed rather than reasoned about: the in-hive `Software\Classes`
|
||||
// link is a link, and a probe that misses costs one failed `open_subkey`.
|
||||
if sid.ends_with("_Classes") {
|
||||
playnite_dir_from_uri_handler(&hive, URI_COMMAND, &mut dirs);
|
||||
continue;
|
||||
}
|
||||
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
}
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hive, CLASSES_URI_COMMAND, &mut dirs);
|
||||
}
|
||||
|
||||
// The conventional per-user location, for every profile on the box — this is where Playnite's
|
||||
@@ -705,6 +721,80 @@ fn playnite_dirs_from_uninstall(
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the directory of Playnite's registered `playnite://` handler from `root\path`, if there is one.
|
||||
///
|
||||
/// This is what finds a **portable** Playnite. It leaves no uninstall entry and lives under no user
|
||||
/// profile, so every other probe here is blind to it — but Playnite registers its own URI scheme,
|
||||
/// and that registration is the very one `explorer.exe "playnite://…"` follows when this host starts
|
||||
/// a Playnite title. If it resolves, this box already opens games with that copy.
|
||||
#[cfg(windows)]
|
||||
fn playnite_dir_from_uri_handler(
|
||||
root: &winreg::RegKey,
|
||||
path: &str,
|
||||
out: &mut Vec<std::path::PathBuf>,
|
||||
) {
|
||||
use winreg::enums::KEY_READ;
|
||||
|
||||
let Ok(command) = root
|
||||
.open_subkey_with_flags(path, KEY_READ)
|
||||
.and_then(|k| k.get_value::<String, _>(""))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = exe_from_shell_command(&command)
|
||||
.map(std::path::Path::new)
|
||||
.and_then(std::path::Path::parent)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
{
|
||||
push_unique(out, dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// The executable out of a registered shell-open command line:
|
||||
/// `"D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1"` → `D:\Apps\Playnite\Playnite.DesktopApp.exe`.
|
||||
///
|
||||
/// Quoted form first, because that is what a registrar writes. The cut at the first `.exe` is the
|
||||
/// fallback for the unquoted spelling, whose path may itself contain spaces and so cannot be split on
|
||||
/// whitespace. `None` when neither shape matches; the result is only ever a directory to probe for an
|
||||
/// exe, so a miss costs one `is_file` and nothing else.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn exe_from_shell_command(command: &str) -> Option<&str> {
|
||||
let command = command.trim();
|
||||
if let Some(rest) = command.strip_prefix('"') {
|
||||
return rest.split('"').next().filter(|p| !p.is_empty());
|
||||
}
|
||||
let end = command.to_ascii_lowercase().find(".exe")? + ".exe".len();
|
||||
Some(&command[..end])
|
||||
}
|
||||
|
||||
/// Windows: every Playnite root on this box, as an **art** root.
|
||||
///
|
||||
/// A portable Playnite keeps its library beside the exe — covers land in
|
||||
/// `<PlayniteDir>\library\files\…` — so for that layout the install dir IS where the art lives, and
|
||||
/// the users base can never cover it: the whole point of portable is that it sits wherever the
|
||||
/// operator put it (`D:\Apps\Playnite` in the report that prompted this). Without it a portable
|
||||
/// install synced its games and had EVERY cover dropped by the confinement. An installed Playnite
|
||||
/// keeps the same tree under `%APPDATA%\Playnite`, already inside the users base; naming that
|
||||
/// directory twice costs one `canonicalize` in [`super::art::art_path_is_confined`].
|
||||
///
|
||||
/// Same shape and same reasoning as [`super::art::steam_art_roots`], and it does not widen what the
|
||||
/// host can be *tricked* into reading: every candidate comes from the host's own registry and
|
||||
/// filesystem probes, never from the plugin lane that supplies the art path, and the extension,
|
||||
/// regular-file, magic-byte and config-dir gates all still apply on top.
|
||||
///
|
||||
/// The per-user hives these candidates partly come from are writable by that user — which is a bar
|
||||
/// this host already stands on, and one rung lower here than where it already stood: the same
|
||||
/// lookup picks the `Playnite.FullscreenApp.exe` a launcher tile SPAWNS. Trusting it to name a
|
||||
/// directory whose image files may be read is strictly weaker than trusting it to name a program to
|
||||
/// run.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn playnite_art_roots() -> Vec<std::path::PathBuf> {
|
||||
playnite_install_dirs()
|
||||
.into_iter()
|
||||
.filter(|d| d.is_dir())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
|
||||
///
|
||||
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
|
||||
@@ -1086,6 +1176,36 @@ mod tests {
|
||||
assert!(!valid_aumid("Foo Bar!Game"));
|
||||
}
|
||||
|
||||
/// The portable-Playnite probe, at the only part of it that can be wrong off-Windows: pulling the
|
||||
/// exe out of the registered `playnite://` command line. A miss here is a portable install the
|
||||
/// host cannot find — no launcher tile, and (through [`playnite_art_roots`]) every cover dropped.
|
||||
#[test]
|
||||
fn exe_is_read_out_of_a_registered_shell_command() {
|
||||
// What Playnite actually registers, portable install on a second drive.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r#""D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1""#),
|
||||
Some(r"D:\Apps\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Unquoted, with a space in the path — which is why this cannot split on whitespace.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe %1"),
|
||||
Some(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Case is the registrar's business, not ours.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE"),
|
||||
Some(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE")
|
||||
);
|
||||
// Nothing exe-shaped, and the empty quoted form: no candidate beats a bogus one, because a
|
||||
// bogus one would become an allowed art root.
|
||||
assert_eq!(
|
||||
exe_from_shell_command("rundll32 shell32.dll,Control_RunDLL"),
|
||||
None
|
||||
);
|
||||
assert_eq!(exe_from_shell_command(r#""" %1"#), None);
|
||||
assert_eq!(exe_from_shell_command(""), None);
|
||||
}
|
||||
|
||||
/// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the
|
||||
/// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be
|
||||
/// used because it is registered to the desktop app (verified on .173, 2026-08-06).
|
||||
|
||||
@@ -94,7 +94,10 @@ pub fn effective_port() -> u16 {
|
||||
/// console's own default. Moving the listener therefore silently broke the console, because nothing
|
||||
/// downstream had any way to learn the new port. Now the host is the single source of truth and
|
||||
/// publishes what it actually bound; consumers keep a 47990 fallback purely so an OLD host with a
|
||||
/// NEW console still works.
|
||||
/// NEW console still works. The plugin runner / SDK (`sdk/src/config.ts::publishedMgmtUrl`) and
|
||||
/// the tray (`pf_paths::published_mgmt_port`) read the same file — both used to be a sixth and
|
||||
/// seventh literal 47990, and a moved port left every plugin dialing the old one in silence
|
||||
/// (field report 2026-08-18).
|
||||
///
|
||||
/// Always loopback, never `bind`'s own address: the console proxies over loopback by design (see
|
||||
/// the module docs — the bearer-token admin surface is confined to loopback peers), so a wide
|
||||
|
||||
@@ -4611,9 +4611,14 @@ fn is_permanent_build_error(chain: &str) -> bool {
|
||||
"virtual displays require linux",
|
||||
"unknown punktfunk_compositor",
|
||||
"could not detect compositor",
|
||||
"could not find output", // KWin < 6.5.6: createVirtualOutput unsupported
|
||||
"must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer
|
||||
"is it installed", // gamescope / kscreen-doctor not on PATH
|
||||
// KWin refused the virtual output. Its own reason arrives TRANSLATED (a field report read
|
||||
// "Não foi possível encontrar saída" and burned all 8 retries), so match OUR English
|
||||
// prefix, not KWin's payload. Every `failed` KWin sends on this path is a config/backend
|
||||
// fact — unsupported compositing type, a backend without `createVirtualOutput`, an output
|
||||
// the workspace declined to enable — none of which a retry 500 ms later changes.
|
||||
"kwin virtual output failed",
|
||||
"must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer
|
||||
"is it installed", // gamescope / kscreen-doctor not on PATH
|
||||
// 4:4:4 NVENC got a CUDA frame — should never happen now the Linux capturer honors gpu=false,
|
||||
// but fail fast instead of 8× retry (~90 s) rather than wedge the session if it ever recurs.
|
||||
"capture/encoder negotiation mismatch",
|
||||
@@ -5329,6 +5334,10 @@ mod tests {
|
||||
assert!(is_permanent_build_error(
|
||||
"create virtual output: KWin virtual output failed: Could not find output"
|
||||
));
|
||||
// Same refusal from a localized KWin — the reason is translated, our prefix is not.
|
||||
assert!(is_permanent_build_error(
|
||||
"create virtual output: KWin virtual output failed: Não foi possível encontrar saída"
|
||||
));
|
||||
assert!(is_permanent_build_error(
|
||||
"unknown PUNKTFUNK_COMPOSITOR 'foo' (kwin|wlroots|mutter|gamescope)"
|
||||
));
|
||||
|
||||
@@ -18,6 +18,9 @@ path = "src/main.rs"
|
||||
# stub main (same pattern as the platform-gated clients).
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
# `config_dir()` + `published_mgmt_port()`: the tray follows the mgmt port the host actually bound
|
||||
# (`<config_dir>/mgmt-endpoint`) instead of assuming 47990. Std-only leaf, no I/O stack.
|
||||
pf-paths = { path = "../pf-paths" }
|
||||
|
||||
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -22,10 +22,13 @@ mod win;
|
||||
#[cfg(windows)]
|
||||
mod win_theme;
|
||||
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
|
||||
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
|
||||
/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the
|
||||
/// autostart command line instead.
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address defaults to loopback; the
|
||||
/// port, when not given, follows what the host PUBLISHED (`<config_dir>/mgmt-endpoint`, rewritten
|
||||
/// on every host start — see `pf_paths::published_mgmt_port`), falling back to 47990. That file is
|
||||
/// how a moved `PUNKTFUNK_MGMT_BIND` reaches the tray: it cannot read `host.env` on Windows (DACL-
|
||||
/// locked to SYSTEM/Administrators), and before this an operator who moved the port had to know to
|
||||
/// edit the autostart command line — nobody did, and the tray reported a running host as
|
||||
/// unreachable (field report 2026-08-18). `--mgmt-port` still pins it explicitly.
|
||||
pub struct Args {
|
||||
/// Ask an already-running tray instance to exit (Windows; used by the uninstaller).
|
||||
pub quit: bool,
|
||||
@@ -34,7 +37,9 @@ pub struct Args {
|
||||
pub autostart: bool,
|
||||
/// Management API address to poll (loopback only; the summary route rejects anything else).
|
||||
pub mgmt_addr: String,
|
||||
pub mgmt_port: u16,
|
||||
/// `None` = follow the published endpoint (re-read on every poll, so a host restarted on a new
|
||||
/// port is picked up without relaunching the tray).
|
||||
pub mgmt_port: Option<u16>,
|
||||
/// Web console port for the "Open web console" action.
|
||||
pub web_port: u16,
|
||||
}
|
||||
@@ -45,7 +50,7 @@ impl Default for Args {
|
||||
quit: false,
|
||||
autostart: false,
|
||||
mgmt_addr: "127.0.0.1".into(),
|
||||
mgmt_port: 47990,
|
||||
mgmt_port: None,
|
||||
web_port: 47992,
|
||||
}
|
||||
}
|
||||
@@ -63,7 +68,7 @@ fn parse_args() -> anyhow::Result<Args> {
|
||||
"--quit" => args.quit = true,
|
||||
"--autostart" => args.autostart = true,
|
||||
"--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?,
|
||||
"--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?,
|
||||
"--mgmt-port" => args.mgmt_port = Some(value("--mgmt-port")?.parse()?),
|
||||
"--web-port" => args.web_port = value("--web-port")?.parse()?,
|
||||
"--version" | "-V" => {
|
||||
println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
@@ -158,7 +158,7 @@ impl Poller {
|
||||
/// had one.
|
||||
pub fn spawn(
|
||||
mgmt_addr: String,
|
||||
mgmt_port: u16,
|
||||
mgmt_port: Option<u16>,
|
||||
web_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||
) -> Poller {
|
||||
@@ -184,15 +184,23 @@ impl Poller {
|
||||
fn poll_loop(
|
||||
shared: &Shared,
|
||||
mgmt_addr: &str,
|
||||
mgmt_port: u16,
|
||||
mgmt_port: Option<u16>,
|
||||
web_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||
) {
|
||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||
let url = if mgmt_addr.contains(':') {
|
||||
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
|
||||
// Resolved PER TICK, not once: with no `--mgmt-port` the port is whatever the host last
|
||||
// published, and a host restarted on a moved `PUNKTFUNK_MGMT_BIND` must not leave the tray
|
||||
// polling the old one until the next login. One tiny file read every 3 s is nothing.
|
||||
let summary_url = || {
|
||||
let port = mgmt_port
|
||||
.or_else(pf_paths::published_mgmt_port)
|
||||
.unwrap_or(47990);
|
||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||
if mgmt_addr.contains(':') {
|
||||
format!("https://[{mgmt_addr}]:{port}/api/v1/local/summary")
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{port}/api/v1/local/summary")
|
||||
}
|
||||
};
|
||||
// `/login`, not `/`: `/` is auth-gated and 302s to `/login`, and ureq follows redirects by
|
||||
// default — so probing `/` spent TLS + `/` + a full cold `/login` SSR render inside one 2 s
|
||||
@@ -212,7 +220,7 @@ fn poll_loop(
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
let s = fetch_summary(&agent, &url);
|
||||
let s = fetch_summary(&agent, &summary_url());
|
||||
match s {
|
||||
Some(_) => unreachable_since = None,
|
||||
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"$comment": "Single source for the install/port facts that used to drift across four surfaces (docs-and-onboarding-overhaul WP1). Consumers: docs-site install pages (<Install platform=…/> and <Ports/> in docs-site/src/components/platforms.tsx, reading the byte-identical snapshot docs-site/src/data/platforms.json that check-docs-drift.sh gates), the website download page (WP3), the guided install script (WP4). A port number, repo URL or install command lives HERE and nowhere else — pages quote it, they don't restate it. `install` is the shell snippet that puts the package on the box (lines, in order); `url` is a download/store link where there is no command. Validated by scripts/ci/check-docs-drift.sh (parse + snapshot sync; ponytail: cross-check against code literals when a consumer exists).",
|
||||
|
||||
"ports": {
|
||||
"mgmt": {
|
||||
"port": 47990,
|
||||
"proto": "tcp",
|
||||
"what": "management REST API (HTTPS + token; read-only status/library off loopback)",
|
||||
"env": "PUNKTFUNK_MGMT_BIND",
|
||||
"conflict": "Sunshine / Apollo / Vibeshine serve their web UI on 47990 too — the one port still shared with them once GameStream compat is off. Move it via PUNKTFUNK_MGMT_BIND; clients relearn it from discovery."
|
||||
},
|
||||
"web": {
|
||||
"port": 47992,
|
||||
"proto": "tcp",
|
||||
"what": "web console (HTTPS, login-gated); plugin interfaces on 47993",
|
||||
"also": [47993]
|
||||
},
|
||||
"native": {
|
||||
"port": 9777,
|
||||
"proto": "udp",
|
||||
"what": "punktfunk/1 QUIC control port",
|
||||
"env": "PUNKTFUNK_NATIVE_PORT"
|
||||
},
|
||||
"data": {
|
||||
"port": null,
|
||||
"proto": "udp",
|
||||
"what": "per-session video data plane — ephemeral port the client hole-punches; nothing fixed to open",
|
||||
"env": "PUNKTFUNK_DATA_PORT"
|
||||
},
|
||||
"mdns": {
|
||||
"port": 5353,
|
||||
"proto": "udp",
|
||||
"what": "mDNS discovery"
|
||||
},
|
||||
"gamestream": {
|
||||
"tcp": [47984, 47989, 48010],
|
||||
"udp": [47998, 47999, 48000],
|
||||
"what": "GameStream/Moonlight-compat planes (opt-in, PUNKTFUNK_GAMESTREAM=1)",
|
||||
"conflict": "Sunshine / Apollo / Vibeshine bind these same fixed ports and advertise the same mDNS name — run only one GameStream host at a time, or keep punktfunk native-only."
|
||||
}
|
||||
},
|
||||
|
||||
"firewall": {
|
||||
"$comment": "Service/profile names the Linux packages install for firewalld and ufw — the packages never open a port themselves.",
|
||||
"native": "punktfunk-native",
|
||||
"gamestream": "punktfunk-gamestream",
|
||||
"web": "punktfunk-web"
|
||||
},
|
||||
|
||||
"installer": {
|
||||
"$comment": "The guided Linux installer (WP4, preview). Canonical URL is the website's /install.sh, a redirect to the raw script on main so it's versioned with the code it installs; the docs hub and the download page quote these lines.",
|
||||
"status": "preview",
|
||||
"url": "https://punktfunk.unom.io/install.sh",
|
||||
"source": "https://git.unom.io/unom/punktfunk/raw/branch/main/scripts/install.sh",
|
||||
"oneLiner": "curl -fsSL https://punktfunk.unom.io/install.sh | sh",
|
||||
"inspectFirst": [
|
||||
"curl -fsSLO https://punktfunk.unom.io/install.sh",
|
||||
"less install.sh",
|
||||
"sh install.sh"
|
||||
],
|
||||
"docs": "/docs/install#guided-install-preview"
|
||||
},
|
||||
|
||||
"conflicts": {
|
||||
"hosts": ["Sunshine", "Apollo", "Vibeshine"],
|
||||
"detect": "punktfunk-host detect-conflicts",
|
||||
"detectExit": "1 only when a conflicting host runs or will start on its own; dormant leftovers print but exit 0",
|
||||
"docs": "/docs/switching-from-sunshine"
|
||||
},
|
||||
|
||||
"platforms": [
|
||||
{
|
||||
"id": "debian",
|
||||
"name": "Debian 13+ / Ubuntu 26.04+",
|
||||
"installs": "host",
|
||||
"packageManager": "apt",
|
||||
"docs": "/docs/debian",
|
||||
"repo": "https://git.unom.io/api/packages/unom/debian",
|
||||
"install": [
|
||||
"sudo install -d -m 0755 /etc/apt/keyrings",
|
||||
"curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key | sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null",
|
||||
"echo \"deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main\" | sudo tee /etc/apt/sources.list.d/punktfunk.list",
|
||||
"sudo apt update",
|
||||
"sudo apt install punktfunk-host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arch",
|
||||
"name": "Arch Linux / CachyOS",
|
||||
"installs": "host",
|
||||
"packageManager": "pacman",
|
||||
"docs": "/docs/arch",
|
||||
"repo": "https://git.unom.io/api/packages/unom/arch",
|
||||
"install": [
|
||||
"curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -",
|
||||
"sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69",
|
||||
"grep -q '^\\[punktfunk\\]' /etc/pacman.conf || printf '\\n[punktfunk]\\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\\n' | sudo tee -a /etc/pacman.conf >/dev/null",
|
||||
"sudo pacman -Syu punktfunk-host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fedora",
|
||||
"name": "Fedora 43+",
|
||||
"installs": "host",
|
||||
"packageManager": "dnf",
|
||||
"docs": "/docs/fedora",
|
||||
"repo": "https://git.unom.io/api/packages/unom/rpm/fedora-44",
|
||||
"install": [
|
||||
"sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'",
|
||||
"[punktfunk]",
|
||||
"name=punktfunk",
|
||||
"# fedora-44 on Fedora 44; bazzite on Fedora 43 (a plain Fedora 43 build of the same package)",
|
||||
"baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44",
|
||||
"enabled=1",
|
||||
"gpgcheck=1",
|
||||
"repo_gpgcheck=1",
|
||||
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
|
||||
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
|
||||
"REPO",
|
||||
"sudo dnf install punktfunk"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "bazzite",
|
||||
"name": "Bazzite / Fedora Atomic",
|
||||
"installs": "host",
|
||||
"packageManager": "sysext",
|
||||
"docs": "/docs/bazzite",
|
||||
"install": [
|
||||
"curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh",
|
||||
"sudo bash punktfunk-sysext.sh install"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nixos",
|
||||
"name": "NixOS",
|
||||
"installs": "host",
|
||||
"packageManager": "nix",
|
||||
"docs": "/docs/nixos",
|
||||
"install": [
|
||||
"# flake input: inputs.punktfunk.url = \"git+https://git.unom.io/unom/punktfunk\";",
|
||||
"# then: imports = [ punktfunk.nixosModules.default ]; services.punktfunk.host.enable = true;"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "steamos",
|
||||
"name": "SteamOS (Steam Deck as host)",
|
||||
"installs": "host",
|
||||
"packageManager": "script",
|
||||
"docs": "/docs/steamos-host",
|
||||
"install": [
|
||||
"git clone https://git.unom.io/unom/punktfunk ~/punktfunk",
|
||||
"bash ~/punktfunk/scripts/steamdeck/install.sh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "windows",
|
||||
"name": "Windows 11 (22H2+)",
|
||||
"installs": "host",
|
||||
"packageManager": "winget",
|
||||
"docs": "/docs/windows-host",
|
||||
"install": [
|
||||
"winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest",
|
||||
"winget install unom.PunktfunkHost"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "linux-client",
|
||||
"name": "Linux client (any distro)",
|
||||
"installs": "client",
|
||||
"packageManager": "flatpak",
|
||||
"docs": "/docs/install-client#linux-desktop-flatpak",
|
||||
"install": [
|
||||
"flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "steam-deck-client",
|
||||
"name": "Steam Deck (Gaming Mode)",
|
||||
"installs": "client",
|
||||
"packageManager": "decky",
|
||||
"docs": "/docs/steam-deck"
|
||||
},
|
||||
{
|
||||
"id": "windows-client",
|
||||
"name": "Windows client",
|
||||
"installs": "client",
|
||||
"packageManager": "msix",
|
||||
"docs": "/docs/install-client#windows",
|
||||
"install": [
|
||||
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
|
||||
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "macos-client",
|
||||
"name": "macOS",
|
||||
"installs": "client",
|
||||
"packageManager": "dmg",
|
||||
"docs": "/docs/install-client#macos",
|
||||
"url": "https://git.unom.io/unom/punktfunk/releases"
|
||||
},
|
||||
{
|
||||
"id": "apple-client",
|
||||
"name": "iPhone, iPad, Apple TV",
|
||||
"installs": "client",
|
||||
"packageManager": "testflight",
|
||||
"docs": "/docs/install-client#ios-ipados-apple-tv",
|
||||
"url": "https://testflight.apple.com/join/Qr7uSemk"
|
||||
},
|
||||
{
|
||||
"id": "android-client",
|
||||
"name": "Android / Android TV",
|
||||
"installs": "client",
|
||||
"packageManager": "play",
|
||||
"docs": "/docs/install-client#android",
|
||||
"url": "https://play.google.com/store/apps/details?id=io.unom.punktfunk"
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
-9
@@ -4,7 +4,12 @@ The Punktfunk documentation site: [Fumadocs](https://fumadocs.dev) on
|
||||
[TanStack Start](https://tanstack.com/start) (Vite + Nitro/bun preset).
|
||||
|
||||
Content lives in [`content/docs/`](content/docs) as `.md`/`.mdx`. This site is the source of truth
|
||||
for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo.
|
||||
for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo, and
|
||||
READMEs and the marketing site link here instead of restating anything — see "Where facts live" in
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md). Pages serve one of two audiences, not both at once: the
|
||||
**get-started track** (quickstart, install, pairing) assumes no Linux expertise — short pages, one
|
||||
task each, happy path only; the **reference track** (configuration, CLI, API, per-compositor
|
||||
pages) is allowed to be dense.
|
||||
|
||||
## API reference
|
||||
|
||||
@@ -19,17 +24,24 @@ cargo run -p punktfunk-host -- openapi > api/openapi.json
|
||||
cp api/openapi.json docs-site/public/openapi.json
|
||||
```
|
||||
|
||||
Nothing in CI diffs the two, so the snapshot goes stale silently — that manual `cp` is the only
|
||||
thing keeping them in sync. Before publishing docs, check that they match:
|
||||
CI keeps the pair honest: the `docs-drift` job fails unless the snapshot is a byte-for-byte copy
|
||||
of `api/openapi.json`, and the `rust` job regenerates the spec and diffs it against the committed
|
||||
one — so a management-API change can't publish stale API docs any more, it fails CI until you run
|
||||
the two commands above.
|
||||
|
||||
```bash
|
||||
diff <(jq -S . api/openapi.json) <(jq -S . docs-site/public/openapi.json)
|
||||
## Install commands and ports
|
||||
|
||||
`src/data/platforms.json` is a byte-identical snapshot of the repo-root
|
||||
[`data/platforms.json`](../data/platforms.json) — the single source for install commands, repo
|
||||
URLs, port facts and the Sunshine/Apollo/Vibeshine conflict facts. The `<Install platform="…" />`
|
||||
and `<Ports />` MDX components (`src/components/platforms.tsx`) render from it, so no page restates
|
||||
a command or a port. It's a snapshot for the same reason as `openapi.json` (the Docker build context
|
||||
is this directory alone), and the same `docs-drift` job fails unless it matches:
|
||||
|
||||
```sh
|
||||
cp data/platforms.json docs-site/src/data/platforms.json # from the repo root, after editing the canonical file
|
||||
```
|
||||
|
||||
That should print nothing. Right now it doesn't: the committed snapshot predates the
|
||||
`/api/v1/update/check`, `/api/v1/update/apply` and `/api/v1/update/status` endpoints, so the
|
||||
published `/api` reference is missing the host self-update surface — re-copy it.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
|
||||
@@ -3,15 +3,14 @@ title: Access levels
|
||||
description: What each paired device may do, and for how long — the three presets, the advanced toggles, temporary access that expires on its own, and what access control honestly does not cover.
|
||||
---
|
||||
|
||||
Pairing used to be all-or-nothing: a paired device had full control of the host, forever. **Access**
|
||||
changes that. Every paired device carries an **access level** — what it may send to the host — and
|
||||
optionally an expiry — how long that lasts. A friend's phone can be a second controller for the
|
||||
evening and nothing more; the living-room TV can watch and play but never type into your desktop;
|
||||
a spectator can see and hear without sending anything.
|
||||
Pairing used to be all-or-nothing: a paired device had full control of the host, forever. Now every
|
||||
paired device carries an **access level** — what it may send to the host — and optionally an
|
||||
expiry: a friend's phone as a second controller for the evening, a TV that can play but never type,
|
||||
a spectator who only watches.
|
||||
|
||||
Access is **enforced by the host**. A client's UI reflects its access as a courtesy, but the host
|
||||
drops anything a device isn't granted regardless of what the client sends — nothing a client can
|
||||
send widens its own access.
|
||||
drops anything a device isn't granted regardless of what the client sends — nothing a client sends
|
||||
can widen its own access.
|
||||
|
||||
You manage access from the host's [web console](/docs/web-console): when you
|
||||
[approve a device or arm pairing](/docs/pairing#choosing-access-when-you-admit-a-device), and any
|
||||
@@ -22,16 +21,16 @@ countdown if it expires) and an edit sheet.
|
||||
|
||||
| Access level | What the device can do |
|
||||
|---|---|
|
||||
| **Full control** | Everything — keyboard, mouse, controllers, clipboard, microphone, launching games. This is what pairing has always meant, and it stays the default: every device paired before access levels existed keeps full control, and so does a plain **Approve**. |
|
||||
| **Full control** | Everything — keyboard, mouse, controllers, clipboard, microphone, launching games. What pairing has always meant, and still the default: every device paired before access levels existed keeps full control, and so does a plain **Approve**. |
|
||||
| **Controller only** | Gamepad input only — the guest and co-play preset. The device's pads show up as additional controllers (with rumble and pad audio), but it cannot type, move the mouse, read the clipboard, use the mic, or launch anything. |
|
||||
| **View only** | See and hear the stream, send nothing. The spectator preset. |
|
||||
|
||||
The preset label is derived from the underlying toggles, so a hand-tuned combination simply shows
|
||||
as **Custom** — there is no separate thing to keep in sync.
|
||||
The preset label is derived from the underlying toggles, so a hand-tuned combination shows as
|
||||
**Custom** — there is no separate thing to keep in sync.
|
||||
|
||||
## The advanced toggles
|
||||
|
||||
Each preset is a bundle of six independent grants, exposed under **Advanced** in the edit sheet:
|
||||
Each preset is a bundle of six independent grants, under **Advanced** in the edit sheet:
|
||||
|
||||
| Toggle | Covers |
|
||||
|---|---|
|
||||
@@ -40,13 +39,13 @@ Each preset is a bundle of six independent grants, exposed under **Advanced** in
|
||||
| **Keyboard** | Key presses. |
|
||||
| **Clipboard** | The [shared clipboard](/docs/clipboard). Both switches still apply: the host operator's clipboard policy *and* this grant have to allow it — the grant can only narrow, never widen, what the operator permits. An ungranted device gets a clean "not permitted" instead of a toggle that silently does nothing. |
|
||||
| **Microphone** | Sending the client's microphone to the host. Without it, the session never attaches to the host's mic service at all. |
|
||||
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than being dropped onto the bare desktop. The library remains *visible* — this governs launching, not browsing. |
|
||||
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than dropped onto the bare desktop. The library stays *visible* — this governs launching, not browsing. |
|
||||
|
||||
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs. If
|
||||
you want a guest picking games from the couch, that's one Advanced toggle away.
|
||||
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs.
|
||||
Want a guest picking games? Turn on that one Advanced toggle.
|
||||
|
||||
A session's quality controls — resolution, bitrate, keyframe requests — are *not* governed. They
|
||||
only shape that device's own stream, so restricting them would cost usability and buy no security.
|
||||
only shape that device's own stream; restricting them would cost usability and buy no security.
|
||||
|
||||
## Temporary access
|
||||
|
||||
@@ -54,7 +53,7 @@ Any grant can carry an expiry, picked when you approve the device or set later i
|
||||
**1 h / 4 h / 8 h / custom / forever**.
|
||||
|
||||
- Expiry is **wall-clock time on the host** — "4 hours" means four hours from now by the host's
|
||||
clock, matching the mental model of "until tonight".
|
||||
clock.
|
||||
- A device streaming when its access runs out gets **warnings at 5 minutes and 1 minute** before
|
||||
the deadline, then its session ends with an explicit reason: *"Your access to this host has
|
||||
expired."* Only that device's sessions end — yours is untouched.
|
||||
@@ -65,43 +64,42 @@ Any grant can carry an expiry, picked when you approve the device or set later i
|
||||
extending re-arms the running session's deadline, and Expire now ends it with the same clean
|
||||
"access expired" message — no lingering stream.
|
||||
|
||||
Edits other than expiry are just as immediate: changing a device's access level while it streams
|
||||
takes effect within moments, and removing the device ends its sessions. Access is per *device*,
|
||||
not per session — two sessions from the same device share one grant.
|
||||
Other edits are just as immediate: changing a device's access level while it streams takes effect
|
||||
within moments, and removing the device ends its sessions. Access is per *device*, not per session
|
||||
— two sessions from the same device share one grant.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
Be honest with yourself about three limits before relying on access levels:
|
||||
Three limits before relying on access levels:
|
||||
|
||||
> **A view-only guest still sees your whole desktop.** On the shared-desktop backends every
|
||||
> session shows the *same* desktop — access levels govern what a device can send *in*, not what it
|
||||
> sees going *out*. A view-only or controller-only guest watches and hears everything you do,
|
||||
> notifications included. Don't read email with a spectator attached.
|
||||
> notifications included.
|
||||
|
||||
- **Moonlight / GameStream devices are not governed yet.** Access levels currently apply to the
|
||||
native Punktfunk protocol. A device paired via [Moonlight](/docs/moonlight) has full control and
|
||||
shows an honest **Full (ungoverned)** chip in the console — not a fake editor. When enforcement
|
||||
reaches the GameStream plane, it will be *silent* from the client's side: the GameStream
|
||||
protocol has no way to tell a Moonlight client about its access, so an ungranted keyboard will
|
||||
simply be inert, with the explanation visible only in the console.
|
||||
- **Moonlight / GameStream devices are not governed yet.** Access levels apply to the native
|
||||
Punktfunk protocol. A device paired via [Moonlight](/docs/moonlight) has full control and shows
|
||||
an honest **Full (ungoverned)** chip in the console — not a fake editor. When enforcement reaches
|
||||
the GameStream plane it will be *silent* from the client's side: the protocol has no way to tell
|
||||
a Moonlight client about its access, so an ungranted keyboard will simply be inert, with the
|
||||
explanation visible only in the console.
|
||||
- **Older Punktfunk clients are enforced, but can't explain it.** The host enforces access
|
||||
identically for every client version. A client from before this feature just lacks the chrome:
|
||||
identically for every client version; a client from before this feature just lacks the chrome:
|
||||
no "Controller only · ends in 2 h" chip, no expiry warnings, a generic disconnect instead of
|
||||
"access expired" — and an ungranted keyboard is silently inert rather than never captured in the
|
||||
first place. If a guest reports "my keyboard does nothing", check their access level in the
|
||||
console first, then whether their client is current.
|
||||
"access expired" — and an ungranted keyboard is silently inert rather than never captured. If a
|
||||
guest reports "my keyboard does nothing", check their access level in the console first, then
|
||||
whether their client is current.
|
||||
|
||||
Up-to-date native clients do get the chrome: they stop capturing what can't land (no keyboard grab
|
||||
Up-to-date native clients get the chrome: they stop capturing what can't land (no keyboard grab
|
||||
without the Keyboard grant), hide the clipboard and mic controls when ungranted, show a small
|
||||
overlay chip naming the session's access and time remaining, and surface the expiry warnings as
|
||||
toasts.
|
||||
|
||||
## Where enforcement happens
|
||||
|
||||
For the security-minded: the host checks every input event against the device's grants before
|
||||
injecting it, refuses ungranted planes at session setup (no Gamepad grant means the virtual pads
|
||||
are never created; no Microphone grant means the mic plane never attaches), and re-pairing a
|
||||
device **preserves** its existing access — the only way to widen a grant is the console's own
|
||||
dialogs, behind the console login. Dropped traffic is logged once per session and category, not
|
||||
per event, so a misbehaving client can't flood the log. See [Security & Safe
|
||||
Use](/docs/security) for the wider picture.
|
||||
The host checks every input event against the device's grants before injecting it, refuses
|
||||
ungranted planes at session setup (no Gamepad grant means the virtual pads are never created; no
|
||||
Microphone grant means the mic plane never attaches), and re-pairing a device **preserves** its
|
||||
existing access — the only way to widen a grant is the console's own dialogs, behind the console
|
||||
login. Dropped traffic is logged once per session and category, not per event, so a misbehaving
|
||||
client can't flood the log. See [Security & Safe Use](/docs/security) for the wider picture.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user