forked from unom/punktfunk
Compare commits
153
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df4ca957f | ||
|
|
13aa11355e | ||
|
|
e989d7457f | ||
|
|
b05bb1dd48 | ||
|
|
2898f6b049 | ||
|
|
4eb4e3465b | ||
|
|
44cd5bfd81 | ||
|
|
8977228a4b | ||
|
|
6a82a602a1 | ||
|
|
fc3b2d0328 | ||
|
|
1c1fd7d9bc | ||
|
|
1c60e641b3 | ||
|
|
eda4b7ebd2 | ||
|
|
730ac43169 | ||
|
|
c9a76287d8 | ||
|
|
cbd3d02817 | ||
|
|
2be444b329 | ||
|
|
669a1bc0ce | ||
|
|
8ff6fe6093 | ||
|
|
1758266bda | ||
|
|
d5fb1e4479 | ||
|
|
ea3c9e1202 | ||
|
|
685c4bd99a | ||
|
|
0519b057d5 | ||
|
|
33b029695f | ||
|
|
1f6f01cb76 | ||
|
|
fade2f7af3 | ||
|
|
6e4cc335c5 | ||
|
|
d9662c010d | ||
|
|
2b0913cf53 | ||
|
|
1280f697be | ||
|
|
3b39710a5a | ||
|
|
19243c30b4 | ||
|
|
dfcffcdd50 | ||
|
|
f2b5b3e567 | ||
|
|
6d7e6f71c0 | ||
|
|
9e3fba10c1 | ||
|
|
fd4f032d20 | ||
|
|
892e683f0e | ||
|
|
7af6c323d0 | ||
|
|
76e6618b84 | ||
|
|
7d2a8778d1 | ||
|
|
6007bc42cd | ||
|
|
16d54b73a1 | ||
|
|
d60b1dda29 | ||
|
|
d7fa5847f1 | ||
|
|
e473a4be7b | ||
|
|
675030935a | ||
|
|
21d9190324 | ||
|
|
7ae8866a5c | ||
|
|
a8099e0f5b | ||
|
|
b4b24f8b57 | ||
|
|
c23fc84bef | ||
|
|
0e5a059098 | ||
|
|
674b16d8eb | ||
|
|
d801cb72f2 | ||
|
|
b03acc9153 | ||
|
|
ace01f06a2 | ||
|
|
e4ec4cec31 | ||
|
|
245173a731 | ||
|
|
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 | ||
|
|
454531030d | ||
|
|
a64a22ccfc | ||
|
|
3717466594 | ||
|
|
c7c9500e89 | ||
|
|
cfbde6aec7 |
@@ -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 }
|
||||
}
|
||||
|
||||
+1024
File diff suppressed because it is too large
Load Diff
+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.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
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.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
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.1"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3613,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3683,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3697,11 +3698,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
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.1"
|
||||
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.1"
|
||||
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)**.
|
||||
|
||||
|
||||
+128
-3
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.29.0"
|
||||
"version": "0.31.1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
@@ -1860,6 +1860,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}/running": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Report which of a provider's titles are running",
|
||||
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
|
||||
"operationId": "reportProviderRunning",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The report was accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6625,7 +6688,7 @@
|
||||
},
|
||||
"HostInfo": {
|
||||
"type": "object",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process).",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
|
||||
"required": [
|
||||
"hostname",
|
||||
"uniqueid",
|
||||
@@ -6671,7 +6734,7 @@
|
||||
},
|
||||
"local_ip": {
|
||||
"type": "string",
|
||||
"description": "Best-effort primary LAN IP."
|
||||
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
@@ -7792,6 +7855,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningAccepted": {
|
||||
"type": "object",
|
||||
"description": "The result of a liveness report.",
|
||||
"required": [
|
||||
"matched",
|
||||
"unknown",
|
||||
"ttl_s"
|
||||
],
|
||||
"properties": {
|
||||
"matched": {
|
||||
"type": "integer",
|
||||
"description": "How many reported titles matched an entry this provider currently publishes.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_s": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unknown": {
|
||||
"type": "integer",
|
||||
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningInput": {
|
||||
"type": "object",
|
||||
"description": "Request body for `reportProviderRunning`.",
|
||||
"properties": {
|
||||
"running": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunningTitle"
|
||||
},
|
||||
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
@@ -7846,6 +7949,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunningTitle": {
|
||||
"type": "object",
|
||||
"description": "One running title in a provider's liveness report.",
|
||||
"required": [
|
||||
"external_id"
|
||||
],
|
||||
"properties": {
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
|
||||
},
|
||||
"pid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -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
|
||||
@@ -94,10 +97,33 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage). Without a
|
||||
// console to draw, a controller drives the touch UI through Compose's own focus.
|
||||
val skiaConsole = remember { SkiaConsole.wanted() }
|
||||
val gamepadUi = skiaConsole && gamepadUiActive(
|
||||
// …AND it actually came up: a console whose native create failed or whose render thread died
|
||||
// ([SkiaConsole.healthy], observable) would front a SurfaceView nothing ever paints — a gray
|
||||
// screen with a working pad probe, which is worse than the touch UI it replaced.
|
||||
val gamepadUi = skiaConsole && SkiaConsole.healthy && gamepadUiActive(
|
||||
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
|
||||
|
||||
@@ -225,7 +96,11 @@ private fun ControllersBody(
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||
// Everything real that is NOT counted as a controller — including a device that claims
|
||||
// a pad source with no pad hardware behind it, which the Gamepads list above now
|
||||
// rejects. One list or the other, never neither: this screen is where someone looks
|
||||
// when the client's idea of "a pad is attached" disagrees with the room.
|
||||
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(InputManager::class.java)
|
||||
@@ -247,32 +122,37 @@ 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) {
|
||||
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
|
||||
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
|
||||
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
|
||||
// here would kill the pad navigation the arriving screen had just installed. The same
|
||||
// teardown also runs when this screen hands the pad to its own input test and back.
|
||||
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
|
||||
// arriving screen's claim with it. The same teardown also runs when this screen hands the
|
||||
// pad to its own input test and back.
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
// ended it still has to be swallowed here — see the B branch below.
|
||||
val consume = consuming
|
||||
// The CORRECTED keycode, so this screen shows the button the stream will send and not
|
||||
// the one Android guessed for a pad it has no key layout for — the two differ on every
|
||||
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
|
||||
// stream is worse than no tester. The raw pair is still reported in "Last input".
|
||||
val code = Gamepad.padKeyCode(event)
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
held[code] = true
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
held[code] = false
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
bHeld = false
|
||||
if (consume) {
|
||||
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
|
||||
@@ -296,37 +176,50 @@ private fun ControllersBody(
|
||||
}
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
|
||||
// field report needs to pin an unmapped pad's report order without the device in hand.
|
||||
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
|
||||
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
|
||||
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
|
||||
event.scanCode,
|
||||
raw,
|
||||
if (code != event.keyCode) " → $fixed" else "",
|
||||
)
|
||||
consume
|
||||
}
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
|
||||
// streaming — on a pad Android has no key layout for, the right stick and the triggers
|
||||
// are not on the axes their names suggest.
|
||||
val map = Gamepad.padMap(event.device)
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
axes["LT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
axes["RT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
axes["RX"] = event.getAxisValue(map.rightStickX)
|
||||
axes["RY"] = event.getAxisValue(map.rightStickY)
|
||||
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.leftTrigger))
|
||||
}
|
||||
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.rightTrigger))
|
||||
}
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
}
|
||||
if (observeInput) {
|
||||
activity?.padKeyProbe = keyProbe
|
||||
activity?.padMotionProbe = motionProbe
|
||||
}
|
||||
onDispose {
|
||||
activity?.let { a ->
|
||||
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
|
||||
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -351,7 +244,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
|
||||
@@ -825,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only when a correction is actually in force: on a pad Android has a key layout for
|
||||
// there is nothing to say, and a line that says "normal" on every device teaches
|
||||
// nobody anything. Named rather than merely flagged, so a field report can quote it.
|
||||
padButtonsNote(info.buttons)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
@@ -920,6 +823,12 @@ internal data class PadInfo(
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
/**
|
||||
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
|
||||
* the pad Android already knows, which is what a screenshot scene wants and what the note
|
||||
* under the card stays silent about.
|
||||
*/
|
||||
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
@@ -929,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
@@ -942,7 +852,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
|
||||
@@ -958,6 +869,20 @@ private fun testRumble(dev: InputDevice) {
|
||||
}
|
||||
|
||||
/** Identity line: VID:PID + the source classes Android assigned. */
|
||||
/**
|
||||
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
|
||||
* has no key layout for it — null for a pad it does know, which needs no explanation.
|
||||
*/
|
||||
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
|
||||
Gamepad.PadButtons.NATIVE -> null
|
||||
Gamepad.PadButtons.GENERIC_SONY ->
|
||||
"Android has no button layout for this controller — read as a PlayStation pad"
|
||||
Gamepad.PadButtons.GENERIC_XBOX ->
|
||||
"Android has no button layout for this controller — read as an Xbox pad"
|
||||
Gamepad.PadButtons.SONY_MODERN ->
|
||||
"Android has no button layout for this controller — face buttons corrected"
|
||||
}
|
||||
|
||||
private fun deviceDetail(dev: InputDevice): String =
|
||||
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -82,8 +83,9 @@ fun GamepadNavEffect(
|
||||
val currentOnOptions by rememberUpdatedState(onOptions)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
|
||||
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
|
||||
// One entry on the MainActivity probe stack (see GamepadNavEffect2D), removed by identity on
|
||||
// dispose — a cross-fading-out screen must take only its OWN claim, never the incoming
|
||||
// screen's, and never the console shell's underneath.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
@@ -95,7 +97,7 @@ fun GamepadNavEffect(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||
@@ -113,13 +115,10 @@ fun GamepadNavEffect(
|
||||
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity.pushPadProbes(it) }
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
probes?.let { activity.removePadProbes(it) }
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
@@ -186,9 +185,11 @@ fun GamepadNavEffect2D(
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
// One entry on the MainActivity probe stack, removed by identity on dispose — during a
|
||||
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
|
||||
// teardown must not null out the incoming screen's just-installed probes.
|
||||
// teardown must take only its own claim. On the console this effect sits OVER the Skia
|
||||
// shell's probes: pushing (not overwriting) is what lets the shell's pad input resurface
|
||||
// the moment this screen pops, instead of dying with a nulled slot.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||
@@ -220,13 +221,10 @@ fun GamepadNavEffect2D(
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity.pushPadProbes(it) }
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
probes?.let { activity.removePadProbes(it) }
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +109,29 @@ class MainActivity : ComponentActivity() {
|
||||
var gamepadRouter: GamepadRouter? = null
|
||||
|
||||
/**
|
||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||
* Called for every key/motion event while not streaming; a `true` return consumes the event —
|
||||
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
|
||||
* One screen's claim on the pad while not streaming: its key/motion observers, consulted for
|
||||
* every event before the focus-navigation fallbacks below; a `true` return consumes the event.
|
||||
* Holders are the Skia console shell, [GamepadNavEffect2D] on the Compose screens the console
|
||||
* opens over itself, and the Controllers screen's input test.
|
||||
*/
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
class PadProbes(val key: (KeyEvent) -> Boolean, val motion: (MotionEvent) -> Boolean)
|
||||
|
||||
/**
|
||||
* The pad-probe claims, a STACK — only the top entry sees events. A single last-writer-wins
|
||||
* slot is how the console shell used to lose the pad for good: a screen composed over it
|
||||
* (Controllers/Licenses) overwrote the slot, then nulled it on its way out, and the shell —
|
||||
* whose install effect had no reason to re-run — never got it back. Pushing on install and
|
||||
* removing BY IDENTITY on dispose survives every ordering Compose produces (cross-fades
|
||||
* compose both screens at once, and dispose is not always LIFO): whatever leaves takes only
|
||||
* its own entry, and whatever is left on top resumes seeing the pad.
|
||||
*/
|
||||
private val padProbes = mutableListOf<PadProbes>()
|
||||
|
||||
fun pushPadProbes(p: PadProbes) { padProbes += p }
|
||||
fun removePadProbes(p: PadProbes) { padProbes.remove(p) }
|
||||
|
||||
private val padKeyProbe: ((KeyEvent) -> Boolean)? get() = padProbes.lastOrNull()?.key
|
||||
private val padMotionProbe: ((MotionEvent) -> Boolean)? get() = padProbes.lastOrNull()?.motion
|
||||
|
||||
/**
|
||||
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
|
||||
@@ -599,7 +616,7 @@ class MainActivity : ComponentActivity() {
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
@@ -691,8 +708,10 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// already move focus on their own, so they fall through to super untouched. Read
|
||||
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
|
||||
// menus on the right buttons too, not only the stream.
|
||||
when (Gamepad.padKeyCode(event)) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||
|
||||
@@ -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)
|
||||
@@ -944,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// The view's CURRENT pixel size, for the ASurfaceControl layer's
|
||||
// destination rect. It is reported here and not only at
|
||||
// surfaceCreated because the view grows a frame or two after the
|
||||
// stream screen appears — hiding the system bars and switching on
|
||||
// cutout drawing both resize it, and neither recreates the surface.
|
||||
// A layer left on the start-up rect paints the picture small, in the
|
||||
// top-left corner. The view's own size, not the buffer geometry in
|
||||
// `width`/`height`: the layer composites in the view's space.
|
||||
NativeBridge.nativeVideoSurfaceSize(
|
||||
handle, this@apply.width, this@apply.height,
|
||||
)
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
|
||||
@@ -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) {
|
||||
@@ -304,15 +317,20 @@ internal object ConsoleJson {
|
||||
j.put("invert_scroll", s.invertScroll)
|
||||
j.put("pad_haptics", s.padHaptics)
|
||||
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
|
||||
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
extra.put("android.low_latency", s.lowLatencyMode)
|
||||
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
extra.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
extra.put("android.sc2_capture", s.sc2Capture)
|
||||
extra.put("android.ds_capture", s.dsCapture)
|
||||
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("extra", extra)
|
||||
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
|
||||
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
|
||||
// whole object into the map under the literal key "extra", where no console row could
|
||||
// read it and every value the console wrote came straight back as the one we had sent.
|
||||
j.put("android.low_latency", s.lowLatencyMode)
|
||||
j.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
j.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
j.put("android.sc2_capture", s.sc2Capture)
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
// A store written by the nesting build carries the stale wrapper; drop it rather than
|
||||
// round-trip a copy of these keys that nothing reads for the life of the install.
|
||||
j.remove("extra")
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -322,7 +340,8 @@ internal object ConsoleJson {
|
||||
*/
|
||||
fun applySettings(s: Settings, j: JSONObject): Settings {
|
||||
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
|
||||
val extra = j.optJSONObject("extra") ?: JSONObject()
|
||||
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
|
||||
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
|
||||
return s.copy(
|
||||
width = j.optInt("width", s.width),
|
||||
height = j.optInt("height", s.height),
|
||||
@@ -359,13 +378,14 @@ internal object ConsoleJson {
|
||||
"off" -> false
|
||||
else -> s.padSpeaker
|
||||
},
|
||||
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
|
||||
import io.unom.punktfunk.ConnectErrors
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
@@ -34,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
|
||||
|
||||
@@ -67,6 +72,17 @@ object SkiaConsole {
|
||||
private const val PREFS = "punktfunk_console_settings"
|
||||
|
||||
private var handle = 0L
|
||||
|
||||
/**
|
||||
* False once the console has proven it cannot draw — the native create failed, or the render
|
||||
* thread died (a GL context that never came up, or one Android reclaimed and that would not
|
||||
* come back). Compose observes it: `App` folds it into the gamepad-UI gate, so the answer to a
|
||||
* dead console is the touch UI — not the gray, never-painted `SurfaceView` the shell would
|
||||
* otherwise sit on for the rest of the process.
|
||||
*/
|
||||
var healthy by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val ioPool = Executors.newCachedThreadPool { r -> Thread(r, "pf-console-io").apply { isDaemon = true } }
|
||||
@@ -90,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`. */
|
||||
@@ -142,6 +159,9 @@ object SkiaConsole {
|
||||
val opts = JSONObject()
|
||||
.put("device_name", deviceName(app))
|
||||
.put("gpu_cache_bytes", gpuCacheBytes(app))
|
||||
// The touch shell exists as a fallback on phones/tablets but not on a TV —
|
||||
// gates the console's own "Controller-optimized UI" off switch.
|
||||
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
|
||||
.put("settings", ConsoleJson.settings(initial, base))
|
||||
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
|
||||
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
|
||||
@@ -149,6 +169,7 @@ object SkiaConsole {
|
||||
handle = runCatching { NativeBridge.nativeConsoleCreate(opts.toString()) }.getOrDefault(0L)
|
||||
if (handle == 0L) {
|
||||
Log.e(TAG, "console: native create failed")
|
||||
healthy = false // see [healthy] — the touch UI fronts everything from here
|
||||
return 0L
|
||||
}
|
||||
Log.i(TAG, "console: created (gpu cache ${gpuCacheBytes(app) shr 20} MB)")
|
||||
@@ -197,6 +218,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) }
|
||||
@@ -236,12 +265,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.
|
||||
@@ -255,6 +286,7 @@ object SkiaConsole {
|
||||
onSettingsChange = null
|
||||
onQuit = null
|
||||
onPlatformScreen = null
|
||||
onPadAction = null
|
||||
onPulse = null
|
||||
}
|
||||
|
||||
@@ -373,7 +405,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)
|
||||
}
|
||||
|
||||
@@ -386,7 +418,10 @@ object SkiaConsole {
|
||||
ev.has("editing") -> {} // the shell draws its own keyboard; nothing to raise here
|
||||
ev.has("settings") -> onSettingsSaved(ev.getJSONObject("settings"))
|
||||
ev.has("gles") -> Log.i(TAG, "console: GLES ${ev.optInt("gles")}")
|
||||
ev.has("dead") -> Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
|
||||
ev.has("dead") -> {
|
||||
Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
|
||||
healthy = false // the touch UI takes over; only a process restart tries again
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,13 +549,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) }
|
||||
}
|
||||
@@ -559,6 +597,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")
|
||||
@@ -568,6 +622,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")
|
||||
|
||||
+134
-20
@@ -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)
|
||||
@@ -139,7 +159,12 @@ fun SkiaConsoleShell(
|
||||
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
|
||||
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (fromPad) {
|
||||
val bit = when (ev.keyCode) {
|
||||
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
|
||||
// under other buttons' names, so read raw this console answered ✕ with whatever
|
||||
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
|
||||
// and the game must not disagree about which button a user pressed.
|
||||
val code = Gamepad.padKeyCode(ev)
|
||||
val bit = when (code) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> 0
|
||||
KeyEvent.KEYCODE_BUTTON_B -> 1
|
||||
KeyEvent.KEYCODE_BUTTON_X -> 2
|
||||
@@ -159,7 +184,7 @@ fun SkiaConsoleShell(
|
||||
}
|
||||
return@probe true
|
||||
}
|
||||
val dbit = when (ev.keyCode) {
|
||||
val dbit = when (code) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> 0
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 1
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> 2
|
||||
@@ -171,7 +196,7 @@ fun SkiaConsoleShell(
|
||||
padState.push(handle)
|
||||
return@probe true
|
||||
}
|
||||
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
|
||||
return@probe true
|
||||
}
|
||||
@@ -228,13 +253,13 @@ fun SkiaConsoleShell(
|
||||
padState.push(handle)
|
||||
true
|
||||
}
|
||||
activity.padKeyProbe = keyProbe
|
||||
activity.padMotionProbe = motionProbe
|
||||
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
|
||||
activity.pushPadProbes(probes)
|
||||
SkiaConsole.padsChanged(Gamepad.firstPad())
|
||||
onDispose {
|
||||
// Only clear what is still ours: a screen composed after us must not lose its probes.
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
// Remove OUR claim only — a platform screen pushed over us keeps its own, and when it
|
||||
// pops, this one resurfaces (the stack is what fixed the pad dying after Controllers).
|
||||
activity.removePadProbes(probes)
|
||||
padState.reset()
|
||||
if (handle != 0L) padState.push(handle)
|
||||
}
|
||||
@@ -267,10 +292,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 +322,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
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.console.ConsoleJson
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
|
||||
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
|
||||
*
|
||||
* They were written and read nested under an `"extra"` object instead. Serde put that whole
|
||||
* object into the map under the literal key `"extra"`, so no console row ever found
|
||||
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
|
||||
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
|
||||
* with nothing happening: the console stayed up, because the setting never moved.
|
||||
*/
|
||||
class ConsoleSettingsExtraTest {
|
||||
@Test
|
||||
fun androidKeysAreWrittenFlat() {
|
||||
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
|
||||
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.low_latency"))
|
||||
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
|
||||
}
|
||||
|
||||
/** A store written by the nesting build must not keep echoing its dead wrapper. */
|
||||
@Test
|
||||
fun aStaleNestedWrapperIsDropped() {
|
||||
val base = JSONObject().put(
|
||||
"extra",
|
||||
JSONObject().put("android.gamepad_ui_enabled", true),
|
||||
)
|
||||
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theConsolesOwnSaveIsReadBack() {
|
||||
val saved = JSONObject()
|
||||
.put("android.gamepad_ui_enabled", false)
|
||||
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
|
||||
.put("android.ds_capture", false)
|
||||
val next = ConsoleJson.applySettings(Settings(), saved)
|
||||
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
|
||||
assertFalse(next.dsCapture)
|
||||
}
|
||||
|
||||
/** Both halves against each other — the shape only holds if they agree. */
|
||||
@Test
|
||||
fun theRoundTripKeepsEveryAndroidRow() {
|
||||
val want = Settings(
|
||||
gamepadUiEnabled = false,
|
||||
gamepadUiMode = GAMEPAD_UI_ALWAYS,
|
||||
lowLatencyMode = false,
|
||||
rumbleOnPhone = true,
|
||||
gyroOnPhone = true,
|
||||
sc2Capture = false,
|
||||
dsCapture = false,
|
||||
)
|
||||
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
|
||||
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
|
||||
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
|
||||
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
|
||||
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
|
||||
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
|
||||
assertEquals(want.sc2Capture, got.sc2Capture)
|
||||
assertEquals(want.dsCapture, got.dsCapture)
|
||||
}
|
||||
}
|
||||
+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() }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -127,8 +128,12 @@ object Gamepad {
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
|
||||
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
|
||||
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
|
||||
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -188,9 +193,53 @@ object Gamepad {
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||
fun pads(): List<InputDevice> =
|
||||
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||
/**
|
||||
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
|
||||
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
|
||||
*
|
||||
* [isPad] alone answers "did this event come from a pad source", which is the right question
|
||||
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
|
||||
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
|
||||
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
|
||||
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
|
||||
* "With a controller" has no way back to the touch UI.
|
||||
*
|
||||
* The capability probe is what separates them: a source class is a claim, a stick or a face
|
||||
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
|
||||
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
|
||||
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
|
||||
* device), which is worth excluding but catches none of the above.
|
||||
*/
|
||||
fun looksLikeController(dev: InputDevice?): Boolean {
|
||||
val d = dev ?: return false
|
||||
return looksLikeController(
|
||||
padSource = isPad(d),
|
||||
virtual = d.isVirtual,
|
||||
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
|
||||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
|
||||
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
|
||||
// a d-pad-only pad) still counts.
|
||||
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
|
||||
.any { it },
|
||||
)
|
||||
}
|
||||
|
||||
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
|
||||
* (an [InputDevice] cannot be built off a device). */
|
||||
fun looksLikeController(
|
||||
padSource: Boolean,
|
||||
virtual: Boolean,
|
||||
hasStick: Boolean,
|
||||
hasFaceButtons: Boolean,
|
||||
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
|
||||
|
||||
/**
|
||||
* All connected controllers, in system enumeration order — the devices that answer "is a pad
|
||||
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
|
||||
*/
|
||||
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { looksLikeController(it) }
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
@@ -293,6 +342,334 @@ object Gamepad {
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Controllers Android has no key layout for
|
||||
//
|
||||
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
|
||||
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
|
||||
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
|
||||
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
|
||||
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
|
||||
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
|
||||
// ORDER — so every keycode after the first divergence is somebody else's button.
|
||||
//
|
||||
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
|
||||
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
|
||||
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
|
||||
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
|
||||
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
|
||||
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
|
||||
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
|
||||
// symptom exactly.
|
||||
//
|
||||
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
|
||||
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
|
||||
// Two things keep it from breaking a pad that already works:
|
||||
//
|
||||
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
|
||||
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
|
||||
// positions too, and that is the fact — not the model — that separates the two firmwares
|
||||
// of the SAME Xbox pad, only the older of which needs any of this.
|
||||
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
|
||||
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
|
||||
// device-specific layout IS in force and knows this pad better than we do.
|
||||
//
|
||||
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
|
||||
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
|
||||
// which is why both pads work there on the same box.
|
||||
//
|
||||
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
|
||||
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
|
||||
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
|
||||
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
|
||||
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
|
||||
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
|
||||
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
|
||||
// does not. Declaration is not report order. Only the axes tell them apart.
|
||||
|
||||
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
|
||||
const val AXIS_NONE = -1
|
||||
|
||||
/**
|
||||
* The report order a controller's buttons are numbered in, and with it which scancode carries
|
||||
* which physical button. Resolved once per device by [padButtons] from what the device
|
||||
* declares; [correct] then maps one scancode to the keycode it should have produced.
|
||||
*/
|
||||
enum class PadButtons {
|
||||
/**
|
||||
* The keycode Android delivered is already right — a device-specific key layout is in
|
||||
* force, or the generic one happens to agree. [correct] changes nothing.
|
||||
*/
|
||||
NATIVE,
|
||||
|
||||
/**
|
||||
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
|
||||
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
|
||||
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
|
||||
* [buttonBit] deliberately drops — the wire carries the axis, never both.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
/**
|
||||
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
|
||||
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
|
||||
* clones the Xbox layout — the same assumption [styleFor] makes for its glyphs.
|
||||
*/
|
||||
GENERIC_XBOX,
|
||||
|
||||
/**
|
||||
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
|
||||
* layout — the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
|
||||
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH (△) and `0x134` is
|
||||
* BTN_WEST (□); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
|
||||
* face pair comes out swapped and nothing else is wrong.
|
||||
*/
|
||||
SONY_MODERN,
|
||||
;
|
||||
|
||||
/**
|
||||
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
|
||||
*
|
||||
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
|
||||
* said for [scan] — anything else is a device-specific layout's answer, which outranks
|
||||
* this table. That guard is what makes the correction idempotent and safe to run on
|
||||
* every pad: it can only ever fire where Android was guessing in the first place.
|
||||
*/
|
||||
fun correct(scan: Int, keyCode: Int): Int {
|
||||
if (this == NATIVE) return keyCode
|
||||
if (keyCode != genericKeyCode(scan)) return keyCode
|
||||
val fixed = when (this) {
|
||||
GENERIC_SONY -> when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
|
||||
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> keyCode // 0x130 A / 0x131 B already agree
|
||||
}
|
||||
// Only the face pair; every other row of Generic.kl is right for these codes.
|
||||
SONY_MODERN -> when (scan) {
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
|
||||
else -> keyCode
|
||||
}
|
||||
NATIVE -> keyCode
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AOSP `Generic.kl`'s gamepad rows — the layout Android falls back to when no device-specific
|
||||
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
|
||||
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
|
||||
* events alone.
|
||||
*/
|
||||
fun genericKeyCode(scan: Int): Int = when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_A
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_B
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_C
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_START
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* How one controller must be read: its button report order plus the axes its right stick and
|
||||
* analog triggers actually arrive on. Resolved once per device by [padMap].
|
||||
*/
|
||||
class PadMap(
|
||||
val buttons: PadButtons,
|
||||
val rightStickX: Int = MotionEvent.AXIS_Z,
|
||||
val rightStickY: Int = MotionEvent.AXIS_RZ,
|
||||
/**
|
||||
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on — that case
|
||||
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
|
||||
* report one pair, the other, or both have always needed.
|
||||
*/
|
||||
val leftTrigger: Int = AXIS_NONE,
|
||||
val rightTrigger: Int = AXIS_NONE,
|
||||
/** Those trigger axes rest at −1 rather than 0, measured off the device's own range. */
|
||||
val triggersSigned: Boolean = false,
|
||||
) {
|
||||
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
|
||||
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
|
||||
}
|
||||
|
||||
/** The map every pad with a key layout uses: Android's own names, unchanged. */
|
||||
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
|
||||
|
||||
/**
|
||||
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] — the device's stable identity
|
||||
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
|
||||
* Nothing here depends on a live connection, so entries never need evicting.
|
||||
*/
|
||||
private val padMaps = ConcurrentHashMap<String, PadMap>()
|
||||
|
||||
/**
|
||||
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
|
||||
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
|
||||
* BUTTON_C and BUTTON_Z.
|
||||
*
|
||||
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
|
||||
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
|
||||
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
|
||||
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
|
||||
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
|
||||
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
|
||||
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
|
||||
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
|
||||
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
|
||||
*
|
||||
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
|
||||
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
|
||||
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
|
||||
* declaring six or more — a standard-layout pad that never presses either included. Read alone
|
||||
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
|
||||
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
|
||||
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
|
||||
* there is one — where a false positive costs nothing.
|
||||
*/
|
||||
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
|
||||
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
|
||||
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
|
||||
}
|
||||
|
||||
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
|
||||
* [InputDevice] cannot be built off a device). */
|
||||
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
|
||||
namedTriggers -> PadButtons.NATIVE
|
||||
declaresCZ && sony -> PadButtons.GENERIC_SONY
|
||||
declaresCZ -> PadButtons.GENERIC_XBOX
|
||||
sony -> PadButtons.SONY_MODERN
|
||||
else -> PadButtons.NATIVE
|
||||
}
|
||||
|
||||
/**
|
||||
* The [PadMap] for [dev] — its button report order and the axes its right stick and triggers
|
||||
* arrive on, resolved once per device model and cached.
|
||||
*
|
||||
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
|
||||
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
|
||||
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
|
||||
* raw axes under the names the HID report gave them. Which two depends on the same report
|
||||
* order the buttons did:
|
||||
*
|
||||
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
|
||||
* stick, then the triggers — so the right stick is already right and only the triggers
|
||||
* (`AXIS_RX`/`AXIS_RY`) are missed;
|
||||
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
|
||||
* the shape that makes pulling a trigger swing the right stick.
|
||||
*
|
||||
* Whether those axes idle at −1 is MEASURED from the device's own range rather than assumed,
|
||||
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
|
||||
*/
|
||||
fun padMap(dev: InputDevice?): PadMap {
|
||||
if (dev == null) return NATIVE_MAP
|
||||
padMaps[dev.descriptor]?.let { return it }
|
||||
fun has(a: Int) = axis(dev, a) != null
|
||||
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
|
||||
val buttons = padButtons(dev, namedTriggers = named)
|
||||
val rx = axis(dev, MotionEvent.AXIS_RX)
|
||||
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
|
||||
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
|
||||
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
|
||||
(rx?.min ?: 0f) < -0.5f
|
||||
} else {
|
||||
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
|
||||
}
|
||||
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
|
||||
padMaps[dev.descriptor] = map
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half of [padMap], decided from four facts about the device so it can be pinned
|
||||
* without one — see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
|
||||
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
|
||||
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
|
||||
* whichever axis pair the fallback picks, never assumed.
|
||||
*/
|
||||
fun padMap(
|
||||
buttons: PadButtons,
|
||||
namedTriggers: Boolean,
|
||||
hasRxRy: Boolean,
|
||||
restsNegative: Boolean,
|
||||
): PadMap = when {
|
||||
namedTriggers || !hasRxRy -> PadMap(buttons)
|
||||
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
|
||||
buttons == PadButtons.GENERIC_SONY -> PadMap(
|
||||
buttons,
|
||||
leftTrigger = MotionEvent.AXIS_RX,
|
||||
rightTrigger = MotionEvent.AXIS_RY,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
|
||||
// right stick makes pulling a trigger swing it.
|
||||
else -> PadMap(
|
||||
buttons,
|
||||
rightStickX = MotionEvent.AXIS_RX,
|
||||
rightStickY = MotionEvent.AXIS_RY,
|
||||
leftTrigger = MotionEvent.AXIS_Z,
|
||||
rightTrigger = MotionEvent.AXIS_RZ,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
}
|
||||
|
||||
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
|
||||
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
|
||||
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
|
||||
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
|
||||
|
||||
/**
|
||||
* The keycode [event] should have carried, given the controller it came from — [event]'s own
|
||||
* keycode for every pad Android has a key layout for, and the scancode's true button for one
|
||||
* it does not (see the block comment above [PadButtons]).
|
||||
*
|
||||
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
|
||||
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
|
||||
* pad is fixed in the menus and in the game at once. Events from anything that is not a
|
||||
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
|
||||
* untouched.
|
||||
*/
|
||||
fun padKeyCode(event: KeyEvent): Int {
|
||||
val dev = event.device ?: return event.keyCode
|
||||
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
|
||||
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
@@ -306,7 +683,12 @@ object Gamepad {
|
||||
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||
* folds the event straight in without re-qualifying it.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||
class AxisMapper(
|
||||
private val handle: Long,
|
||||
private val pad: Int,
|
||||
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
|
||||
private val map: PadMap = NATIVE_MAP,
|
||||
) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
@@ -317,30 +699,18 @@ object Gamepad {
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
|
||||
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
|
||||
// reporting NONE of those names is one Android has no key layout for, and [map]
|
||||
// carries the raw axes its triggers really landed on instead.
|
||||
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
|
||||
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
|
||||
sendAxis(AXIS_LT, trigger(lt))
|
||||
sendAxis(AXIS_RT, trigger(rt))
|
||||
|
||||
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||
@@ -383,6 +753,17 @@ object Gamepad {
|
||||
hatY = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
|
||||
* else the max of the two names Android gives a trigger it does know.
|
||||
*/
|
||||
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
|
||||
if (resolvedAxis == AXIS_NONE) {
|
||||
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
|
||||
} else {
|
||||
map.level(event.getAxisValue(resolvedAxis))
|
||||
}
|
||||
|
||||
private fun sendAxis(id: Int, v: Int) {
|
||||
if (last[id] == v) return
|
||||
last[id] = v
|
||||
|
||||
@@ -605,7 +605,7 @@ class GamepadRouter(
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
|
||||
@@ -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
|
||||
@@ -290,6 +298,18 @@ object NativeBridge {
|
||||
surfaceH: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`.
|
||||
*
|
||||
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
|
||||
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
|
||||
* switches the window to draw into the display cutout a frame or two later, and neither
|
||||
* recreates the surface. Without this the layer keeps painting at its start-up size in the
|
||||
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
|
||||
* cheap (one atomic store), UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.PadButtons.correct] — the scancode resolution for controllers Android
|
||||
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
|
||||
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
|
||||
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
|
||||
* landing on the wrong actions — "L1 being L2". Neither pad has a key layout on that box (AOSP
|
||||
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
|
||||
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
|
||||
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
|
||||
* after the first divergence belongs to a different button.
|
||||
*
|
||||
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
|
||||
* right; the assertions read it back the other way.
|
||||
*/
|
||||
class PadButtonsTest {
|
||||
|
||||
private fun sony(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
private fun xbox(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
/**
|
||||
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
|
||||
* BUTTON_L1 — so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
|
||||
* read as a face button.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's shoulders stop being each other's buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
|
||||
}
|
||||
|
||||
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
|
||||
@Test
|
||||
fun `a DualSense's face buttons land on their Xbox positions`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
|
||||
* which took the exit, mic and stats chords with it — every one of them is built on Select.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's menu buttons and stick clicks are themselves`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
|
||||
}
|
||||
|
||||
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
|
||||
@Test
|
||||
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
|
||||
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@Test
|
||||
fun `an Xbox pad numbering straight through keeps its own layout`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
|
||||
}
|
||||
|
||||
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
|
||||
@Test
|
||||
fun `a driver-backed Sony pad has only its face pair corrected`() {
|
||||
val m = Gamepad.PadButtons.SONY_MODERN
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
|
||||
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
|
||||
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
|
||||
* `Generic.kl` would have said came from a device-specific key layout, which knows this
|
||||
* controller better than any table here. Correcting it would break a pad that works.
|
||||
*/
|
||||
@Test
|
||||
fun `a keycode a device layout already resolved is never second-guessed`() {
|
||||
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
}
|
||||
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BACK,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
assertEquals(once, p.correct(scan, once))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half. A pad that names its triggers something Android knows is read exactly as it
|
||||
* always was — this is the branch that must NOT fire on the pads that already work.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is read unchanged`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
|
||||
}
|
||||
// Same when there is no Rx/Ry to fall back to in the first place.
|
||||
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
|
||||
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry — left stick, right
|
||||
* stick, then the triggers. Only the triggers were being missed; the sticks already read
|
||||
* right and must be left alone.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
|
||||
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
|
||||
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it — so the two
|
||||
* pairs must never be mixed up, which is the whole point of pinning them.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger axis that idles at −1 is rescaled; one that idles at 0 must NOT be, or it would
|
||||
* read as a permanent half-pull. Which it is gets measured off the device, never assumed —
|
||||
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
|
||||
*/
|
||||
@Test
|
||||
fun `only a trigger that idles negative is rescaled`() {
|
||||
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(0f, signed.level(-1f), 1e-6f)
|
||||
assertEquals(0.5f, signed.level(0f), 1e-6f)
|
||||
assertEquals(1f, signed.level(1f), 1e-6f)
|
||||
|
||||
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(0f, unsigned.level(0f), 1e-6f)
|
||||
assertEquals(1f, unsigned.level(1f), 1e-6f)
|
||||
}
|
||||
|
||||
/** A pad Android does know is untouched, which is most of them. */
|
||||
@Test
|
||||
fun `a pad with a key layout is left alone`() {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val generic = Gamepad.genericKeyCode(scan)
|
||||
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
|
||||
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
|
||||
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
|
||||
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
|
||||
* pad ever presses them or not. Naming the triggers is what tells the two apart.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is never corrected, whatever it declares`() {
|
||||
for (sony in listOf(false, true)) {
|
||||
for (declaresCZ in listOf(false, true)) {
|
||||
assertEquals(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four buttons the field reports named, on a pad whose report order is already standard:
|
||||
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
|
||||
* keeps them themselves — the correction tables are right for the pads they are for, and this
|
||||
* is about not reaching one of them.
|
||||
*/
|
||||
@Test
|
||||
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
|
||||
val native = Gamepad.PadButtons.NATIVE
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
// What the old heuristic did to each of them, kept here so the difference stays visible.
|
||||
val wrong = Gamepad.PadButtons.GENERIC_XBOX
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
}
|
||||
|
||||
/** Past the gate, which straight-through order to read is still the question it always was. */
|
||||
@Test
|
||||
fun `an unnamed-trigger pad still resolves its report order`() {
|
||||
fun order(sony: Boolean, declaresCZ: Boolean) =
|
||||
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
|
||||
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The truth table behind "is a controller attached" — the question the console UI's
|
||||
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
|
||||
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
|
||||
* dismiss it, because the phantom pad never disconnects.
|
||||
*/
|
||||
class PadPresenceTest {
|
||||
|
||||
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
|
||||
@Test
|
||||
fun realPadsCount() {
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// An arcade stick / d-pad-only pad — buttons, no analog stick.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// A wheel or flight stick — axes, no A/B.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
|
||||
* gamepad source class. This is the field report — the console UI that could not be dismissed. */
|
||||
@Test
|
||||
fun virtualDevicesAreNotControllers() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A device that claims a pad source with nothing behind it is not a pad either. */
|
||||
@Test
|
||||
fun aSourceClaimWithoutHardwareIsNotAController() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
|
||||
@Test
|
||||
fun nonPadSourcesNeverCount() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use super::egl::{EglContext, EglSurface, GlesVersion};
|
||||
use super::gpu::Gpu;
|
||||
use anyhow::Result;
|
||||
use anyhow::{bail, Result};
|
||||
use ndk::native_window::NativeWindow;
|
||||
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
|
||||
@@ -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,15 @@ 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
|
||||
// failure alone is logged, the loop retries, and the screen stays a gray never-painted
|
||||
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
|
||||
let mut gl_failures = 0u32;
|
||||
const GL_FAILURE_LIMIT: u32 = 3;
|
||||
|
||||
loop {
|
||||
// Take everything queued. With no surface up, block until something arrives.
|
||||
@@ -297,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),
|
||||
@@ -347,11 +370,15 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
surface = Some(s);
|
||||
window = Some(w);
|
||||
gl_failures = 0;
|
||||
// A fresh surface is a fresh entry: snapshot the pad so a button
|
||||
// still held from before does not fire into the first frame.
|
||||
nav.reset();
|
||||
}
|
||||
Err(e) => log::error!("console: window surface: {e:#}"),
|
||||
Err(e) => {
|
||||
log::error!("console: window surface: {e:#}");
|
||||
gl_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::SurfaceChanged => {
|
||||
@@ -402,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 {
|
||||
@@ -411,8 +445,14 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
if need_wrap {
|
||||
skia = None;
|
||||
match g.wrap_window(&egl, w, h) {
|
||||
Ok(surf) => skia = Some((surf, w, h)),
|
||||
Err(e) => log::error!("console: {e:#}"),
|
||||
Ok(surf) => {
|
||||
skia = Some((surf, w, h));
|
||||
gl_failures = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: {e:#}");
|
||||
gl_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((surf, _, _)) = skia.as_mut() {
|
||||
@@ -441,6 +481,16 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
}
|
||||
|
||||
if gl_failures >= GL_FAILURE_LIMIT {
|
||||
// Same release order as `Cmd::Quit`: the Skia surface, the current binding, then (on
|
||||
// return) the EGL surface + window + context drop.
|
||||
drop(skia.take());
|
||||
if surface.is_some() {
|
||||
egl.release_current();
|
||||
}
|
||||
bail!("GL surface failed {gl_failures} times in a row — giving the screen back");
|
||||
}
|
||||
|
||||
// Publish what the console raised.
|
||||
while let Some(a) = console.take_action() {
|
||||
shared.emit(HostEvent::Action(a));
|
||||
|
||||
@@ -45,6 +45,10 @@ struct CreateOptions {
|
||||
device_name: String,
|
||||
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
|
||||
gpu_cache_bytes: usize,
|
||||
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
|
||||
/// gates the console-off settings row. Default false: absent means don't offer it.
|
||||
#[serde(default)]
|
||||
fallback_ui: bool,
|
||||
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
|
||||
settings: pf_client_core::trust::Settings,
|
||||
/// The profile catalog as `[[id, name], …]`.
|
||||
@@ -83,6 +87,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)]
|
||||
@@ -143,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
|
||||
let console_opts = ConsoleOptions {
|
||||
device_name: opts.device_name,
|
||||
deck: false,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
store: Some(store.clone()),
|
||||
platform: Platform::Android,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
|
||||
@@ -316,8 +328,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 +347,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 +358,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 +475,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 +501,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,
|
||||
@@ -142,21 +142,21 @@ pub(super) struct AscBackend {
|
||||
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.
|
||||
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
|
||||
/// `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
surface_w: i32,
|
||||
surface_h: i32,
|
||||
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
panel_hz: i32,
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
) -> Option<AscBackend> {
|
||||
let layer = Layer::create(window, surface_w, surface_h)?;
|
||||
let layer = Layer::create(window, surface_size)?;
|
||||
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
|
||||
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
let reader = match ImageReader::new_with_usage(
|
||||
@@ -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};
|
||||
@@ -96,8 +96,7 @@ pub(super) fn run_async(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -192,17 +191,14 @@ 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,
|
||||
mode.height as i32,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size,
|
||||
panel_hz,
|
||||
initial_ds,
|
||||
mode.refresh_hz,
|
||||
@@ -449,7 +445,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)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
|
||||
|
||||
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
||||
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
///
|
||||
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
|
||||
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
|
||||
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
|
||||
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
|
||||
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
|
||||
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
|
||||
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
|
||||
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
|
||||
/// couldn't read it yet, and the backend falls back to the window buffer size.
|
||||
pub surface_w: i32,
|
||||
pub surface_h: i32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
|
||||
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
|
||||
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
|
||||
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
|
||||
/// yet, and the backend falls back to the window buffer size.
|
||||
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
use super::async_loop::DecodeEvent;
|
||||
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
|
||||
pub(super) struct Layer {
|
||||
api: Api,
|
||||
sc: Arc<ScHandle>,
|
||||
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
|
||||
dest_w: i32,
|
||||
dest_h: i32,
|
||||
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
|
||||
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
|
||||
/// because the view resizes under a surface that is never recreated (see `dest`).
|
||||
surface_size: Arc<AtomicU64>,
|
||||
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
|
||||
/// view when video started): the window's own buffer geometry, the best remaining guess.
|
||||
fallback_w: i32,
|
||||
fallback_h: i32,
|
||||
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
|
||||
configured: bool,
|
||||
}
|
||||
@@ -287,13 +293,16 @@ impl Layer {
|
||||
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
|
||||
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
|
||||
///
|
||||
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
|
||||
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
|
||||
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
|
||||
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
|
||||
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
|
||||
/// yet) falls back to that buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
|
||||
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
|
||||
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
|
||||
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
|
||||
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
|
||||
/// using it shrank the picture to the top-left corner. It is read fresh on every present
|
||||
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
|
||||
/// screen hides the system bars and switches on cutout drawing a frame or two after
|
||||
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
|
||||
/// view yet) falls back to the buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
|
||||
let api = Api::resolve()?;
|
||||
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
|
||||
// static NUL-terminated string; the call returns null on failure (checked).
|
||||
@@ -303,20 +312,11 @@ impl Layer {
|
||||
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
let dest_w = if dest_w > 0 {
|
||||
dest_w
|
||||
} else {
|
||||
window.width().max(1)
|
||||
};
|
||||
let dest_h = if dest_h > 0 {
|
||||
dest_h
|
||||
} else {
|
||||
window.height().max(1)
|
||||
};
|
||||
let fallback_w = window.width().max(1);
|
||||
let fallback_h = window.height().max(1);
|
||||
log::info!(
|
||||
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
|
||||
window.width(),
|
||||
window.height(),
|
||||
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
|
||||
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
|
||||
);
|
||||
Some(Layer {
|
||||
sc: Arc::new(ScHandle {
|
||||
@@ -324,16 +324,25 @@ impl Layer {
|
||||
release: api.ac_release,
|
||||
}),
|
||||
api,
|
||||
dest_w,
|
||||
dest_h,
|
||||
surface_size,
|
||||
fallback_w,
|
||||
fallback_h,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The destination rectangle for this present: the live view size, or the window's buffer
|
||||
/// geometry while Kotlin has reported nothing.
|
||||
fn dest(&self) -> (i32, i32) {
|
||||
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
|
||||
.unwrap_or((self.fallback_w, self.fallback_h))
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -369,11 +378,12 @@ impl Layer {
|
||||
right: src_w.max(1),
|
||||
bottom: src_h.max(1),
|
||||
};
|
||||
let (dest_w, dest_h) = self.dest();
|
||||
let dst = ARect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: self.dest_w,
|
||||
bottom: self.dest_h,
|
||||
right: dest_w,
|
||||
bottom: dest_h,
|
||||
};
|
||||
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
|
||||
if dataspace != 0 {
|
||||
|
||||
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
|
||||
panel_hz: _,
|
||||
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
|
||||
// SurfaceView, so it never needs the view's on-screen size.
|
||||
surface_w: _,
|
||||
surface_h: _,
|
||||
surface_size: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
|
||||
@@ -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 {
|
||||
@@ -466,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
|
||||
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
|
||||
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
|
||||
/// sends, read by the ASurfaceControl presenter before each present.
|
||||
///
|
||||
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
|
||||
/// that is never recreated: hiding the system bars and switching the window to
|
||||
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
|
||||
/// and each one grows the video view. A destination rect captured once at creation then keeps
|
||||
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
|
||||
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
|
||||
/// falls back to the window's buffer geometry.
|
||||
pub surface_size: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
|
||||
/// single atomic load and can never see a torn pair (a new width against an old height).
|
||||
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
|
||||
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
|
||||
if packed == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pack_surface_size, unpack_surface_size};
|
||||
|
||||
/// The pair the presenter reads as one atomic load must survive the round trip — including a
|
||||
/// size wider than a signed 16-bit value, which every panel this runs on now is.
|
||||
#[test]
|
||||
fn surface_size_round_trips() {
|
||||
assert_eq!(
|
||||
unpack_surface_size(pack_surface_size(2800, 1260)),
|
||||
Some((2800, 1260))
|
||||
);
|
||||
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
|
||||
}
|
||||
|
||||
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
|
||||
/// to the window's buffer geometry rather than composing into an empty rectangle.
|
||||
#[test]
|
||||
fn non_positive_sizes_are_the_sentinel() {
|
||||
assert_eq!(pack_surface_size(0, 0), 0);
|
||||
assert_eq!(pack_surface_size(1920, 0), 0);
|
||||
assert_eq!(pack_surface_size(-1, 1080), 0);
|
||||
assert_eq!(unpack_surface_size(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
|
||||
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
|
||||
// current from here on (the bars hide and the cutout mode changes AFTER this call).
|
||||
h.surface_size.store(
|
||||
super::pack_surface_size(surface_w, surface_h),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature,
|
||||
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
surface_w,
|
||||
surface_h,
|
||||
surface_size: h.surface_size.clone(),
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
|
||||
/// on-screen pixel size, re-reported on every `surfaceChanged`.
|
||||
///
|
||||
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
|
||||
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
|
||||
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
|
||||
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
|
||||
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
|
||||
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
|
||||
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
|
||||
/// + an atomic store, so it links on the host build too.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
width: jni::sys::jint,
|
||||
height: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
let packed = super::pack_surface_size(width, height);
|
||||
if handle == 0 || packed == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.surface_size
|
||||
.store(packed, std::sync::atomic::Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
|
||||
Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
|
||||
Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
|
||||
let mut ls = send;
|
||||
let lp = loss_ppm.clone();
|
||||
let df = dropped_frames.clone();
|
||||
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
|
||||
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
|
||||
// sentinel — which is exactly what makes it usable for testing that path.
|
||||
let rxp = rx_wire_packets.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut last_report = std::time::Instant::now();
|
||||
let mut last_dropped = 0u64;
|
||||
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
|
||||
// window while it is zero, once when the first packets land, then stop — so a host that
|
||||
// predates the message is not flooded with "unknown control message" on a good session.
|
||||
let mut delivery_confirmed = false;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let d = df.load(Relaxed);
|
||||
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
|
||||
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
|
||||
last_report = std::time::Instant::now();
|
||||
let v = lp.swap(u32::MAX, Relaxed);
|
||||
// Independent of whether there is a fresh loss sample: "no fresh sample" is
|
||||
// exactly the shape a dead data plane has, so gating it on one would silence
|
||||
// it in the state it exists to report.
|
||||
let received = rxp.load(Relaxed);
|
||||
if received == 0 || !delivery_confirmed {
|
||||
delivery_confirmed = received > 0;
|
||||
if io::write_msg(
|
||||
&mut ls,
|
||||
&DeliveryReport {
|
||||
packets_received: received,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break; // control stream gone
|
||||
}
|
||||
}
|
||||
if v != u32::MAX
|
||||
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
|
||||
.await
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user