Compare commits
82
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 | ||
|
|
9cefa0a3ea | ||
|
|
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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -14,7 +13,6 @@ import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -49,11 +47,8 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
@@ -61,158 +56,34 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Connected-controllers debug view (Settings → Host → Connected controllers): everything the app
|
||||
* can see about attached input devices, plus a live input test. This exists for exactly the support
|
||||
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||
* Connected-controllers debug view (Settings -> Controller -> Connected controllers): everything
|
||||
* the app can see about attached input devices, plus a live input test. This exists for exactly
|
||||
* the support case where a pad "doesn't work" - adapters and BT-to-USB dongles often enumerate
|
||||
* with a different identity than the physical pad, or not as a gamepad at all, and punktfunk only
|
||||
* forwards devices Android classifies as gamepad/joystick. This screen makes that visible on the
|
||||
* device itself.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
|
||||
* field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has
|
||||
* to be the same one whichever interface asked.
|
||||
* The TOUCH presentation, and since 2026-08 the only one: the console reaches the same answer
|
||||
* through its own Skia screen (`crates/pf-console-ui/src/screens/controllers.rs`), which keeps the
|
||||
* console's input on the page instead of suspending it behind a Compose takeover. What this screen
|
||||
* still owns alone is the live input test - the console receives only the aggregated navigation
|
||||
* sample, which is nowhere near a per-device axis/trigger readout. Everything the console DOES
|
||||
* need from here it asks for as a `ConsoleCmd::PadAction` (see [SkiaConsoleShell]), which is why
|
||||
* [padInfoOf] and [testRumble] are internal rather than private.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
observeInput = true,
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same screen on the console's field — the couch route to it, which a TV box has no other way to
|
||||
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
|
||||
*
|
||||
* Navigation, and how the pad is shared with the test:
|
||||
* * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and
|
||||
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
|
||||
* * A starts the input test, which is the one thing on this screen a controller can act on;
|
||||
* * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleControllersScreen(
|
||||
internal fun ControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
// Events are OBSERVED (not consumed) while the test is off, which is what keeps the
|
||||
// "Last input" line live while browsing. Nothing else here wants the pad.
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
// Off while the test runs: both want the same single probe slot, and the test is the one
|
||||
// the user just asked for. The identity check in each teardown (here and in the body) is
|
||||
// what makes the handover safe in either direction.
|
||||
active = navActive && !testing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Nothing on this screen steps sideways; paging is the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { testing = true },
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
|
||||
// and the aurora is ambience. Only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// The body is written against the touch theme; on the console field it has to be inked
|
||||
// from the palette or it is grey-on-pastel over the six pale palettes.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
// proved it is seen — by moving the cursor here.
|
||||
observeInput = testing,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
// Clears the floating legend zone, like every other console list.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Connected controllers", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (testing) {
|
||||
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
|
||||
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
|
||||
// test, after which B is the universal Back again. Tappable as the touch hatch.
|
||||
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
|
||||
} else {
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// Advertised only where they exist — a TV remote has no shoulders, and
|
||||
// claiming otherwise is both a lie and the reason a narrow legend overflows.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('A', "Test inputs") { testing = true },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
|
||||
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
|
||||
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
|
||||
*
|
||||
* [observeInput] decides whether this body installs the shared MainActivity probes at all — see the
|
||||
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControllersBody(
|
||||
gamepadSetting: Int,
|
||||
scroll: ScrollState,
|
||||
testing: Boolean,
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val onTestingChange: (Boolean) -> Unit = { testing = it }
|
||||
val contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp)
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
@@ -247,14 +118,14 @@ private fun ControllersBody(
|
||||
var bHeld by remember { mutableStateOf(false) }
|
||||
// The hold has lasted long enough; the test ends when B is let go (see the probe).
|
||||
var holdSatisfied by remember { mutableStateOf(false) }
|
||||
// The probes below are built ONCE per `observeInput` and then read these for the life of that
|
||||
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
|
||||
// would freeze the values they had when the probe was made — the test would consume nothing.
|
||||
// The probes below are built ONCE and then read these for the life of the screen, so
|
||||
// capturing `testing` plainly would freeze the value it had when the probe was made — the
|
||||
// test would consume nothing.
|
||||
val consuming by rememberUpdatedState(testing)
|
||||
// The console's refusal thud, on whatever actuator the driving pad or this device has.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
|
||||
DisposableEffect(observeInput) {
|
||||
DisposableEffect(Unit) {
|
||||
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
|
||||
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
|
||||
// composed, and only the identity removal keeps this screen's teardown from taking the
|
||||
@@ -317,11 +188,9 @@ private fun ControllersBody(
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
}
|
||||
val probes = if (observeInput) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity?.pushPadProbes(it) }
|
||||
onDispose {
|
||||
probes?.let { activity?.removePadProbes(it) }
|
||||
}
|
||||
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
|
||||
activity?.pushPadProbes(probes)
|
||||
onDispose { activity?.removePadProbes(probes) }
|
||||
}
|
||||
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
|
||||
@@ -346,7 +215,7 @@ private fun ControllersBody(
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
heading()
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
@@ -937,7 +806,8 @@ private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
dev.vibrator.hasVibrator()
|
||||
}
|
||||
|
||||
private fun testRumble(dev: InputDevice) {
|
||||
/** A short pulse on the pad's own motor. Also the console's `PadAction::Rumble`. */
|
||||
internal fun testRumble(dev: InputDevice) {
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vm = dev.vibratorManager
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,7 @@ import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.padInfoOf
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -64,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)
|
||||
@@ -105,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)
|
||||
@@ -128,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)
|
||||
@@ -229,16 +233,25 @@ internal object ConsoleJson {
|
||||
|
||||
/**
|
||||
* `{"label", "pref", "pads": [...]}` — the controller chip's text (the driving pad's name),
|
||||
* the glyph style's pref byte, and one entry per connected pad for the settings rows.
|
||||
* the glyph style's pref byte, and one entry per connected pad for the settings rows and the
|
||||
* console's Connected-controllers screen.
|
||||
*
|
||||
* `detail`/`forwarded`/`rumble` come straight from [padInfoOf], the same reader the touch
|
||||
* Controllers screen renders from: the support answer a user gets must not depend on which
|
||||
* interface asked, and two readers of `InputDevice` would be two answers waiting to drift.
|
||||
*/
|
||||
fun pads(pads: List<InputDevice>, driving: InputDevice?): String {
|
||||
val arr = JSONArray()
|
||||
for (d in pads) {
|
||||
val info = padInfoOf(d)
|
||||
val entry = JSONObject()
|
||||
.put("name", d.name)
|
||||
.put("key", "${d.vendorId}:${d.productId}:${d.name}")
|
||||
.put("pref", Gamepad.prefFor(d))
|
||||
.put("steam_virtual", false)
|
||||
.put("detail", info.detail)
|
||||
.put("forwarded", info.forwarded)
|
||||
.put("rumble", info.canRumble)
|
||||
val battery = if (android.os.Build.VERSION.SDK_INT >= 31) {
|
||||
val b = d.batteryState
|
||||
if (b.isPresent && b.capacity >= 0f) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,6 +106,7 @@ object SkiaConsole {
|
||||
private var onSettingsChange: ((Settings) -> Unit)? = null
|
||||
private var onQuit: (() -> Unit)? = null
|
||||
private var onPlatformScreen: ((String) -> Unit)? = null
|
||||
private var onPadAction: ((String, String) -> Unit)? = null
|
||||
private var onPulse: ((String) -> Unit)? = null
|
||||
|
||||
/** The connect in flight, if any — cancelable through `OverlayAction::CancelConnect`. */
|
||||
@@ -212,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) }
|
||||
@@ -251,12 +262,14 @@ object SkiaConsole {
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
onPlatformScreen: (String) -> Unit,
|
||||
onPadAction: (String, String) -> Unit,
|
||||
onPulse: (String) -> Unit,
|
||||
) {
|
||||
this.onConnected = onConnected
|
||||
this.onSettingsChange = onSettingsChange
|
||||
this.onQuit = onQuit
|
||||
this.onPlatformScreen = onPlatformScreen
|
||||
this.onPadAction = onPadAction
|
||||
this.onPulse = onPulse
|
||||
discovery?.restart()
|
||||
// The touch UI may have paired/forgotten/edited hosts or profiles while we were away.
|
||||
@@ -270,6 +283,7 @@ object SkiaConsole {
|
||||
onSettingsChange = null
|
||||
onQuit = null
|
||||
onPlatformScreen = null
|
||||
onPadAction = null
|
||||
onPulse = null
|
||||
}
|
||||
|
||||
@@ -388,7 +402,7 @@ object SkiaConsole {
|
||||
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
|
||||
}
|
||||
|
||||
private fun notice(text: String) {
|
||||
internal fun notice(text: String) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleNotice(handle, text)
|
||||
}
|
||||
|
||||
@@ -532,13 +546,16 @@ 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 }
|
||||
?.let { onPlatformScreen?.invoke(it) }
|
||||
}
|
||||
@@ -577,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")
|
||||
@@ -586,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")
|
||||
|
||||
+121
-12
@@ -1,5 +1,9 @@
|
||||
package io.unom.punktfunk.console
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
@@ -26,15 +30,20 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import androidx.core.app.ActivityCompat
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
|
||||
import io.unom.punktfunk.MainActivity
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.SettingsStore
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.LibraryReturn
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
import io.unom.punktfunk.rememberConsoleHaptics
|
||||
import io.unom.punktfunk.testRumble
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -44,9 +53,9 @@ import kotlin.math.roundToInt
|
||||
*
|
||||
* What lives here is only what needs a composition: the surface lifecycle, the safe-area insets,
|
||||
* the pad probes (raw pad → the shared menu synthesizer, over JNI), the system Back, the
|
||||
* platform-native sub-screens the console can open (Controllers, Licences — Compose, drawn over the
|
||||
* surface), and the two intents the app hands over on the way in (a deep link, "come back to this
|
||||
* shelf").
|
||||
* platform-native sub-screen the console can open (Licences — Compose, drawn over the surface;
|
||||
* Connected controllers is the console's own Skia screen now), and the two intents the app hands
|
||||
* over on the way in (a deep link, "come back to this shelf").
|
||||
*/
|
||||
@Composable
|
||||
fun SkiaConsoleShell(
|
||||
@@ -74,6 +83,7 @@ fun SkiaConsoleShell(
|
||||
onSettingsChange = { currentOnSettingsChange(it) },
|
||||
onQuit = { activity?.moveTaskToBack(true) },
|
||||
onPlatformScreen = { platformScreen = it },
|
||||
onPadAction = { action, key -> padAction(activity, action, key) },
|
||||
onPulse = { pulse ->
|
||||
when (pulse) {
|
||||
"move" -> haptics.tick()
|
||||
@@ -102,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)
|
||||
@@ -115,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)
|
||||
@@ -267,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
|
||||
@@ -293,16 +317,101 @@ fun SkiaConsoleShell(
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"controllers" -> ConsoleControllersScreen(
|
||||
gamepadSetting = settings.gamepad,
|
||||
onBack = { platformScreen = null },
|
||||
navActive = true,
|
||||
)
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `ConsoleCmd::PadAction` from the console's Connected-controllers screen — the handful of
|
||||
* things only the platform can do: a rumble pulse on the real [InputDevice], the USB/Bluetooth
|
||||
* grant dialogs, the DualSense pad-audio self test. The touch Controllers screen keeps its own
|
||||
* buttons for the same actions; both routes end in the same helpers ([testRumble], the grant
|
||||
* intents, `nativePadAudioSelfTest`), so the support answer cannot drift between interfaces.
|
||||
* Runs on the main thread (the command drain lives there); results ride [SkiaConsole.notice].
|
||||
*/
|
||||
private fun padAction(activity: MainActivity?, action: String, padKey: String) {
|
||||
if (activity == null) return
|
||||
val settings = SettingsStore(activity).load()
|
||||
val usb = activity.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
when (action) {
|
||||
"rumble" ->
|
||||
Gamepad.pads()
|
||||
.firstOrNull { "${it.vendorId}:${it.productId}:${it.name}" == padKey }
|
||||
?.let(::testRumble)
|
||||
"sc2_bluetooth" -> when {
|
||||
!settings.sc2Capture ->
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
Sc2BleLink.permissionGranted(activity) ->
|
||||
SkiaConsole.notice("Bluetooth access is already granted.")
|
||||
// The system dialog pauses the activity; onResume re-probes and engages the capture,
|
||||
// the same way the menu-time auto-ask completes.
|
||||
else -> Sc2BleLink.CONNECT_PERMISSION?.let {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(it), 5)
|
||||
}
|
||||
}
|
||||
"sc2_usb" ->
|
||||
if (!settings.sc2Capture) {
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
} else {
|
||||
// Asks for the USB grant when one is missing and engages the capture on it.
|
||||
activity.startSc2MenuNav(forceAsk = true)
|
||||
}
|
||||
"ds_usb" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
!settings.dsCapture ->
|
||||
SkiaConsole.notice(
|
||||
"Enable \"DualSense / DualShock passthrough (USB)\" in Settings first.",
|
||||
)
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense or DualShock 4 detected.")
|
||||
usb.hasPermission(dev) -> SkiaConsole.notice("USB access is already granted.")
|
||||
else -> usb.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
activity, 3, // requestCode 3 — shared with the touch card's button
|
||||
Intent(DS_USB_PERMISSION_ACTION).setPackage(activity.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
"ds_haptics" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense detected.")
|
||||
DsDevice.modelFor(dev.productId) == DsDevice.Model.DUALSHOCK4 ->
|
||||
SkiaConsole.notice("The DualShock 4 has no haptics audio device.")
|
||||
!usb.hasPermission(dev) -> SkiaConsole.notice("Grant USB access first.")
|
||||
else -> Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be shared with
|
||||
// another transfer engine, and that applies to this test as much as to the
|
||||
// real path (same rule as the touch card's test).
|
||||
val conn = runCatching { usb.openDevice(dev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) NativeBridge.nativePadAudioSelfTest(fd, 3, 60) else -1
|
||||
conn?.close()
|
||||
SkiaConsole.notice(
|
||||
when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 ->
|
||||
"Could not open the pad's audio interface. Some kernels " +
|
||||
"refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
},
|
||||
)
|
||||
}, "pf-pad-selftest-console").start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The raw pad as one `MenuSample`, pushed whenever any part of it changes. */
|
||||
private class PadState {
|
||||
var deviceId = -1
|
||||
|
||||
+3
-16
@@ -127,9 +127,9 @@ class ScreenshotTest {
|
||||
WakeTimedOutScene()
|
||||
}
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
// The licences view — the one screen the console still opens as a Compose takeover. Shot on a
|
||||
// dark AND a pale palette, because the console draws it through a ColorScheme derived from the
|
||||
// palette's ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@@ -137,9 +137,6 @@ class ScreenshotTest {
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
@@ -148,12 +145,6 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The same shelf as the TOUCH grid — the presentation a finger gets from a host card's
|
||||
* "Browse library…". Portrait (the default qualifiers), because that is the orientation a
|
||||
@@ -162,10 +153,6 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun libraryTouch() = shootRoot("library-touch") { TouchLibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -62,7 +62,6 @@ import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
@@ -559,36 +558,24 @@ private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two screens the console could not reach at all until WP8.3 — the open-source notices and the
|
||||
* connected-controllers view — in their console presentation.
|
||||
* The one Compose screen the console still opens over itself — the open-source notices — in its
|
||||
* console presentation. (Connected controllers used to be its sibling here; it is the console's
|
||||
* own Skia screen now, covered by pf-console-ui's tests.)
|
||||
*
|
||||
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
|
||||
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* Worth a shot, and worth a PALE one: it is an ordinary Material screen underneath, and the
|
||||
* console shows it through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Its touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
* The controllers screen with [shotPads] injected — Robolectric enumerates no input devices, and
|
||||
* the connected-pad card is the point of the shot. Wrapped in a background [Surface]: the
|
||||
* activity provides the dark ground in the app, and without one here the content color falls
|
||||
* back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
|
||||
@@ -40,7 +40,4 @@ class TvScreenshotTest {
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -83,6 +83,13 @@ struct PadJson {
|
||||
steam_virtual: bool,
|
||||
#[serde(default)]
|
||||
battery: Option<BatteryJson>,
|
||||
/// `VID:PID · gamepad · dpad` — what the controllers screen prints under the name.
|
||||
#[serde(default)]
|
||||
detail: String,
|
||||
#[serde(default)]
|
||||
forwarded: bool,
|
||||
#[serde(default)]
|
||||
rumble: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -316,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,
|
||||
@@ -334,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,
|
||||
@@ -344,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) {
|
||||
@@ -454,8 +470,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNavi
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetPads(handle, padsJson)` — the connected controllers for the
|
||||
/// chip + settings rows: `{"label": "DualSense", "pref": 1, "pads": [{name, key, pref,
|
||||
/// steam_virtual, battery: {percent, charging} | null}]}`.
|
||||
/// chip, the settings rows and the controllers screen: `{"label": "DualSense", "pref": 1,
|
||||
/// "pads": [{name, key, pref, steam_virtual, battery: {percent, charging} | null, detail,
|
||||
/// forwarded, rumble}]}`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPads(
|
||||
mut env: EnvUnowned,
|
||||
@@ -479,6 +496,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetP
|
||||
percent: b.percent.min(100),
|
||||
charging: b.charging,
|
||||
}),
|
||||
detail: j.detail,
|
||||
forwarded: j.forwarded,
|
||||
rumble: j.rumble,
|
||||
})
|
||||
.collect();
|
||||
h.shared.send(Cmd::Pads {
|
||||
|
||||
@@ -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,
|
||||
@@ -667,9 +669,12 @@ impl ServiceState {
|
||||
r.request();
|
||||
}
|
||||
}
|
||||
// A platform-native screen (Android's Controllers/Licences views) — the desktop
|
||||
// shell has no such rows, so this never arrives here.
|
||||
// A platform-native screen (Android's Licences view) — the desktop shell has no
|
||||
// such row, so this never arrives here.
|
||||
ConsoleCmd::OpenPlatformScreen { .. } => {}
|
||||
// Grants and rumble tests from the controllers screen. Android-only for the same
|
||||
// reason: the settings row that opens that screen is not on the desktop's list.
|
||||
ConsoleCmd::PadAction { .. } => {}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id,
|
||||
@@ -695,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,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())
|
||||
@@ -842,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,
|
||||
|
||||
@@ -1075,6 +1075,14 @@ impl Worker {
|
||||
// Unknowable from an ID-based getter — SDL reports power only for an OPEN
|
||||
// device. `publish` fills it in for the one pad this service holds open.
|
||||
battery: None,
|
||||
// The three below feed the console's controllers screen, which is Android-only
|
||||
// (design android-skia-console-port.md D3) — nothing on the desktop reads them.
|
||||
// SDL enumerates only gamepad-classified devices, so the joystick-only case
|
||||
// `forwarded` exists to name cannot arise here; rumble, like battery, needs the
|
||||
// device OPEN and so is not knowable from this getter.
|
||||
detail: format!("{vid:04X}:{pid:04X}"),
|
||||
forwarded: true,
|
||||
rumble: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -235,6 +235,20 @@ pub struct PadInfo {
|
||||
/// virtual gamepad reports nothing about the physical device behind it. Anything reading
|
||||
/// this must degrade to "no battery shown" rather than to "0 %".
|
||||
pub battery: Option<PadBattery>,
|
||||
/// The identity line the console's controllers screen shows under the name —
|
||||
/// `VID:PID · gamepad · dpad`. Support's first question when a pad "doesn't work" is
|
||||
/// whether the OS enumerated the pad or the adapter in front of it, and the name alone
|
||||
/// never answers that. Written by whoever enumerated the device; empty is "nothing more
|
||||
/// to say", never an error.
|
||||
pub detail: String,
|
||||
/// Actually forwarded to the host: a real, non-virtual controller the OS classifies as a
|
||||
/// GAMEPAD. A joystick-only node — an adapter that enumerates as a bare joystick, a
|
||||
/// DualSense's motion-sensor sibling — is listed and NOT forwarded, which is the single
|
||||
/// most common cause of "my pad is connected and nothing happens".
|
||||
pub forwarded: bool,
|
||||
/// The device reports a rumble motor. `false` is what turns the controllers screen's
|
||||
/// rumble test into the sentence explaining why host rumble will be silent on this pad.
|
||||
pub rumble: bool,
|
||||
}
|
||||
|
||||
/// A controller's power state, as SDL reports it.
|
||||
|
||||
@@ -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,12 +225,36 @@ 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
|
||||
/// while it is up, and the console never learns what it looked like. The desktop raises
|
||||
/// none — its settings list has no such rows.
|
||||
OpenPlatformScreen { id: String },
|
||||
/// Something only the PLATFORM can do to a controller, raised by the controllers screen:
|
||||
/// Android's USB / Bluetooth grant dialogs, a rumble pulse on the real `InputDevice`, the
|
||||
/// DualSense pad-audio self test. `action` is a
|
||||
/// [`crate::screens::controllers::PadAction::id`]; `pad_key` addresses one of
|
||||
/// [`crate::screens::Ctx::pads`] and is empty for the actions that are about a device the
|
||||
/// pad list cannot name (an SC2 in lizard mode is no input device at all).
|
||||
///
|
||||
/// ONE parameterised command rather than one per button: the host's answer to every one
|
||||
/// of them is the same shape — do the platform thing, report back as a notice — and a
|
||||
/// command per grant would make adding the next pad a change in three crates.
|
||||
PadAction { action: String, pad_key: String },
|
||||
}
|
||||
|
||||
/// The overlay→binary command queue. A plain deque under the same locking discipline as
|
||||
@@ -265,6 +294,7 @@ mod tests {
|
||||
online: false,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
clipboard_sync: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Which platform the shell fronts. One shell, two hosts (design
|
||||
//! android-skia-console-port.md D3/D7): the screens are the same everywhere, but not every
|
||||
//! settings row means something on every platform — a decoder picker is a desktop concept,
|
||||
//! low-latency decode an Android one — and only Android has native sub-screens (its
|
||||
//! Controllers and Licenses views) for the settings list to open. Everything platform-shaped
|
||||
//! is decided by asking this enum, so the row tables stay one union and no screen carries a
|
||||
//! `cfg`.
|
||||
//! low-latency decode an Android one — and only Android has a native sub-screen (its
|
||||
//! Licenses view) for the settings list to open. Everything platform-shaped is decided by
|
||||
//! asking this enum, so the row tables stay one union and no screen carries a `cfg`.
|
||||
|
||||
/// The host platform.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -20,9 +19,10 @@ pub enum Platform {
|
||||
/// its own input until the host says the screen closed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PlatformScreen {
|
||||
/// Android's connected-controllers view (USB grant, rumble/haptics tests, DS capture).
|
||||
Controllers,
|
||||
/// The open-source licences view.
|
||||
/// The open-source licences view. The last one: Connected controllers used to be here
|
||||
/// too, and is a shared Skia screen now ([`crate::screens::controllers`]) — the console
|
||||
/// keeps its own input on that page, and only the grant dialogs it cannot draw go back
|
||||
/// to the host, as a [`crate::model::ConsoleCmd::PadAction`].
|
||||
Licenses,
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ impl PlatformScreen {
|
||||
/// The stable id the host matches on (crosses JNI as a string).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PlatformScreen::Controllers => "controllers",
|
||||
PlatformScreen::Licenses => "licenses",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! 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;
|
||||
pub(crate) mod library;
|
||||
pub(crate) mod options;
|
||||
@@ -179,6 +181,14 @@ 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.
|
||||
Controllers(controllers::ControllersScreen),
|
||||
/// The context menu: a subject and the actions that apply to it — a host's Wake / Copy
|
||||
/// link / Edit / Forget, a title's Copy link — raised by [`Outbox::options`]. It still
|
||||
/// carries the host menu's name because [`host_options`] does; both are one rename.
|
||||
@@ -200,6 +210,8 @@ 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),
|
||||
}
|
||||
}
|
||||
@@ -218,6 +230,8 @@ 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),
|
||||
}
|
||||
}
|
||||
@@ -267,6 +281,8 @@ 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(),
|
||||
}
|
||||
}
|
||||
@@ -280,6 +296,8 @@ 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),
|
||||
}
|
||||
}
|
||||
@@ -304,6 +322,8 @@ 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,
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
//! "Connected controllers" — everything the client can see about the attached pads, and the
|
||||
//! handful of actions only the platform can perform on them. Reached from the settings
|
||||
//! list's Controller tab.
|
||||
//!
|
||||
//! This exists for exactly one support case: a pad "doesn't work". Adapters and BT-to-USB
|
||||
//! dongles often enumerate with a different identity than the physical pad, or not as a
|
||||
//! gamepad at all, and only devices the OS classifies as a gamepad are forwarded — so the
|
||||
//! screen's real content is the identity line under each name, not the name.
|
||||
//!
|
||||
//! It was a Compose screen the Android host drew OVER the console (the D7 platform-screen
|
||||
//! mechanism) until 2026-08. Drawing it here instead is what lets the console keep its own
|
||||
//! input on the page; what genuinely cannot move — the USB and Bluetooth grant dialogs, a
|
||||
//! rumble pulse on a real `InputDevice` — stays with the host and is asked for by
|
||||
//! [`ConsoleCmd::PadAction`].
|
||||
//
|
||||
// ponytail: the Compose screen's live input test (button grid + axis bars, entered with A,
|
||||
// left by holding B) did NOT move here — the console only receives the aggregated
|
||||
// `MenuSample` (6 buttons, lx/ly, dpad), nowhere near a per-device axis/trigger readout,
|
||||
// and the hold-to-exit gesture has no home in the edge-triggered MenuEvent grammar. The
|
||||
// touch Controllers screen keeps the full test, so the feature exists on-device; add it
|
||||
// here by widening the pad-sample bridge with a per-device payload while the test is open.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::platform::Platform;
|
||||
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, PadInfo};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Work on a controller that only the HOST can do — every one of these needs a permission
|
||||
/// dialog or a real device handle, neither of which exists on this side of the bridge.
|
||||
/// Ordered as they are listed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum PadAction {
|
||||
/// Pulse the focused pad's motor (the "is rumble even wired up" test).
|
||||
Rumble,
|
||||
/// `BLUETOOTH_CONNECT`, without which a BLE-paired Steam Controller 2 is invisible —
|
||||
/// not "detected and idle", absent, which is why the row is offered rather than hidden
|
||||
/// behind a detection that cannot run.
|
||||
Sc2Bluetooth,
|
||||
/// USB access for a wired or Puck-dongle Steam Controller 2.
|
||||
Sc2Usb,
|
||||
/// USB access for a wired Sony pad (DualSense, Edge, DualShock 4).
|
||||
DsUsb,
|
||||
/// The DualSense pad-audio self test: can this phone drive the pad's audio endpoint at
|
||||
/// all. Deliberately reachable with no stream running — it exists to rule the pad out
|
||||
/// when a session misbehaves, and gating it behind a session would make it depend on
|
||||
/// the very thing under suspicion.
|
||||
DsHaptics,
|
||||
}
|
||||
|
||||
impl PadAction {
|
||||
/// The stable id the host matches on (crosses JNI inside [`ConsoleCmd::PadAction`]).
|
||||
pub(crate) fn id(self) -> &'static str {
|
||||
match self {
|
||||
PadAction::Rumble => "rumble",
|
||||
PadAction::Sc2Bluetooth => "sc2_bluetooth",
|
||||
PadAction::Sc2Usb => "sc2_usb",
|
||||
PadAction::DsUsb => "ds_usb",
|
||||
PadAction::DsHaptics => "ds_haptics",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The passthrough rows, in list order. Platform-gated as one union exactly like the
|
||||
/// settings row table (`settings::row_on`): the desktop captures nothing over raw USB and
|
||||
/// asks for no grants, so it has no such rows — never a control that changes nothing.
|
||||
const PASSTHROUGH: [(PadAction, &str, &str); 4] = [
|
||||
(
|
||||
PadAction::Sc2Bluetooth,
|
||||
"Steam Controller 2 over Bluetooth",
|
||||
"Grant",
|
||||
),
|
||||
(PadAction::Sc2Usb, "Steam Controller 2 over USB", "Grant"),
|
||||
(PadAction::DsUsb, "DualSense / DualShock over USB", "Grant"),
|
||||
(PadAction::DsHaptics, "DualSense haptics self-test", "Test"),
|
||||
];
|
||||
|
||||
/// One line in the list. Pads first, then whatever the platform can be asked to do.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Row {
|
||||
/// An index into [`Ctx::pads`].
|
||||
Pad(usize),
|
||||
/// No pads at all — an inert row, so the list is never empty and the cursor always has
|
||||
/// something to sit on while the passthrough rows below it stay reachable.
|
||||
NoPads,
|
||||
/// An index into [`PASSTHROUGH`].
|
||||
Passthrough(usize),
|
||||
}
|
||||
|
||||
fn rows_for(ctx: &Ctx) -> Vec<Row> {
|
||||
let mut rows: Vec<Row> = if ctx.pads.is_empty() {
|
||||
vec![Row::NoPads]
|
||||
} else {
|
||||
(0..ctx.pads.len()).map(Row::Pad).collect()
|
||||
};
|
||||
if ctx.platform == Platform::Android {
|
||||
rows.extend((0..PASSTHROUGH.len()).map(Row::Passthrough));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub(crate) struct ControllersScreen {
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
impl ControllersScreen {
|
||||
pub(crate) fn new() -> ControllersScreen {
|
||||
ControllersScreen {
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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 rows = rows_for(ctx);
|
||||
let (msg, pulse) = self.list.menu(ev, rows.len());
|
||||
self.activate(msg, pulse, &rows, ctx, fx)
|
||||
}
|
||||
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
let rows = rows_for(ctx);
|
||||
let (msg, pulse) = self.list.pointer(p, rows.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.activate(msg, pulse, &rows, ctx, fx);
|
||||
true
|
||||
}
|
||||
|
||||
/// One list message against the focused row — shared by the pad path and the pointer's,
|
||||
/// so a click and an A press can never drift apart.
|
||||
fn activate(
|
||||
&mut self,
|
||||
msg: ListMsg,
|
||||
pulse: Option<MenuPulse>,
|
||||
rows: &[Row],
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
let Some(&focused) = rows.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// Nothing here steps: every row is a button or a statement.
|
||||
if matches!(msg, ListMsg::Adjust(_)) {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
if !matches!(msg, ListMsg::Activate) {
|
||||
return pulse;
|
||||
}
|
||||
let (action, pad_key) = match focused {
|
||||
Row::NoPads => return Some(MenuPulse::Boundary),
|
||||
Row::Pad(i) => {
|
||||
// A pad with no motor has nothing to test; say so with the thud rather than
|
||||
// sending a command the host would silently drop.
|
||||
if !ctx.pads[i].rumble {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
(PadAction::Rumble, ctx.pads[i].key.clone())
|
||||
}
|
||||
// The grants are about a device the pad list cannot name (an SC2 in lizard mode
|
||||
// is no input device at all), so they carry no key.
|
||||
Row::Passthrough(i) => (PASSTHROUGH[i].0, String::new()),
|
||||
};
|
||||
fx.cmds.push(ConsoleCmd::PadAction {
|
||||
action: action.id().to_string(),
|
||||
pad_key,
|
||||
});
|
||||
pulse
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
let rows = rows_for(ctx);
|
||||
let confirm = match rows.get(self.list.cursor) {
|
||||
Some(Row::Pad(i)) if ctx.pads[*i].rumble => Some("Test rumble"),
|
||||
Some(Row::Passthrough(i)) => Some(match PASSTHROUGH[*i].0 {
|
||||
PadAction::DsHaptics => "Test haptics",
|
||||
_ => "Grant access",
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let mut hints = Vec::new();
|
||||
if let Some(label) = confirm {
|
||||
hints.push(Hint::new(HintKey::Confirm, label));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Back, "Done"));
|
||||
hints
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
// The focused row's explainer takes a reserved band under the list — the settings
|
||||
// screen's shape, and here it is the whole point: the identity of the device is the
|
||||
// support answer, and it is far too long to live on the row.
|
||||
let detail_h = 34.0 * k;
|
||||
let rows = rows_for(ctx);
|
||||
let specs: Vec<RowSpec> = rows.iter().map(|r| spec(*r, ctx)).collect();
|
||||
self.list.render(
|
||||
canvas,
|
||||
Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
),
|
||||
&specs,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
true,
|
||||
);
|
||||
let detail = rows
|
||||
.get(self.list.cursor)
|
||||
.map_or_else(String::new, |r| detail(*r, ctx));
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&detail,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(row: Row, ctx: &Ctx) -> RowSpec {
|
||||
match row {
|
||||
Row::NoPads => RowSpec {
|
||||
header: Some("Gamepads"),
|
||||
..RowSpec::action("No controller detected", false)
|
||||
},
|
||||
Row::Pad(i) => {
|
||||
let pad = &ctx.pads[i];
|
||||
RowSpec {
|
||||
header: (i == 0).then_some("Gamepads"),
|
||||
label: pad.name.clone(),
|
||||
value: Some(
|
||||
if pad.rumble {
|
||||
"Test rumble"
|
||||
} else {
|
||||
"No rumble"
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
value_dim: !pad.rumble,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: pad.rumble,
|
||||
}
|
||||
}
|
||||
Row::Passthrough(i) => {
|
||||
let (_, label, verb) = PASSTHROUGH[i];
|
||||
RowSpec {
|
||||
header: (i == 0).then_some("Passthrough"),
|
||||
label: label.into(),
|
||||
value: Some(verb.into()),
|
||||
value_dim: false,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The band under the list: what this row is, in one sentence.
|
||||
fn detail(row: Row, ctx: &Ctx) -> String {
|
||||
match row {
|
||||
Row::NoPads => "Punktfunk only forwards devices the system classifies as a gamepad or \
|
||||
joystick — a pad behind an adapter or hub may enumerate with the \
|
||||
adapter's identity, or not at all."
|
||||
.into(),
|
||||
Row::Pad(i) => pad_detail(&ctx.pads[i]),
|
||||
Row::Passthrough(i) => match PASSTHROUGH[i].0 {
|
||||
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 => {
|
||||
"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 => {
|
||||
"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 => {
|
||||
"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()
|
||||
}
|
||||
// Not offered as a passthrough row — the pads carry it.
|
||||
PadAction::Rumble => String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A pad's identity line: what the OS enumerated, whether it is forwarded, what the host
|
||||
/// will build for it, and its charge if it reports one.
|
||||
fn pad_detail(pad: &PadInfo) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !pad.detail.is_empty() {
|
||||
parts.push(pad.detail.clone());
|
||||
}
|
||||
if !pad.forwarded {
|
||||
parts.push("not forwarded — not classified as a gamepad".into());
|
||||
}
|
||||
let kind = pad.kind_label();
|
||||
parts.push(format!(
|
||||
"streams as {}",
|
||||
if kind.is_empty() { "Xbox 360" } else { kind }
|
||||
));
|
||||
if let Some(b) = pad.battery {
|
||||
parts.push(if b.charging {
|
||||
format!("battery {} %, charging", b.percent)
|
||||
} else {
|
||||
format!("battery {} %", b.percent)
|
||||
});
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::trust::Settings;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
fn pad(name: &str, rumble: bool) -> PadInfo {
|
||||
PadInfo {
|
||||
name: name.into(),
|
||||
key: format!("054c:0ce6:{name}"),
|
||||
pref: GamepadPref::DualSense,
|
||||
steam_virtual: false,
|
||||
battery: None,
|
||||
detail: "054C:0CE6 · gamepad".into(),
|
||||
forwarded: true,
|
||||
rumble,
|
||||
}
|
||||
}
|
||||
|
||||
fn drive(
|
||||
screen: &mut ControllersScreen,
|
||||
platform: Platform,
|
||||
pads: &[PadInfo],
|
||||
ev: MenuEvent,
|
||||
) -> (Outbox, Option<MenuPulse>) {
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = screen.menu(ev, &mut ctx, &mut fx);
|
||||
(fx, pulse)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_on_a_pad_asks_the_host_for_a_rumble_pulse() {
|
||||
let pads = [pad("DualSense", true)];
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::PadAction {
|
||||
action: "rumble".into(),
|
||||
pad_key: "054c:0ce6:DualSense".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_with_no_motor_thuds_instead_of_sending_a_pulse() {
|
||||
let pads = [pad("Adapter", false)];
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, pulse) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grant_rows_are_androids_alone_and_carry_no_pad_key() {
|
||||
// Desktop: pads and nothing else — it asks for no grants and captures nothing raw.
|
||||
let pads = [pad("DualSense", true)];
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
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::Desktop, &mut settings, &library, &pads)).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
rows_for(&ctx(Platform::Android, &mut settings, &library, &pads)).len(),
|
||||
1 + PASSTHROUGH.len()
|
||||
);
|
||||
|
||||
// Down onto the first grant row, then A.
|
||||
let mut s = ControllersScreen::new();
|
||||
drive(
|
||||
&mut s,
|
||||
Platform::Android,
|
||||
&pads,
|
||||
MenuEvent::Move(pf_client_core::menu_nav::MenuDir::Down),
|
||||
);
|
||||
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::PadAction {
|
||||
action: "sc2_bluetooth".into(),
|
||||
pad_key: String::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_pads_the_list_still_has_the_grants_under_an_inert_row() {
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, pulse) = drive(&mut s, Platform::Android, &[], MenuEvent::Confirm);
|
||||
assert!(fx.cmds.is_empty(), "the empty-state row does nothing");
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
@@ -488,17 +513,27 @@ impl SettingsScreen {
|
||||
ListMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
// The platform's own screens: A asks the host to open one; nothing here edits.
|
||||
RowId::Controllers | RowId::Licenses => {
|
||||
// Connected controllers is one of ours now — a shared Skia screen, so the console
|
||||
// keeps its own input on the page and only the grant dialogs go back to the host.
|
||||
RowId::Controllers => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
fx.push(Screen::Controllers(
|
||||
super::controllers::ControllersScreen::new(),
|
||||
));
|
||||
pulse
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
// The one screen still the platform's: A asks the host to open it; nothing here
|
||||
// edits.
|
||||
RowId::Licenses => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
let screen = if focused == RowId::Controllers {
|
||||
crate::platform::PlatformScreen::Controllers
|
||||
} else {
|
||||
crate::platform::PlatformScreen::Licenses
|
||||
};
|
||||
fx.cmds.push(crate::model::ConsoleCmd::OpenPlatformScreen {
|
||||
id: screen.id().to_string(),
|
||||
id: crate::platform::PlatformScreen::Licenses.id().to_string(),
|
||||
});
|
||||
pulse
|
||||
}
|
||||
@@ -598,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,
|
||||
@@ -722,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 {
|
||||
@@ -851,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()),
|
||||
@@ -939,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. \
|
||||
@@ -1013,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. \
|
||||
@@ -1046,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 \
|
||||
@@ -1249,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)
|
||||
@@ -1549,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();
|
||||
@@ -1861,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 {
|
||||
@@ -2037,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user