Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7e6f71c0 | ||
|
|
9e3fba10c1 | ||
|
|
fd4f032d20 | ||
|
|
892e683f0e | ||
|
|
d7fa5847f1 | ||
|
|
e473a4be7b | ||
|
|
21d9190324 | ||
|
|
7ae8866a5c | ||
|
|
a8099e0f5b | ||
|
|
b4b24f8b57 | ||
|
|
c23fc84bef | ||
|
|
674b16d8eb | ||
|
|
d801cb72f2 | ||
|
|
b03acc9153 | ||
|
|
ace01f06a2 | ||
|
|
e4ec4cec31 | ||
|
|
230d253b06 | ||
|
|
def215ae8e | ||
|
|
dfcc530ee7 | ||
|
|
6b5307618f | ||
|
|
cdacd5636e | ||
|
|
47f01149bf | ||
|
|
59ef285f08 | ||
|
|
5bec450402 | ||
|
|
cd6ce34892 | ||
|
|
20568d988f | ||
|
|
2a60f94f74 | ||
|
|
60406c9d72 | ||
|
|
65f697651e | ||
|
|
a00c4d2a6a | ||
|
|
e12ef6633c | ||
|
|
38a0f54b09 | ||
|
|
c420ae4676 | ||
|
|
bfd0de8973 | ||
|
|
1dd5df0127 | ||
|
|
08c45b96eb | ||
|
|
a52d60e242 | ||
|
|
c2f5e91b3d | ||
|
|
44cb7f7815 | ||
|
|
52b89a1592 | ||
|
|
57446f9ed9 | ||
|
|
a4f6e259e3 | ||
|
|
6d82716598 | ||
|
|
f584eebb92 | ||
|
|
cd0a370229 | ||
|
|
3301f5aa60 | ||
|
|
1f0b12de6d | ||
|
|
3def50a88a | ||
|
|
8f9e451395 | ||
|
|
0d22333831 | ||
|
|
47602f7e59 | ||
|
|
f415c7d090 | ||
|
|
e86c6367e1 | ||
|
|
0fd44d8242 | ||
|
|
6807d7951c | ||
|
|
f50721aedb | ||
|
|
5d3301ed9b | ||
|
|
bd140bd232 | ||
|
|
d161c12680 | ||
|
|
7537e8e6b2 | ||
|
|
2b81bd286f | ||
|
|
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.)
|
||||
@@ -248,7 +248,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
|
||||
+52
-2
@@ -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
|
||||
@@ -326,8 +339,19 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
|
||||
# has the why: this box runs many jobs in parallel and drops packets under that load).
|
||||
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
|
||||
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
|
||||
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
|
||||
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
|
||||
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
|
||||
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
|
||||
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
|
||||
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
|
||||
# 30s is enough to ride out a load burst without making that wait a minute and a half.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: it generates the orval API client + paraglide messages that
|
||||
# typechecking imports.
|
||||
- name: Build
|
||||
@@ -355,8 +379,9 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
# Retried — see the web job above; this is the job the flake was measured on.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -390,3 +415,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 curl git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
|
||||
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
|
||||
# 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
|
||||
|
||||
@@ -231,7 +231,9 @@ jobs:
|
||||
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
|
||||
# neither consumes nor commits, whose only effect here is to make the install depend on
|
||||
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
|
||||
@@ -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,69 @@
|
||||
# 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'
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
! command -v punktfunk-host
|
||||
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
|
||||
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
|
||||
@@ -111,3 +111,20 @@ jobs:
|
||||
name: punktfunk-linux-client-screenshots
|
||||
path: clients/linux/screenshots
|
||||
retention-days: 30
|
||||
|
||||
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
|
||||
# blocked reusing these shots for the docs. Publish them to the generic package registry
|
||||
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
|
||||
# public repo:
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
|
||||
- name: Publish screenshots to the package registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
|
||||
for f in clients/linux/screenshots/*.png; do
|
||||
name=$(basename "$f")
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
|
||||
echo "published $BASE/$name"
|
||||
done
|
||||
|
||||
+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
|
||||
|
||||
@@ -37,15 +37,18 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
|
||||
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
|
||||
# is the worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Build the SDK (file:../sdk dependency source)
|
||||
working-directory: sdk
|
||||
run: |
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
|
||||
@@ -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
|
||||
@@ -175,7 +176,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
|
||||
@@ -221,6 +224,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
|
||||
|
||||
@@ -39,8 +39,11 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
|
||||
# worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -40,8 +40,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
|
||||
# explicitly since build-storybook has no prebuild hook of its own.
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
- name: Generate API client + i18n messages
|
||||
run: bun run codegen
|
||||
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
|
||||
|
||||
@@ -56,8 +56,12 @@
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
|
||||
# from one assembled layout:
|
||||
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
|
||||
# (stable path Steam can launch: overlay + Big Picture work)
|
||||
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
|
||||
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -283,6 +287,28 @@ jobs:
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
|
||||
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
|
||||
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
|
||||
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
|
||||
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
|
||||
- name: Pack + sign installer + portable zip
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-client-installer.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
@@ -301,7 +327,10 @@ jobs:
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
# The installer + portable zip (the default download; docs point at these alias URLs).
|
||||
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
|
||||
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
@@ -324,10 +353,11 @@ jobs:
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
|
||||
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
|
||||
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
|
||||
# don't collide.
|
||||
- name: Attach client artifacts to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -335,6 +365,6 @@ jobs:
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
+703
@@ -12,6 +12,709 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.31.0
|
||||
|
||||
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
|
||||
|
||||
One versioned surface moves, additively: the **C ABI goes 24 → 25**, a single new symbol
|
||||
(`punktfunk_set_log_callback`) that lets an embedder hear the core's own log lines. Nothing else
|
||||
does — `WIRE_VERSION` stays **2**, the driver protocol, gamepad channel and plugin index schema are
|
||||
unchanged, and no `trust::Settings` field, capability bit or control-message type byte was added.
|
||||
No existing C function changed its signature or behaviour and no `#[repr(C)]` struct grew a field,
|
||||
so an embedder that adopts nothing rebuilds against the new header and is done. Every 0.30.x host,
|
||||
client, driver and plugin keeps interoperating in both directions, with no re-pairing.
|
||||
|
||||
Beneath the versioned surfaces, four things are worth a packager's or embedder's attention: the
|
||||
**Windows client's default download changes** to a per-user installer plus a portable zip, with the
|
||||
MSIX kept for the Store; the Linux host package installs **three new system files** (a udev rule, a
|
||||
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
|
||||
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
|
||||
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
|
||||
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 | **25** | one additive step: v25 adds `punktfunk_set_log_callback` and the `PunktfunkLogCb` typedef (below). No existing declaration moved and no struct grew a field. Also new in `punktfunk-core`, Rust-only: three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`) |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| 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.31.0** | **the stamp only** — no management-API surface moved this cycle. The file had been left at 0.29.0 while the crate was already 0.31.0; #337's regenerate-and-diff caught it and it was regenerated, which is a one-line change to both copies. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
|
||||
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
|
||||
|
||||
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`), so their rows record what the registry holds, not
|
||||
what this tag ships. Both were cut during this cycle rather than left owed — a plugin resolves them
|
||||
from the registry, so a fix that never ships there never reaches one.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None that break a build.** No wire change, no driver-protocol change, no plugin-contract change.
|
||||
The C ABI moves 24 → 25 by **addition only**:
|
||||
|
||||
- **v25 — `punktfunk_set_log_callback(max_level, cb, user)`.** The core logs through `tracing`; an
|
||||
embedder that installs no Rust subscriber hears none of it — transport warnings, connection events,
|
||||
handshake notes — and a client log bundle carries the shell's half alone, which is exactly what an
|
||||
Apple TV field report turned out to be. The call registers a `log::Log` backend behind a C callback
|
||||
(`PunktfunkLogCb`: level, target, message, user), gated by `log::set_max_level` so anything above
|
||||
the ceiling costs no formatting; `NULL` detaches, and it answers `Unsupported` when another log
|
||||
backend already owns the process (`android_logger`). Both strings are borrowed for the call only,
|
||||
and an interior NUL drops the line rather than truncating it. `punktfunk-core` now declares
|
||||
`tracing`'s `log` feature explicitly — it had been on transitively via quinn, which an ABI promise
|
||||
must not rest on. An embedder that never calls it is byte-compatible with v24; see
|
||||
`docs/embedding-the-c-abi.md` §2.6.
|
||||
- **One header comment was wrong and is corrected, with no signature change:**
|
||||
`punktfunk_connect_ex10`'s summary still stated the pre-2026-08-16 rule that only a format other
|
||||
than 48000/16 requests the lossless plane. Any non-zero format at all does, 48000/16 included —
|
||||
which is what its own warning already said and what the code always did. Embedders reading the
|
||||
summary were reading the old rule.
|
||||
|
||||
Five more things are worth attention; none breaks a build:
|
||||
|
||||
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
|
||||
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
|
||||
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.
|
||||
- **The Windows client's default download is a per-user installer, not the MSIX.** The MSIX stays,
|
||||
for the Store; the installer and a portable zip are what the download page now offers, and the
|
||||
release carries `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip` alongside it.
|
||||
Anyone scripting against the MSIX asset name is unaffected; anyone scripting against "the Windows
|
||||
client download" gets a different artifact. See the Windows client section.
|
||||
|
||||
### DualSense audio and haptics on Linux: five faults, and the files they needed
|
||||
|
||||
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.
|
||||
- **Colour tagging, which the SurfaceView path never had to do.** MediaCodec tags its own window
|
||||
buffers; with `AImageReader` → `ASurfaceControl` the transaction is the only carrier, and a
|
||||
dataspace of 0 means `setBufferDataSpace` is never called. Two consequences, both fixed inside the
|
||||
cycle: **HDR** was seeded from a hardcoded `BT2020_ITU_PQ` guess and then overwritten by whatever
|
||||
the codec echoed on the first output-format change — a decoder that omits color-transfer (common)
|
||||
echoes None, clobbering the dataspace to 0 before the first present, so P010 buffers composited as
|
||||
sRGB, and an HLG stream was mis-seeded PQ. The initial dataspace now derives from `client.color`
|
||||
(PQ vs HLG, range) and a format change only *refines* it when the codec actually reports an HDR
|
||||
transfer, never resets it — the SurfaceView path's semantics. And **SDR** was untagged entirely:
|
||||
a limited-range BT.709 buffer read as full range shows black (16) as grey, so SDR now maps to
|
||||
`ADATASPACE_BT709` and every ASC buffer is tagged.
|
||||
- **One owner for the system bars.** Console → stream rides an `AnimatedContent` cross-fade, so the
|
||||
outgoing console shell stays composed until the fade ends and its
|
||||
`onDispose { show(systemBars()) }` fired *after* `StreamScreen`'s hide — parking the status and
|
||||
gesture bars over the video for the whole session. Hide/show now lives once in `App.kt`, keyed on
|
||||
the resolved intent (streaming or console fronting = immersive, touch shell = bars back), and both
|
||||
screens' per-screen bar management is deleted.
|
||||
- **Idle gates** (from the console-ui sweep): the reachability sweep only probes while the console is
|
||||
attached, and the render thread drops to half rate after 60 s without input.
|
||||
|
||||
### Hyprland / sway: `topology: exclusive` (closes #284)
|
||||
|
||||
`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: it no longer touches the display manager at all
|
||||
|
||||
This landed in two steps within the cycle, and the second retired the first — read the end state.
|
||||
|
||||
**The storm.** On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin
|
||||
path, so every autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind
|
||||
sessions in 3.7 min, system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy
|
||||
crash-looping ~16 starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per
|
||||
event, read the pad at ~1.4 Hz. Masking without stopping the display manager is not a weaker
|
||||
defence; it is the storm's engine.
|
||||
|
||||
**Then stopping the DM proved wrong too.** With no display manager there is nothing on the box able
|
||||
to start a desktop session, so Steam's own "Switch to Desktop" sat on its modal until a reboot
|
||||
(field report 2026-08-18, `.41`). It could not even be detected and worked around: on a
|
||||
steamos-manager box every trace of that switch is written by the component we had just stopped —
|
||||
the `~/.config/steamos-session-select` sentinel is never written (that is the ChimeraOS/Nobara
|
||||
layout), `/var/lib/sddm/state.conf` only advances when sddm actually *starts* a session,
|
||||
`get-default-login-mode` stays `game` for a non-persistent switch, and `graphical-session.target`
|
||||
going inactive fires at takeover time as well.
|
||||
|
||||
**End state: idle the autologin, leave the display manager alone.** The takeover drops a unit
|
||||
override over the `gamescope-session-plus@` template replacing `ExecStart` with a process that
|
||||
sleeps. The autologin still *succeeds*, so there is no failed unit to relogin against; the session
|
||||
runs nothing, so Steam is free; and the DM is alive, so the box can service its own session switch.
|
||||
No privilege, no DM-flavour matrix, no detection. Measured on `.41` in both directions: takeover
|
||||
leaves `steam` down, `sddm` active, the unit `active (running)` with `NRestarts=0`; the switch that
|
||||
used to hang brings Plasma up in ~10 s; the restore puts Steam back within 5 s. The drop-in lives
|
||||
under `$XDG_RUNTIME_DIR` (a copy outliving the host would be a box whose Game Mode silently does
|
||||
nothing), is swept unconditionally at startup, and its removal sits above every early return in the
|
||||
restore — the desktop-active return is exactly the path that would leak it. The restore *restarts*
|
||||
rather than starts, because `start` on an active-but-idle unit is a no-op that would log success
|
||||
over it.
|
||||
|
||||
With nothing stopping a display manager any more, the whole chain built to survive doing so is
|
||||
deleted: `try_stop_display_manager`, `ensure_host_survives_dm_stop`, `host_is_under_user_manager`,
|
||||
`cgroup_under_user_manager`, `linger_enabled` and `dm_plan`'s mask input — 142 lines out, 17 in.
|
||||
**Two shipped facts became false and are corrected:** the takeover no longer has to stop the display
|
||||
manager, and it no longer needs the `punktfunk` group (the docs and the shipped Bazzite `host.env`
|
||||
both said it did). That group still gates the usbip nodes the virtual Steam Deck pad attaches
|
||||
through, which is what the advice now narrows to. Kept from the first step: `any_live` counts
|
||||
`deactivating` and `reloading` (a unit mid-teardown used to read as a dead leftover, so a box that
|
||||
*is* in gaming mode sampled as idle); `DmHelperError::shape()`; `watch_for_relogin_storm()` (two
|
||||
`read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s, detect-only, and it states that
|
||||
no audio, input or PipeWire measurement taken during a storm is valid); and `systemctl_system`
|
||||
capturing stderr at DEBUG, since that verb is *expected* to fail on an unprivileged host and its
|
||||
"requires interactive authentication" line was going to the journal on the successful path.
|
||||
|
||||
### KWin 6.6 creates our virtual output disabled, and refuses to stream it
|
||||
|
||||
On KWin ≥ 6.6 `streamVirtualOutput` creates the output on the backend and then hands
|
||||
`workspace()->findOutput(output)` to the stream — null for an output the workspace does not manage
|
||||
(`wantsToManage` = `isEnabled() && !isNonDesktop()`). An output KWin creates **disabled** is
|
||||
therefore refused with "Could not find output", translated into the session's language and logged
|
||||
nowhere, because disabling an output is a perfectly valid configuration that applies successfully.
|
||||
6.4/6.5 passed the backend output straight through and streamed it either way. It repeats forever:
|
||||
the host asks for a *stable* per-client output name precisely so KWin persists that client's scale
|
||||
and mode against it, so a stored configuration naming it `enabled: false` is reapplied to every
|
||||
future session for that client — and the user cannot fix it in System Settings, because the output
|
||||
only exists for the few milliseconds the request is alive. The host now enables the output and
|
||||
retries. Related, from the same investigation: a **translated** KWin refusal used to burn all 8
|
||||
retries because the match was against KWin's message rather than our own prefix.
|
||||
|
||||
### Windows client: a per-user installer and a portable zip, because Steam must spawn the exe
|
||||
|
||||
A user report — launching through Big Picture does not work and the Steam overlay never appears —
|
||||
turned out to be nothing to do with the app being UWP (it is full-trust Win32 under MSIX too) and
|
||||
everything to do with the MSIX install **shape**: the exe lives under the ACL'd `WindowsApps`
|
||||
directory that Steam's non-Steam-game picker cannot browse, and alias / `shell:AppsFolder`
|
||||
activation defeats the overlay's injection. Steam has to spawn the exe itself, from a normal path.
|
||||
|
||||
- **`punktfunk-client.iss`** — a per-user Inno Setup install (no UAC) to
|
||||
`%LOCALAPPDATA%\Programs\Punktfunk`, re-creating in `HKCU` what the MSIX manifest granted: the
|
||||
`punktfunk://` scheme, the Start entries, and `{app}` on the user PATH for the `punktfunk` CLI. It
|
||||
fetches the Windows App Runtime when missing.
|
||||
- **`pack-client-installer.ps1`** consumes `pack-msix.ps1`'s layout (one assembly, three artifacts),
|
||||
signs the four exes individually and emits `setup.exe` plus a portable zip — same signing backends
|
||||
and fail-closed-on-tags rule as its siblings, and no `.cer`, because an exe runs untrusted.
|
||||
- **`windows-client.yml`** packs after the MSIX and publishes/attaches the new artifacts;
|
||||
canary/latest aliases are `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip`.
|
||||
- **`deeplink.rs`**: `write_shortcut` targets the app-execution alias only under package identity —
|
||||
an unpackaged install has no alias but does have a stable path, so it targets `current_exe()`.
|
||||
`has_package_identity()` is now shared with `main.rs`'s AppUserModelID probe.
|
||||
- Uninstall is `Settings → Apps → Installed apps` (per-user, no admin prompt) or
|
||||
`unins000.exe /VERYSILENT`; a portable unzip registers nothing and is deleted by hand. Documented
|
||||
in install-client (with a "Launching through Steam" section), channels, clients, uninstall, and
|
||||
both copies of `platforms.json`.
|
||||
|
||||
### Windows host: two session-killers
|
||||
|
||||
- **`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.
|
||||
|
||||
### `scripts/install.sh`: a guided Linux host install (preview)
|
||||
|
||||
Plain POSIX `sh`, dash-clean, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`. Detect the
|
||||
distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows and unknown
|
||||
distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora 45 hit the
|
||||
documented floors with the right docs link) → install using the `data/platforms.json` lines
|
||||
**verbatim** (channel and the Fedora group are edited into the string at run time) → run
|
||||
`punktfunk-host detect-conflicts` (exit 1 = an active Sunshine-family host) → offer to keep both by
|
||||
moving the management API port (`PUNKTFUNK_MGMT_BIND`, default 47991, which the firewall step then
|
||||
opens) → input group (`ujust` on Bazzite) → optional `punktfunk` group, GameStream compat and shared
|
||||
clipboard, all defaulting to no → firewalld/ufw profiles → enable host + console (+ the plugin
|
||||
runner where it is not) → optional linger → verify (unit active, UDP 9777 bound) and print the
|
||||
console URL, the password command and the pairing steps.
|
||||
|
||||
`--dry-run` prints every command and changes nothing; `--uninstall` reverses the install and the
|
||||
service enable per family (user units off first, then only the punktfunk packages actually
|
||||
installed, then the repo — config, groups and firewall stay, as `/docs/uninstall` states). Every
|
||||
prompt has a `PUNKTFUNK_INSTALL_*` environment twin so `--yes` (or no terminal) runs unattended, and
|
||||
stdin is never read, because under `curl | sh` stdin *is* the script. Re-running is safe. The
|
||||
end-of-run check catches the two NVIDIA silent failures on every family — no driver at all, and a
|
||||
module the kernel refused to load under Secure Boot — via an `nvidia-smi` probe pointing at the
|
||||
troubleshooting anchor.
|
||||
|
||||
It is labelled **PREVIEW** on purpose: the per-distro docs pages remain the documented default until
|
||||
it has mileage. CI runs it: a new `installer-smoke.yml` exercises install and `--uninstall` per
|
||||
package family, and `check-docs-drift.sh` gate 7 runs the 16-file os-release detection matrix
|
||||
through the real script under `--dry-run` on every push. One bug fixed by the first smoke run: the
|
||||
`/dev/tty` probe used `-r`/`-w`, which answer yes in a container that has the node but no
|
||||
controlling terminal, so the redirect failed — it opens the device instead now.
|
||||
|
||||
### One home per fact: `data/platforms.json`, and CI gates against drift
|
||||
|
||||
Install commands, repo URLs and port numbers had drifted across four surfaces. They now live in
|
||||
`data/platforms.json` and nowhere else: the docs-site install pages quote it through an
|
||||
`<Install platform="…"/>` MDX component reading a byte-identical snapshot at
|
||||
`docs-site/src/data/platforms.json` (the Docker build context is `docs-site/` alone, the same
|
||||
arrangement `openapi.json` uses), `<Ports/>` renders the port table from it, the website download
|
||||
page vendors it, and `install.sh` runs it. `scripts/ci/check-docs-drift.sh` gates the parse, the
|
||||
snapshot sync, undocumented `PUNKTFUNK_*` knobs (against a checked-in baseline) and the detection
|
||||
matrix; `check-docs-links.sh` covers dead links.
|
||||
|
||||
⚠ **Two consequences for whoever cuts this release.** The website vendors `platforms.json` and only
|
||||
refreshes when someone runs `bun run sync-platforms` in punktfunk-website and commits — the release
|
||||
flow in `docs/releases/README.md` gained that step, and `platforms.json` **did** change this cycle
|
||||
(the Windows client download). And the `.gitea/PULL_REQUEST_TEMPLATE.md` now asks the one question
|
||||
CI cannot: did a user-facing fact change, and is the page that owns it updated in the same PR.
|
||||
|
||||
### Clients can send their logs to the host, on every platform that has a console
|
||||
|
||||
0.30 shipped "Send logs to host" on the Gaming Mode console alone and named the Apple and Android
|
||||
legs as follow-ups. Both landed here.
|
||||
|
||||
- **Apple** — a `ClientLog` drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)` with the
|
||||
same call shape, writing os_log *and* a process-global ring bounded at 4096 lines / 768 KiB (under
|
||||
the host's 1 MiB cap), stamped wall-clock ISO-8601 so a bundle lines up with the host log;
|
||||
`.debug` stays out of the ring, which is the Steam Deck DPB lesson applied in advance. 13 `Logger`
|
||||
declarations swapped. `MgmtTransport`/`MgmtConnection` POST a length-framed body on the same
|
||||
pooled, pinned mTLS connection; `SendLogs.toHost` requires identity and pinned fingerprint, the
|
||||
same gates as the library. Reachable from the host card's context menu and the gamepad host
|
||||
options. Paired with ABI v25 above, the Swift client finally hears the core's own lines too
|
||||
(`core.<crate>`, info ceiling by default, `PUNKTFUNK_CORE_LOG_LEVEL` raises it).
|
||||
- **Android** — `pf-client-core`'s logring RING half (note/render/wallclock, std-only) is
|
||||
Android-enabled, with `send_to_host` still desktop-gated alongside the ureq fetches; `wallclock`
|
||||
moves in from the session's ring layer so every feeder stamps lines identically. `JNI_OnLoad`
|
||||
installs a `RingTee`, so every `log` record goes to logcat **and** into the ring in the desktop
|
||||
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
|
||||
upload rides the client's own mTLS.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
|
||||
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), `PUNKTFUNK_CORE_LOG_LEVEL` (Apple: raises the ABI v25 log
|
||||
sink's ceiling above its info default), the three Apple screenshot-harness hooks above, and nine
|
||||
`PUNKTFUNK_INSTALL_*` twins for `install.sh`'s prompts (`_YES`, `_CHANNEL`, `_GAMESTREAM`,
|
||||
`_CLIPBOARD`, `_PUNKTFUNK_GROUP`, `_LINGER`, `_MGMT_PORT`, `_DRY_RUN`, `_OS_RELEASE`).
|
||||
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
|
||||
- **A Steam Deck never learned a host's wake MAC, so Wake-on-LAN was skipped there in silence.**
|
||||
Every wake gate reads `!host.mac.is_empty()`, and the MAC only ever reached the store through
|
||||
`trust::learn_mac`, whose two callers were the GTK and WinUI hosts pages — neither of which runs
|
||||
in Gaming Mode. Rather than add the missing call twice, the three per-field learners (`learn_mac`,
|
||||
`learn_os`, `learn_mgmt_port`) collapse into one `learn_from_advert`, called wherever an advert
|
||||
meets a saved record: both desktop hosts pages, the console home, and the CLI's `discover`.
|
||||
Remembering one call is not something a front-end can half-do; remembering three is what produced
|
||||
this (#322).
|
||||
- **`HostRow` gains `clipboard_sync`** (`#[serde(default)]`) and `ConsoleCmd` two variants,
|
||||
`BindProfile` and `SetClipboard` — additive and default-tolerant. From the 2026-08-19 console-ui
|
||||
sweep, which also brought touch deferred-tap and drag-to-scroll to the console (a swipe across the
|
||||
settings list used to cycle whatever value it landed on, because `MenuList` presses focus *and*
|
||||
activate), Controller haptics/speaker rows, and two Android idle gates (the reachability sweep
|
||||
only probes while the console is attached, and the render thread halves its rate after 60 s
|
||||
without input).
|
||||
- **Cancelling a connect returns the console immediately.** The takeover could only be dismissed by
|
||||
a session phase coming back from the embedder and nothing guaranteed one would: Android's shell
|
||||
sent no phase at all on the cancelled path, and the desktop shell waited on a pump parked inside
|
||||
the blocking `NativeClient::connect*`, which had no abort — 15 s on a normal dial, **185 s** on a
|
||||
request-access connect the host holds pending approval. The private `connect_*` inner fn takes a
|
||||
trailing `cancel: Option<Arc<AtomicBool>>`; not exported through the C ABI.
|
||||
- **A portable Playnite's covers survive the art confinement.** A Playnite unzipped outside the
|
||||
users base keeps its library beside the exe, so every cover it exports sits outside every default
|
||||
art root: the games synced and all **70** covers were dropped, with `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
the only way out. The Playnite install dirs are art roots now, exactly as Steam's install root
|
||||
already was, and `playnite_install_dirs` learned to find a portable copy at all — it registers no
|
||||
uninstall entry and sits under no profile, but it does register the `playnite://` handler, which
|
||||
is the very registration the launch path already follows. So a portable install also gets its
|
||||
Fullscreen launcher tile, which it never had. The confinement is not loosened: roots come from the
|
||||
host's own registry and filesystem probes, never from the plugin lane that supplies the art path.
|
||||
Paired with the plugin-kit fix below, a fixed host no longer needs a cache file deleted.
|
||||
- **`plugin-kit`: `startup` and `manual` sync reasons always publish.** The fingerprint says the
|
||||
plugin would compute the same entries again; it does *not* say the host still holds them — and the
|
||||
host may accept a payload and store less of it (an art path outside its roots is stripped and the
|
||||
games kept, deliberately, because a cover must not cost a library). Once that happened the
|
||||
fingerprint was a permanent "no changes", and the only way out was deleting the plugin's cache
|
||||
file, which is exactly the advice a portable-Playnite library with 70 dropped covers was given.
|
||||
The two triggers with a person behind them now always apply.
|
||||
- **Nix:** nixpkgs bumped because its gamescope 3.16.24 no longer took our patch 0009 (the publish
|
||||
tier was red on every build); `enableWsi` is a nixpkgs *function argument* defaulting to false, so
|
||||
the plain derivation shipped a compositor with **no WSI layer at all** and nothing under it could
|
||||
obtain an HDR10 swapchain — our own postInstall assertion caught it. Also: the prune makes `$out`
|
||||
writable first (reshade installs read-only), the bun builds are serialised and the OOM is measured
|
||||
against the real 7 GiB cgroup cap rather than guessed at, and a dispatch opt-in compared against
|
||||
the string `"true"` silently skipped when the API delivered a real JSON boolean — the step was
|
||||
skipped and the job still reported success.
|
||||
- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules`
|
||||
(+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix),
|
||||
`scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs.
|
||||
- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the
|
||||
five triage labels; single-context domain docs). A host audio-source comment corrected
|
||||
(`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission).
|
||||
- **CI:** the Nix publish job records `df` after the build as well as before; the
|
||||
`linux-client-screenshots` run publishes its PNGs to the generic package registry as well as the
|
||||
v3 artifact store (which is browser-only, so nothing could reuse the shots for the docs — that is
|
||||
how the get-started track got its fifth screenshot, a client's host list); and **every Linux
|
||||
`bun install` is now wrapped in `scripts/ci/retry.sh`**. That last one is a real failure, not
|
||||
tidying: `bun install` streams download-and-extract, so a tarball truncated by the runner's
|
||||
packet loss under parallel load surfaces as `error: Fail extracting tarball for "<pkg>"` and
|
||||
names a package that is perfectly intact — measured on run 19630, where docs-site died on
|
||||
`@rolldown/binding-linux-x64-gnu` while the web job installed the same registry in the same run
|
||||
and run 19632 installed the identical lockfile seven minutes later. The tarball's sha512 matches
|
||||
the lockfile and bun 1.3.13 and 1.3.14 both extract it from disk, so neither the package nor the
|
||||
floating `oven/bun:1` bump was ever at fault. `retry.sh`'s header had already diagnosed this
|
||||
class and said to wrap every single-shot network command; `bun install` was the one still
|
||||
unwrapped. Three attempts rather than the usual five, so a genuinely stale lockfile still fails
|
||||
fast under `--frozen-lockfile`.
|
||||
- **The web console's Virtual displays page** put the Streamed-screen and session-lifetime cards
|
||||
below the tab shell, so both rendered on both tabs; they are policy surfaces and now sit inside
|
||||
the Configuration tab, leaving the Live tab as the live list plus arrangement.
|
||||
|
||||
### Verification status
|
||||
|
||||
Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`):
|
||||
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
|
||||
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the android.yml
|
||||
Play notes gate run verbatim — 456/500 characters and not byte-identical to any prior release's;
|
||||
both openapi copies `cmp` identical, both stamped 0.31.0; notes voice scan clean outside the
|
||||
For developers section.
|
||||
|
||||
⚠ **This release was cut more than once.** The first cut (`601f040f`, merged as #320) was never
|
||||
tagged, and 41 more non-merge commits landed on top of it — the Windows client installer, the
|
||||
guided Linux installer, the docs overhaul, ABI v25, the KWin 6.6 repair and the takeover's final
|
||||
shape among them; a handful more (the Virtual displays tab fix, the fifth get-started screenshot)
|
||||
arrived while the second cut was being written. This section, the version table and the notes are
|
||||
all re-measured on the latest tip; where the cuts disagreed, the earlier text was **rewritten
|
||||
rather than appended to**, because none of
|
||||
the intervening work ever shipped. Specifically: the "C ABI unchanged / header byte-identical"
|
||||
claim is gone (it is 25 now), the openapi row moved off 0.29.0, the SDK and plugin-kit rows record
|
||||
cuts that have happened rather than cuts that were owed, and the Gaming Mode takeover section
|
||||
describes idling the autologin rather than stopping the display manager — a within-cycle correction
|
||||
no user could have seen.
|
||||
|
||||
⚠ **The C ABI harness (`tests/c_abi.rs`) did not run on this cut**, and this time the header *did*
|
||||
change: it links the staticlib with `-lopus` and this machine has no libopus (`ld: library 'opus'
|
||||
not found`), which is an environment gap, not a code fault. `punktfunk_set_log_callback` is
|
||||
therefore compiled by cbindgen and by the Rust unit tests here, but the generated header has not
|
||||
been compiled by a C compiler on this cut — the CI runner is its first. Worth naming because ABI 25
|
||||
is the one versioned surface that moved.
|
||||
|
||||
⚠ **Verified by reading only** — compiled nowhere available to the cutting host: the Windows client
|
||||
installer and portable zip (`punktfunk-client.iss`, `pack-client-installer.ps1` — the pack step is a
|
||||
Windows runner's), the Windows runner log redirect (`scripting-run.cmd`), the tray's `Option<u16>`
|
||||
port on Windows, and the sway half of `topology: exclusive` (no live sway in the fleet, as with
|
||||
#283).
|
||||
|
||||
⚠ **Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink
|
||||
capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min
|
||||
of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed
|
||||
when it landed; the 96 kbps speaker lane was judged on glass by ear only; the Android
|
||||
`ASurfaceControl` path was verified on one device (Nothing Phone 3), with the fallback presenter
|
||||
byte-for-byte the 0.30 one; the Mac Accessibility intercept (the tap ahead of Spotlight, inside the
|
||||
sandbox) needs a granted Accessibility switch the dev machine does not have; and `install.sh` is
|
||||
smoke-tested per package family in CI containers but is shipped **preview** precisely because it has
|
||||
no real-box mileage, Bazzite above all.
|
||||
|
||||
⚠ **Owed outside this repository:** `data/platforms.json` changed this cycle (the Windows client
|
||||
download), and the website's download page vendors a copy that only refreshes when someone runs
|
||||
`bun run sync-platforms` in punktfunk-website and commits — step 1 of `docs/releases/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
+18
-5
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
// `read`, not `load`: this verb never hands a record's id back, so it has no business
|
||||
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
|
||||
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
|
||||
// both together) — after which the ids one of them already handed out no longer resolve.
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
// The one write this verb does make, and why it doesn't contradict the above: an advert
|
||||
// is the only place a host's wake MAC is ever published, and this verb is the only one
|
||||
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
|
||||
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
|
||||
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
|
||||
// taught the record something new — so a steady-state panel refresh touches no disk.
|
||||
for (d, saved) in &rows {
|
||||
if let Some(k) = saved {
|
||||
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
|
||||
}
|
||||
}
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
|
||||
};
|
||||
let host = &known.hosts[i];
|
||||
if host.mac.is_empty() {
|
||||
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
|
||||
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
|
||||
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
|
||||
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if !has(args, "--wait") {
|
||||
|
||||
@@ -1087,33 +1087,20 @@ impl HostsPage {
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
|
||||
let online = self.adverts.values().any(|a| matches(k, a))
|
||||
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.mac.is_empty())
|
||||
{
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the icon then survives the host going offline.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.os.is_empty())
|
||||
{
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — and this one is not cosmetic: without it a host
|
||||
// that moved off 47990 loses its library the moment mDNS is unavailable, because
|
||||
// the advert was the only place the real port ever lived.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && a.mgmt_port.is_some())
|
||||
{
|
||||
if let Some(p) = a.mgmt_port {
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
|
||||
// its OS chain (so the icon survives it going offline), and its management port
|
||||
// — the last one not cosmetic, since a host that moved off 47990 loses its
|
||||
// library the moment mDNS is unavailable and the advert is the only place the
|
||||
// real port ever lived.
|
||||
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
saved.push_back(HostCard {
|
||||
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,11 +800,21 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
// Write the advertised mgmt port down while the host is visible, so this console
|
||||
// keeps working against a moved port once it is not. No-op (and no disk write)
|
||||
// Write down everything the advert teaches while the host is visible: the mgmt
|
||||
// port (so this console keeps working against a moved one once it is not), the
|
||||
// OS chain, and the wake MAC — which matters most here, because this console and
|
||||
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
|
||||
// record that never learned a MAC can never be woken. No-op (and no disk write)
|
||||
// when unchanged, so this is safe on every refresh tick.
|
||||
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
|
||||
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
|
||||
if let Some(a) = advert {
|
||||
pf_client_core::trust::learn_from_advert(
|
||||
&h.fp_hex,
|
||||
&h.addr,
|
||||
h.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
@@ -784,6 +833,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 +892,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::*;
|
||||
|
||||
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
|
||||
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
|
||||
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
|
||||
|
||||
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
|
||||
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
|
||||
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
|
||||
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
|
||||
compatibility).
|
||||
|
||||
## Get it
|
||||
|
||||
Install the signed MSIX from the package registry — see
|
||||
Install the signed installer from the package registry — see
|
||||
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
|
||||
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
|
||||
|
||||
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
|
||||
```
|
||||
|
||||
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
|
||||
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
|
||||
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -79,7 +82,7 @@ src/
|
||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
||||
probe.rs · wol.rs speed probe · Wake-on-LAN
|
||||
logfile.rs log tee to %LOCALAPPDATA%
|
||||
packaging/ MSIX manifest, signing, pack script
|
||||
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
# punktfunk Windows client — MSIX packaging
|
||||
# punktfunk Windows client — packaging
|
||||
|
||||
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
|
||||
The Windows client ships **three ways, packed from one assembled layout** by CI
|
||||
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels):
|
||||
|
||||
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
|
||||
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
|
||||
install shape breaks the top user-reported flows: the exe lands under the ACL'd
|
||||
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
|
||||
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
|
||||
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
|
||||
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
|
||||
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
|
||||
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
|
||||
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
|
||||
nothing registered.
|
||||
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
|
||||
compatibility. Everything below the fold documents this path.
|
||||
|
||||
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
|
||||
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
|
||||
signs its container).
|
||||
|
||||
# MSIX packaging
|
||||
|
||||
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
|
||||
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
|
||||
portable .zip, from the layout pack-msix.ps1 already assembled.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim — one assembly,
|
||||
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
|
||||
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
|
||||
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
|
||||
|
||||
Steps:
|
||||
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
|
||||
2. sign the four exes individually (the MSIX only signs its container),
|
||||
3. zip the stage -> the portable build,
|
||||
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
|
||||
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
|
||||
|
||||
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
|
||||
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
|
||||
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust — an untrusted
|
||||
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
|
||||
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
|
||||
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
|
||||
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
|
||||
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
|
||||
[switch]$NoSign # skip signing (local debug)
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
|
||||
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
|
||||
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
|
||||
}
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$iss = Join-Path $here 'punktfunk-client.iss'
|
||||
|
||||
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
|
||||
function Find-Iscc {
|
||||
foreach ($p in @(
|
||||
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
|
||||
'C:\Program Files\Inno Setup 6\ISCC.exe')) {
|
||||
if (Test-Path $p) { return $p }
|
||||
}
|
||||
$c = Get-Command iscc -ErrorAction SilentlyContinue
|
||||
if ($c) { return $c.Source }
|
||||
throw "ISCC.exe (Inno Setup 6, any 6.x) not found - install it (choco install innosetup -y)."
|
||||
}
|
||||
function Find-SdkTool([string]$name) {
|
||||
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
|
||||
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
|
||||
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
|
||||
Select-Object -Last 1
|
||||
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$iscc = Find-Iscc
|
||||
Write-Host "ISCC: $iscc"
|
||||
|
||||
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
|
||||
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
|
||||
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
|
||||
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
$stage = Join-Path $OutDir 'portable'
|
||||
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
foreach ($f in $required) {
|
||||
$src = Join-Path $LayoutDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $src (Join-Path $stage $f) -Force
|
||||
}
|
||||
$licSrc = Join-Path $LayoutDir 'licenses'
|
||||
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
|
||||
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
|
||||
|
||||
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
|
||||
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
if ($NoSign -and $requireCert) {
|
||||
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
|
||||
}
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'none'
|
||||
$signtool = $null
|
||||
if (-not $NoSign) {
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "signtool: $signtool"
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to " +
|
||||
"an interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
}
|
||||
elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
}
|
||||
else {
|
||||
$signMode = 'selfsigned'
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
|
||||
-KeyUsage DigitalSignature -FriendlyName 'punktfunk client installer (self-signed)' `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
|
||||
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
|
||||
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
|
||||
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
|
||||
function Sign-File([string]$Path) {
|
||||
if ($NoSign) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
|
||||
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
|
||||
"untrusted within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- sign the inner exes, zip the stage (portable build), then build + sign the installer ------
|
||||
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
|
||||
Sign-File (Join-Path $stage $f)
|
||||
}
|
||||
|
||||
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
|
||||
Write-Host "==> portable zip: $zip"
|
||||
|
||||
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
|
||||
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
|
||||
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
|
||||
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
|
||||
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
|
||||
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
|
||||
$brandStage = Join-Path $OutDir 'branding'
|
||||
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
|
||||
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
|
||||
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
|
||||
|
||||
$defines = @(
|
||||
"/DMyAppVersion=$Version",
|
||||
"/DArch=$Arch",
|
||||
"/DLayoutDir=$stage",
|
||||
"/DBrandingDir=$brandStage",
|
||||
"/DOutputDir=$OutDir"
|
||||
)
|
||||
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
|
||||
& $iscc @defines $issLocal
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
|
||||
|
||||
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
|
||||
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
|
||||
Sign-File $setup
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> installer: $setup"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA."
|
||||
}
|
||||
elseif ($signMode -ne 'none') {
|
||||
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
|
||||
}
|
||||
if ($env:GITHUB_ENV) {
|
||||
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
|
||||
;
|
||||
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
|
||||
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
|
||||
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
|
||||
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
|
||||
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
|
||||
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
|
||||
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
|
||||
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
|
||||
;
|
||||
; Built by pack-client-installer.ps1, e.g.:
|
||||
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
|
||||
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
|
||||
;
|
||||
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
|
||||
; elevation and uninstall leaves nothing behind):
|
||||
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
|
||||
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
|
||||
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
|
||||
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
|
||||
; Microsoft.WindowsAppRuntime.2 PackageDependency
|
||||
; -> download + run the runtime installer when missing ([Code])
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Arch
|
||||
#define Arch "x64"
|
||||
#endif
|
||||
#ifndef LayoutDir
|
||||
#define LayoutDir "."
|
||||
#endif
|
||||
#ifndef BrandingDir
|
||||
#define BrandingDir "..\..\..\packaging\windows\branding"
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
|
||||
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
|
||||
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
|
||||
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
|
||||
AppName=Punktfunk
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher=unom
|
||||
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
||||
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
|
||||
; see the header (Steam overlay / Big Picture).
|
||||
DefaultDirName={userpf}\Punktfunk
|
||||
PrivilegesRequired=lowest
|
||||
DisableProgramGroupPage=yes
|
||||
UsePreviousAppDir=yes
|
||||
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
|
||||
MinVersion=10.0.17763
|
||||
#if Arch == "arm64"
|
||||
ArchitecturesAllowed=arm64
|
||||
ArchitecturesInstallIn64BitMode=arm64
|
||||
#else
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
#endif
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
|
||||
#if VER >= EncodeVer(6,6,0)
|
||||
WizardStyle=modern dynamic windows11
|
||||
#else
|
||||
WizardStyle=modern
|
||||
#endif
|
||||
SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||
UninstallDisplayName=Punktfunk {#MyAppVersion}
|
||||
UninstallDisplayIcon={app}\punktfunk-client.exe
|
||||
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
|
||||
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
|
||||
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
|
||||
ChangesEnvironment=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
|
||||
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
|
||||
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
|
||||
; skipped by "the MSIX build already signed them".
|
||||
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
|
||||
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
|
||||
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
|
||||
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
|
||||
Comment: "Controller-driven couch interface for TVs and HTPCs"
|
||||
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
|
||||
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
|
||||
; command line, so this lands in the same positional URL parse in main() that the packaged
|
||||
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
|
||||
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
|
||||
ValueData: "{app}\punktfunk-client.exe,0"
|
||||
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
|
||||
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
|
||||
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
|
||||
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
|
||||
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
|
||||
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
|
||||
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
|
||||
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
|
||||
|
||||
[Code]
|
||||
const
|
||||
EnvKey = 'Environment'; { the HKCU per-user environment key }
|
||||
|
||||
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
|
||||
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
|
||||
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
|
||||
retargeted from the HKLM machine key to HKCU.) }
|
||||
function PathNeedsAdd(Param: String): Boolean;
|
||||
var
|
||||
OrigPath: String;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
begin
|
||||
Result := True; { no Path value at all - the append creates it }
|
||||
exit;
|
||||
end;
|
||||
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
|
||||
end;
|
||||
|
||||
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
|
||||
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
|
||||
procedure RemoveAppFromPath;
|
||||
var
|
||||
OrigPath, NewPath, Entry: String;
|
||||
Target: String;
|
||||
P: Integer;
|
||||
begin
|
||||
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
|
||||
exit;
|
||||
Target := Uppercase(ExpandConstant('{app}'));
|
||||
NewPath := '';
|
||||
OrigPath := OrigPath + ';';
|
||||
repeat
|
||||
P := Pos(';', OrigPath);
|
||||
Entry := Trim(Copy(OrigPath, 1, P - 1));
|
||||
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
|
||||
if (Entry <> '') and (Uppercase(Entry) <> Target) then
|
||||
begin
|
||||
if NewPath <> '' then NewPath := NewPath + ';';
|
||||
NewPath := NewPath + Entry;
|
||||
end;
|
||||
until OrigPath = '';
|
||||
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
RemoveAppFromPath;
|
||||
end;
|
||||
|
||||
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
|
||||
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
|
||||
runtime installer and run it quietly — it registers Store-signed framework packages, which
|
||||
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
|
||||
carry, because the app itself reports the missing runtime on first launch too. }
|
||||
function AppRuntimeMissing(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
|
||||
download below is idempotent and the runtime installer no-ops when it is present. }
|
||||
if not Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
Result := ResultCode <> 0;
|
||||
end;
|
||||
|
||||
procedure EnsureAppRuntime;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
Installer: String;
|
||||
begin
|
||||
if not AppRuntimeMissing() then
|
||||
exit;
|
||||
Installer := 'windowsappruntimeinstall.exe';
|
||||
try
|
||||
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
|
||||
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
|
||||
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
|
||||
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
|
||||
except
|
||||
SuppressibleMsgBox(
|
||||
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
|
||||
'Punktfunk needs it to start. Install it from ' + #13#10 +
|
||||
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
|
||||
'and then launch Punktfunk normally.',
|
||||
mbInformation, MB_OK, IDOK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
|
||||
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
|
||||
if CurStep = ssInstall then
|
||||
Exec(ExpandConstant('{sys}\taskkill.exe'),
|
||||
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
|
||||
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
|
||||
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
|
||||
if CurStep = ssPostInstall then
|
||||
EnsureAppRuntime;
|
||||
end;
|
||||
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.iter()
|
||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||
// it once it sleeps (no-op / no disk write when unchanged).
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& !h.mac.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the tile's mark then survives the host going offline.
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — load-bearing, unlike the two above: a host moved off
|
||||
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
|
||||
if let Some(p) = hosts
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
|
||||
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
|
||||
// offline), and its management port — the last load-bearing rather than cosmetic, as
|
||||
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
|
||||
// the port down. No-op, and no disk write, when unchanged.
|
||||
if let Some(a) = hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& h.mgmt_port.is_some()
|
||||
})
|
||||
.and_then(|h| h.mgmt_port)
|
||||
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
{
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
|
||||
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
|
||||
INBOX.lock().unwrap().push(url);
|
||||
}
|
||||
|
||||
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
|
||||
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
|
||||
/// (`set_app_user_model_id` in main.rs).
|
||||
pub(crate) fn has_package_identity() -> bool {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
|
||||
// PROBE — it writes nothing and only reports whether this process is packaged.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
|
||||
///
|
||||
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
|
||||
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
|
||||
/// still works if it isn't, because it invokes the client directly — which is the whole point
|
||||
/// of a shortcut being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
|
||||
/// it valid across updates, since the install path changes and the alias doesn't.
|
||||
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
|
||||
/// internet shortcut. Both would work while the scheme is registered; only this one still works
|
||||
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
|
||||
/// being a container for a URL rather than a second launch mechanism
|
||||
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
|
||||
/// installed: under MSIX the install path changes on every update but the app execution alias
|
||||
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
|
||||
/// but a stable install dir, so unpackaged runs target the absolute exe path.
|
||||
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
|
||||
use windows::core::{Interface, HSTRING};
|
||||
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
|
||||
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
|
||||
.map_err(|_| "USERPROFILE isn't set".to_string())?;
|
||||
let path = desktop.join(format!("{}.lnk", file_name(label)));
|
||||
// Alias when packaged, absolute path when not — see the doc comment above.
|
||||
let target = if has_package_identity() {
|
||||
"punktfunk-client.exe".to_string()
|
||||
} else {
|
||||
std::env::current_exe()
|
||||
.map_err(|e| format!("current exe: {e}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
|
||||
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
|
||||
// synchronous call; nothing here dereferences a pointer the caller supplied.
|
||||
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
|
||||
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| format!("shell link: {e}"))?;
|
||||
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
|
||||
link.SetPath(&HSTRING::from(target.as_str()))
|
||||
.ok()
|
||||
.map_err(|e| format!("shortcut target: {e}"))?;
|
||||
link.SetArguments(&HSTRING::from(url))
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
|
||||
@@ -173,18 +173,12 @@ fn main() {
|
||||
/// processes are left alone. Must run before any window exists.
|
||||
#[cfg(windows)]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::Win32::appmodel::GetCurrentPackageFullName;
|
||||
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
|
||||
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
|
||||
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
|
||||
// documented identity PROBE — it writes nothing and only reports whether this process is
|
||||
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
if deeplink::has_package_identity() {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
|
||||
unsafe {
|
||||
let mut len: u32 = 0;
|
||||
// No buffer: just probe whether the process has package identity.
|
||||
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
|
||||
return; // packaged (or indeterminate) — leave the identity alone
|
||||
}
|
||||
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
|
||||
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
|
||||
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
|
||||
/// data the live host advertised, purely because it came earlier in the file.
|
||||
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
|
||||
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
|
||||
/// hand a stale record at the same address the data the live host advertised, purely because it
|
||||
/// came earlier in the file.
|
||||
fn learn_target<'a>(
|
||||
known: &'a mut KnownHosts,
|
||||
fp_hex: &str,
|
||||
@@ -692,61 +692,62 @@ fn learn_target<'a>(
|
||||
known.hosts.get_mut(i)
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
if mac.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
return;
|
||||
}
|
||||
h.mac = mac.to_vec();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched
|
||||
/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
if os.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.os == os {
|
||||
return;
|
||||
}
|
||||
h.os = os.to_string();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
|
||||
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
|
||||
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
|
||||
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
|
||||
/// management port — and report whether anything actually moved, so the caller writes only when
|
||||
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
|
||||
///
|
||||
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
|
||||
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
|
||||
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
|
||||
if mgmt_port == 0 {
|
||||
return;
|
||||
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
|
||||
/// TXT, and forgetting a MAC already learned would cost the user their wake.
|
||||
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
|
||||
let mut changed = false;
|
||||
if !mac.is_empty() && h.mac != mac {
|
||||
h.mac = mac.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
if !os.is_empty() && h.os != os {
|
||||
h.os = os.to_string();
|
||||
changed = true;
|
||||
}
|
||||
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
|
||||
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
|
||||
h.mgmt_port = mgmt_port;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
|
||||
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
|
||||
/// the record already says all three, so a surface can call this on every discovery tick.
|
||||
///
|
||||
/// ONE call rather than three. Each field used to be learned by its own function, which meant
|
||||
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
|
||||
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
|
||||
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
|
||||
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
|
||||
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
|
||||
///
|
||||
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
|
||||
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
|
||||
/// a MAC is no reason to become one.
|
||||
///
|
||||
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
|
||||
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
|
||||
pub fn learn_from_advert(
|
||||
fp_hex: &str,
|
||||
addr: &str,
|
||||
port: u16,
|
||||
mac: &[String],
|
||||
os: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let mut known = KnownHosts::read();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
if apply_advert(h, mac, os, mgmt_port) {
|
||||
let _ = known.save();
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||
@@ -785,7 +786,7 @@ pub fn touch_last_used(fp_hex: &str) {
|
||||
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
|
||||
/// fingerprint alone — the identity a just-connected client is certain of.
|
||||
///
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] can only fire
|
||||
/// where an advert is visible, whereas this fires on any successful connect, including a host
|
||||
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
|
||||
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
|
||||
@@ -2293,6 +2294,33 @@ mod tests {
|
||||
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
|
||||
}
|
||||
|
||||
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
|
||||
/// the same advert reports no change — which is what lets every surface call this on every
|
||||
/// discovery tick without churning the store.
|
||||
#[test]
|
||||
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
|
||||
let mut h = KnownHost::default();
|
||||
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
|
||||
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// The same advert a tick later: nothing moved, so there is nothing to persist.
|
||||
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
|
||||
// would cost the user their wake, so an absent field must never overwrite a known one.
|
||||
assert!(!apply_advert(&mut h, &[], "", None));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
|
||||
assert!(!apply_advert(&mut h, &[], "", Some(0)));
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// A host that genuinely moved: the new value wins.
|
||||
assert!(apply_advert(&mut h, &[], "", Some(47992)));
|
||||
assert_eq!(h.mgmt_port, Some(47992));
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user