forked from unom/punktfunk
Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded8586123 | ||
|
|
07af2f9e97 | ||
|
|
018c00e94b | ||
|
|
87cb431437 | ||
|
|
92de6a8ce3 | ||
|
|
db0f4f1ef8 | ||
|
|
7c0faeebc8 | ||
|
|
c3e3333c40 | ||
|
|
2d0a4270b4 | ||
|
|
6ef7230ace | ||
|
|
29bfbcb950 | ||
|
|
f4f318b8f4 | ||
|
|
ec621882f0 | ||
|
|
86fbd8121b | ||
|
|
6092f80df2 | ||
|
|
92f80bdfde | ||
|
|
c53dd15313 | ||
|
|
b61566e6c6 | ||
|
|
d3616aaef9 | ||
|
|
9c164aa614 | ||
|
|
559b185247 | ||
|
|
5317c583ab | ||
|
|
a79343784c | ||
|
|
f4a2698711 | ||
|
|
eb8117b551 | ||
|
|
d2286da955 | ||
|
|
ac14b6554e | ||
|
|
47d9a7d2fa | ||
|
|
247832014a | ||
|
|
2031066539 | ||
|
|
31eb91e2b1 | ||
|
|
7df36a1b3d | ||
|
|
ab5e642f84 | ||
|
|
891f165f82 | ||
|
|
53621a0fe0 | ||
|
|
1121734141 | ||
|
|
44929dcbf8 | ||
|
|
0b473cdb38 | ||
|
|
ee5e89d7d3 | ||
|
|
2b6bde577a | ||
|
|
de19a98f1b | ||
|
|
45d3ff6ab7 | ||
|
|
91fa05bc21 | ||
|
|
0d8b156dcc | ||
|
|
f6d85f11e2 | ||
|
|
1dbee2e7ed | ||
|
|
9615cd7612 | ||
|
|
bbc01cdd8a | ||
|
|
b97fa1186f | ||
|
|
5f77074e51 | ||
|
|
8358f9ed37 | ||
|
|
cba7587684 | ||
|
|
2b107f5a18 | ||
|
|
a387ddd4ab | ||
|
|
5da68ab858 | ||
|
|
2cd5787ed6 | ||
|
|
72959ef07d | ||
|
|
fa0f66e151 | ||
|
|
d3a5f13a45 | ||
|
|
3fe1af991a | ||
|
|
57444e7be7 | ||
|
|
809f1faa26 | ||
|
|
d34d431618 | ||
|
|
7ebdd7e5d9 | ||
|
|
c62cfd58de | ||
|
|
1a604cbf3e | ||
|
|
c1bffa9e7b | ||
|
|
8ca86b1682 | ||
|
|
dcedd7147f | ||
|
|
4dcc31dc3b | ||
|
|
987ecabfde | ||
|
|
2bb6af3b92 | ||
|
|
2a951a6bb5 | ||
|
|
5bf64e07bf | ||
|
|
ae13b29abd | ||
|
|
9c278ee351 | ||
|
|
f79e9eb524 | ||
|
|
eed7b5e589 | ||
|
|
933074fafc | ||
|
|
54c3414f36 | ||
|
|
637d438532 | ||
|
|
1511374959 | ||
|
|
f632ee68d2 | ||
|
|
b43363b141 | ||
|
|
1198522931 | ||
|
|
79982060c3 | ||
|
|
fc6060f274 | ||
|
|
4399664217 | ||
|
|
e684b3e4bd | ||
|
|
1b8d4799ef | ||
|
|
8c4b1b8c62 | ||
|
|
98e68a49a7 | ||
|
|
b64ac3cb32 | ||
|
|
f92b093f92 | ||
|
|
b9adcc4897 | ||
|
|
d2c6e1c9c0 | ||
|
|
1d755ebeeb | ||
|
|
c407f6a6d9 | ||
|
|
1e2b956de6 | ||
|
|
49b5ffa2d8 | ||
|
|
d65b9f3b1b | ||
|
|
cd0b53f8fe | ||
|
|
00a9d16201 | ||
|
|
540e282e60 | ||
|
|
7246f0fe60 | ||
|
|
e0a822016f | ||
|
|
b6938a9890 | ||
|
|
faf94087c5 | ||
|
|
46d9e0d20f | ||
|
|
8e8451ca0c | ||
|
|
0e1bab019c | ||
|
|
7951d12b06 | ||
|
|
4690a166ca | ||
|
|
8c628b4e6c | ||
|
|
aef7f7877f | ||
|
|
f60b6e30e2 | ||
|
|
4d155f4985 | ||
|
|
4b5f0dac6b | ||
|
|
5e30805490 | ||
|
|
7312f0ddba | ||
|
|
e7ebaf591c | ||
|
|
010949fead | ||
|
|
f5931650e0 | ||
|
|
519d004cab | ||
|
|
46201fd9c3 | ||
|
|
f320f4b465 | ||
|
|
89eb031cd6 | ||
|
|
2991001fe4 | ||
|
|
19df33e0f7 | ||
|
|
c920204184 | ||
|
|
6f4613e146 | ||
|
|
3b08da11ff | ||
|
|
3ee88bb8cf | ||
|
|
773eea24d9 | ||
|
|
3b5c95959b | ||
|
|
a2bc9a2bdc | ||
|
|
cb07a8f983 | ||
|
|
11abff5343 | ||
|
|
cf7baf3ba8 |
@@ -63,7 +63,7 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Promote
|
||||
env:
|
||||
|
||||
@@ -29,10 +29,10 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Cache (gradle)
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# FIRST, because it costs a second and everything after it costs ten minutes.
|
||||
#
|
||||
@@ -171,12 +171,19 @@ jobs:
|
||||
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
|
||||
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
# Must precede every cargo step below: skia-bindings' ~19 MB prebuilt download runs inside
|
||||
# a build script with no retry, and a truncated transfer here does not surface as a network
|
||||
# error — it silently becomes a from-source Skia build that dies in the container. See the
|
||||
# script for the measured failure.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
|
||||
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
|
||||
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
|
||||
# anymore — sccache covers recompilation without shipping multi-GB tars per run.
|
||||
- name: Cache (cargo registry)
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
@@ -185,7 +192,7 @@ jobs:
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Cache (gradle)
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -209,9 +216,25 @@ jobs:
|
||||
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
|
||||
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
|
||||
# both pointer widths are load-bearing and why the environment must not be duplicated here.
|
||||
# The `STARTING A FULL BUILD` check turns the manual rule in this workflow's `env:` block
|
||||
# ("Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED") into something that fails the
|
||||
# job by itself. Without it a missed prebuilt reads as a Gradle stack trace with the real
|
||||
# cause ~1,800 lines up — which is exactly how 2026-08-22 spent a week looking like a lint
|
||||
# failure. This is the first cargo step in the job, so it catches the drop earliest.
|
||||
#
|
||||
# No pipefail: the runner is dash. Capture, then decide.
|
||||
- name: Clippy (Android target, deny warnings)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:cargoNdkClippy --stacktrace
|
||||
run: |
|
||||
set -e
|
||||
rc=0
|
||||
./gradlew :kit:cargoNdkClippy --stacktrace > /tmp/android-clippy.log 2>&1 || rc=$?
|
||||
cat /tmp/android-clippy.log
|
||||
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
|
||||
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
|
||||
exit 1
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
|
||||
@@ -29,7 +29,11 @@ jobs:
|
||||
announce:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# SHA-pinned, unlike the plain `@v4` the build workflows use: this job holds
|
||||
# UPDATE_MANIFEST_KEY — the Ed25519 key every host pins to decide whether an update is real —
|
||||
# and a tag is mutable, so whoever can move it runs code in front of that key. Same style as
|
||||
# the appleboy pins in deploy-services.yml; the trailing comment is the release it resolves to.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Publish the SIGNED stable update manifest — the moment every host's update check learns
|
||||
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
|
||||
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
gitea.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Rust toolchain (self-healing on a fresh runner)
|
||||
run: |
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
TEAM_ID: F4H37KF6WC
|
||||
PROJECT: clients/apple/Punktfunk.xcodeproj
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Select release Xcode
|
||||
run: |
|
||||
@@ -690,7 +690,7 @@ jobs:
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
|
||||
run: |
|
||||
|
||||
@@ -115,10 +115,12 @@ jobs:
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
}
|
||||
# Arch ships bun in [extra], so the bootstrap takes the pacman-signed package instead of
|
||||
# piping bun.sh's installer into root's shell — this job builds and publishes the package,
|
||||
# and the installer would be upstream code choosing bytes we then ship. Kept behind the
|
||||
# `command -v` guard rather than folded into the list above: the image's baked bun is not
|
||||
# in pacman's db, so `--needed` cannot see it and would re-download bun on every run.
|
||||
command -v bun >/dev/null || pacman -S --noconfirm --needed bun
|
||||
bun --version
|
||||
|
||||
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
|
||||
@@ -161,7 +163,7 @@ jobs:
|
||||
fi
|
||||
echo "healed: builder now links $HAVE"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
|
||||
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
|
||||
@@ -172,7 +174,7 @@ jobs:
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
|
||||
# is ever compiled here. Cached, that cost is paid once per runner.
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
@@ -239,7 +241,7 @@ jobs:
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
@@ -357,7 +359,7 @@ jobs:
|
||||
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
|
||||
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
|
||||
# against newer system libs is the same risk the distro's own package carries between rebuilds.
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: gamescope
|
||||
with:
|
||||
path: dist-gamescope
|
||||
|
||||
+13
-13
@@ -78,9 +78,9 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist.
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- name: Install git + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# `bun audit` queries the registry advisory DB for the versions pinned in the tree's
|
||||
# bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
|
||||
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
- name: Install git + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: bun audit (non-blocking)
|
||||
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
|
||||
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
run:
|
||||
working-directory: clients/decky
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
|
||||
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
|
||||
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
|
||||
@@ -197,8 +197,8 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
@@ -291,21 +291,21 @@ jobs:
|
||||
# sites; do not blanket-disable the check.
|
||||
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
|
||||
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
|
||||
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
|
||||
# belongs with the toolchain, not with the lockfile.
|
||||
- name: cache the nightly toolchain + Miri sysroots
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
|
||||
~/.cache/miri
|
||||
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
|
||||
- name: cache the cargo registry
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
|
||||
@@ -412,17 +412,17 @@ jobs:
|
||||
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
|
||||
RUSTC_WRAPPER: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
|
||||
# note there (and so an incomplete save from one job can never starve the other).
|
||||
- name: cache the nightly toolchain
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
|
||||
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
|
||||
- name: cache the cargo registry
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: [self-hosted, gpu]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Tier-3 GPU stream benchmark
|
||||
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
|
||||
# substituted before the shell parses the line, so an input carrying shell syntax would run
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
|
||||
+27
-13
@@ -64,7 +64,7 @@ jobs:
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
@@ -107,9 +107,15 @@ jobs:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
@@ -122,7 +128,7 @@ jobs:
|
||||
# control is operator-side: Gitea's "require approval for fork PRs".)
|
||||
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-ci-
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: target
|
||||
# -v3-: the prior `cargo-target-<rustc>-*` cache was poisoned when the runner ran
|
||||
@@ -263,16 +269,22 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
|
||||
# Skia from the same no-retry build-script fetch that took the android job out on
|
||||
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
|
||||
- name: curl with retries (skia-bindings' prebuilt fetch has none)
|
||||
run: sh scripts/ci/install-retrying-curl.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
@@ -285,7 +297,7 @@ jobs:
|
||||
# control is operator-side: Gitea's "require approval for fork PRs".)
|
||||
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-ci-
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: target
|
||||
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
|
||||
@@ -338,7 +350,7 @@ jobs:
|
||||
- name: Install git + node + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# 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
|
||||
@@ -358,9 +370,11 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
|
||||
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
|
||||
# that only a browser rejects, which nothing else here would catch.
|
||||
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
|
||||
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
|
||||
# mode is a well-formed header that only a browser rejects, and the one picking which of the
|
||||
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
|
||||
# Neither would be caught anywhere else.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
@@ -378,7 +392,7 @@ jobs:
|
||||
- name: Install git + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Retried — see the web job above; this is the job the flake was measured on.
|
||||
- name: Install dependencies
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
@@ -409,7 +423,7 @@ jobs:
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
|
||||
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
|
||||
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
|
||||
@@ -430,7 +444,7 @@ jobs:
|
||||
# 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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# 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.
|
||||
|
||||
+30
-14
@@ -100,7 +100,7 @@ jobs:
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
@@ -148,14 +148,14 @@ jobs:
|
||||
# cache is NOT; see below.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: target
|
||||
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml).
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
@@ -220,9 +220,25 @@ jobs:
|
||||
run: |
|
||||
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
|
||||
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
|
||||
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb (BUN_BIN, below),
|
||||
# so an install script piped into root's shell is upstream code choosing bytes we then
|
||||
# publish under REGISTRY_TOKEN. Not in Debian/Ubuntu, so a pin is the only option here.
|
||||
# ONE bun across the repo: same version as rpm.yml and windows-host.yml, and the same
|
||||
# asset + sum as rpm.yml (windows pins bun-windows-x64.zip, so its sum differs) — bump
|
||||
# all three together (the sums are in the release's SHASUMS256.txt). `-baseline` on
|
||||
# purpose: it needs no AVX2, so the bun we ship starts on every x86-64 box — something the
|
||||
# auto-detecting installer never promised, since it reads the BUILDER's CPU, not the user's.
|
||||
command -v bun >/dev/null || {
|
||||
apt-get install -y --no-install-recommends unzip
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
BUN_VER=bun-v1.3.14
|
||||
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
|
||||
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
|
||||
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
|
||||
install -m0755 /tmp/bun /usr/local/bin/bun
|
||||
}
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
cd web
|
||||
@@ -327,7 +343,7 @@ jobs:
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
@@ -357,14 +373,14 @@ jobs:
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: target
|
||||
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
||||
@@ -478,7 +494,7 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
|
||||
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
|
||||
@@ -497,7 +513,7 @@ jobs:
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
@@ -571,7 +587,7 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
@@ -606,14 +622,14 @@ jobs:
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: target
|
||||
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
|
||||
@@ -693,7 +709,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
|
||||
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
run:
|
||||
working-directory: clients/decky
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: pnpm
|
||||
run: |
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Sync compose file
|
||||
# SHA-pinned (receives DEPLOY_SSH_KEY): a moved tag would mean credential
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Sync flatpak server compose + Caddyfile
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Sync nix cache compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Sync winget source compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- image: punktfunk-flatpak-ci
|
||||
dockerfile: ci/flatpak-ci.Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The key is the git TREE HASH of ci/ — every byte any of these Dockerfiles can see
|
||||
# (they all use ci/ as build context). One key for the whole family on purpose: a
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
env:
|
||||
IMAGE: punktfunk-rust-ci-arm64cross
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Content key
|
||||
run: |
|
||||
@@ -305,7 +305,7 @@ jobs:
|
||||
dockerfile: docs-site/Dockerfile
|
||||
context: docs-site
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Login to registry
|
||||
# Username must be the owner of the REGISTRY_TOKEN PAT, not the push actor.
|
||||
@@ -340,7 +340,7 @@ jobs:
|
||||
needs: apps
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Sync compose file
|
||||
# SHA-pinned (not tag-pinned): this action receives DEPLOY_SSH_KEY + host/user/port, so a
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
# node comes from the image now (act_runner execs a JS action with the CONTAINER's
|
||||
# node and injects none of its own), so checkout needs no install step ahead of it.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Tooling (baked — assert, don't install)
|
||||
run: |
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
# overwriting the baked installation with an older copy of itself. The crate sources
|
||||
# stay cached — they are keyed on Cargo.lock, which no image can pin.
|
||||
- name: Cache flatpak-builder state (crate sources, ccache)
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .flatpak-builder
|
||||
key: flatpak-builder-state-${{ hashFiles('Cargo.lock', 'packaging/flatpak/**') }}
|
||||
@@ -215,8 +215,17 @@ jobs:
|
||||
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
|
||||
# untouched; cargo --offline only needs sources for the crates it compiles.
|
||||
run: |
|
||||
# PINNED to a commit and checked by SHA-256. `master` is a mutable ref, and this is
|
||||
# third-party python executed in the SAME job that holds FLATPAK_GPG_PRIVATE_KEY — it
|
||||
# chooses which crate sources the signed build vendors, so an upstream push (or a bad
|
||||
# day at raw.githubusercontent) would be picking bytes we then sign. Bump both together:
|
||||
# curl -fsSL .../<new-sha>/cargo/flatpak-cargo-generator.py | sha256sum
|
||||
GEN_REF=f03a673abe6ce189cea1c2857e2b44af2dd79d1f
|
||||
GEN_SHA=b373c8ab1a05378ec5d8ed0645c7b127bcec7d2f7a1798694fbc627d570d856c
|
||||
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GEN_REF/cargo/flatpak-cargo-generator.py"
|
||||
echo "$GEN_SHA /tmp/flatpak-cargo-generator.py" | sha256sum -c - \
|
||||
|| { echo "::error::flatpak-cargo-generator.py sha256 mismatch at $GEN_REF"; exit 1; }
|
||||
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
-o packaging/flatpak/cargo-sources.json
|
||||
@@ -413,10 +422,14 @@ jobs:
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"` — the same repo
|
||||
# secret nix.yml publishes with (packaging/nix/README.md). Gated with the rest below: no
|
||||
# pinned host key, no deploy, never a first-contact-trusts-anything push.
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
|
||||
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
|
||||
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_*/DEPLOY_KNOWN_HOSTS not set — skipping repo deploy (bundle still published). See packaging/nix/README.md for the host key."
|
||||
exit 0
|
||||
fi
|
||||
# 1) Import the signing key into a throwaway keyring; sign the repo.
|
||||
@@ -481,7 +494,13 @@ jobs:
|
||||
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
|
||||
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"
|
||||
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
|
||||
# FLATPAK_GPG_PRIVATE_KEY and ships the signed OSTree repo, so `accept-new` — which trusts
|
||||
# the first key it ever sees, and every run starts with an empty known_hosts, so EVERY run
|
||||
# is a first contact — would hand the deploy key and the publish to anything that won the
|
||||
# race for the address. The guard above skips the deploy when the secret is unset.
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
|
||||
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
|
||||
|
||||
@@ -39,18 +39,21 @@ jobs:
|
||||
- family: debian-13
|
||||
image: debian:trixie
|
||||
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
|
||||
rmweb: apt-get purge -y punktfunk-web
|
||||
- family: fedora-44
|
||||
image: fedora:44
|
||||
prep: dnf install -y -q curl git nodejs
|
||||
rmweb: dnf remove -y punktfunk-web
|
||||
- family: arch
|
||||
image: archlinux:base
|
||||
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
|
||||
rmweb: pacman -Rns --noconfirm punktfunk-web
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
steps:
|
||||
- name: Prepare the container (${{ matrix.family }})
|
||||
run: ${{ matrix.prep }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# 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
|
||||
@@ -59,8 +62,25 @@ jobs:
|
||||
run: |
|
||||
punktfunk-host --version
|
||||
punktfunk-host detect-conflicts
|
||||
# The console is the whole management surface — pairing, approving a device, every setting.
|
||||
# A host without it can be installed and still be useless, so assert it by name: the binary
|
||||
# the package puts on PATH and the user unit step 6 enables.
|
||||
- name: The web console is installed too
|
||||
run: |
|
||||
command -v punktfunk-web-server
|
||||
test -f /usr/lib/systemd/user/punktfunk-web.service
|
||||
- name: Re-running is a no-op install
|
||||
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
|
||||
# The reported Fedora failure, as a test: a box that has the host but lost (or never got)
|
||||
# the console must get one back from a re-run. Before the per-package check, the installer
|
||||
# saw punktfunk-host on PATH, declared itself done, and left the box without a console
|
||||
# while still printing the console's URL.
|
||||
- name: A host without a console gets one back on re-run
|
||||
run: |
|
||||
${{ matrix.rmweb }}
|
||||
! command -v punktfunk-web-server
|
||||
sh scripts/install.sh --yes --no-start
|
||||
command -v punktfunk-web-server
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
@@ -72,14 +72,14 @@ jobs:
|
||||
# it is profile-independent.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: target
|
||||
# This job builds RELEASE (see the build step) in the same image and target layout as
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
# and a real `nix build` of a trivial derivation succeeds).
|
||||
NIX_REMOTE: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# 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
|
||||
@@ -235,16 +235,19 @@ jobs:
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"`. Gated here with
|
||||
# the rest: no pinned host key, no publish — never a first-contact-trusts-anything deploy.
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
# 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
|
||||
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ] && [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; 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)."
|
||||
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST/DEPLOY_KNOWN_HOSTS not set — skipping the binary cache publish (see packaging/nix/README.md)."
|
||||
fi
|
||||
|
||||
- name: Build the publishable packages
|
||||
@@ -269,6 +272,7 @@ jobs:
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
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
|
||||
@@ -313,7 +317,13 @@ jobs:
|
||||
# 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"
|
||||
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
|
||||
# NIX_CACHE_SIGNING_KEY and ships the signed cache, so `accept-new` — which trusts the
|
||||
# first key it ever sees, and every run starts with an empty known_hosts, so EVERY run is
|
||||
# a first contact — would hand the deploy key and the publish to anything that won the
|
||||
# race for the address. Preflight above skips the publish when the secret is unset.
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
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
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# 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
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
env:
|
||||
CARGO_HOME: /usr/local/cargo
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
@@ -119,13 +119,27 @@ jobs:
|
||||
dnf -y install gamescope || true
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
|
||||
# This job holds RPM_GPG_PRIVATE_KEY, and the spec VENDORS this very binary into
|
||||
# punktfunk-web, so an install script piped into root's shell is upstream code running in
|
||||
# front of the signing key AND choosing bytes we then sign. Same discipline as
|
||||
# windows-host.yml's bun pin. Bump BUN_VER and BUN_SHA together (the sums are published in
|
||||
# the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we
|
||||
# ship starts on every x86-64 box — something the auto-detecting installer never promised,
|
||||
# since it reads the BUILDER's CPU, not the user's.
|
||||
command -v bun >/dev/null || {
|
||||
dnf -y install unzip
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
BUN_VER=bun-v1.3.14
|
||||
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
|
||||
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
|
||||
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
|
||||
install -m0755 /tmp/bun /usr/local/bin/bun
|
||||
}
|
||||
bun --version
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: cargo-home-fedora-${{ hashFiles('Cargo.lock') }}
|
||||
@@ -167,7 +181,7 @@ jobs:
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
@@ -270,7 +284,7 @@ jobs:
|
||||
# script). So the key is that directory's hash and a normal push restores a binary instead of
|
||||
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
|
||||
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
steps:
|
||||
# fetch-depth 0: the dispatch path derives the canary base from the tag history
|
||||
# (scripts/ci/pf-version.sh), which a shallow clone cannot see.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# 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
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install git + node + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# --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
|
||||
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
# rust-skia adds the target.
|
||||
session_flags: '--no-default-features'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Probe driver toolchain (informational — never fails the job)
|
||||
continue-on-error: true
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
|
||||
# retired that — see design/windows-build-and-packaging.md.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
# Shared self-provision step (also used by windows-client.yml/windows-host.yml) so
|
||||
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
|
||||
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
# see unom/infra runners/ci-core/README.md).
|
||||
- name: Cache web console output
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-win-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
@@ -541,7 +541,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Publish the canary update manifest
|
||||
env:
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
|
||||
# so the result cannot drift and re-running any tag reproduces it byte for byte.
|
||||
|
||||
+1405
File diff suppressed because it is too large
Load Diff
Generated
+41
-40
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -1959,9 +1959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,13 +2996,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"libc",
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
"mdns-sd",
|
||||
"openh264",
|
||||
"opus",
|
||||
@@ -3022,6 +3023,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
@@ -3032,7 +3035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3139,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3172,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3202,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,23 +3541,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3605,7 +3606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3614,7 +3615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3684,7 +3685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3698,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3722,7 +3723,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -102,7 +102,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk punktfunk-web punktfunk-scripting` *(after adding the repo)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
|
||||
+4
-1
@@ -82,7 +82,10 @@ us beyond the download itself.
|
||||
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
|
||||
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
|
||||
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
|
||||
public key is baked into the script rather than fetched from the feed.
|
||||
public key is baked into the script rather than fetched from the feed. The manifest also names
|
||||
the feed it was signed for and carries a monotonic publish serial, both inside the signed bytes,
|
||||
so a genuinely-signed manifest replayed from another channel — or an older one put back — is
|
||||
refused too.
|
||||
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
|
||||
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
|
||||
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.2"
|
||||
"version": "0.32.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
|
||||
@@ -46,16 +46,26 @@ ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
|
||||
&& cargo install cargo-ndk --locked \
|
||||
# Version-pinned like every other tool baked in here: unpinned, a rebuild months apart
|
||||
# silently bakes a different cargo-ndk, and this one drives the shipped Android .so builds.
|
||||
# crates.io is append-only with a checksummed index, so the version IS the pin. Bump freely.
|
||||
&& cargo install cargo-ndk@4.1.2 --locked \
|
||||
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo ndk --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
|
||||
|
||||
+19
-10
@@ -53,21 +53,30 @@ RUN pacman -Syu --noconfirm --needed \
|
||||
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
|
||||
# and its `-static-libstdc++` link is untouched.
|
||||
mold \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
|
||||
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
|
||||
# bootstrap the official binary — once, here, instead of per run.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& rm -rf /root/.bun \
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1) — so these bytes end up inside the
|
||||
# package arch.yml signs and publishes. Arch ships bun in [extra], so take the
|
||||
# pacman-signed package (pacman verifies package signatures by default) instead of piping
|
||||
# bun.sh's installer into root's shell, which would be upstream code choosing them. Same
|
||||
# call as arch.yml's bootstrap guard. It rides THIS transaction rather than a later layer
|
||||
# on purpose: -Syu refreshes the db in the same step that installs, so a cache-hit rebuild
|
||||
# can never resolve bun against a stale snapshot the mirrors no longer carry.
|
||||
bun \
|
||||
&& pacman -Scc --noconfirm \
|
||||
&& bun --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
|
||||
|
||||
@@ -17,8 +17,8 @@ RUN dnf -y install \
|
||||
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
||||
&& dnf -y install \
|
||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
|
||||
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
|
||||
# installer.
|
||||
# — the punktfunk-web console builds AND runs on bun (installed below); unzip extracts the
|
||||
# pinned bun zip.
|
||||
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||
# build toolchain + bindgen
|
||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||
@@ -43,8 +43,22 @@ RUN dnf -y install \
|
||||
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
|
||||
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
|
||||
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`. The spec
|
||||
# VENDORS this very binary into punktfunk-web, so the installer would be upstream code choosing
|
||||
# bytes rpm.yml then signs with RPM_GPG_PRIVATE_KEY. ONE bun across the repo: same version, asset
|
||||
# and sum as rpm.yml, deb.yml and rust-ci.Dockerfile — bump BUN_VERSION and BUN_SHA together (the
|
||||
# sums are in the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun
|
||||
# we ship starts on every x86-64 box — something the auto-detecting installer never promised, since
|
||||
# it reads the BUILDER's CPU, not the user's.
|
||||
ARG BUN_VERSION=1.3.14
|
||||
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
RUN curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
|
||||
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
|
||||
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
|
||||
&& install -m0755 /tmp/bun /usr/local/bin/bun \
|
||||
&& rm -f /tmp/bun.zip /tmp/bun \
|
||||
&& bun --version
|
||||
|
||||
# libcuda link stub — the zerocopy path links a fixed set of cuXxx driver symbols, but CI has
|
||||
@@ -78,9 +92,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
|
||||
@@ -47,7 +47,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg — together with the commit SHA it is pinned to below.
|
||||
#
|
||||
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
|
||||
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
|
||||
@@ -57,16 +57,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
|
||||
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# The COMMIT that tag points at. A git tag is MUTABLE — upstream can move one, and unlike a branch
|
||||
# nobody would notice — and these .so's are BUNDLED into the host .deb every Ubuntu user installs.
|
||||
# The clone below asserts HEAD against this, so a moved tag fails the build loudly instead of
|
||||
# shipping. Same shape as the bun/sccache sha256 pins: a mismatch stops the build, it does not
|
||||
# silently "fix" itself. Bump alongside FFMPEG_TAG:
|
||||
# git ls-remote --tags https://github.com/FFmpeg/FFmpeg.git 'refs/tags/<new-tag>^{}'
|
||||
# Take the `^{}` line: these are ANNOTATED tags, so the bare ref is the tag OBJECT and the peeled
|
||||
# `^{}` is the commit — the commit is what a clone leaves at HEAD, and what this compares against.
|
||||
ARG FFMPEG_SHA=140fd653aed8cad774f991ba083e2d01e86420c7
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
|
||||
ARG NVHDR_TAG=n12.2.72.0
|
||||
# Commit for NVHDR_TAG, asserted after checkout — see FFMPEG_SHA above for why and how to bump:
|
||||
# git ls-remote --tags https://github.com/FFmpeg/nv-codec-headers.git 'refs/tags/<new-tag>^{}'
|
||||
ARG NVHDR_SHA=c69278340ab1d5559c7d7bf0edf615dc33ddbba7
|
||||
RUN set -eux; \
|
||||
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
|
||||
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
|
||||
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
|
||||
test "$(git -C /tmp/nvhdr rev-parse HEAD)" = "$NVHDR_SHA" \
|
||||
|| { echo "error: nv-codec-headers $NVHDR_TAG is not $NVHDR_SHA — tag moved upstream" >&2; exit 1; }; \
|
||||
make -C /tmp/nvhdr install PREFIX=/usr/local; \
|
||||
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
|
||||
test "$(git -C /tmp/ffmpeg rev-parse HEAD)" = "$FFMPEG_SHA" \
|
||||
|| { echo "error: FFmpeg $FFMPEG_TAG is not $FFMPEG_SHA — tag moved upstream" >&2; exit 1; }; \
|
||||
cd /tmp/ffmpeg; \
|
||||
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
|
||||
--prefix=/opt/ffmpeg \
|
||||
@@ -98,9 +114,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
|
||||
+26
-5
@@ -11,7 +11,7 @@
|
||||
FROM ubuntu:26.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip extracts the pinned bun zip
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
|
||||
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
|
||||
@@ -34,8 +34,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||
# ci.yml's web/docs jobs use the oven/bun image instead, so this is only for the deb job.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
|
||||
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb, so the installer would be
|
||||
# upstream code choosing bytes a signing job then publishes. ONE bun across the repo: same version,
|
||||
# asset and sum as deb.yml and rpm.yml — bump BUN_VERSION and BUN_SHA together (the sums are in the
|
||||
# release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we ship starts on
|
||||
# every x86-64 box — something the auto-detecting installer never promised, since it reads the
|
||||
# BUILDER's CPU, not the user's.
|
||||
ARG BUN_VERSION=1.3.14
|
||||
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
RUN curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
|
||||
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
|
||||
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
|
||||
&& install -m0755 /tmp/bun /usr/local/bin/bun \
|
||||
&& rm -f /tmp/bun.zip /tmp/bun \
|
||||
&& bun --version
|
||||
|
||||
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
|
||||
@@ -60,9 +74,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.unom.punktfunk.models.PendingLinkConnect
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
|
||||
@@ -165,6 +166,36 @@ fun RequestAccessPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `punktfunk://` link that named a saved host by its label or its address rather than by its
|
||||
* stable id: both are guessable, and the activity is exported, so the dial happens on the user's
|
||||
* tap instead of on the link's say-so. A link that names the id — every shortcut Punktfunk itself
|
||||
* emits — never reaches this prompt.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkConnectPrompt(
|
||||
target: PendingLinkConnect,
|
||||
onConnect: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
title = "Open this link?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Connect", primary = true, onClick = onConnect),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("A link asks to connect to ${target.host.name} (${target.host.address}).")
|
||||
target.launch?.let { PromptText("It also asks the host to launch “$it”.") }
|
||||
PromptText(
|
||||
"It names the host by its label or address, which anything that can open a link " +
|
||||
"could guess. Shortcuts made in Punktfunk name the host's id and connect " +
|
||||
"without asking.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||
|
||||
@@ -144,7 +144,7 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
activity.pendingDeepLink = null
|
||||
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
|
||||
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
|
||||
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
|
||||
val sameHost = target is HostResolution.Record && target.host.id == live.hostId
|
||||
if (!sameHost) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
|
||||
@@ -80,6 +80,8 @@ internal fun ConnectGrid(
|
||||
onEdit: (KnownHost) -> Unit,
|
||||
onWake: (KnownHost) -> Unit,
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
/** Upload this device's recent log to the host — see the menu row's gate below. */
|
||||
onSendLogs: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> Unit,
|
||||
/** The experimental game-library toggle — off hides "Browse library…" everywhere. */
|
||||
@@ -108,6 +110,14 @@ internal fun ConnectGrid(
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
|
||||
}
|
||||
// "Send logs to host" — the same row the console's host menu carries
|
||||
// (`pf-console-ui`'s `options.rs`), on the same gate: the upload authenticates with the
|
||||
// streaming cert, so it needs a paired identity and a host that is answering. It belongs
|
||||
// HERE too and not only in the console: a device whose console never comes up is exactly
|
||||
// the one whose logs somebody needs, and the touch home was its only shell.
|
||||
if (pin == null && kh.paired && kh.isOnline(discovered, reachable)) {
|
||||
add(HostMenuItem("Send logs to host") { onSendLogs(kh) })
|
||||
}
|
||||
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
|
||||
@@ -3,12 +3,14 @@ package io.unom.punktfunk
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingLinkConnect
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
/**
|
||||
* Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing
|
||||
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
|
||||
* edit form, the local-network rationale, and finally the connect takeover.
|
||||
* ceremony, a link's connect confirmation, the parked "Waiting for approval…", the console's host
|
||||
* options, the speed test, the edit form, the local-network rationale, and finally the connect
|
||||
* takeover.
|
||||
*
|
||||
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
|
||||
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can
|
||||
@@ -33,6 +35,11 @@ internal fun ConnectPrompts(
|
||||
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
|
||||
onPaired: (PendingTrust, String) -> Unit,
|
||||
onRequestAccess: (PendingTrust) -> Unit,
|
||||
// ---- a link that named a saved host by a guessable reference ----------------------------
|
||||
/** Non-null while such a link waits for the OK that turns it into a plain dial. */
|
||||
pendingLinkConnect: PendingLinkConnect?,
|
||||
onConfirmLinkConnect: (PendingLinkConnect) -> Unit,
|
||||
onDismissLinkConnect: () -> Unit,
|
||||
// ---- the parked no-PIN request ----------------------------------------------------------
|
||||
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
|
||||
awaitingHostName: String?,
|
||||
@@ -89,6 +96,14 @@ internal fun ConnectPrompts(
|
||||
}
|
||||
}
|
||||
|
||||
pendingLinkConnect?.let { plc ->
|
||||
LinkConnectPrompt(
|
||||
target = plc,
|
||||
onConnect = { onConfirmLinkConnect(plc) },
|
||||
onDismiss = onDismissLinkConnect,
|
||||
)
|
||||
}
|
||||
|
||||
awaitingHostName?.let { hostLabel ->
|
||||
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.PendingLinkConnect
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -284,6 +285,8 @@ fun ConnectScreen(
|
||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
|
||||
// request-access-or-PIN choice).
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
// A `punktfunk://` link that named a saved host by a guessable reference, awaiting the OK.
|
||||
var pendingLinkConnect by remember { mutableStateOf<PendingLinkConnect?>(null) }
|
||||
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||
// A saved host being edited (name / address / port / MAC).
|
||||
@@ -632,6 +635,22 @@ fun ConnectScreen(
|
||||
if (copied) notice = message else status = message
|
||||
}
|
||||
|
||||
// "Send logs to host" — [SendLogs], the same upload the console's host menu runs. The outcome
|
||||
// is a notice either way (success and failure both name the host), because the row's whole job
|
||||
// is to tell a reporter whether the bundle actually landed.
|
||||
fun sendLogs(kh: KnownHost) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
notice = "Sending logs to ${kh.name.ifBlank { kh.address }}…"
|
||||
status = null
|
||||
scope.launch {
|
||||
val message = withContext(Dispatchers.IO) { SendLogs.toHost(context, id, kh) }
|
||||
notice = message
|
||||
}
|
||||
}
|
||||
|
||||
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
|
||||
//
|
||||
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
|
||||
@@ -673,8 +692,10 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
|
||||
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
|
||||
is HostResolution.Known -> {
|
||||
// A saved record. Pinned AND named by its (unguessable) id is the one-click contract:
|
||||
// do exactly what tapping its card does. Named by anything a web page could guess —
|
||||
// its label, its address — the same dial waits for a tap on the confirmation.
|
||||
is HostResolution.Record -> {
|
||||
// A pin that contradicts the stored one is the link being stale or lying. Hard
|
||||
// refusal: this is the one case where doing what the card does would be wrong.
|
||||
if (link.pinConflict(resolved.host)) {
|
||||
@@ -691,6 +712,10 @@ fun ConnectScreen(
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (resolved is HostResolution.Confirm) {
|
||||
pendingLinkConnect = PendingLinkConnect(resolved.host, profileRef, link.launch)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
connect(
|
||||
resolved.host.address, resolved.host.port,
|
||||
oneOffProfile = profileRef, launch = link.launch,
|
||||
@@ -767,6 +792,7 @@ fun ConnectScreen(
|
||||
onEdit = { kh -> editTarget = kh },
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onSendLogs = { kh -> sendLogs(kh) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
@@ -821,6 +847,15 @@ fun ConnectScreen(
|
||||
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
|
||||
},
|
||||
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
|
||||
pendingLinkConnect = pendingLinkConnect,
|
||||
onConfirmLinkConnect = { plc ->
|
||||
pendingLinkConnect = null
|
||||
connect(
|
||||
plc.host.address, plc.host.port,
|
||||
oneOffProfile = plc.profile, launch = plc.launch,
|
||||
)
|
||||
},
|
||||
onDismissLinkConnect = { pendingLinkConnect = null },
|
||||
awaitingHostName = awaiting?.target?.name,
|
||||
onCancelApproval = {
|
||||
awaiting?.cancelled?.set(true)
|
||||
|
||||
@@ -932,6 +932,10 @@ private val TEST_BUTTONS = listOf(
|
||||
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
"Start" to KeyEvent.KEYCODE_BUTTON_START,
|
||||
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
|
||||
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
|
||||
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
|
||||
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
|
||||
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
|
||||
"↑" to KeyEvent.KEYCODE_DPAD_UP,
|
||||
"↓" to KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
"←" to KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
|
||||
@@ -628,7 +628,7 @@ class MainActivity : ComponentActivity() {
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// 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)) {
|
||||
if (fromPad(event)) {
|
||||
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
|
||||
@@ -710,7 +710,7 @@ class MainActivity : ComponentActivity() {
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
lastPadIsGamepad = fromPad(event)
|
||||
if (lastPadIsGamepad) {
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
lastPadDeviceId = event.deviceId
|
||||
@@ -718,7 +718,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
if (fromPad(event)) {
|
||||
// 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. Read
|
||||
@@ -741,6 +741,32 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this key event come from a controller — the question every pad branch here actually
|
||||
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
|
||||
*
|
||||
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
|
||||
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
|
||||
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
|
||||
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
|
||||
* to the device — but only for keycodes that cannot be anything BUT a gamepad button.
|
||||
*
|
||||
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
|
||||
* `KEYCODE_BUTTON_*` block — no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` — and both exclusions are
|
||||
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
|
||||
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
|
||||
* keep leaving the stream, which for a device with no pad on it is the documented way out
|
||||
* ([Gamepad.padButtonBit]). Widening on the device alone — or on its vendor id, which for
|
||||
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too — routes both into the pad
|
||||
* branch and breaks them.
|
||||
*
|
||||
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
|
||||
* and both the raw and the corrected keycode are in this block for every button concerned.
|
||||
*/
|
||||
private fun fromPad(event: KeyEvent): Boolean =
|
||||
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
|
||||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
|
||||
|
||||
/**
|
||||
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
|
||||
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
|
||||
@@ -848,7 +874,7 @@ class MainActivity : ComponentActivity() {
|
||||
val url = deepLinkFrom(intent) ?: return false
|
||||
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
|
||||
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
|
||||
return target is HostResolution.Known && target.host.id == live.hostId
|
||||
return target is HostResolution.Record && target.host.id == live.hostId
|
||||
}
|
||||
|
||||
/** The host a live stream is on — see [liveStream]. */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* "Send logs to host" — this device's log ring ([NativeBridge.nativeRenderLogs], fed by
|
||||
* `pf_client_core::logring`) posted to a paired host's `POST /api/v1/client-logs` over the same
|
||||
* mTLS identity the library fetch uses. The bundle is then listed in that host's web console, on
|
||||
* its Logs page, beside the host's own log.
|
||||
*
|
||||
* ONE implementation for both Android shells — the Skia console's host menu
|
||||
* (`console.SkiaConsole`) and the touch home's card menu ([ConnectGrid]). It lived only in the
|
||||
* console, which made it unreachable on exactly the devices that most need it: a phone whose
|
||||
* console never comes up has no route to its own logs at all, and the touch UI is the shell a
|
||||
* reporter is looking at when something is wrong. The wording is the desktop console's verbatim
|
||||
* (`clients/session/src/console.rs`) so a quoted message means the same thing on every client.
|
||||
*
|
||||
* Blocking — call it off the main thread.
|
||||
*/
|
||||
object SendLogs {
|
||||
/** `punktfunk-android <ver> (android <rel>; <abi>) — client log bundle`, the desktop's shape. */
|
||||
fun header(context: Context): String {
|
||||
val version = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull() ?: "?"
|
||||
return "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
|
||||
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
|
||||
}
|
||||
|
||||
/** The user-facing outcome: the success line, or "Couldn't send logs — <why>". */
|
||||
fun toHost(context: Context, identity: ClientIdentity, host: KnownHost): String =
|
||||
toHost(
|
||||
context, identity,
|
||||
addr = host.address, mgmtPort = host.effectiveMgmtPort, fpHex = host.fpHex,
|
||||
hostName = host.name.ifBlank { host.address },
|
||||
)
|
||||
|
||||
/**
|
||||
* The address-and-port form, for the console — its menu addresses a `HostRow`, which carries
|
||||
* the mgmt port the ADVERT taught it (fresher than the saved record's).
|
||||
*/
|
||||
fun toHost(
|
||||
context: Context,
|
||||
identity: ClientIdentity,
|
||||
addr: String,
|
||||
mgmtPort: Int,
|
||||
fpHex: String,
|
||||
hostName: String,
|
||||
): String {
|
||||
val err = runCatching {
|
||||
val body = NativeBridge.nativeRenderLogs(header(context))
|
||||
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
|
||||
identity.certPem, identity.privateKeyPem, addr, fpHex,
|
||||
)
|
||||
val req = Request.Builder()
|
||||
.url("https://$addr:$mgmtPort/api/v1/client-logs")
|
||||
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { resp ->
|
||||
// The host answers 201 Created, not 200 — this is a route that STORES a bundle
|
||||
// (`mgmt/client_logs.rs`). Any 2xx is a success; OkHttp's own predicate spares us
|
||||
// a second hand-written list of codes to get wrong.
|
||||
if (resp.isSuccessful) "" else "host answered HTTP ${resp.code}"
|
||||
}
|
||||
}.getOrElse { it.message ?: "upload failed" }
|
||||
return if (err.isEmpty()) {
|
||||
"Logs sent to $hostName — download them from its web console's Logs page"
|
||||
} else {
|
||||
"Couldn't send logs — $err"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,22 @@ data class Settings(
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* Draw the console UI at 1080p and let the display scale it up, instead of at the panel's own
|
||||
* resolution. Off by default — this is a deliberate sharpness-for-smoothness trade, not
|
||||
* something to impose on a device that does not need it.
|
||||
*
|
||||
* It exists for 4K TVs and projectors. Their graphics chips are chosen to decode and composite
|
||||
* video, not to shade a UI, and are far slower than a phone's; at 4K every pass the console
|
||||
* draws — the mesh backdrop above all — costs four times what it does at 1080p on hardware
|
||||
* that is nowhere near four times faster. A "premium" 4K box is MORE likely to want this than
|
||||
* a cheap 1080p stick, which never had the extra pixels to begin with.
|
||||
*
|
||||
* Read by [io.unom.punktfunk.console.SkiaConsoleShell], which applies it with
|
||||
* `SurfaceHolder.setFixedSize` — the compositor then scales the smaller buffer up for free.
|
||||
* The stream is untouched; that has its own `renderScale`.
|
||||
*/
|
||||
val reduceUiResolution: Boolean = false,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
@@ -329,6 +345,7 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
reduceUiResolution = prefs.getBoolean(K_REDUCE_UI_RES, false),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
@@ -373,6 +390,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_REDUCE_UI_RES, s.reduceUiResolution)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
@@ -415,6 +433,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_REDUCE_UI_RES = "reduce_ui_resolution"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
@@ -589,7 +589,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||
)
|
||||
}
|
||||
SettingsGroup("Interface") {
|
||||
// The footer is null on every device where the console works, so it costs nothing there —
|
||||
// and on the ones where it doesn't, it is the only place the app admits that this switch
|
||||
// is being overruled. See `SkiaConsole.unavailable`.
|
||||
SettingsGroup("Interface", footer = io.unom.punktfunk.console.SkiaConsole.unavailable()) {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Swap the touch home for the console home — the host carousel and " +
|
||||
|
||||
@@ -328,6 +328,7 @@ internal object ConsoleJson {
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
j.put("android.reduce_ui_resolution", s.reduceUiResolution)
|
||||
// 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")
|
||||
@@ -386,6 +387,7 @@ internal object ConsoleJson {
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
reduceUiResolution = j.optBoolean("android.reduce_ui_resolution", s.reduceUiResolution),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,8 @@ 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
|
||||
|
||||
@@ -132,6 +130,26 @@ object SkiaConsole {
|
||||
return backendProp() != "none"
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the console cannot front the gamepad UI here, or null when it can — for the settings
|
||||
* screen to print under the switch that asks for it.
|
||||
*
|
||||
* `App` gates the console on `wanted() && healthy` on top of the user's own setting, and those
|
||||
* two terms are the ONLY ones that can veto "Always": the mode, the attached pad, the TV check
|
||||
* and the dev flag are ORed together, so a device where the console never comes up ignores
|
||||
* every one of them. Until this existed that produced a switch the app silently disobeyed —
|
||||
* indistinguishable, from the outside, from the switch itself being broken, and it is what a
|
||||
* report of "the gamepad UI just doesn't activate, even on Always, even with a controller"
|
||||
* looks like. Reads [healthy] as Compose state, so the note clears itself if it ever recovers.
|
||||
*/
|
||||
fun unavailable(): String? = when {
|
||||
!wanted() -> "This device has no console UI in this build, so the touch layout stays up."
|
||||
!healthy -> "The console UI couldn't start on this device, so the touch layout is " +
|
||||
"standing in. Restart the app to try again — and if it keeps happening, send this " +
|
||||
"host your logs from a saved host's ⋮ menu."
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun backendProp(): String = runCatching {
|
||||
val cls = Class.forName("android.os.SystemProperties")
|
||||
cls.getMethod("get", String::class.java, String::class.java)
|
||||
@@ -331,10 +349,11 @@ object SkiaConsole {
|
||||
}
|
||||
|
||||
/**
|
||||
* A `punktfunk://` link while the console is up. Known-and-pinned is the one-click contract
|
||||
* (the same dial the console's own Launch takes); anything that would need a trust decision
|
||||
* is a notice here — a link may never establish trust, and the console's Pair screen is
|
||||
* reached from the host's tile, not from a URL.
|
||||
* A `punktfunk://` link while the console is up. Named-by-id and pinned is the one-click
|
||||
* contract (the same dial the console's own Launch takes); anything that would need a trust
|
||||
* decision — or that named the host by a guessable label or address — is a notice here. A link
|
||||
* may never establish trust, the console's Pair screen is reached from the host's tile rather
|
||||
* than from a URL, and the console draws no prompt this shell could ask a question through.
|
||||
*/
|
||||
fun handleDeepLink(url: String) {
|
||||
if (handle == 0L) return
|
||||
@@ -357,7 +376,7 @@ object SkiaConsole {
|
||||
}
|
||||
}
|
||||
when (val resolved = io.unom.punktfunk.kit.link.DeepLinks.resolveHost(link, knownHostStore.all())) {
|
||||
is io.unom.punktfunk.kit.link.HostResolution.Known -> {
|
||||
is io.unom.punktfunk.kit.link.HostResolution.Record -> {
|
||||
val kh = resolved.host
|
||||
if (link.pinConflict(kh)) {
|
||||
notice("That link's fingerprint doesn't match the one pinned for ${kh.name}.")
|
||||
@@ -367,6 +386,10 @@ object SkiaConsole {
|
||||
notice("Pair with ${kh.name} first — a link can't establish trust.")
|
||||
return
|
||||
}
|
||||
if (resolved is io.unom.punktfunk.kit.link.HostResolution.Confirm) {
|
||||
notice("A link can only dial ${kh.name} by its id — open it from the list.")
|
||||
return
|
||||
}
|
||||
launch(
|
||||
JSONObject()
|
||||
.put("addr", kh.address).put("port", kh.port).put("fp_hex", kh.fpHex)
|
||||
@@ -623,11 +646,8 @@ object SkiaConsole {
|
||||
}
|
||||
|
||||
/**
|
||||
* `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`).
|
||||
* `ConsoleCmd::SendLogs` — [io.unom.punktfunk.SendLogs], the same upload the touch home's
|
||||
* card menu runs; the result comes back here as a notice.
|
||||
*/
|
||||
private fun sendLogs(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
|
||||
@@ -637,34 +657,10 @@ object SkiaConsole {
|
||||
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"
|
||||
val app = appContext ?: return
|
||||
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"
|
||||
},
|
||||
)
|
||||
}
|
||||
val message = io.unom.punktfunk.SendLogs.toHost(app, id, addr, mgmt, fp, hostName)
|
||||
main.post { notice(message) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
@@ -137,13 +138,53 @@ fun SkiaConsoleShell(
|
||||
// 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.75f).coerceIn(0.75f, 3f)
|
||||
// The SurfaceView's own laid-out size, fed back by `onSizeChanged` below — deliberately not
|
||||
// `displayMetrics`. The reduced buffer's aspect ratio has to match the RECT it is scaled into
|
||||
// or the compositor stretches the whole interface, and while those two normally agree,
|
||||
// `displayMetrics` has a long history of disagreeing with a view's real size by a system bar
|
||||
// depending on the version and on who is currently hiding what. "Normally agree" is not
|
||||
// something to hang picture geometry on. Zero until the first layout, which is exactly what
|
||||
// `render` wants: the surface comes up at its natural size and is re-fixed a frame later.
|
||||
var viewW by remember { mutableStateOf(0) }
|
||||
var viewH by remember { mutableStateOf(0) }
|
||||
// "Reduce interface resolution" (`Settings.reduceUiResolution`): cap the console's BUFFER at
|
||||
// 1920 on its long edge and let the compositor scale it up to the panel. 1 means "draw at the
|
||||
// panel's own resolution" — the setting is off, or the display is already at or under 1080p
|
||||
// and there is nothing to give back.
|
||||
//
|
||||
// ONE factor on both axes, so the aspect ratio survives exactly and no layout can stretch.
|
||||
// Everything else in this function that speaks in SURFACE pixels multiplies by it — the insets
|
||||
// and design-unit scale just below, the pointer coordinates further down — because
|
||||
// `setFixedSize` shrinks the buffer WITHOUT shrinking the view: a mouse still reports its
|
||||
// position in view pixels, and handing those straight to a half-size surface would land the
|
||||
// cursor at twice its true offset.
|
||||
val render = if (!settings.reduceUiResolution) 1f else {
|
||||
val long = maxOf(viewW, viewH)
|
||||
if (long > 1920) 1920f / long else 1f
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
// The pointer listeners below are installed in `factory`, which runs ONCE — capturing `render`
|
||||
// directly would freeze them at its first-composition value (1, before the first layout has
|
||||
// reported a size), and a mouse would keep reporting view pixels into a half-size surface for
|
||||
// the rest of the session. Same reason `platformUp` is held this way.
|
||||
val currentRender by rememberUpdatedState(render)
|
||||
val dm = context.resources.displayMetrics
|
||||
val scale = if (tv) 0f else {
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
// `render` too: the design-unit scale is in SURFACE pixels, so shrinking the buffer without
|
||||
// shrinking this would draw the type larger on screen than the same phone draws it today.
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f) * render
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale, render) {
|
||||
if (handle != 0L) {
|
||||
NativeBridge.nativeConsoleSetViewport(
|
||||
handle,
|
||||
left * render,
|
||||
top * render,
|
||||
right * render,
|
||||
bottom * render,
|
||||
scale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
|
||||
@@ -272,7 +313,9 @@ fun SkiaConsoleShell(
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { viewW = it.width; viewH = it.height },
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
// The console draws opaque, edge to edge; Compose overlays sit above it.
|
||||
@@ -305,7 +348,8 @@ fun SkiaConsoleShell(
|
||||
MotionEvent.ACTION_CANCEL -> 5
|
||||
else -> return@setOnTouchListener false
|
||||
}
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
|
||||
// View pixels → SURFACE pixels (see `render` above).
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x * currentRender, ev.y * currentRender, 0f)
|
||||
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
|
||||
true
|
||||
}
|
||||
@@ -313,13 +357,27 @@ fun SkiaConsoleShell(
|
||||
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
|
||||
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
|
||||
) {
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x * currentRender, ev.y * currentRender, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
true
|
||||
} else false
|
||||
}
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
},
|
||||
// Applied here rather than in `factory` so flipping the setting takes effect without
|
||||
// leaving the console: `setFixedSize` re-creates the buffer and the render thread
|
||||
// re-wraps it through the ordinary surfaceChanged path. `setSizeFromLayout` is the
|
||||
// documented way back to "the view's own size" when the setting goes off again.
|
||||
update = { view ->
|
||||
if (render < 1f) {
|
||||
view.holder.setFixedSize(
|
||||
(viewW * render).roundToInt().coerceAtLeast(1),
|
||||
(viewH * render).roundToInt().coerceAtLeast(1),
|
||||
)
|
||||
} else {
|
||||
view.holder.setSizeFromLayout()
|
||||
}
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
|
||||
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
|
||||
enum class Tab(val label: String, val icon: ImageVector) {
|
||||
@@ -37,6 +38,22 @@ data class PendingTrust(
|
||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
|
||||
}
|
||||
|
||||
/**
|
||||
* A `punktfunk://` link that named a saved host by something GUESSABLE — its display name or its
|
||||
* address — instead of by its stable record id, waiting for the user's OK before it dials.
|
||||
*
|
||||
* MainActivity is exported with a BROWSABLE `punktfunk://` filter, so any app or web page can emit
|
||||
* `punktfunk://connect/Gaming%20PC?launch=steam:570`; guessing a label must not be enough to start
|
||||
* a stream and boot a game. A link that names the record id (the shortcuts this app emits) still
|
||||
* connects on its own. [profile] and [launch] are the link's, carried across the detour exactly as
|
||||
* [PendingTrust] carries them.
|
||||
*/
|
||||
data class PendingLinkConnect(
|
||||
val host: KnownHost,
|
||||
val profile: String? = null,
|
||||
val launch: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A stream session that just opened, and the state the stream screen needs about it.
|
||||
*
|
||||
|
||||
@@ -282,6 +282,17 @@ object Gamepad {
|
||||
* `KEYCODE_DPAD_*` are included but must only be routed here when the event is from a gamepad
|
||||
* (a keyboard's arrow keys share these keycodes and belong to the VK path) — see MainActivity.
|
||||
* L2/R2 are forwarded as the analog trigger axes, never as buttons.
|
||||
*
|
||||
* [BTN_TOUCHPAD] and [BTN_MISC1] have no Android keycode at all, so
|
||||
* [PadButtons.GENERIC_SONY] BORROWS the last two rows of `Generic.kl`'s joystick block for
|
||||
* them ([KEYCODE_BUTTON_15][KeyEvent.KEYCODE_BUTTON_15] / `_16`, evdev `BTN_BASE5`/`BTN_BASE6`)
|
||||
* — see there. This table is global, so a device that genuinely presses one of those two
|
||||
* emits the bit as well. That is the cost of the borrow, and it is why the borrow is at the
|
||||
* TOP of the block rather than at `BUTTON_1`/`BUTTON_2`: those are a flight stick's trigger
|
||||
* and thumb button, which any joystick-usage HID device reports, whereas reaching `BUTTON_15`
|
||||
* takes a pad that declares fifteen. The residual case — a fifteen-button HOTAS whose button
|
||||
* 16 also toggles the client's mic — is the one this leaves on the table; narrowing it
|
||||
* further needs per-device knowledge the router does not have (see `GamepadRouter`).
|
||||
*/
|
||||
fun buttonBit(keyCode: Int): Int = when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> BTN_A
|
||||
@@ -295,6 +306,8 @@ object Gamepad {
|
||||
KeyEvent.KEYCODE_BUTTON_START -> BTN_START
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> BTN_BACK
|
||||
KeyEvent.KEYCODE_BUTTON_MODE -> BTN_GUIDE
|
||||
KeyEvent.KEYCODE_BUTTON_15 -> BTN_TOUCHPAD // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_BUTTON_16 -> BTN_MISC1 // borrowed — see the KDoc
|
||||
KeyEvent.KEYCODE_DPAD_UP -> BTN_DPAD_UP
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> BTN_DPAD_DOWN
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> BTN_DPAD_LEFT
|
||||
@@ -404,9 +417,12 @@ object Gamepad {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* L2 R2 Create Options L3 R3 PS touchpad mute, i.e. `0x130`..`0x13e` 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.
|
||||
*
|
||||
* This order — and ONLY this order — is where `0x13d`/`0x13e` mean the touchpad click and
|
||||
* the mute button. Everywhere else they are L3/R3.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
@@ -453,7 +469,23 @@ object Gamepad {
|
||||
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.
|
||||
// Touchpad click and mute. The wire has bits for both ([BTN_TOUCHPAD] /
|
||||
// [BTN_MISC1]) and Android has no keycode for either, so these two borrow
|
||||
// BUTTON_15/BUTTON_16 to reach [buttonBit] — see its KDoc for the cost.
|
||||
//
|
||||
// ONLY here. `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR (L3/R3) in the standard
|
||||
// Linux mapping — [genericKeyCode] says so itself — and they mean touchpad and
|
||||
// mute purely because a driverless DualSense enumerates its buttons straight
|
||||
// through in its own report order, which is what GENERIC_SONY IS. Hoisting
|
||||
// this above `padMap(dev)` would put L3 on the touchpad and R3 on the mic for
|
||||
// every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation`
|
||||
// DualSense on the couch. There is no scancode that means the same button on
|
||||
// all pads; that is the entire reason this enum exists.
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_15 // touchpad click → BTN_TOUCHPAD
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_16 // mute → BTN_MISC1
|
||||
// Unreachable with the guard above in force (it only lets `0x130`..`0x13e`
|
||||
// through, and every one of those is now named), and KEYCODE_UNKNOWN is the
|
||||
// safe answer if that ever changes.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
|
||||
@@ -101,6 +101,18 @@ class GamepadRouter(
|
||||
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
|
||||
*/
|
||||
val motionReaches: Boolean = true,
|
||||
/**
|
||||
* Whether [Gamepad.BTN_MISC1] means a MUTE button on this particular pad — the one bit
|
||||
* whose physical meaning differs per controller, and the gate on the mic toggle in
|
||||
* [slotButton].
|
||||
*
|
||||
* A DualSense has one; a Steam Controller 2 puts its QAM button on the same wire bit
|
||||
* (`Sc2Device`), and QAM must not mute anyone's microphone. Asked once at open, off the
|
||||
* fact each path actually knows: the report order for an [InputDevice] (only
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] mints this bit there), the declared pad kind for a
|
||||
* capture link.
|
||||
*/
|
||||
val hasMuteButton: Boolean = false,
|
||||
) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
@@ -160,7 +172,8 @@ class GamepadRouter(
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
|
||||
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* a pad, or a pad's own mute button ([Gamepad.BTN_MISC1] — a DualSense's) is pressed — the
|
||||
* couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
|
||||
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
|
||||
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
|
||||
@@ -234,15 +247,40 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this bit's WIRE SEND kept with this device, though the bit is otherwise tracked normally?
|
||||
*
|
||||
* Exactly one is: a real mute button ([Slot.hasMuteButton]) under the "local" [systemForward]
|
||||
* policy. It is tracked — the mic toggle in [slotButton] is edge-triggered off held state —
|
||||
* but not forwarded, so every send site has to ask, including [releaseHeld]'s close-time
|
||||
* flush, or a mute held across a disconnect would put a release on the wire for a press that
|
||||
* never went out. Every other system button under that policy leaves [slotButton] at the top
|
||||
* and never reaches a send at all.
|
||||
*/
|
||||
private fun localOnly(slot: Slot, bit: Int): Boolean =
|
||||
!systemForward && bit == Gamepad.BTN_MISC1 && slot.hasMuteButton
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD], and the mute button's own mic toggle).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either. A Steam
|
||||
// Controller 2's QAM button is BTN_MISC1 and keeps exactly that behaviour.
|
||||
//
|
||||
// A real MUTE button ([Slot.hasMuteButton]) is deliberately exempt: that policy's own
|
||||
// words are "keeps them entirely with this device", and toggling this device's microphone
|
||||
// is precisely what a mute button does with itself. Returning here would have left the
|
||||
// button present and silently dead under `local`, for a reason nobody would ever find. It
|
||||
// loses its wire send instead (see [localOnly]) and keeps the held tracking the toggle's
|
||||
// edge-trigger reads. It cannot leak into a chord — MISC1 is in none of them.
|
||||
if (!systemForward &&
|
||||
(bit == Gamepad.BTN_GUIDE || (bit == Gamepad.BTN_MISC1 && !slot.hasMuteButton))
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
@@ -258,7 +296,7 @@ class GamepadRouter(
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
if (send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
@@ -268,11 +306,26 @@ class GamepadRouter(
|
||||
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
|
||||
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
|
||||
// face button. Both leave the press on the wire: the game still gets its buttons.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
|
||||
//
|
||||
// A pad's own mute button is a second trigger for the SAME toggle, not a new
|
||||
// mechanism — so it gets the same edge-trigger, expressed as the one-button chord it
|
||||
// is. That is load-bearing rather than tidy: [onButton] deliberately still calls this
|
||||
// with `down = true` on auto-repeat and suppresses only `send` (its repeatCount
|
||||
// guard), so an unguarded `bit == BTN_MISC1` would flap the mic for as long as the
|
||||
// button is held down.
|
||||
//
|
||||
// [Slot.hasMuteButton] is the other half, and it is not belt-and-braces: BTN_MISC1 is
|
||||
// the wire's misc/QAM bit, and `Sc2Device` puts a Steam Controller 2's QAM button on
|
||||
// it. Reading "any MISC1" as mute would mute the microphone on every QAM press.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD) ||
|
||||
(slot.hasMuteButton && completesChord(wasHeld, bit, Gamepad.BTN_MISC1))
|
||||
) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
if (!owned && send && forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -543,7 +596,15 @@ class GamepadRouter(
|
||||
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
|
||||
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
|
||||
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
// `DsDevice` raises BTN_MISC1 from the DualSense report's mute bit; `Sc2Device` raises the
|
||||
// same bit from the Steam Controller 2's QAM button, which must not touch the microphone.
|
||||
// The declared kind separates them (a DualShock 4 has no mute button either).
|
||||
val hasMute = pref == Gamepad.PREF_DUALSENSE || pref == Gamepad.PREF_DUALSENSEEDGE
|
||||
slots[syntheticId] = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
hasMuteButton = hasMute,
|
||||
)
|
||||
return ExternalPad(syntheticId, index, motionReaches)
|
||||
}
|
||||
|
||||
@@ -603,10 +664,15 @@ class GamepadRouter(
|
||||
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
|
||||
// which under Automatic answers for whichever pad happened to be active at dial time. Held
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val map = Gamepad.padMap(dev)
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
Gamepad.AxisMapper(handle, index, map),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
// The only route to BTN_MISC1 on this path is GENERIC_SONY's `0x13e` row, so the
|
||||
// report order IS the answer — and unlike `pref` it survives the user pinning every
|
||||
// pad to one type, which would otherwise cost a DualSense its mute button.
|
||||
hasMuteButton = map.buttons == Gamepad.PadButtons.GENERIC_SONY,
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
@@ -652,7 +718,9 @@ class GamepadRouter(
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding && !localOnly(slot, bit)) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
|
||||
@@ -154,15 +154,22 @@ object DeepLinks {
|
||||
|
||||
/**
|
||||
* Resolve a link's host reference against the local store, in the documented order: stable
|
||||
* record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
|
||||
* the recovery path — a self-emitted shortcut that outlived the record it was written from
|
||||
* still lands on the right box (degraded to the confirmation sheet).
|
||||
* record id → unique case-insensitive name → `addr[:port]` literal, then the `host=` recovery
|
||||
* parameter — a self-emitted shortcut that outlived the record it was written from still lands
|
||||
* on the right box.
|
||||
*
|
||||
* Only the record id is UNGUESSABLE, so only the record id resolves to [HostResolution.Known],
|
||||
* the silent one-click contract. A display name comes from an mDNS instance name or a user
|
||||
* label ("Gaming PC"), and an address is a LAN address: any zero-permission app or web page can
|
||||
* emit `punktfunk://connect/Gaming%20PC` and would otherwise start a stream (and launch a
|
||||
* title) on a guess. Those all resolve to [HostResolution.Confirm] — the same host, behind the
|
||||
* user's OK.
|
||||
*/
|
||||
fun resolveHost(link: DeepLink, hosts: List<KnownHost>): HostResolution {
|
||||
hosts.firstOrNull { it.id == link.hostRef }?.let { return HostResolution.Known(it) }
|
||||
val byName = hosts.filter { it.name.equals(link.hostRef, ignoreCase = true) }
|
||||
when (byName.size) {
|
||||
1 -> return HostResolution.Known(byName[0])
|
||||
1 -> return HostResolution.Confirm(byName[0])
|
||||
0 -> Unit
|
||||
else -> return HostResolution.Ambiguous
|
||||
}
|
||||
@@ -173,7 +180,7 @@ object DeepLinks {
|
||||
val literal = if (looksLikeAddress(link.hostRef)) parseAddrPort(link.hostRef) else null
|
||||
for ((addr, port) in listOfNotNull(literal, link.host)) {
|
||||
hosts.firstOrNull { it.address == addr && it.port == port }
|
||||
?.let { return HostResolution.Known(it) }
|
||||
?.let { return HostResolution.Confirm(it) }
|
||||
}
|
||||
val fallback = literal ?: link.host ?: return HostResolution.Unresolvable
|
||||
return HostResolution.Unknown(fallback.first, fallback.second, link.name, link.fp)
|
||||
@@ -429,8 +436,23 @@ sealed interface DeepLinkResult {
|
||||
|
||||
/** What the local host store made of a link's references. */
|
||||
sealed interface HostResolution {
|
||||
/** A record we already trust (subject to [DeepLink.pinConflict]). */
|
||||
data class Known(val host: KnownHost) : HostResolution
|
||||
/** A saved record — [Known] may act on its own, [Confirm] only once the user says so. */
|
||||
sealed interface Record : HostResolution {
|
||||
val host: KnownHost
|
||||
}
|
||||
|
||||
/**
|
||||
* A record we already trust, named by its stable (unguessable) id: the one-click contract,
|
||||
* subject to [DeepLink.pinConflict].
|
||||
*/
|
||||
data class Known(override val host: KnownHost) : Record
|
||||
|
||||
/**
|
||||
* The same record, but named by something GUESSABLE — its display name, its address, or the
|
||||
* `host=` recovery parameter. A link may not start a stream on a guess, so this one goes to
|
||||
* the confirmation the front-end shows: same dial, one tap later.
|
||||
*/
|
||||
data class Confirm(override val host: KnownHost) : Record
|
||||
|
||||
/**
|
||||
* No record, but the link says where to dial: the confirmation sheet's input, from which the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -155,6 +156,44 @@ class GamepadChordTest {
|
||||
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/**
|
||||
* A pad's own mute button (a DualSense's) is a second trigger for the mic toggle, and
|
||||
* `slotButton` reads it through the SAME edge rule expressed as a one-button chord.
|
||||
*
|
||||
* That is not decoration. `onButton` deliberately still calls `slotButton(down = true)` on
|
||||
* auto-repeat and suppresses only the wire send (its repeatCount guard), so a plain
|
||||
* `bit == BTN_MISC1` would toggle the mic on every repeat — hold the button and the mic
|
||||
* flaps. `completesChord` against a single-bit mask is exactly "a fresh press of it".
|
||||
*
|
||||
* The other half is which buttons must NOT reach it. `0x13e` is R3 on every pad but a
|
||||
* driverless Sony one, so a mapping that leaked touchpad/mute meanings outside
|
||||
* [Gamepad.PadButtons.GENERIC_SONY] would put the mic toggle on every R3 press in the house.
|
||||
*
|
||||
* `slotButton` ANDs this rule with `Slot.hasMuteButton`, because BTN_MISC1 is the wire's
|
||||
* misc/QAM bit and a Steam Controller 2's QAM button rides it too. That term needs a live
|
||||
* `Slot`, which needs an InputManager and a main Looper, so it is out of reach from here —
|
||||
* the edge rule below is the half a unit test can hold.
|
||||
*/
|
||||
@Test
|
||||
fun `the mute button toggles the mic once per press`() {
|
||||
fun fires(wasHeld: Int, bit: Int) =
|
||||
GamepadRouter.completesChord(wasHeld, bit, Gamepad.BTN_MISC1)
|
||||
|
||||
assertTrue("a fresh press must toggle", fires(0, Gamepad.BTN_MISC1))
|
||||
assertFalse("auto-repeat re-fired the toggle", fires(Gamepad.BTN_MISC1, Gamepad.BTN_MISC1))
|
||||
assertTrue(
|
||||
"a press while other buttons are held is still a fresh press",
|
||||
fires(Gamepad.BTN_A or Gamepad.BTN_BACK, Gamepad.BTN_MISC1),
|
||||
)
|
||||
for (other in listOf(
|
||||
Gamepad.BTN_A, Gamepad.BTN_X, Gamepad.BTN_Y, Gamepad.BTN_BACK,
|
||||
Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, Gamepad.BTN_GUIDE, Gamepad.BTN_TOUCHPAD,
|
||||
)) {
|
||||
assertFalse("$other toggled the mic", fires(0, other))
|
||||
assertFalse("$other toggled the mic under a held mute", fires(Gamepad.BTN_MISC1, other))
|
||||
}
|
||||
}
|
||||
|
||||
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
|
||||
@Test
|
||||
fun `chord masks are wire button bits`() {
|
||||
|
||||
@@ -64,12 +64,44 @@ class PadButtonsTest {
|
||||
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. */
|
||||
/**
|
||||
* The touchpad click and the mute button reach the wire, on the two bits that exist for them.
|
||||
* Android has no keycode for either, so [Gamepad.PadButtons.GENERIC_SONY] borrows BUTTON_15
|
||||
* and BUTTON_16 to carry them into [Gamepad.buttonBit] — the keycode is an implementation
|
||||
* detail of that hop, the BIT is the contract, so both halves are pinned here.
|
||||
*/
|
||||
@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)))
|
||||
fun `a DualSense's touchpad and mute reach their wire buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_15, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_16, sony(0x13e))
|
||||
assertEquals(Gamepad.BTN_TOUCHPAD, Gamepad.buttonBit(sony(0x13d)))
|
||||
assertEquals(Gamepad.BTN_MISC1, Gamepad.buttonBit(sony(0x13e)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression the touchpad/mute mapping is one hoist away from causing, and the reason it
|
||||
* lives inside GENERIC_SONY rather than anywhere above `padMap(dev)`.
|
||||
*
|
||||
* `0x13d`/`0x13e` are `BTN_THUMBL`/`BTN_THUMBR` — L3 and R3 — in the standard Linux/AOSP
|
||||
* mapping, which is what [Gamepad.genericKeyCode] says they are. They mean touchpad click and
|
||||
* mute ONLY inside the straight-through enumeration a driverless Sony pad uses. Read as
|
||||
* touchpad and mute anywhere else, every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
|
||||
* `hid-playstation` DualSense loses both stick clicks — and R3 starts toggling the microphone.
|
||||
*/
|
||||
@Test
|
||||
fun `every other pad keeps L3 and R3 on those scancodes`() {
|
||||
for (p in listOf(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.PadButtons.GENERIC_XBOX,
|
||||
Gamepad.PadButtons.SONY_MODERN,
|
||||
)) {
|
||||
val l3 = p.correct(0x13d, Gamepad.genericKeyCode(0x13d))
|
||||
val r3 = p.correct(0x13e, Gamepad.genericKeyCode(0x13e))
|
||||
assertEquals("$p L3", KeyEvent.KEYCODE_BUTTON_THUMBL, l3)
|
||||
assertEquals("$p R3", KeyEvent.KEYCODE_BUTTON_THUMBR, r3)
|
||||
assertEquals("$p L3 bit", Gamepad.BTN_LS_CLICK, Gamepad.buttonBit(l3))
|
||||
assertEquals("$p R3 bit", Gamepad.BTN_RS_CLICK, Gamepad.buttonBit(r3))
|
||||
}
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@@ -116,6 +148,30 @@ class PadButtonsTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard's NEGATIVE path — the half that decides anything.
|
||||
*
|
||||
* The cases above all deliver the keycode `Generic.kl` would have produced, so the guard is
|
||||
* transparent in every one of them and the assertions would hold with it deleted. These are
|
||||
* the ones that fail without it: a device-specific key layout answering something the table
|
||||
* disagrees with, on a scancode the table has an opinion about. The layout wins — it knows
|
||||
* this controller, and the table is only ever a guess about a pad nothing knew.
|
||||
*/
|
||||
@Test
|
||||
fun `a device layout outranks the table on a scancode the table would have rewritten`() {
|
||||
// `Generic.kl` calls 0x134 BUTTON_Y, and GENERIC_SONY/GENERIC_XBOX both rewrite that
|
||||
// scancode to BUTTON_L1. A layout that says BUTTON_X must survive both.
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_SONY, Gamepad.PadButtons.GENERIC_XBOX)) {
|
||||
assertEquals("$p", KeyEvent.KEYCODE_BUTTON_X, p.correct(0x134, KeyEvent.KEYCODE_BUTTON_X))
|
||||
}
|
||||
// And the two rows added for the touchpad and mute are no different: a pad whose layout
|
||||
// resolved 0x13d itself keeps that answer rather than the borrowed BUTTON_15.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BUTTON_1,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(0x13d, KeyEvent.KEYCODE_BUTTON_1),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
|
||||
@@ -70,7 +70,9 @@ class DeepLinkVectorTest {
|
||||
* Resolution and emission — the half the vector file can't cover, because it depends on what is in
|
||||
* THIS device's host store. The rules are the one-click contract in resolution form: an id beats a
|
||||
* name beats an address, an ambiguous name refuses rather than guesses, and a link whose record is
|
||||
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying.
|
||||
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying. Only the id — the
|
||||
* one reference nothing can guess — dials on its own; a name or an address resolves to the same
|
||||
* host behind a confirmation.
|
||||
*/
|
||||
class DeepLinkResolutionTest {
|
||||
private val fp = "a".repeat(64)
|
||||
@@ -86,20 +88,47 @@ class DeepLinkResolutionTest {
|
||||
|
||||
@Test
|
||||
fun idBeatsNameBeatsAddress() {
|
||||
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Record).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Record).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Record).host)
|
||||
assertEquals(
|
||||
desk,
|
||||
(resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Record).host,
|
||||
)
|
||||
// Two hosts answer to "Couch" — refuse with a notice, never pick one.
|
||||
assertEquals(HostResolution.Ambiguous, resolve("punktfunk://connect/couch"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The record id is a UUID nothing can guess; a display name ("Gaming PC") and a LAN address are
|
||||
* guesses any web page can make. So the id — and only the id — is the silent one-click dial;
|
||||
* everything else that finds a saved host stops at [HostResolution.Confirm].
|
||||
*/
|
||||
@Test
|
||||
fun onlyTheRecordIdDialsWithoutAsking() {
|
||||
assertEquals(HostResolution.Known(desk), resolve("punktfunk://connect/${desk.id}"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/desk"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/DESK"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50:9777"))
|
||||
// …including the `host=` recovery path, exactly as its own doc always claimed.
|
||||
assertEquals(
|
||||
HostResolution.Confirm(desk),
|
||||
resolve("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
|
||||
)
|
||||
// A launch id doesn't buy a name any authority it didn't have.
|
||||
assertEquals(
|
||||
HostResolution.Confirm(desk),
|
||||
resolve("punktfunk://connect/desk?launch=steam:570"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleIdRecoversThroughTheHostParameter() {
|
||||
val stale = "00000000-0000-4000-8000-000000000000"
|
||||
assertEquals(
|
||||
desk,
|
||||
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Known).host,
|
||||
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Record).host,
|
||||
)
|
||||
// …but a stale id is NOT a hostname: dialing "00000000-…" would be a confusing dead end
|
||||
// rather than the recovery the grammar specifies.
|
||||
|
||||
@@ -16,7 +16,8 @@ use ndk::native_window::NativeWindow;
|
||||
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
|
||||
use pf_console_ui::{
|
||||
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, Insets, Key, SnapshotStore, Viewport,
|
||||
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, InputSource, Insets, Key, SnapshotStore,
|
||||
Viewport,
|
||||
};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::collections::VecDeque;
|
||||
@@ -223,6 +224,7 @@ impl ConsoleHost {
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-console".into())
|
||||
.spawn(move || {
|
||||
boost_thread_priority();
|
||||
let run = || -> Result<()> {
|
||||
let console = Console::new(opts, entry, &thread_handles)?;
|
||||
render_loop(console, thread_shared.clone(), thread_store)
|
||||
@@ -249,6 +251,34 @@ impl ConsoleHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: lift the console's render thread off the default nice band, the same way
|
||||
/// `decode::setup::boost_thread_priority` lifts the decode thread. This thread IS the console's
|
||||
/// frame loop — every menu press waits on it — and at default priority a TV box's scheduler is
|
||||
/// free to park it on a little core behind whatever else the system is doing, which reads as a
|
||||
/// UI that lags the remote. `-8` rather than the decode path's `-10`: a stream's frames are the
|
||||
/// harder deadline, and the two should not compete when the console is up during a session.
|
||||
///
|
||||
/// Non-fatal if the platform refuses (the exact floor a foreground app may set is policy).
|
||||
fn boost_thread_priority() {
|
||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; PRIO_PROCESS
|
||||
// with a TID targets that one task on Linux — the idiom `Process.setThreadPriority` uses.
|
||||
unsafe {
|
||||
let tid = libc::gettid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||
log::debug!(
|
||||
"console: setpriority(-8) failed (non-fatal): {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the render loop reports what a frame is costing it. Nothing in a bug report from a
|
||||
/// TV said whether the console was drawing at 4K or at 60 Hz, so "it feels sluggish" could not be
|
||||
/// triaged from a log bundle at all — this is that missing line. One line a minute is cheap
|
||||
/// enough to leave on for everyone, and the answer is only useful from the box that is slow.
|
||||
const FRAME_REPORT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// 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.
|
||||
@@ -283,6 +313,9 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
|
||||
let mut gl_failures = 0u32;
|
||||
const GL_FAILURE_LIMIT: u32 = 3;
|
||||
// What a frame is costing, reported once a `FRAME_REPORT` window (see there).
|
||||
let (mut frames, mut frame_time, mut frame_peak) = (0u32, Duration::ZERO, Duration::ZERO);
|
||||
let mut report_at = Instant::now();
|
||||
|
||||
loop {
|
||||
// Take everything queued. With no surface up, block until something arrives.
|
||||
@@ -314,7 +347,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
Cmd::Menu(ev) => {
|
||||
last_input = Instant::now();
|
||||
if let Some(p) = console.menu(ev) {
|
||||
// Discrete events are the remote/keyboard path (Kotlin routes pad
|
||||
// buttons through PadSample) — with one wrinkle: a pad's SELECT also
|
||||
// arrives here (SkiaConsoleShell's ▲-on-Home shortcut), briefly
|
||||
// reading as keys. The next real pad press corrects the legend.
|
||||
if let Some(p) = console.menu(ev, InputSource::Keys) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
@@ -422,7 +459,7 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
menu_out.clear();
|
||||
nav.poll(&sample, Instant::now(), &mut menu_out);
|
||||
for ev in menu_out.drain(..) {
|
||||
if let Some(p) = console.menu(ev) {
|
||||
if let Some(p) = console.menu(ev, InputSource::Pad) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
@@ -446,8 +483,17 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
skia = None;
|
||||
match g.wrap_window(&egl, w, h) {
|
||||
Ok(surf) => {
|
||||
// The console's real render resolution — the one number a bug report
|
||||
// from a TV never carried. A 4K panel is 4× the fragment work of 1080p
|
||||
// for every pass the shell draws.
|
||||
log::info!("console: drawing at {w}×{h}");
|
||||
skia = Some((surf, w, h));
|
||||
gl_failures = 0;
|
||||
// Start the frame window here, not at loop entry: the console parks
|
||||
// with no surface while a stream is up, and a window that had been
|
||||
// open across that would report its first frame as "1 frame in 20 min".
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: {e:#}");
|
||||
@@ -462,6 +508,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
insets,
|
||||
scale,
|
||||
};
|
||||
// Around the DRAW only, not the swap: `eglSwapBuffers` blocks on vsync, so
|
||||
// wall-clock per iteration is always ~the panel period and says nothing. What
|
||||
// matters is how much of that period the shell spends building the frame —
|
||||
// once that passes the period, the console is missing vsyncs.
|
||||
let drew = Instant::now();
|
||||
console.frame(
|
||||
surf.canvas(),
|
||||
&viewport,
|
||||
@@ -470,6 +521,20 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
&pads,
|
||||
);
|
||||
g.context.flush_and_submit();
|
||||
let cost = drew.elapsed();
|
||||
frame_time += cost;
|
||||
frame_peak = frame_peak.max(cost);
|
||||
frames += 1;
|
||||
if report_at.elapsed() >= FRAME_REPORT {
|
||||
log::info!(
|
||||
"console: {w}×{h}, {frames} frames in {:?} — {:.1} ms/frame mean, {:.1} ms peak",
|
||||
report_at.elapsed(),
|
||||
frame_time.as_secs_f64() * 1000.0 / f64::from(frames),
|
||||
frame_peak.as_secs_f64() * 1000.0,
|
||||
);
|
||||
(frames, frame_time, frame_peak, report_at) =
|
||||
(0, Duration::ZERO, Duration::ZERO, Instant::now());
|
||||
}
|
||||
if let Err(e) = s.swap() {
|
||||
// The window went away under us; wait for the next surface.
|
||||
log::warn!("console: {e:#} — dropping the surface");
|
||||
|
||||
@@ -21,6 +21,25 @@
|
||||
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
|
||||
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
|
||||
//! auto-fallback with the residual risk being visual, not a crash.
|
||||
//!
|
||||
//! **The acquire fence must come from `acquireNextImageAsync`, never `acquireLatestImageAsync`.**
|
||||
//! `AImageReader::acquireLatestImage` (`NdkImageReader.cpp`, unfixed as of AOSP main) drains with
|
||||
//! one `int*` out-param it overwrites per image, then releases each dropped image with whatever the
|
||||
//! out-param currently holds — the *successor's* fence:
|
||||
//!
|
||||
//! ```text
|
||||
//! acquireImageLocked(&prev, fd) → *fd = F1 (prev = img1)
|
||||
//! acquireImageLocked(&next, fd) → *fd = F2 (next = img2; F1 overwritten and leaked)
|
||||
//! prev->close(*fd) → reader adopts F2 as img1's release fence, then closes it
|
||||
//! acquireImageLocked(&next, fd) → no buffer; leaves *fd alone
|
||||
//! returns img2 with *fd = F2 ← already given away and closed
|
||||
//! ```
|
||||
//!
|
||||
//! So the moment a burst gives it two images to collapse, the caller is handed a stale fd plus one
|
||||
//! leaked fd per extra drop. Passing that stale fd to `setBuffer` transfers it to SurfaceFlinger,
|
||||
//! which closes it again — an `fdsan` `SIGABRT` on the decode thread, either at `Fence::Fence(int)`
|
||||
//! inside `setBuffer` (the number was already re-owned) or at the end of `Transaction::apply` when
|
||||
//! the layer state is torn down. `AscBackend::drain_reader` therefore does newest-wins itself.
|
||||
|
||||
use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
|
||||
@@ -145,6 +164,21 @@ impl AscBackend {
|
||||
/// 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.
|
||||
///
|
||||
/// `overlay` sets the reader's gralloc ask. `true` adds `COMPOSER_OVERLAY`, letting HWC scan
|
||||
/// the buffer out directly instead of paying a GPU composition pass — the right default, and
|
||||
/// what every device the presenter was tuned on allocates without blinking. It is also the one
|
||||
/// reader parameter that can make `AMediaCodec_start` fail AFTER a clean configure: start is
|
||||
/// where ACodec dequeues (= gralloc-allocates) every codec output buffer from this reader's
|
||||
/// window, with our consumer usage OR'd into the decoder's own producer bits — and an old
|
||||
/// 32-bit OMX BSP (the Mi TV Stick's Amlogic gralloc) can refuse the combined
|
||||
/// overlay + GPU-sampled + vendor-vdec allocation outright. `false` asks for
|
||||
/// `GPU_SAMPLED_IMAGE` alone — the SurfaceTexture shape every TextureView/WebView video path
|
||||
/// exercises, the most universally allocatable there is; SurfaceFlinger then GPU-composites the
|
||||
/// layer (one 1080p quad — noise), and everything else about the backend is identical: real
|
||||
/// latches, real fences, `setBuffer` has no overlay requirement. (`READER_MAX_IMAGES` is NOT a
|
||||
/// start-time factor — consumer-side images allocate lazily during streaming — so usage is the
|
||||
/// only axis a start-failure retry needs.)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
@@ -155,10 +189,13 @@ impl AscBackend {
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
overlay: bool,
|
||||
) -> Option<AscBackend> {
|
||||
let layer = Layer::create(window, surface_size)?;
|
||||
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
|
||||
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
let mut usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE;
|
||||
if overlay {
|
||||
usage |= ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
}
|
||||
let reader = match ImageReader::new_with_usage(
|
||||
src_w.max(1),
|
||||
src_h.max(1),
|
||||
@@ -192,11 +229,12 @@ impl AscBackend {
|
||||
),
|
||||
};
|
||||
log::info!(
|
||||
"asc: backend up — {} ({}x{} @ {} Hz src, panel seed {} Hz, dataspace {:#x})",
|
||||
"asc: backend up — {}, reader usage {} ({}x{} @ {} Hz src, panel seed {} Hz, dataspace {:#x})",
|
||||
match priority {
|
||||
PresentPriority::Latency => "latency (newest-wins)".to_string(),
|
||||
PresentPriority::Smooth { buffer } => format!("smooth (buffer {buffer})"),
|
||||
},
|
||||
if overlay { "overlay" } else { "gpu-only" },
|
||||
src_w,
|
||||
src_h,
|
||||
source_hz,
|
||||
@@ -376,19 +414,24 @@ impl AscBackend {
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
|
||||
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older ones
|
||||
/// drop back to the pool as they are superseded); smooth keeps order up to capacity.
|
||||
///
|
||||
/// Both modes drain with `acquireNextImageAsync`, one image at a time. `acquireLatestImageAsync`
|
||||
/// is the obvious newest-wins call and is NOT usable — see the acquire-fence note at the top of
|
||||
/// this module.
|
||||
fn drain_reader(&mut self) {
|
||||
if self.fifo_capacity == 0 {
|
||||
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
|
||||
if let Some(acq) = self.acquire(true) {
|
||||
// Newest-wins: collapse the burst to the freshest buffer ourselves. Each superseded
|
||||
// candidate drops here — its image returns to the pool, its own acquire fence closes.
|
||||
while let Some(acq) = self.acquire() {
|
||||
if self.candidate.replace(acq).is_some() {
|
||||
self.skipped += 1; // an un-presented candidate was superseded
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
|
||||
while let Some(acq) = self.acquire(false) {
|
||||
while let Some(acq) = self.acquire() {
|
||||
self.fifo.push_back(acq);
|
||||
while self.fifo.len() > self.fifo_capacity {
|
||||
self.fifo.pop_front();
|
||||
@@ -398,19 +441,13 @@ impl AscBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
|
||||
/// `None` when the reader is empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
|
||||
/// Acquire the next image and pair its decode stamps + cadence due. `None` when the reader is
|
||||
/// empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self) -> Option<Acquired> {
|
||||
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
|
||||
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
|
||||
// acquire requires.
|
||||
let res = unsafe {
|
||||
if latest {
|
||||
self.reader.acquire_latest_image_async()
|
||||
} else {
|
||||
self.reader.acquire_next_image_async()
|
||||
}
|
||||
};
|
||||
let res = unsafe { self.reader.acquire_next_image_async() };
|
||||
let (image, fence) = match res {
|
||||
Ok(AcquireResult::Image(pair)) => pair,
|
||||
Ok(_) => return None, // no buffer available / max acquired
|
||||
|
||||
@@ -74,6 +74,90 @@ pub(super) enum DecodeEvent {
|
||||
Error { fatal: bool },
|
||||
}
|
||||
|
||||
/// The decoder bring-up rungs, in order, as `(present backend, aggressive low-latency keys)`.
|
||||
/// The backend is `Some(overlay)` for ASC with that reader-usage profile (see
|
||||
/// [`AscBackend::create`]'s `overlay` doc), `None` for the SurfaceView presenter. See the ladder's
|
||||
/// comment in [`run_async`] for why these axes, and why in this order.
|
||||
///
|
||||
/// Consecutive duplicates are collapsed: the `present_backend` sysprop and the low-latency toggle
|
||||
/// may each already have shed what a rung was going to shed, and re-running a configuration the
|
||||
/// codec just refused buys nothing but another failed `start`. The first rung is always exactly
|
||||
/// what the session asked for, so a device that works is never charged for this ladder.
|
||||
fn bring_up_rungs(asc_wanted: bool, low_latency: bool) -> Vec<(Option<bool>, bool)> {
|
||||
let mut rungs = vec![
|
||||
(asc_wanted.then_some(true), low_latency),
|
||||
(asc_wanted.then_some(false), low_latency),
|
||||
(None, low_latency),
|
||||
(None, false),
|
||||
];
|
||||
rungs.dedup();
|
||||
rungs
|
||||
}
|
||||
|
||||
/// Human label for a rung's present backend, for the retry / decoder-started log lines — the
|
||||
/// string a field log bundle is grepped for, so it names the reader profile, not just "ASC".
|
||||
fn backend_label(backend: Option<bool>) -> &'static str {
|
||||
match backend {
|
||||
Some(true) => "ASurfaceControl (overlay reader)",
|
||||
Some(false) => "ASurfaceControl (GPU-composited reader)",
|
||||
None => "SurfaceView",
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `codec` into async-notify mode, forwarding every codec callback onto `ev_tx`.
|
||||
///
|
||||
/// Must run BEFORE `configure()`/`start()` so we're async from the first buffer, and once per
|
||||
/// bring-up rung — a codec that failed `start` is discarded, and its replacement needs its own
|
||||
/// registration. Each closure only *pushes an event*: no `AMediaCodec` call happens on the codec's
|
||||
/// looper thread, which is what keeps every buffer op on the decode thread that owns the codec.
|
||||
///
|
||||
/// `false` ⇒ the platform refused async mode; that is not something a simpler format or a
|
||||
/// different output surface can fix, so the caller gives up rather than trying the next rung.
|
||||
fn install_async_callbacks(codec: &mut MediaCodec, ev_tx: &mpsc::Sender<DecodeEvent>) -> bool {
|
||||
let out_tx = ev_tx.clone();
|
||||
let in_tx = ev_tx.clone();
|
||||
let fmt_tx = ev_tx.clone();
|
||||
let err_tx = ev_tx.clone();
|
||||
let cb = AsyncNotifyCallback {
|
||||
on_input_available: Some(Box::new(move |idx| {
|
||||
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
|
||||
})),
|
||||
on_output_available: Some(Box::new(move |idx, info| {
|
||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||
index: idx,
|
||||
pts_us: info.presentation_time_us().max(0) as u64,
|
||||
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
||||
// decode stage ends when the frame actually became available — not after the
|
||||
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||
decoded_ns: now_realtime_ns(),
|
||||
// Its monotonic twin, from the same instant. The stats are REALTIME (they
|
||||
// fold the host's clock offset in), while the cadence loop and
|
||||
// `releaseOutputBufferAtTime` are both CLOCK_MONOTONIC — and the loop is fed
|
||||
// and read in one domain, never converted (`punktfunk_core::phase`: a
|
||||
// constant offset between domains is what its offset estimator absorbs).
|
||||
decoded_mono_ns: now_monotonic_ns(),
|
||||
});
|
||||
})),
|
||||
on_format_changed: Some(Box::new(move |_fmt| {
|
||||
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
|
||||
})),
|
||||
on_error: Some(Box::new(move |e, code, _detail| {
|
||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
||||
if fatal {
|
||||
log::error!("decode: fatal codec error — stream will stop: {e:?}");
|
||||
} else {
|
||||
log::warn!("decode: codec error {e:?} (recoverable)");
|
||||
}
|
||||
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||
})),
|
||||
};
|
||||
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
|
||||
log::error!("decode: set_async_notify_callback failed: {e}");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
|
||||
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
|
||||
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
|
||||
@@ -101,132 +185,177 @@ pub(super) fn run_async(
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
let mime = codec_mime(client.codec);
|
||||
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log::error!("decode: no {mime} decoder on this device");
|
||||
return;
|
||||
// HDR static metadata (ST.2086 mastering + content light level): fetched ONCE, ahead of the
|
||||
// bring-up ladder, so a retry rung never pays the wait again. MediaCodec wants it BEFORE
|
||||
// configure(), and the host sends a 0xCE right after the handshake, so it's typically already
|
||||
// queued; wait briefly otherwise. The Surface DataSpace (applied on FormatChanged below)
|
||||
// carries transfer/primaries regardless — this adds the luminance the tone-mapper needs.
|
||||
let hdr_static = if client.color.is_hdr() {
|
||||
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||
Ok(meta) => {
|
||||
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||
Some(android_hdr_static_info(&meta))
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let codec_name = codec.name().unwrap_or_default();
|
||||
stats.set_decoder(&codec_name, ll_feature);
|
||||
log::info!(
|
||||
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
|
||||
);
|
||||
|
||||
// Resolve the present intent once (shared by both backends).
|
||||
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
|
||||
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
|
||||
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
|
||||
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
|
||||
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
|
||||
{
|
||||
let out_tx = ev_tx.clone();
|
||||
let in_tx = ev_tx.clone();
|
||||
let fmt_tx = ev_tx.clone();
|
||||
let err_tx = ev_tx.clone();
|
||||
let cb = AsyncNotifyCallback {
|
||||
on_input_available: Some(Box::new(move |idx| {
|
||||
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
|
||||
})),
|
||||
on_output_available: Some(Box::new(move |idx, info| {
|
||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||
index: idx,
|
||||
pts_us: info.presentation_time_us().max(0) as u64,
|
||||
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
||||
// decode stage ends when the frame actually became available — not after the
|
||||
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||
decoded_ns: now_realtime_ns(),
|
||||
// Its monotonic twin, from the same instant. The stats are REALTIME (they
|
||||
// fold the host's clock offset in), while the cadence loop and
|
||||
// `releaseOutputBufferAtTime` are both CLOCK_MONOTONIC — and the loop is fed
|
||||
// and read in one domain, never converted (`punktfunk_core::phase`: a
|
||||
// constant offset between domains is what its offset estimator absorbs).
|
||||
decoded_mono_ns: now_monotonic_ns(),
|
||||
});
|
||||
})),
|
||||
on_format_changed: Some(Box::new(move |_fmt| {
|
||||
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
|
||||
})),
|
||||
on_error: Some(Box::new(move |e, code, _detail| {
|
||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
||||
if fatal {
|
||||
log::error!("decode: fatal codec error — stream will stop: {e:?}");
|
||||
} else {
|
||||
log::warn!("decode: codec error {e:?} (recoverable)");
|
||||
}
|
||||
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||
})),
|
||||
};
|
||||
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
|
||||
log::error!("decode: set_async_notify_callback failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the low-latency format (identical keys to the sync path).
|
||||
let mut format = MediaFormat::new();
|
||||
format.set_str("mime", mime);
|
||||
format.set_i32("width", mode.width as i32);
|
||||
format.set_i32("height", mode.height as i32);
|
||||
format.set_i32(
|
||||
"max-input-size",
|
||||
(mode.width * mode.height).max(2_000_000) as i32,
|
||||
);
|
||||
configure_low_latency(&mut format, &codec_name, low_latency_mode);
|
||||
if client.color.is_hdr() {
|
||||
match client.next_hdr_meta(Duration::from_millis(250)) {
|
||||
Ok(meta) => {
|
||||
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
|
||||
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve the present intent once (shared by both backends).
|
||||
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||
// The present backend. ASurfaceControl (default) drives its own `AImageReader` output surface +
|
||||
// compositor layer, scheduling against the panel's real present clock; the SurfaceView presenter
|
||||
// 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() {
|
||||
// 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_size,
|
||||
panel_hz,
|
||||
initial_ds,
|
||||
mode.refresh_hz,
|
||||
priority,
|
||||
)
|
||||
} else {
|
||||
// ── Decoder bring-up ladder ──────────────────────────────────────────────────────────────
|
||||
// `configure()` can succeed and `start()` still fail: start is where the codec negotiates
|
||||
// buffers with its output consumer and allocates them, so a decoder that accepted the format
|
||||
// can still refuse the surface it has to render into. Observed on a Xiaomi Mi TV Stick
|
||||
// (2026-08-27; Android 11, armeabi-v7a, `OMX.amlogic.hevc.decoder.awesome2`): EVERY session
|
||||
// logged `start failed: ErrorUnknown` and returned, so this thread died before feeding a single
|
||||
// AU while the pump kept receiving video — the frame queue filled, the pump jumped to live once
|
||||
// per `FLUSH_COOLDOWN`, and the host read that perfect 2 s keyframe cadence as a client too
|
||||
// slow to keep up. Audio, input and the library all kept working, so it presented to the user
|
||||
// as a permanent black screen with sound, and to us as a decoder that was never even running.
|
||||
//
|
||||
// A codec that failed `start` is in an error state and cannot be reconfigured, so each rung
|
||||
// builds a fresh one. The rungs shed what a start can choke on, most-suspect first:
|
||||
//
|
||||
// 1. The ASC reader's `COMPOSER_OVERLAY` usage. Start dequeues every codec output buffer
|
||||
// from the reader's window with OUR consumer usage OR'd into the decoder's own producer
|
||||
// bits, and overlay + GPU-sampled + vendor-vdec in one allocation is exactly what an old
|
||||
// OMX-era gralloc can refuse (see [`AscBackend::create`]'s `overlay` doc). The retry
|
||||
// keeps the whole ASC backend — real latches, real fences — and asks only for the
|
||||
// SurfaceTexture-shaped `GPU_SAMPLED_IMAGE` allocation every video path exercises;
|
||||
// SurfaceFlinger GPU-composites the layer instead of scanning it out. Usage is the ONLY
|
||||
// reader axis worth a rung: `READER_MAX_IMAGES` is not a start-time factor (consumer-side
|
||||
// images allocate lazily during streaming).
|
||||
// 2. The `AImageReader` entirely — an app-side BufferQueue consumer at all is the residual
|
||||
// suspect (a vendor OMX component keying on queues-to-composer).
|
||||
// 3. The aggressive low-latency key set.
|
||||
//
|
||||
// Every downstream branch here already keys off `asc.is_some()`, so a fallen-back session just
|
||||
// runs the SurfaceView presenter that has always been the API < 29 / ASC-init-failure
|
||||
// fallback — nothing below this block needs to know.
|
||||
//
|
||||
// The rung that wins is logged: on a device that needs one, that line names the real culprit,
|
||||
// which no amount of host-side log reading could.
|
||||
//
|
||||
// ponytail: no ASC + plain-keys rung. If a field case ever shows the low-latency keys alone
|
||||
// were at fault, that rung belongs between 2 and 3 — the ASC presenter is the better one and
|
||||
// is worth keeping whenever it can start.
|
||||
let asc_wanted = asc_backend_selected();
|
||||
if !asc_wanted {
|
||||
log::info!("decode: present backend = SurfaceView (present_backend sysprop)");
|
||||
None
|
||||
}
|
||||
let rungs = bring_up_rungs(asc_wanted, low_latency_mode);
|
||||
|
||||
let mut brought_up: Option<(MediaCodec, Option<AscBackend>)> = None;
|
||||
for (rung, &(backend, aggressive)) in rungs.iter().enumerate() {
|
||||
if rung > 0 {
|
||||
log::warn!(
|
||||
"decode: decoder refused that configuration — retrying through {} with aggressive \
|
||||
low-latency keys {}",
|
||||
backend_label(backend),
|
||||
if aggressive { "ON" } else { "OFF" }
|
||||
);
|
||||
}
|
||||
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log::error!("decode: no {mime} decoder on this device");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it
|
||||
// fell back) drives both the HUD label and which vendor low-latency keys apply.
|
||||
let codec_name = codec.name().unwrap_or_default();
|
||||
if rung == 0 {
|
||||
stats.set_decoder(&codec_name, ll_feature);
|
||||
log::info!(
|
||||
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
|
||||
);
|
||||
}
|
||||
if !install_async_callbacks(&mut codec, &ev_tx) {
|
||||
return; // the platform refused async mode outright — no rung changes that
|
||||
}
|
||||
// Build the low-latency format (identical keys to the sync path).
|
||||
let mut format = MediaFormat::new();
|
||||
format.set_str("mime", mime);
|
||||
format.set_i32("width", mode.width as i32);
|
||||
format.set_i32("height", mode.height as i32);
|
||||
format.set_i32(
|
||||
"max-input-size",
|
||||
(mode.width * mode.height).max(2_000_000) as i32,
|
||||
);
|
||||
configure_low_latency(&mut format, &codec_name, aggressive);
|
||||
if let Some(info) = hdr_static.as_ref() {
|
||||
format.set_buffer("hdr-static-info", info);
|
||||
}
|
||||
// The present backend. ASurfaceControl (default) drives its own `AImageReader` output
|
||||
// surface + compositor layer, scheduling against the panel's real present clock; the
|
||||
// SurfaceView presenter below is the fallback for API < 29, an ASC init failure, the
|
||||
// `present_backend=surfaceview` sysprop, or a rung that dropped it. A non-null `asc` means
|
||||
// the codec renders into the reader, not the SurfaceView window.
|
||||
let asc = if let Some(overlay) = backend {
|
||||
// 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.
|
||||
AscBackend::create(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
surface_size.clone(),
|
||||
panel_hz,
|
||||
color_dataspace(&client.color),
|
||||
mode.refresh_hz,
|
||||
priority,
|
||||
overlay,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The decoder's output surface: the reader's window when ASC is active, else the SurfaceView.
|
||||
let configure_window: &NativeWindow = asc.as_ref().map_or(&window, |a| a.reader_window());
|
||||
if let Err(e) = codec.configure(
|
||||
&format,
|
||||
Some(configure_window),
|
||||
MediaCodecDirection::Decoder,
|
||||
) {
|
||||
log::error!("decode: configure failed: {e}");
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = codec.start() {
|
||||
log::error!("decode: start failed: {e}");
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"decode: decoder started (async) at {}x{} through {}",
|
||||
mode.width,
|
||||
mode.height,
|
||||
// `asc.as_ref().and(backend)`, not `backend`: an ASC rung whose backend failed to
|
||||
// CREATE fell back to the SurfaceView within the rung, and this line must report what
|
||||
// actually runs.
|
||||
backend_label(asc.as_ref().and(backend))
|
||||
);
|
||||
brought_up = Some((codec, asc));
|
||||
break;
|
||||
}
|
||||
let Some((codec, mut asc)) = brought_up else {
|
||||
// Every rung refused. Say so loudly and in the shape the next reporter can act on: the
|
||||
// session stays up (audio/input/library all still work), so without this line the only
|
||||
// symptom is a black screen and a keyframe request every 2 s that blames the network.
|
||||
log::error!(
|
||||
"decode: the {mime} decoder refused EVERY configuration — this session has no video. \
|
||||
Audio and input keep working, so the stream will look alive while the screen stays \
|
||||
black, and the host will see a keyframe recovery request every 2 s that is this, not \
|
||||
a slow link. See the `configure failed` / `start failed` lines above for the reason \
|
||||
each rung gave"
|
||||
);
|
||||
return;
|
||||
};
|
||||
// The decoder's output surface: the reader's window when ASC is active, else the SurfaceView.
|
||||
let configure_window: &NativeWindow = asc.as_ref().map_or(&window, |a| a.reader_window());
|
||||
if let Err(e) = codec.configure(
|
||||
&format,
|
||||
Some(configure_window),
|
||||
MediaCodecDirection::Decoder,
|
||||
) {
|
||||
log::error!("decode: configure failed: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = codec.start() {
|
||||
log::error!("decode: start failed: {e}");
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"decode: decoder started (async) at {}x{}",
|
||||
mode.width,
|
||||
mode.height
|
||||
);
|
||||
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||
// off, every form factor gets the original soft seamless hint. ASC votes the rate on its own
|
||||
// layer instead (the SurfaceView window shows nothing under the ASC path).
|
||||
@@ -1259,3 +1388,52 @@ fn asc_present_ready(
|
||||
}
|
||||
stats.note_skipped(withheld); // gate-withheld frames (the reader-drop skips ride `asc.flush`)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bring_up_rungs;
|
||||
|
||||
/// The ladder that turns a decoder which refuses to start from a permanent black screen into
|
||||
/// a retry or two away from a picture (the 2026-08-27 Mi TV Stick case). Order and
|
||||
/// de-duplication are the whole of its logic — everything else in the loop is MediaCodec I/O.
|
||||
#[test]
|
||||
fn rungs_shed_the_overlay_then_asc_then_the_aggressive_keys_and_never_repeat_one() {
|
||||
// The default: shed the reader's COMPOSER_OVERLAY usage first (keeping ASC — the whole
|
||||
// point of the middle rung), then the `AImageReader` entirely, then the aggressive keys.
|
||||
assert_eq!(
|
||||
bring_up_rungs(true, true),
|
||||
[
|
||||
(Some(true), true),
|
||||
(Some(false), true),
|
||||
(None, true),
|
||||
(None, false)
|
||||
]
|
||||
);
|
||||
// `present_backend=surfaceview` already shed ASC — both ASC rungs collapse away.
|
||||
assert_eq!(bring_up_rungs(false, true), [(None, true), (None, false)]);
|
||||
// Low-latency mode off ⇒ the keys are already the plain set; the backend is the only axis.
|
||||
assert_eq!(
|
||||
bring_up_rungs(true, false),
|
||||
[(Some(true), false), (Some(false), false), (None, false)]
|
||||
);
|
||||
// Nothing left to shed: one attempt, and no pointless second `start` of the same thing.
|
||||
assert_eq!(bring_up_rungs(false, false), [(None, false)]);
|
||||
|
||||
for asc in [true, false] {
|
||||
for ll in [true, false] {
|
||||
let rungs = bring_up_rungs(asc, ll);
|
||||
// A device that works must pay nothing for this ladder: rung 0 is always exactly
|
||||
// what the session asked for.
|
||||
assert_eq!(rungs[0], (asc.then_some(true), ll));
|
||||
// Every ladder ends at the most conservative configuration there is.
|
||||
assert_eq!(*rungs.last().unwrap(), (None, false));
|
||||
// Monotonic: a rung only ever sheds, never re-enables what an earlier one dropped
|
||||
// (`Option<bool>`'s Ord: `None < Some(false) < Some(true)`), so the ladder always
|
||||
// descends towards the conservative end.
|
||||
assert!(rungs
|
||||
.windows(2)
|
||||
.all(|w| w[1].0 <= w[0].0 && w[1].1 <= w[0].1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,16 @@ pub(super) fn run_sync(
|
||||
return;
|
||||
}
|
||||
if let Err(e) = codec.start() {
|
||||
log::error!("decode: start failed: {e}");
|
||||
// No bring-up ladder here, unlike the async loop: this path only runs with low-latency
|
||||
// mode OFF, which is already the conservative key set, and it renders straight into the
|
||||
// SurfaceView rather than an `AImageReader` — the two things that ladder sheds are both
|
||||
// already shed. Name the symptom instead, because the session stays up around this
|
||||
// failure (audio, input and the library all keep working) and the host sees only a
|
||||
// keyframe request every 2 s that reads as a slow link.
|
||||
log::error!(
|
||||
"decode: start failed: {e} — this session has no video. Audio and input keep working, \
|
||||
so the stream will look alive while the screen stays black"
|
||||
);
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
|
||||
@@ -317,6 +317,12 @@ impl ImageReader {
|
||||
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
|
||||
/// access the [`Image`] returned.
|
||||
///
|
||||
/// **The returned fence is unsound whenever the platform actually drops an older image.**
|
||||
/// `AImageReader::acquireLatestImage` reuses one out-param across the drain and releases each
|
||||
/// dropped image with the *successor's* fence fd, so the fd handed back has already been given
|
||||
/// to the reader (and closed by it) — adopting it here yields a double close and an `fdsan`
|
||||
/// abort. Drain with [`ImageReader::acquire_next_image_async()`] and pick the newest yourself.
|
||||
///
|
||||
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
|
||||
#[cfg(feature = "api-level-26")]
|
||||
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
|
||||
|
||||
@@ -24,8 +24,9 @@ struct ContentView: View {
|
||||
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
|
||||
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
|
||||
// The dev auto-connect hook (DEBUG-only — see `autoConnectIfAsked`) writes these three, so
|
||||
// they stay observed here; every OTHER stream setting reaches a session through
|
||||
// `EffectiveSettings`, resolved once per connect.
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -52,6 +53,29 @@ struct ContentView: View {
|
||||
/// a live session is already up. Surfaced as an informational alert (distinct from the
|
||||
/// "Connection failed" one, which is for actual connect errors).
|
||||
@State private var deepLinkNotice: String?
|
||||
/// A `punktfunk://` deep link that named a saved host by something GUESSABLE — its display
|
||||
/// name, its address, or the `host=` recovery parameter — instead of by its stable record id.
|
||||
/// Anything that can open a URL can guess "Gaming PC", so the link's action waits for this
|
||||
/// confirmation; a link that names the id (every shortcut this app emits) still runs on its own.
|
||||
private struct DeepLinkConfirm {
|
||||
let host: StoredHost
|
||||
let launch: String?
|
||||
let profile: ProfileSelection
|
||||
/// A `browse` link: open the host's library instead of dialing it.
|
||||
let browse: Bool
|
||||
|
||||
var actionTitle: String { browse ? "Open Library" : "Connect" }
|
||||
var message: String {
|
||||
let asked = browse
|
||||
? "open \(host.displayName)'s game library"
|
||||
: "connect to \(host.displayName)"
|
||||
+ (launch.map { " and launch \u{201C}\($0)\u{201D}" } ?? "")
|
||||
return "A link asked to \(asked). It names the host by its label or address, which "
|
||||
+ "anything that can open a link could guess — a shortcut made in Punktfunk names "
|
||||
+ "the host's id and opens without asking."
|
||||
}
|
||||
}
|
||||
@State private var deepLinkConfirm: DeepLinkConfirm?
|
||||
#if os(iOS)
|
||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||
/// the session model's published state below; iPhone/iPad only.
|
||||
@@ -193,6 +217,29 @@ struct ContentView: View {
|
||||
} message: {
|
||||
Text(deepLinkNotice ?? "")
|
||||
}
|
||||
// A link that named a saved host by a guessable reference: the dial (or the library)
|
||||
// happens on the user's word rather than on the link's.
|
||||
.alert(
|
||||
"Open this link?",
|
||||
isPresented: deepLinkConfirmPresented,
|
||||
presenting: deepLinkConfirm
|
||||
) { confirm in
|
||||
Button(confirm.actionTitle) { runDeepLinkConfirm(confirm) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { confirm in
|
||||
Text(confirm.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmed link's action: exactly what a `.known` (id-referenced) link would have done,
|
||||
/// one tap later.
|
||||
private func runDeepLinkConfirm(_ confirm: DeepLinkConfirm) {
|
||||
deepLinkConfirm = nil
|
||||
if confirm.browse {
|
||||
libraryTarget = LibraryTarget(host: confirm.host, profile: confirm.profile)
|
||||
} else {
|
||||
connect(confirm.host, launchID: confirm.launch, profile: confirm.profile)
|
||||
}
|
||||
}
|
||||
|
||||
private var driven: some View {
|
||||
@@ -482,6 +529,12 @@ struct ContentView: View {
|
||||
set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
private var deepLinkConfirmPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { deepLinkConfirm != nil && !consolePromptShowing },
|
||||
set: { if !$0 { deepLinkConfirm = nil } })
|
||||
}
|
||||
|
||||
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
|
||||
/// tvOS, whose alerts the focus engine drives natively.
|
||||
private var consolePromptShowing: Bool {
|
||||
@@ -558,6 +611,20 @@ struct ContentView: View {
|
||||
},
|
||||
])
|
||||
}
|
||||
if let confirm = deepLinkConfirm {
|
||||
return GamepadPrompt(
|
||||
id: "link-confirm",
|
||||
title: "Open this link?",
|
||||
message: confirm.message,
|
||||
actions: [
|
||||
GamepadPromptAction(id: "go", title: confirm.actionTitle, isPrimary: true) {
|
||||
runDeepLinkConfirm(confirm)
|
||||
},
|
||||
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||
deepLinkConfirm = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
if let notice = deepLinkNotice {
|
||||
return GamepadPrompt(
|
||||
id: "cant-open",
|
||||
@@ -654,11 +721,13 @@ struct ContentView: View {
|
||||
/// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
|
||||
/// `fp`/`host` recovery parameters and a one-off `profile`.
|
||||
///
|
||||
/// The security posture is the parser's plus three rules that live here, and none of them
|
||||
/// The security posture is the parser's plus four rules that live here, and none of them
|
||||
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
|
||||
/// confirmation, not a connect), never preempts a live session (same host → focus, different
|
||||
/// host → say so; NEVER tear one down on a background tap), and carries only references — a
|
||||
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
|
||||
/// confirmation, not a connect), never dials on a GUESSABLE reference (only the stable record
|
||||
/// id connects unattended — a label or an address becomes a confirmation), never preempts a
|
||||
/// live session (same host → focus, different host → say so; NEVER tear one down on a
|
||||
/// background tap), and carries only references — a profile it can't honor refuses with a
|
||||
/// notice rather than streaming with the wrong settings.
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
let link: DeepLink
|
||||
do {
|
||||
@@ -703,8 +772,12 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
}
|
||||
switch link.resolveHost(in: store.hosts) {
|
||||
case .known(let host):
|
||||
let resolution = link.resolveHost(in: store.hosts)
|
||||
switch resolution {
|
||||
// A saved record. `.known` (named by its unguessable id) dials straight away; `.confirm`
|
||||
// (named by its label or its address, which anything that can open a URL could guess)
|
||||
// takes the same dial one tap later.
|
||||
case .known(let host), .confirm(let host):
|
||||
guard !link.pinConflict(with: host) else {
|
||||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||
@@ -718,10 +791,19 @@ struct ContentView: View {
|
||||
}
|
||||
return // deep-linked to the host we're already on — nothing to do
|
||||
}
|
||||
if case .confirm = resolution {
|
||||
deepLinkConfirm = DeepLinkConfirm(
|
||||
host: host, launch: link.launch, profile: selection, browse: false)
|
||||
return
|
||||
}
|
||||
connect(host, launchID: link.launch, profile: selection)
|
||||
case .unknown(let address, let port, let name, let fp):
|
||||
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
|
||||
// the user makes the trust decision with their eyes on it.
|
||||
// Never a silent connect — an unsaved host is a trust decision, and a link is not
|
||||
// where it gets made. This only NAMES what the link pointed at; adding the host is a
|
||||
// deliberate trip to the + button, where the fingerprint is on screen. (Linux, Android
|
||||
// and Windows instead pre-fill their trust prompt from the link; the outcome is the
|
||||
// same — nothing connects until a person looks at it — but the sheet is not seeded
|
||||
// here, so don't read this as doing that.)
|
||||
guard model.phase == .idle else {
|
||||
deepLinkNotice = "Already streaming. End that session first."
|
||||
return
|
||||
@@ -765,8 +847,10 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
}
|
||||
switch link.resolveHost(in: store.hosts) {
|
||||
case .known(let host):
|
||||
let resolution = link.resolveHost(in: store.hosts)
|
||||
switch resolution {
|
||||
// Same rule as a connect link: only the record id opens on the link's own say-so.
|
||||
case .known(let host), .confirm(let host):
|
||||
guard !link.pinConflict(with: host) else {
|
||||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||
@@ -780,6 +864,11 @@ struct ContentView: View {
|
||||
}
|
||||
return // browsing the host we're already streaming — nothing to do
|
||||
}
|
||||
if case .confirm = resolution {
|
||||
deepLinkConfirm = DeepLinkConfirm(
|
||||
host: host, launch: nil, profile: selection, browse: true)
|
||||
return
|
||||
}
|
||||
libraryTarget = LibraryTarget(host: host, profile: selection)
|
||||
case .unknown(let address, _, let name, _):
|
||||
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
||||
@@ -1425,7 +1514,12 @@ struct ContentView: View {
|
||||
/// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope|… overrides the
|
||||
/// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
|
||||
/// pad type (same names as the host env knobs). (IPv4/hostname only.)
|
||||
///
|
||||
/// DEBUG-ONLY, and compiled out of a release build: it streams to whatever host an
|
||||
/// environment variable names with the trust prompt auto-confirmed, which is a dev lever
|
||||
/// (`swift run`, the shot harness), never something a shipped app should answer to.
|
||||
private func autoConnectIfAsked() {
|
||||
#if DEBUG
|
||||
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
|
||||
!target.isEmpty, model.phase == .idle
|
||||
else { return }
|
||||
@@ -1460,5 +1554,6 @@ struct ContentView: View {
|
||||
effective.bitrateKbps = v
|
||||
}
|
||||
model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ final class SessionModel: ObservableObject {
|
||||
let hz = UInt32(clamping: effective.refreshHz)
|
||||
let compositor = PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
|
||||
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||||
var bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||||
let audioChannels = UInt8(clamping: effective.audioChannels)
|
||||
// The audio format this session ASKS for — the user's choice, at every channel count.
|
||||
//
|
||||
@@ -419,6 +419,15 @@ final class SessionModel: ObservableObject {
|
||||
let (audioRateHz, audioBits) = audioFormat.wire
|
||||
let hdrEnabled = effective.hdrEnabled
|
||||
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
|
||||
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
|
||||
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
|
||||
// the host's operator ceiling — send 0 and let the host pin its per-mode rate. Gated
|
||||
// like the advertisement below: a device that failed the Metal probe never offers the
|
||||
// codec, falls back to H.26x, and the user's rate must survive there. The stored
|
||||
// setting is untouched, so switching codecs back restores it.
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
bitrateKbps = 0
|
||||
}
|
||||
let pin = host.pinnedSHA256
|
||||
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||
|
||||
@@ -256,11 +256,25 @@ extension SettingsView {
|
||||
|
||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0 and the
|
||||
// host pins a per-mode rate, so a live rate control here would change nothing. Same
|
||||
// support gate as the codec picker offering the option; the stored rate is untouched,
|
||||
// so switching the codec back restores it.
|
||||
if effective.codec == "pyrowave", MetalWaveletDecoder.supported {
|
||||
described("PyroWave sets its own rate from the stream mode — a fixed bitrate "
|
||||
+ "doesn't apply.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: .constant(true))
|
||||
.disabled(true)
|
||||
}
|
||||
} else {
|
||||
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
}
|
||||
}
|
||||
if effective.bitrateKbps != 0 {
|
||||
if effective.codec != "pyrowave" || !MetalWaveletDecoder.supported,
|
||||
effective.bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
|
||||
@@ -450,15 +450,24 @@ struct SettingsView: View {
|
||||
title: "Render scale",
|
||||
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||||
selection: $renderScale)
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0
|
||||
// and the host pins a per-mode rate. tvOS has no codec picker, so this only
|
||||
// fires on a codec synced from another device — but the row must not offer a
|
||||
// rate the session ignores. The stored value is kept.
|
||||
if codec == "pyrowave", MetalWaveletDecoder.supported {
|
||||
tvCaption("PyroWave sets its own rate from the stream mode — the bitrate "
|
||||
+ "setting doesn't apply.")
|
||||
} else {
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
|
||||
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
|
||||
// fallback for unsigned builds — see `query(dataProtection:)`). The certificate's fingerprint is how
|
||||
// hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from
|
||||
// every host, so the pair is presented on every connect but never regenerated once
|
||||
// stored. That invariant drives the error handling below: a Keychain that *refuses
|
||||
// access* (locked, ACL denied) is an error, not a first run — minting a replacement
|
||||
// would silently shadow the durable identity and break every existing pairing.
|
||||
// generated once and stored in the data-protection Keychain, this-device-only (with a legacy
|
||||
// file-keychain fallback for unsigned builds — see `query(dataProtection:)`). The certificate's
|
||||
// fingerprint is how hosts recognize this client after PIN pairing — losing the key un-pairs this
|
||||
// Mac from every host, so the pair is presented on every connect but never regenerated once
|
||||
// stored (and never leaves this device: see `add`). That invariant drives the error handling
|
||||
// below: a Keychain that *refuses access* (locked, ACL denied) is an error, not a first run —
|
||||
// minting a replacement would silently shadow the durable identity and break every existing
|
||||
// pairing.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
@@ -115,6 +116,16 @@ final class ClientIdentityStore: @unchecked Sendable {
|
||||
if case .denied(errSecMissingEntitlement) = result {
|
||||
return read(dataProtection: false)
|
||||
}
|
||||
// An item added before the this-device-only switch keeps the accessibility class it was
|
||||
// added with — it would keep riding backups forever, because the identity is never
|
||||
// regenerated. Re-stamp it on the way past (best-effort: a refusal just leaves the old
|
||||
// class, and the identity still reads).
|
||||
if case .found = result {
|
||||
SecItemUpdate(
|
||||
Self.query(dataProtection: true) as CFDictionary,
|
||||
[kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||
as CFDictionary)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -141,9 +152,12 @@ final class ClientIdentityStore: @unchecked Sendable {
|
||||
else { return errSecParam }
|
||||
var add = Self.query(dataProtection: true)
|
||||
add[kSecValueData as String] = data
|
||||
// After-first-unlock so a background reconnect can still read it; the access-group
|
||||
// entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
// After-first-unlock so a background reconnect can still read it, THIS DEVICE ONLY so it
|
||||
// never rides an encrypted backup or a device migration: this key is the whole credential
|
||||
// a host pairs with, and a restored backup would silently re-pair the restoring device
|
||||
// with every host. The access-group entitlement (not a per-binary ACL) gates it, so it
|
||||
// still survives rebuilds prompt-free.
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecMissingEntitlement else { return status }
|
||||
// Ad-hoc / unsigned build: persist to the legacy file keychain instead.
|
||||
|
||||
@@ -1479,39 +1479,28 @@ public final class SessionAudio {
|
||||
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
|
||||
#endif
|
||||
|
||||
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||
// Encode a single mono bus (folded from the tap's own buffer format): the resampler goes
|
||||
// mono@inputSR → the encoder's 48 kHz mono, so it handles the rate change and the
|
||||
// wrong-channel downmix never happens. Mono end to end — the host's decoder upmixes,
|
||||
// so the old duplicate-into-stereo step only cost bits and cycles.
|
||||
//
|
||||
// `mono`/`staging` are the per-callback scratch buffers, preallocated HERE (grown only
|
||||
// if a larger-than-expected device quantum ever arrives) — the steady-state tap path
|
||||
// allocates nothing.
|
||||
// `chain` carries the rate-dependent pieces INCLUDING the per-callback scratch buffers,
|
||||
// preallocated HERE for the rate the input currently reports — the steady-state tap path
|
||||
// allocates nothing. The tap rebuilds it if the device's real rate or quantum differ,
|
||||
// which is the price of installing the tap with the bus's own format (see below).
|
||||
let scratchFrames: AVAudioFrameCount = 8192
|
||||
let stagingCapacity = { (frames: AVAudioFrameCount) -> AVAudioFrameCount in
|
||||
AVAudioFrameCount(
|
||||
(Double(frames) * 48_000 / inFormat.sampleRate).rounded(.up)) + 64
|
||||
}
|
||||
guard let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
|
||||
channels: 1, interleaved: false),
|
||||
let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
|
||||
guard let encoder = try? OpusEncoder(),
|
||||
var chain = Self.micChain(
|
||||
rate: inFormat.sampleRate, frames: scratchFrames, to: encoder.pcmFormat),
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
|
||||
let monoScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: scratchFrames),
|
||||
let stagingScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket)
|
||||
else {
|
||||
log.error("Opus encoder unavailable — mic uplink disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
// Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in
|
||||
// `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
|
||||
var mono = monoScratch
|
||||
var staging = stagingScratch
|
||||
// Tap-thread-confined state: fold into `chain.mono`, resample into `chain.staging`,
|
||||
// accumulate in `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
|
||||
var fifo: [Float] = []
|
||||
fifo.reserveCapacity(48_000)
|
||||
var seq: UInt32 = 0
|
||||
@@ -1533,22 +1522,32 @@ public final class SessionAudio {
|
||||
// 480 frames = 10 ms, matching the packet duration. Advisory — CoreAudio delivers the
|
||||
// device quantum whatever we ask (the old 2048 request came back as 42.7 ms bursts, most
|
||||
// of the uplink's latency) — but where the system honors it, the tap fires per-packet.
|
||||
input.installTap(onBus: 0, bufferSize: 480, format: inFormat) { buffer, _ in
|
||||
// `format: nil` — NOT the format read above. `installTap` validates a non-nil format
|
||||
// against the bus and raises an Objective-C exception on any mismatch; Swift cannot catch
|
||||
// that, so it aborts the process (SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`). The
|
||||
// format was necessarily read a moment EARLIER, and on macOS the input can move underneath
|
||||
// it — a device switch, a clock/rate change, or the `setDevice` swap `startCapture` itself
|
||||
// performs two lines before this. `nil` means "whatever the bus emits", which is what the
|
||||
// chain wants anyway, and the mismatch cannot arise by construction. The tap then follows
|
||||
// the real format below.
|
||||
input.installTap(onBus: 0, bufferSize: 480, format: nil) { buffer, _ in
|
||||
if flag.isStopped { return }
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let src = buffer.floatChannelData else { return }
|
||||
if frames > Int(mono.frameCapacity) {
|
||||
// A quantum larger than the scratch (bufferSize is advisory both ways) — regrow
|
||||
// once to the new high-water mark; the steady state stays allocation-free.
|
||||
guard let biggerMono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let biggerStaging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat,
|
||||
frameCapacity: stagingCapacity(buffer.frameLength))
|
||||
// Rebuild the rate-dependent chain when the device changes rate under a live tap
|
||||
// (resampling by the old ratio would pitch-shift the mic), and when a quantum larger
|
||||
// than the scratch arrives (`bufferSize` is advisory both ways) — regrown once to the
|
||||
// new high-water mark, so the steady state stays allocation-free.
|
||||
if buffer.format.sampleRate != chain.monoFormat.sampleRate
|
||||
|| buffer.frameLength > chain.mono.frameCapacity {
|
||||
guard let rebuilt = Self.micChain(
|
||||
rate: buffer.format.sampleRate,
|
||||
frames: max(buffer.frameLength, scratchFrames),
|
||||
to: encoder.pcmFormat)
|
||||
else { return }
|
||||
mono = biggerMono
|
||||
staging = biggerStaging
|
||||
chain = rebuilt
|
||||
}
|
||||
let mono = chain.mono, staging = chain.staging, resampler = chain.resampler
|
||||
guard let dst = mono.floatChannelData?[0] else { return }
|
||||
mono.frameLength = buffer.frameLength
|
||||
|
||||
@@ -1620,6 +1619,41 @@ public final class SessionAudio {
|
||||
return true
|
||||
}
|
||||
|
||||
/// The rate-dependent half of the mic chain: a mono bus at `rate`, the resampler from it onto
|
||||
/// the encoder's 48 kHz mono, and the two scratch buffers sized for `frames`. Grouped so the
|
||||
/// tap can swap all four together — they are only ever valid as a set.
|
||||
struct MicChain {
|
||||
let monoFormat: AVAudioFormat
|
||||
let resampler: AVAudioConverter
|
||||
let mono: AVAudioPCMBuffer
|
||||
let staging: AVAudioPCMBuffer
|
||||
}
|
||||
|
||||
/// Build a `MicChain` for `rate`, or nil if the rate is unusable or an allocation fails.
|
||||
/// Built once up front for the format the input reports, and again from the tap whenever the
|
||||
/// device's real rate differs — a macOS input can change rate under a live tap, and a chain
|
||||
/// pinned to the old rate resamples by the wrong ratio (a pitch-shifted mic).
|
||||
/// `internal` for unit testing: it needs no engine, device or permission.
|
||||
static func micChain(
|
||||
rate: Double, frames: AVAudioFrameCount, to pcmFormat: AVAudioFormat
|
||||
) -> MicChain? {
|
||||
// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio from `rate`
|
||||
// (a 44.1 kHz quantum grows by ~1.088); +64 covers the converter's own slack.
|
||||
guard rate > 0, frames > 0,
|
||||
let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: rate, channels: 1,
|
||||
interleaved: false),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: pcmFormat),
|
||||
let mono = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: frames),
|
||||
let staging = AVAudioPCMBuffer(
|
||||
pcmFormat: pcmFormat,
|
||||
frameCapacity: AVAudioFrameCount(
|
||||
(Double(frames) * 48_000 / rate).rounded(.up)) + 64)
|
||||
else { return nil }
|
||||
return MicChain(
|
||||
monoFormat: monoFormat, resampler: resampler, mono: mono, staging: staging)
|
||||
}
|
||||
|
||||
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
|
||||
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel — the fix for a
|
||||
|
||||
@@ -327,16 +327,22 @@ public struct DeepLink: Equatable, Sendable {
|
||||
}
|
||||
|
||||
/// Resolve this link's host reference against the local store, in the documented order:
|
||||
/// stable record id → unique case-insensitive name → `addr[:port]` literal. The `host=`
|
||||
/// parameter is the recovery path — a self-emitted shortcut that outlived the record it was
|
||||
/// written from still lands on the right box (degraded to the confirmation sheet).
|
||||
/// stable record id → unique case-insensitive name → `addr[:port]` literal, then the `host=`
|
||||
/// recovery path — a self-emitted shortcut that outlived the record it was written from still
|
||||
/// lands on the right box.
|
||||
///
|
||||
/// Only the record id is UNGUESSABLE, so only the record id resolves to `.known`, the silent
|
||||
/// one-click contract. A display name is an mDNS instance name or a user label ("Gaming PC")
|
||||
/// and an address is a LAN address: anything that can open a URL can guess those, and a guess
|
||||
/// must not be able to start a stream (or launch a title). They resolve to `.confirm` — the
|
||||
/// same host, behind the user's OK.
|
||||
public func resolveHost(in hosts: [StoredHost]) -> HostResolution {
|
||||
let reference = hostRef.lowercased()
|
||||
if let match = hosts.first(where: { $0.id.uuidString.lowercased() == reference }) {
|
||||
return .known(match)
|
||||
}
|
||||
let byName = hosts.filter { !$0.name.isEmpty && $0.name.lowercased() == reference }
|
||||
if byName.count == 1 { return .known(byName[0]) }
|
||||
if byName.count == 1 { return .confirm(byName[0]) }
|
||||
if byName.count > 1 { return .ambiguous }
|
||||
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
|
||||
// other per-host lookup in the client matches. The literal is only considered when the
|
||||
@@ -347,7 +353,7 @@ public struct DeepLink: Equatable, Sendable {
|
||||
if let match = hosts.first(where: {
|
||||
$0.address == candidate.address && $0.port == candidate.port
|
||||
}) {
|
||||
return .known(match)
|
||||
return .confirm(match)
|
||||
}
|
||||
}
|
||||
guard let target = literal ?? host else { return .unresolvable }
|
||||
@@ -356,8 +362,13 @@ public struct DeepLink: Equatable, Sendable {
|
||||
|
||||
/// What the local host store made of a link's references.
|
||||
public enum HostResolution: Equatable, Sendable {
|
||||
/// A record we already have (subject to `pinConflict`).
|
||||
/// A record we already have, named by its stable (unguessable) id: the one-click contract
|
||||
/// (subject to `pinConflict`).
|
||||
case known(StoredHost)
|
||||
/// The same record, named by something GUESSABLE — its display name, its address, or the
|
||||
/// `host=` recovery parameter. A link may not act on a guess, so this one waits for the
|
||||
/// user's confirmation; past that it is the `.known` path exactly.
|
||||
case confirm(StoredHost)
|
||||
/// No record, but the link says where to dial: the confirmation sheet's input, from which
|
||||
/// the normal pairing flow proceeds under the user's eyes. Never an auto-connect.
|
||||
case unknown(address: String, port: UInt16, name: String?, fp: String?)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// The rate-dependent half of the mic chain (SessionAudio.micChain). The tap now installs with
|
||||
// `format: nil` — a non-nil format is validated against the bus and raises an Objective-C
|
||||
// exception on mismatch, which Swift cannot catch, so it aborted the whole app (SIGABRT in
|
||||
// AVAudioEngineGraph::InstallTapOnNode, reported against 0.31.0). With nil the tap follows
|
||||
// whatever the bus emits, which means the chain has to be rebuildable at the device's real rate.
|
||||
// This pins the sizing arithmetic that rebuild depends on, without an engine, device or mic grant.
|
||||
|
||||
#if !os(tvOS)
|
||||
import AVFoundation
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioMicChainTests: XCTestCase {
|
||||
/// The encoder's target: 48 kHz mono float — what every chain resamples ONTO.
|
||||
private let target = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1, interleaved: false)!
|
||||
|
||||
/// A chain is built at the device's rate, mono, and resamples onto the 48 kHz encoder format.
|
||||
func testBuildsMonoChainAtDeviceRate() throws {
|
||||
let chain = try XCTUnwrap(
|
||||
SessionAudio.micChain(rate: 44_100, frames: 8192, to: target))
|
||||
XCTAssertEqual(chain.monoFormat.sampleRate, 44_100)
|
||||
XCTAssertEqual(chain.monoFormat.channelCount, 1)
|
||||
XCTAssertEqual(chain.mono.frameCapacity, 8192)
|
||||
XCTAssertEqual(chain.resampler.outputFormat.sampleRate, 48_000)
|
||||
}
|
||||
|
||||
/// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio — the bug this
|
||||
/// guards is a staging buffer sized for the input rate, which silently truncates every packet
|
||||
/// when the device runs below 48 kHz.
|
||||
func testStagingFitsUpwardResampleRatio() throws {
|
||||
for rate in [8_000.0, 16_000, 44_100, 48_000, 96_000] {
|
||||
let chain = try XCTUnwrap(
|
||||
SessionAudio.micChain(rate: rate, frames: 1024, to: target))
|
||||
let needed = (1024.0 * 48_000 / rate).rounded(.up)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
Double(chain.staging.frameCapacity), needed,
|
||||
"staging too small to hold 1024 frames resampled from \(rate) Hz")
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate the device cannot report is refused rather than producing a chain that would
|
||||
/// divide by zero in the staging arithmetic. The tap treats nil as "skip this buffer".
|
||||
func testRejectsUnusableRateAndEmptyQuantum() {
|
||||
XCTAssertNil(SessionAudio.micChain(rate: 0, frames: 8192, to: target))
|
||||
XCTAssertNil(SessionAudio.micChain(rate: -48_000, frames: 8192, to: target))
|
||||
XCTAssertNil(SessionAudio.micChain(rate: 48_000, frames: 0, to: target))
|
||||
}
|
||||
|
||||
/// The rebuild path: a device that switches 48 kHz → 44.1 kHz under a live tap yields a chain
|
||||
/// at the NEW rate. Resampling by the stale ratio is what pitch-shifts the mic.
|
||||
func testRebuildFollowsNewRate() throws {
|
||||
let first = try XCTUnwrap(SessionAudio.micChain(rate: 48_000, frames: 512, to: target))
|
||||
let second = try XCTUnwrap(SessionAudio.micChain(rate: 44_100, frames: 512, to: target))
|
||||
XCTAssertEqual(first.monoFormat.sampleRate, 48_000)
|
||||
XCTAssertEqual(second.monoFormat.sampleRate, 44_100)
|
||||
XCTAssertGreaterThan(second.staging.frameCapacity, first.staging.frameCapacity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -207,7 +207,8 @@ final class SharedFoundationTests: XCTestCase {
|
||||
}
|
||||
|
||||
/// Resolution order — id beats a unique name beats an address — plus the two refusals a
|
||||
/// front-end must surface rather than guess through.
|
||||
/// front-end must surface rather than guess through, and the rule that keeps a guessable
|
||||
/// reference from dialing: only the record id resolves to `.known`.
|
||||
func testDeepLinkHostResolution() throws {
|
||||
let desk = StoredHost(
|
||||
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
|
||||
@@ -222,14 +223,21 @@ final class SharedFoundationTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
try resolve("punktfunk://connect/11111111-2222-4333-8444-555555555555"), .known(desk))
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .known(desk))
|
||||
// The id is a UUID nothing can guess; a display name and a LAN address are guesses any web
|
||||
// page can make. So the id — and only the id — dials unattended; everything else that finds
|
||||
// a saved host stops at the confirmation.
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .confirm(desk))
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/DESK"), .confirm(desk))
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/couch"), .ambiguous)
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .known(desk))
|
||||
// A stale id with the recovery parameter: the address finds the record anyway.
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .confirm(desk))
|
||||
XCTAssertEqual(
|
||||
try resolve("punktfunk://connect/desk?launch=steam:570"), .confirm(desk))
|
||||
// A stale id with the recovery parameter: the address finds the record anyway — and, being
|
||||
// an address, behind the confirmation exactly as its own doc always said.
|
||||
XCTAssertEqual(
|
||||
try resolve(
|
||||
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
|
||||
.known(desk))
|
||||
.confirm(desk))
|
||||
// Nothing local matches: the sheet gets the address, the claimed name and the pin — which
|
||||
// is what makes a first connect verified rather than blind trust-on-first-use.
|
||||
XCTAssertEqual(
|
||||
|
||||
+88
-20
@@ -50,7 +50,7 @@ mod cli {
|
||||
punktfunk — the Punktfunk client, headless
|
||||
|
||||
punktfunk discover [--json] [--timeout SECS]
|
||||
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
|
||||
punktfunk pair <host[:port]> [--pin N|-] [--name LABEL]
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
punktfunk hosts forget <host-ref>
|
||||
@@ -58,7 +58,7 @@ punktfunk — the Punktfunk client, headless
|
||||
punktfunk library <host-ref> [--json]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
punktfunk open <punktfunk://…>
|
||||
punktfunk open <punktfunk://…> [--yes]
|
||||
punktfunk reachable <host-ref>
|
||||
punktfunk speed-test <host-ref>
|
||||
punktfunk profiles list [--json]
|
||||
@@ -98,7 +98,10 @@ address with `punktfunk hosts add` and it shows in `hosts list --probe`."
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
|
||||
--pin N the PIN the host is showing; without it the command asks, and
|
||||
refuses (exit 6) when there is no terminal to ask on
|
||||
refuses (exit 6) when there is no terminal to ask on. The value
|
||||
sits on argv, which every local user can read (/proc/*/cmdline)
|
||||
--pin - read the PIN from stdin instead (one line) — what a script or
|
||||
another program should use, so the secret never hits argv
|
||||
--name LABEL the label the host files this device under
|
||||
(default: this machine's name)
|
||||
|
||||
@@ -187,7 +190,12 @@ Same parser and same refusal rules as clicking the link in a shell: a
|
||||
contradicted fingerprint refuses and says so, an ambiguous name refuses
|
||||
rather than guessing, and an unknown host is never trusted from a URL —
|
||||
that is a decision for a person, at a surface that can show the fingerprint
|
||||
(exit 6 points at `punktfunk pair`). --exec as in launch."
|
||||
(exit 6 points at `punktfunk pair`). --exec as in launch.
|
||||
|
||||
A link that names its host by the stable record id opens straight away. One
|
||||
that names it by label or address is a guess anything could make, so it asks
|
||||
first; --yes answers for a script, and without a terminal it refuses (exit 6)
|
||||
rather than opening unasked."
|
||||
}
|
||||
"reachable" => {
|
||||
"\
|
||||
@@ -272,6 +280,11 @@ from the config directory for a true factory reset."
|
||||
/// Resolve a host reference the way every other surface does: stable id, then a unique
|
||||
/// name, then `addr[:port]` (design/client-deep-links.md §2). Sharing `resolve_host` is
|
||||
/// what keeps `punktfunk launch desk` and `punktfunk://connect/desk` from disagreeing.
|
||||
///
|
||||
/// [`HostResolution::Confirm`] — a guessable reference — is accepted WITHOUT a prompt here,
|
||||
/// and only here: this reference is an argument the user typed in their own terminal, so
|
||||
/// there is nobody else to confirm it with. The guessable-reference rule exists for URLs
|
||||
/// handed to us by someone else; that path is `open`, which does ask.
|
||||
fn resolve(reference: &str) -> Result<(KnownHosts, usize), u8> {
|
||||
let known = KnownHosts::load();
|
||||
let link = DeepLink {
|
||||
@@ -279,7 +292,7 @@ from the config directory for a true factory reset."
|
||||
..Default::default()
|
||||
};
|
||||
match deeplink::resolve_host(&link, &known) {
|
||||
HostResolution::Known(i) => Ok((known, i)),
|
||||
HostResolution::Known(i) | HostResolution::Confirm(i) => Ok((known, i)),
|
||||
HostResolution::Ambiguous => {
|
||||
eprintln!(
|
||||
"more than one saved host is called \"{reference}\" — use its address or id"
|
||||
@@ -459,31 +472,34 @@ from the config directory for a true factory reset."
|
||||
})
|
||||
}
|
||||
|
||||
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// `pair <host[:port]> [--pin N|-]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
|
||||
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
|
||||
/// forever is worse than an exit code.
|
||||
///
|
||||
/// `--pin -` reads the PIN from stdin instead. A value on argv is readable by every local
|
||||
/// user (`/proc/*/cmdline` is world-readable on every distro we target) and the PIN is the
|
||||
/// only secret binding the ceremony to the operator's intent, so programmatic callers — the
|
||||
/// Decky backend among them — pipe it in rather than spelling it on the command line.
|
||||
fn pair(args: &[String]) -> u8 {
|
||||
let Some(target) = positional(args, 0) else {
|
||||
eprintln!("usage: punktfunk pair <host[:port]> [--pin N]");
|
||||
eprintln!("usage: punktfunk pair <host[:port]> [--pin N|-]");
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let (addr, port) = split_host_port(&target);
|
||||
let pin = match value(args, "--pin") {
|
||||
Some(p) => p,
|
||||
let pin = match value(args, "--pin").as_deref() {
|
||||
Some("-") => read_pin(None),
|
||||
Some(p) => Some(p.to_string()),
|
||||
None if is_tty() => read_pin(Some(&addr)),
|
||||
None => {
|
||||
if !is_tty() {
|
||||
eprintln!("no --pin and no terminal to ask on");
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
eprint!("PIN shown on {addr}: ");
|
||||
let mut line = String::new();
|
||||
if std::io::stdin().read_line(&mut line).is_err() {
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
line.trim().to_string()
|
||||
eprintln!("no --pin and no terminal to ask on");
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
};
|
||||
let Some(pin) = pin else {
|
||||
eprintln!("no PIN on stdin");
|
||||
return NEEDS_INTERACTION;
|
||||
};
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
@@ -898,6 +914,31 @@ from the config directory for a true factory reset."
|
||||
);
|
||||
match outcome {
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
|
||||
// The link named the host by something GUESSABLE — its label, its address — rather
|
||||
// than by its record id. A URL handed to us by someone else may not dial on a guess,
|
||||
// so a person says yes first. `--yes` is the scripted escape (and the only way in
|
||||
// without a terminal to ask on).
|
||||
Ok(PlanOutcome::ConfirmConnect(plan)) => {
|
||||
if !has(args, "--yes") {
|
||||
if !is_tty() {
|
||||
eprintln!(
|
||||
"that link names {} by label or address, not by its id — re-run with \
|
||||
--yes to open it",
|
||||
plan.host.name
|
||||
);
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
eprint!("Connect to {} ({})? [y/N] ", plan.host.name, plan.host.addr);
|
||||
let mut line = String::new();
|
||||
if std::io::stdin().read_line(&mut line).is_err()
|
||||
|| !line.trim().eq_ignore_ascii_case("y")
|
||||
{
|
||||
eprintln!("cancelled");
|
||||
return OK;
|
||||
}
|
||||
}
|
||||
run_plan(*plan, has(args, "--exec"), false)
|
||||
}
|
||||
// A URL may never pair or trust on its own — that is a decision for a person, at a
|
||||
// surface that can show them the fingerprint.
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => {
|
||||
@@ -1049,7 +1090,11 @@ from the config directory for a true factory reset."
|
||||
..Default::default()
|
||||
};
|
||||
let (addr, port) = match deeplink::resolve_host(&link, &known) {
|
||||
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
// Nothing is dialled and no title is launched, so a guessable reference needs no
|
||||
// confirmation — it only picks which address to send one probe packet to.
|
||||
HostResolution::Known(i) | HostResolution::Confirm(i) => {
|
||||
(known.hosts[i].addr.clone(), known.hosts[i].port)
|
||||
}
|
||||
_ => split_host_port(&reference),
|
||||
};
|
||||
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||
@@ -1220,6 +1265,19 @@ from the config directory for a true factory reset."
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
|
||||
/// One line of PIN from stdin — prompted when we're asking a person, silent for `--pin -`
|
||||
/// (a pipe from another program). `None` on a read error or an empty line (EOF), which the
|
||||
/// caller turns into [`NEEDS_INTERACTION`] rather than sending an empty PIN to the host.
|
||||
fn read_pin(prompt_for: Option<&str>) -> Option<String> {
|
||||
if let Some(addr) = prompt_for {
|
||||
eprint!("PIN shown on {addr}: ");
|
||||
}
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line).ok()?;
|
||||
let pin = line.trim();
|
||||
(!pin.is_empty()).then(|| pin.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1250,6 +1308,16 @@ from the config directory for a true factory reset."
|
||||
Some("10.0.0.1".into())
|
||||
);
|
||||
assert_eq!(positional(&argv(&["--json"]), 0), None);
|
||||
// `--pin -` (the PIN comes down stdin, never argv): the lone dash is that flag's
|
||||
// VALUE, not the host to pair with.
|
||||
assert_eq!(
|
||||
positional(&argv(&["--pin", "-", "desk"]), 0),
|
||||
Some("desk".into())
|
||||
);
|
||||
assert_eq!(
|
||||
value(&argv(&["desk", "--pin", "-"]), "--pin"),
|
||||
Some("-".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+18
-4
@@ -407,11 +407,17 @@ def _cli_argv() -> list[str] | None:
|
||||
return [str(sibling)] if sibling.exists() else None
|
||||
|
||||
|
||||
async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
|
||||
async def _run_cli(
|
||||
args: list[str], timeout: float = 20.0, stdin_text: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout
|
||||
is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would
|
||||
corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out.
|
||||
|
||||
``stdin_text`` is written to the child and the pipe closed — the way a secret reaches the CLI,
|
||||
because argv does not qualify: ``/proc/*/cmdline`` is world-readable, so every process on the
|
||||
Deck can read a flag's value. See :meth:`Plugin.pair`.
|
||||
|
||||
The same ``_flatpak_env`` repair the client runs needed applies here unchanged — Decky's
|
||||
PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the
|
||||
sandbox is being started."""
|
||||
@@ -422,10 +428,12 @@ async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, st
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*prefix, *args,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
payload = stdin_text.encode() if stdin_text is not None else None
|
||||
out, err = await asyncio.wait_for(proc.communicate(payload), timeout=timeout)
|
||||
rc = proc.returncode if proc.returncode is not None else -1
|
||||
return (
|
||||
rc,
|
||||
@@ -761,21 +769,27 @@ class Plugin:
|
||||
return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0)
|
||||
|
||||
async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
||||
"""The PIN ceremony (``punktfunk pair <addr:port> --pin N --name LABEL``).
|
||||
"""The PIN ceremony (``punktfunk pair <addr:port> --pin - --name LABEL``).
|
||||
|
||||
The operator arms pairing on the host, which shows a 4-digit PIN; entering it here
|
||||
verifies the host end to end and pins its fingerprint, so every later connect is silent.
|
||||
``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host
|
||||
that isn't armed, and ``unreachable`` is a host that never answered.
|
||||
|
||||
The PIN goes down the child's STDIN (``--pin -``), never on argv: it is the only secret
|
||||
binding the ceremony to the operator's intent, and a value on the command line is readable
|
||||
by every local process for as long as the call runs (~100 s here) — long enough for anyone
|
||||
on the Deck to complete the pairing with their own keypair instead.
|
||||
|
||||
The budget is generous because the ceremony waits on a person at the other end."""
|
||||
rc, out, err = await _run_cli(
|
||||
[
|
||||
"pair", f"{addr}:{int(port)}",
|
||||
"--pin", str(pin).strip(),
|
||||
"--pin", "-",
|
||||
"--name", name,
|
||||
],
|
||||
timeout=100.0,
|
||||
stdin_text=f"{str(pin).strip()}\n",
|
||||
)
|
||||
if rc == 0:
|
||||
fp = ""
|
||||
|
||||
+167
-9
@@ -148,8 +148,17 @@ pub enum AppMsg {
|
||||
ended: Option<String>,
|
||||
tofu: bool,
|
||||
},
|
||||
/// Hand over to the gamepad console (`punktfunk-session --browse`) — the couch UI's
|
||||
/// door from the desktop shell.
|
||||
OpenConsole,
|
||||
/// The console child exited; `Some` carries why it ended badly.
|
||||
ConsoleExited(Option<String>),
|
||||
/// Request-access Cancel: the child was killed; release busy quietly.
|
||||
CancelPending,
|
||||
/// Upload the client log ring to this paired host (`logring::send_to_host`); the
|
||||
/// outcome lands as a Toast either way. The mgmt port rides along, resolved like
|
||||
/// OpenLibrary's.
|
||||
SendLogs(ConnectRequest, Option<u16>),
|
||||
/// The speed-test dialog resolved (either way) — release `busy`.
|
||||
SpeedTestDone,
|
||||
ShowPreferences,
|
||||
@@ -261,6 +270,7 @@ impl SimpleComponent for AppModel {
|
||||
HostsOutput::Pair(req) => AppMsg::Pair(req),
|
||||
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
|
||||
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
|
||||
HostsOutput::SendLogs(req, mgmt) => AppMsg::SendLogs(req, mgmt),
|
||||
HostsOutput::Toast(msg) => AppMsg::Toast(msg),
|
||||
});
|
||||
|
||||
@@ -418,6 +428,44 @@ impl SimpleComponent for AppModel {
|
||||
}
|
||||
}
|
||||
AppMsg::SpeedTest(req) => self.speed_test(req, &sender),
|
||||
AppMsg::SendLogs(req, mgmt_port) => {
|
||||
// Blocking network (the library agent's 5 s connect / 10 s global budgets) —
|
||||
// a worker thread, with the outcome routed back as a Toast. Wording is the
|
||||
// console's verbatim, so a quoted message means the same thing everywhere.
|
||||
let identity = self.identity.clone();
|
||||
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let mgmt = mgmt_port.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT);
|
||||
self.toast(&format!("Sending logs to {}…", req.name));
|
||||
let out = sender.input_sender().clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-sendlogs".into())
|
||||
.spawn(move || {
|
||||
let header = format!(
|
||||
"punktfunk-client {} ({} {}) — client log bundle",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
);
|
||||
let msg = match pf_client_core::logring::send_to_host(
|
||||
&req.addr, mgmt, &identity, pin, &header,
|
||||
) {
|
||||
Ok(id) => {
|
||||
tracing::info!(host = %req.name, id, "client logs uploaded");
|
||||
format!(
|
||||
"Logs sent to {} — download them from its web console's \
|
||||
Logs page",
|
||||
req.name
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(host = %req.name, error = %e, "client log upload failed");
|
||||
format!("Couldn't send logs — {e}")
|
||||
}
|
||||
};
|
||||
let _ = out.send(AppMsg::Toast(msg));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
AppMsg::SpeedTestDone => self.busy = false,
|
||||
AppMsg::OpenLibrary(req, mgmt_port) => {
|
||||
crate::ui_library::open(self, &sender, req, mgmt_port);
|
||||
@@ -519,6 +567,51 @@ impl SimpleComponent for AppModel {
|
||||
))),
|
||||
}
|
||||
}
|
||||
AppMsg::OpenConsole => {
|
||||
if std::mem::replace(&mut self.busy, true) {
|
||||
return;
|
||||
}
|
||||
// The console owns the screen and the pads while it runs, so it takes `busy`
|
||||
// like a stream does. `gio::Subprocess` is the GLib-native child: its
|
||||
// `wait_check_async` lands the exit on this very main loop — no thread, no
|
||||
// channel — and reports a non-zero exit as an error. That is also how a
|
||||
// build without the session's `ui` feature (Nix) surfaces: the child prints
|
||||
// "--browse needs the console UI" and exits non-zero, and we banner it.
|
||||
let mut argv = vec![
|
||||
std::ffi::OsString::from(crate::spawn::session_binary()),
|
||||
"--browse".into(),
|
||||
];
|
||||
// Same knob a stream uses — the session also fullscreens itself on the Deck
|
||||
// and under gamescope regardless.
|
||||
if self.settings.borrow().fullscreen_on_stream {
|
||||
argv.push("--fullscreen".into());
|
||||
}
|
||||
let argv: Vec<&std::ffi::OsStr> =
|
||||
argv.iter().map(std::ffi::OsString::as_os_str).collect();
|
||||
match gio::Subprocess::newv(&argv, gio::SubprocessFlags::NONE) {
|
||||
Ok(child) => {
|
||||
let sender = sender.clone();
|
||||
child.wait_check_async(gio::Cancellable::NONE, move |res| {
|
||||
sender.input(AppMsg::ConsoleExited(res.err().map(|e| e.to_string())));
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.busy = false;
|
||||
self.hosts.emit(HostsMsg::ShowError(format!(
|
||||
"Couldn't start the console UI — {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMsg::ConsoleExited(err) => {
|
||||
self.busy = false;
|
||||
// Quitting the console (B at its root) exits 0 and returns here silently.
|
||||
if let Some(e) = err {
|
||||
self.hosts
|
||||
.emit(HostsMsg::ShowError(format!("Console UI ended — {e}")));
|
||||
}
|
||||
self.hosts.emit(HostsMsg::Refresh);
|
||||
}
|
||||
AppMsg::CancelPending => {
|
||||
self.close_waiting();
|
||||
self.busy = false;
|
||||
@@ -562,10 +655,10 @@ impl AppModel {
|
||||
}
|
||||
|
||||
/// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile
|
||||
/// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is
|
||||
/// only the GTK end of it — turn the outcome into the same messages a card click raises,
|
||||
/// so a link gets the identical wake, trust and error surfaces and NOT a second connect
|
||||
/// path of its own.
|
||||
/// resolution and every refusal rule — including "only a stable record id may dial
|
||||
/// unattended" — live in the shared brain (`plan_from_link`); this is only the GTK end of
|
||||
/// it: turn the outcome into the same messages a card click raises, so a link gets the
|
||||
/// identical wake, trust and error surfaces and NOT a second connect path of its own.
|
||||
fn open_deep_link(&mut self, url: &str, sender: &ComponentSender<AppModel>) {
|
||||
use pf_client_core::deeplink;
|
||||
use pf_client_core::orchestrate::{plan_from_link, PlanOutcome};
|
||||
@@ -610,6 +703,51 @@ impl AppModel {
|
||||
AppMsg::Connect(req)
|
||||
});
|
||||
}
|
||||
Ok(PlanOutcome::ConfirmConnect(plan)) => {
|
||||
// The link named this (saved, pinned) host by its LABEL or its ADDRESS rather
|
||||
// than by its record id. `x-scheme-handler/punktfunk` is registered by our
|
||||
// .desktop, so any web page can hand us such a URL and both of those are
|
||||
// guessable — the dial waits for a person. Deliberately not the PIN ceremony
|
||||
// below: this host is already pinned, and re-pairing it would throw that away.
|
||||
if self.busy {
|
||||
return self.toast("A session is already running — end it first.");
|
||||
}
|
||||
let req = ConnectRequest {
|
||||
name: plan.host.name.clone(),
|
||||
addr: plan.host.addr.clone(),
|
||||
port: plan.host.port,
|
||||
fp_hex: plan.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
launch: plan.launch.clone().map(|id| (id.clone(), id)),
|
||||
mac: plan.host.mac.clone(),
|
||||
profile: plan.profile_override.clone(),
|
||||
};
|
||||
let mut body = format!("A link asks to connect to {} ({}).", req.name, req.addr);
|
||||
if let Some((id, _)) = &req.launch {
|
||||
body.push_str(&format!("\n\nIt also asks the host to launch “{id}”."));
|
||||
}
|
||||
body.push_str(
|
||||
"\n\nIt names the host by its label or address, which anything that can \
|
||||
open a link could guess. A link that names the host's id connects without \
|
||||
asking.",
|
||||
);
|
||||
let dialog = adw::AlertDialog::new(Some("Open this link?"), Some(&body));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]);
|
||||
dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_close_response("cancel");
|
||||
let sender = sender.clone();
|
||||
let wake = plan.wake;
|
||||
dialog.connect_response(Some("connect"), move |_, _| {
|
||||
// The same two messages the `Connect` arm raises, so the confirmed link
|
||||
// gets the identical wake / trust / error surfaces a card click gets.
|
||||
sender.input(if wake {
|
||||
AppMsg::WakeConnect(req.clone())
|
||||
} else {
|
||||
AppMsg::Connect(req.clone())
|
||||
});
|
||||
});
|
||||
dialog.present(Some(&self.window));
|
||||
}
|
||||
Ok(PlanOutcome::ConfirmUnknown(unknown)) => {
|
||||
// Known-but-unpinned, or not known at all: the link may not pair and may not
|
||||
// trust on its own, so it opens the ordinary ceremony under the user's eyes —
|
||||
@@ -895,11 +1033,30 @@ fn clear_steam_sdl_device_filter() {
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
// The fmt layer as before, plus the in-process ring (`pf_client_core::logring`, DEBUG+ regardless
|
||||
// of RUST_LOG) that "Send logs to host" uploads — the env filter scopes the stderr layer
|
||||
// only, because the ring exists precisely for the diagnostics nobody enabled before the
|
||||
// bug happened. The spawned session's own stderr joins the ring too (`orchestrate` pipes
|
||||
// it through `logring::forward_child_stderr`), so a bundle from this shell carries the
|
||||
// stream's trail, not just the launcher's.
|
||||
{
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::Layer;
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
// The default (stdout) writer, exactly as `fmt().init()` had it.
|
||||
tracing_subscriber::fmt::layer().with_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
),
|
||||
)
|
||||
.with(
|
||||
pf_client_core::logring::RingLayer
|
||||
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||
// real devices (same rationale as the session binary).
|
||||
@@ -1007,6 +1164,7 @@ fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<App
|
||||
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
|
||||
window.add_action(&add("about", || AppMsg::ShowAbout));
|
||||
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
|
||||
window.add_action(&add("console", || AppMsg::OpenConsole));
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
|
||||
|
||||
@@ -94,6 +94,8 @@ pub enum CardOutput {
|
||||
Pair(ConnectRequest),
|
||||
SpeedTest(ConnectRequest),
|
||||
Library(ConnectRequest),
|
||||
/// Upload this device's recent log ring to the host (`logring::send_to_host`).
|
||||
SendLogs(ConnectRequest),
|
||||
/// Open the host edit sheet (name, profile binding, pinned cards, clipboard).
|
||||
Edit {
|
||||
fp_hex: String,
|
||||
@@ -333,6 +335,13 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
Box::new(move || CardOutput::Library(req.clone())),
|
||||
);
|
||||
}
|
||||
{
|
||||
let req = req.clone();
|
||||
add(
|
||||
"send-logs",
|
||||
Box::new(move || CardOutput::SendLogs(req.clone())),
|
||||
);
|
||||
}
|
||||
{
|
||||
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
|
||||
add(
|
||||
@@ -520,6 +529,13 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
look.append(Some("Browse library\u{2026}"), Some("card.library"));
|
||||
}
|
||||
look.append(Some("Test network speed\u{2026}"), Some("card.speed"));
|
||||
// The same row the console's host menu carries, on the same gate: the
|
||||
// upload authenticates with the paired identity, and an offline host
|
||||
// could only ever toast an error. The bundle lands on the host's web
|
||||
// console (Logs page) beside the host's own log.
|
||||
if k.paired && *online {
|
||||
look.append(Some("Send logs to host"), Some("card.send-logs"));
|
||||
}
|
||||
// An explicit wake only when offline and a MAC is known.
|
||||
if !online && !k.mac.is_empty() {
|
||||
look.append(Some("Wake host"), Some("card.wake"));
|
||||
@@ -729,6 +745,8 @@ pub enum HostsOutput {
|
||||
SpeedTest(ConnectRequest),
|
||||
/// With the advertised mgmt port when a live advert carries one.
|
||||
Library(ConnectRequest, Option<u16>),
|
||||
/// With the mgmt port resolved the same way as [`HostsOutput::Library`]'s.
|
||||
SendLogs(ConnectRequest, Option<u16>),
|
||||
}
|
||||
|
||||
impl SimpleComponent for HostsPage {
|
||||
@@ -859,7 +877,14 @@ impl SimpleComponent for HostsPage {
|
||||
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
|
||||
}
|
||||
header.pack_start(&rescan_btn);
|
||||
// The couch UI's front door, beside the page's other actions (same placement the
|
||||
// WinUI shell gives it). It was previously reachable only as `--browse` on the
|
||||
// command line, which is no way to find a mode.
|
||||
let console_btn = gtk::Button::from_icon_name("input-gaming-symbolic");
|
||||
console_btn.set_tooltip_text(Some("Console UI — the controller-driven couch interface"));
|
||||
console_btn.set_action_name(Some("win.console"));
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Console UI"), Some("win.console"));
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
menu.append(Some("About Punktfunk"), Some("win.about"));
|
||||
@@ -869,7 +894,9 @@ impl SimpleComponent for HostsPage {
|
||||
.primary(true)
|
||||
.tooltip_text("Main menu")
|
||||
.build();
|
||||
// Packed after the menu so the hamburger stays rightmost (pack_end fills inward).
|
||||
header.pack_end(&menu_btn);
|
||||
header.pack_end(&console_btn);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
@@ -1011,6 +1038,10 @@ impl SimpleComponent for HostsPage {
|
||||
let mgmt = self.mgmt_port_for(&req);
|
||||
let _ = sender.output(HostsOutput::Library(req, mgmt));
|
||||
}
|
||||
CardOutput::SendLogs(req) => {
|
||||
let mgmt = self.mgmt_port_for(&req);
|
||||
let _ = sender.output(HostsOutput::SendLogs(req, mgmt));
|
||||
}
|
||||
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
|
||||
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
|
||||
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
|
||||
|
||||
@@ -640,6 +640,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("enable_444") {
|
||||
o.enable_444 = Some(values.enable_444);
|
||||
}
|
||||
if touched.has("ten_bit_sdr") {
|
||||
o.ten_bit_sdr = Some(values.ten_bit_sdr);
|
||||
}
|
||||
if touched.has("compositor") {
|
||||
o.compositor = Some(values.compositor.clone());
|
||||
}
|
||||
@@ -649,6 +652,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("audio_format") {
|
||||
o.audio_format = Some(values.audio_format.clone());
|
||||
}
|
||||
if touched.has("keep_host_audio") {
|
||||
o.keep_host_audio = Some(values.keep_host_audio);
|
||||
}
|
||||
if touched.has("mic_enabled") {
|
||||
o.mic_enabled = Some(values.mic_enabled);
|
||||
}
|
||||
@@ -1250,7 +1256,9 @@ pub fn show_scoped(
|
||||
"Above 1× supersamples for sharpness; below is lighter on the host",
|
||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
// 1 Mbit/s per step: the rungs that matter on a thin link are 3, 4, 6 — a 5-wide step
|
||||
// could not name any of them, and typing was the only way to reach one.
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 1.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row
|
||||
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
|
||||
@@ -1279,6 +1287,13 @@ pub fn show_scoped(
|
||||
only, and only where the host can encode it.",
|
||||
)
|
||||
.build();
|
||||
let ten_bit_sdr_row = adw::SwitchRow::builder()
|
||||
.title("10-bit SDR")
|
||||
.subtitle(
|
||||
"Smoother gradients without HDR \u{2014} 10-bit encoding precision. Needs an \
|
||||
NVIDIA host; HDR takes over when it engages.",
|
||||
)
|
||||
.build();
|
||||
let decoder_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -1464,6 +1479,10 @@ pub fn show_scoped(
|
||||
w.set_sensitive(surround_row.selected() == 0);
|
||||
surround_row.connect_changed(move |i| w.set_sensitive(i == 0));
|
||||
}
|
||||
let keep_host_audio_row = adw::SwitchRow::builder()
|
||||
.title("Keep host audio playing")
|
||||
.subtitle("The host's speakers or headphones keep playing while you stream — needs a host on 0.32+")
|
||||
.build();
|
||||
let mic_row = adw::SwitchRow::builder()
|
||||
.title("Stream microphone")
|
||||
.subtitle("Sends your microphone to the host's virtual mic — Ctrl+Alt+Shift+V mutes it mid-stream")
|
||||
@@ -1709,10 +1728,12 @@ pub fn show_scoped(
|
||||
wake_row.set_active(s.auto_wake);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
invert_row.set_active(s.invert_scroll);
|
||||
keep_host_audio_row.set_active(s.keep_host_audio);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
echo_row.set_active(s.echo_cancel);
|
||||
hdr_row.set_active(s.hdr_enabled);
|
||||
chroma_row.set_active(s.enable_444);
|
||||
ten_bit_sdr_row.set_active(s.ten_bit_sdr);
|
||||
surround_row.set_selected(index::surround(s));
|
||||
audio_format_row.set_selected(index::audio_format(s));
|
||||
// `set_selected` never fires the changed hook, so mirror the stereo gate here — the same
|
||||
@@ -1968,6 +1989,12 @@ pub fn show_scoped(
|
||||
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
|
||||
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
|
||||
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
|
||||
toggle!(
|
||||
ten_bit_sdr_row,
|
||||
"ten_bit_sdr",
|
||||
o.ten_bit_sdr.is_some(),
|
||||
ten_bit_sdr
|
||||
);
|
||||
toggle!(
|
||||
fullscreen_row,
|
||||
"fullscreen_on_stream",
|
||||
@@ -1986,6 +2013,12 @@ pub fn show_scoped(
|
||||
o.invert_scroll.is_some(),
|
||||
invert_scroll
|
||||
);
|
||||
toggle!(
|
||||
keep_host_audio_row,
|
||||
"keep_host_audio",
|
||||
o.keep_host_audio.is_some(),
|
||||
keep_host_audio
|
||||
);
|
||||
toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some(), mic_enabled);
|
||||
toggle!(
|
||||
echo_row,
|
||||
@@ -2057,6 +2090,7 @@ pub fn show_scoped(
|
||||
quality_group.add(codec_row.widget());
|
||||
quality_group.add(&hdr_row);
|
||||
quality_group.add(&chroma_row);
|
||||
quality_group.add(&ten_bit_sdr_row);
|
||||
// Decoder and GPU are facts about THIS device's hardware — never per profile (tier G).
|
||||
if !profile_mode {
|
||||
quality_group.add(decoder_row.widget());
|
||||
@@ -2095,6 +2129,7 @@ pub fn show_scoped(
|
||||
let audio_group = group("", "Applies from the next session.");
|
||||
audio_group.add(surround_row.widget());
|
||||
audio_group.add(audio_format_row.widget());
|
||||
audio_group.add(&keep_host_audio_row);
|
||||
// The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they
|
||||
// render only in the defaults scope; the surround/format + mic-uplink rows above are
|
||||
// profileable.
|
||||
@@ -2240,10 +2275,12 @@ pub fn show_scoped(
|
||||
if want_speaker != pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
|
||||
s.pad_speaker = if want_speaker { "pad" } else { "off" }.to_string();
|
||||
}
|
||||
s.keep_host_audio = keep_host_audio_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.echo_cancel = echo_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
s.enable_444 = chroma_row.is_active();
|
||||
s.ten_bit_sdr = ten_bit_sdr_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
1 => 6,
|
||||
2 => 8,
|
||||
|
||||
@@ -39,11 +39,6 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# The log ring normalizes `log`-crate events off the bridge's "log" shim target
|
||||
# (`ring_layer.rs`) so the vendored decoder's per-frame DEBUG chatter can be gated by its real
|
||||
# module path; both are already in the graph through tracing-subscriber's default features.
|
||||
tracing-log = "0.2"
|
||||
log = "0.4"
|
||||
|
||||
# This crate carries NO toolkit, deliberately: it is the renderer the shells spawn, and the
|
||||
# `--no-default-features` build is what a minimal/embedded image installs. GTK4/libadwaita/relm4
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
mod ring_layer;
|
||||
|
||||
/// The session control socket: a line-per-connection unix socket other same-user
|
||||
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
|
||||
@@ -415,7 +414,11 @@ mod session_main {
|
||||
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
|
||||
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
|
||||
// frames actually took.
|
||||
video_caps: pf_client_core::video::video_caps_for(settings.hdr_enabled, want_444),
|
||||
video_caps: pf_client_core::video::video_caps_for(
|
||||
settings.hdr_enabled,
|
||||
settings.ten_bit_sdr,
|
||||
want_444,
|
||||
),
|
||||
// This panel's HDR colour volume → the host's virtual-display EDID, so host
|
||||
// apps tone-map to the real glass. Windows reads it from DXGI (the
|
||||
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
|
||||
@@ -443,6 +446,7 @@ mod session_main {
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
keep_host_audio: settings.keep_host_audio,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||
@@ -642,7 +646,7 @@ mod session_main {
|
||||
),
|
||||
)
|
||||
.with(
|
||||
crate::ring_layer::RingLayer
|
||||
pf_client_core::logring::RingLayer
|
||||
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
||||
)
|
||||
.init();
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Thin `tracing` layer feeding `pf_client_core::logring` — the source for the console's
|
||||
//! "Send logs to host" action. Captures at DEBUG+ regardless of `RUST_LOG` (its own filter is
|
||||
//! applied at install), mirroring the host's `log_capture::RingLayer`: the whole point is that
|
||||
//! a field report carries the diagnostics nobody thought to enable beforehand.
|
||||
//!
|
||||
//! …which is exactly why it also has to keep OUT the chatter that would evict them. The ring
|
||||
//! holds 4096 lines. The vendored H.265 parser (`cros_codecs`, behind `pf-bitstream`) DEBUG-logs
|
||||
//! its DPB bookkeeping — "Retaining pic POC", "Stored picture", "Set reference", "Bumping POC",
|
||||
//! one `find_short_term_ref_by_poc` per reference — a dozen lines PER FRAME, so at 120 fps the
|
||||
//! ring turns over in about three seconds. The 2026-08-17 field bundle from a Steam Deck read
|
||||
//! `… 2037456 older lines evicted from the ring …` followed by 3.5 s of DPB chatter: the whole
|
||||
//! 27-minute session, including the 10 s `audio playback buffer_ms= underruns=` line three
|
||||
//! investigation rounds had been waiting for, was gone. A field ring that a healthy decoder can
|
||||
//! flush is worse than no ring, because it looks like diagnostics and carries none.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::layer::Context;
|
||||
|
||||
/// Targets whose DEBUG/TRACE output is steady-state per-frame chatter, not diagnostics. The ring
|
||||
/// keeps their INFO-and-up. Prefix-matched on module-path boundaries, so `cros_codecs::codec::…`
|
||||
/// is gated and a hypothetical `cros_codecs_probe` is not. Same shape as the host's
|
||||
/// `log_capture::NOISY_DEBUG_TARGETS`.
|
||||
const NOISY_DEBUG_TARGETS: &[&str] = &["cros_codecs"];
|
||||
|
||||
fn is_noisy_debug(target: &str) -> bool {
|
||||
NOISY_DEBUG_TARGETS.iter().any(|t| {
|
||||
target
|
||||
.strip_prefix(t)
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with("::"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct RingLayer;
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
// Events from `log`-crate dependencies (the vendored decoder among them) arrive through
|
||||
// the tracing-log bridge under the shim target "log", with the record's real module path
|
||||
// tucked into `log.target=`. Normalize back to the real metadata so the noise gate below
|
||||
// and the target column both see `cros_codecs::…` — under the shim target every bridged
|
||||
// event is indistinguishable from every other, and the field bundle's target column read
|
||||
// `log` for two million lines.
|
||||
use tracing_log::NormalizeEvent;
|
||||
let normalized = event.normalized_metadata();
|
||||
let meta = normalized.as_ref().unwrap_or_else(|| event.metadata());
|
||||
if *meta.level() > tracing::Level::INFO && is_noisy_debug(meta.target()) {
|
||||
return;
|
||||
}
|
||||
struct V(String);
|
||||
impl Visit for V {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
// The message leads; fields follow. Events put it first anyway, so
|
||||
// this is belt-and-braces against odd macro orderings.
|
||||
let rest = std::mem::take(&mut self.0);
|
||||
let _ = write!(self.0, "{value:?}");
|
||||
self.0.push_str(&rest);
|
||||
} else if !field.name().starts_with("log.") {
|
||||
// `log.target`/`log.module_path`/`log.file`/`log.line` are the bridge's own
|
||||
// bookkeeping — already surfaced through the normalized target above, and
|
||||
// 150 bytes of repeated path per line otherwise.
|
||||
let _ = write!(self.0, " {}={:?}", field.name(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v = V(String::new());
|
||||
event.record(&mut v);
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
pf_client_core::logring::wallclock(),
|
||||
meta.level().as_str(),
|
||||
meta.target(),
|
||||
v.0
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gate is a prefix match on module-path boundaries, nothing looser.
|
||||
#[test]
|
||||
fn noisy_gate_matches_the_crate_and_its_modules_only() {
|
||||
assert!(is_noisy_debug("cros_codecs"));
|
||||
assert!(is_noisy_debug("cros_codecs::codec::h265::dpb"));
|
||||
assert!(!is_noisy_debug("cros_codecs_probe"));
|
||||
assert!(!is_noisy_debug("pf_bitstream::h265"));
|
||||
assert!(!is_noisy_debug("pf_client_core::audio"));
|
||||
}
|
||||
|
||||
/// End to end through the bridge: a `log::debug!` from the vendored decoder's module path
|
||||
/// must NOT reach the ring, its `warn!` must (under its real target, without the bridge's
|
||||
/// bookkeeping fields), and a DEBUG event from our own audio module — the very line the gate
|
||||
/// exists to protect — must land.
|
||||
///
|
||||
/// The ring is process-global, so the assertions look for lines this test wrote (unique
|
||||
/// markers) rather than at the ring's size, and the subscriber is installed only for the
|
||||
/// duration of the test.
|
||||
#[test]
|
||||
fn bridged_decoder_debug_is_dropped_and_the_audio_line_survives() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::Layer;
|
||||
let sub = tracing_subscriber::registry()
|
||||
.with(RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG));
|
||||
// The bridge may already be installed by another test in this binary; either way the
|
||||
// `log` max level has to admit DEBUG for the planted records to be dispatched at all.
|
||||
let _ = tracing_log::LogTracer::builder()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.init();
|
||||
log::set_max_level(log::LevelFilter::Debug);
|
||||
let _guard = tracing::subscriber::set_default(sub);
|
||||
|
||||
let marker = format!("ringgate-{}", std::process::id());
|
||||
log::debug!(target: "cros_codecs::codec::h265::dpb", "Retaining pic POC {marker}-dpb: true");
|
||||
log::warn!(target: "cros_codecs::codec::h265::parser", "{marker}-parser-warn");
|
||||
tracing::debug!(target: "pf_client_core::audio", buffer_ms = 15u32, "audio playback {marker}-audio");
|
||||
|
||||
let text = pf_client_core::logring::render("test");
|
||||
assert!(
|
||||
!text.contains(&format!("{marker}-dpb")),
|
||||
"decoder DPB DEBUG chatter must not reach the ring"
|
||||
);
|
||||
let warn_line = text
|
||||
.lines()
|
||||
.find(|l| l.contains(&format!("{marker}-parser-warn")))
|
||||
.expect("decoder WARN must be kept");
|
||||
assert!(
|
||||
warn_line.contains("cros_codecs::codec::h265::parser"),
|
||||
"bridged events must carry their real target, not the `log` shim: {warn_line}"
|
||||
);
|
||||
assert!(
|
||||
!warn_line.contains("log.target="),
|
||||
"bridge bookkeeping fields must be dropped: {warn_line}"
|
||||
);
|
||||
let audio_line = text
|
||||
.lines()
|
||||
.find(|l| l.contains(&format!("{marker}-audio")))
|
||||
.expect("our own DEBUG audio line must survive");
|
||||
assert!(audio_line.contains("pf_client_core::audio"));
|
||||
assert!(audio_line.contains("buffer_ms=15"));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ use windows_reactor::*;
|
||||
const MENU_CONNECT: &str = "Connect";
|
||||
const MENU_LIBRARY: &str = "Browse library\u{2026}";
|
||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||
/// Upload this device's recent log ring to the host (`logring::send_to_host`), where the web
|
||||
/// console's Logs page lists it beside the host's own log. Paired + online only — the same
|
||||
/// gate as the console UI's row, because the upload authenticates with the paired identity
|
||||
/// and an offline host could only ever report an error.
|
||||
const MENU_SEND_LOGS: &str = "Send logs to host";
|
||||
const MENU_WAKE: &str = "Wake host";
|
||||
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
|
||||
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
|
||||
@@ -758,6 +763,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
items.push(menu_item(MENU_LIBRARY));
|
||||
}
|
||||
items.push(menu_item(MENU_SPEED));
|
||||
// See [`MENU_SEND_LOGS`] for the gate.
|
||||
if k.paired && online {
|
||||
items.push(menu_item(MENU_SEND_LOGS));
|
||||
}
|
||||
// An explicit wake only when the host is offline and we have a MAC.
|
||||
if can_wake {
|
||||
items.push(menu_item(MENU_WAKE));
|
||||
@@ -848,6 +857,54 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
svc.set_screen.call(Screen::Library);
|
||||
}
|
||||
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
||||
MENU_SEND_LOGS => {
|
||||
// Blocking network (the library agent's 5 s connect / 10 s global
|
||||
// budgets) — a worker thread, with the outcome routed to the
|
||||
// status line. Wording is the console's verbatim, so a quoted
|
||||
// message means the same thing everywhere.
|
||||
let identity = svc.ctx.identity.clone();
|
||||
let target = target.clone();
|
||||
let set_status = svc.set_status.clone();
|
||||
set_status.call(format!("Sending logs to {}…", target.name));
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-sendlogs".into())
|
||||
.spawn(move || {
|
||||
let header = format!(
|
||||
"punktfunk-client {} ({} {}) — client log bundle",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
);
|
||||
let pin = target
|
||||
.fp_hex
|
||||
.as_deref()
|
||||
.and_then(crate::trust::parse_hex32);
|
||||
let mgmt = target
|
||||
.mgmt_port
|
||||
.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT);
|
||||
let msg = match pf_client_core::logring::send_to_host(
|
||||
&target.addr,
|
||||
mgmt,
|
||||
&identity,
|
||||
pin,
|
||||
&header,
|
||||
) {
|
||||
Ok(id) => {
|
||||
tracing::info!(host = %target.name, id, "client logs uploaded");
|
||||
format!(
|
||||
"Logs sent to {} — download them from its web \
|
||||
console's Logs page",
|
||||
target.name
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(host = %target.name, error = %e, "client log upload failed");
|
||||
format!("Couldn't send logs — {e}")
|
||||
}
|
||||
};
|
||||
set_status.call(msg);
|
||||
});
|
||||
}
|
||||
MENU_SPEED => {
|
||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||
// New run: invalidate any still-in-flight probe, reset the screen.
|
||||
|
||||
+127
-52
@@ -326,6 +326,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// later instance over WM_COPYDATA) and this poll pulls them onto the UI thread. Thread-fed
|
||||
// state must be root state, like the pad count below.
|
||||
let (deep_link, set_deep_link) = cx.use_async_state(Option::<String>::None);
|
||||
// A link that named its host by something GUESSABLE (its label, its address, the `host=`
|
||||
// recovery parameter) rather than by the stable record id: `Some(plan)` arms the "Open this
|
||||
// link?" confirmation built at the bottom of this function. The plan is byte-for-byte the one
|
||||
// an id-referenced link carries, so confirming runs the identical dial one click later. Root
|
||||
// state like every other dialog flag in this shell.
|
||||
let (link_confirm, set_link_confirm) =
|
||||
cx.use_async_state(Option::<Box<pf_client_core::orchestrate::ConnectPlan>>::None);
|
||||
cx.use_effect((), {
|
||||
let set_deep_link = set_deep_link.clone();
|
||||
move || {
|
||||
@@ -363,16 +370,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let (library, set_library) = cx.use_async_state(library::LibraryState::default());
|
||||
|
||||
// Continuous LAN discovery (spawned once).
|
||||
// Route an arriving link. Parsing, host and profile resolution and every refusal rule live
|
||||
// in the shared brain (`plan_from_link`); this is only the WinUI end — turn the outcome into
|
||||
// the same call a tile click makes, so a link gets the identical wake, trust and error
|
||||
// surfaces rather than a second connect path of its own.
|
||||
// Route an arriving link. Parsing, host and profile resolution and every refusal rule —
|
||||
// including "only a stable record id may dial unattended" — live in the shared brain
|
||||
// (`plan_from_link`); this is only the WinUI end — turn the outcome into the same call a tile
|
||||
// click makes, so a link gets the identical wake, trust and error surfaces rather than a
|
||||
// second connect path of its own.
|
||||
cx.use_effect(deep_link.clone(), {
|
||||
let (ctx, set_screen, set_status, set_deep_link) = (
|
||||
let (ctx, set_screen, set_status, set_deep_link, set_link_confirm) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
set_deep_link.clone(),
|
||||
set_link_confirm.clone(),
|
||||
);
|
||||
let screen_now = screen.clone();
|
||||
move || {
|
||||
@@ -403,41 +412,16 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
);
|
||||
use pf_client_core::orchestrate::PlanOutcome;
|
||||
match plan {
|
||||
Ok(PlanOutcome::Connect(p)) => {
|
||||
let target = Target {
|
||||
name: p.host.name.clone(),
|
||||
addr: p.host.addr.clone(),
|
||||
port: p.host.port,
|
||||
fp_hex: p.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
mac: p.host.mac.clone(),
|
||||
mgmt_port: p.host.mgmt_port,
|
||||
profile: p.profile_override.clone(),
|
||||
launch: None, // routed explicitly below (initiate_launch*)
|
||||
};
|
||||
// With a MAC it takes the dial first wake path, so a sleeping host wakes
|
||||
// instead of erroring — exactly what clicking its tile would do. The
|
||||
// link's `launch=` id must reach the session (`--launch`) — this arm used
|
||||
// to drop it, so a game link opened a plain desktop session.
|
||||
match (p.launch.clone(), p.wake && !target.mac.is_empty()) {
|
||||
(Some(id), true) => {
|
||||
connect::initiate_launch_waking(
|
||||
&ctx,
|
||||
target,
|
||||
id,
|
||||
&set_screen,
|
||||
&set_status,
|
||||
);
|
||||
}
|
||||
(Some(id), false) => {
|
||||
connect::initiate_launch(&ctx, target, id, &set_screen, &set_status);
|
||||
}
|
||||
(None, true) => {
|
||||
connect::initiate_waking(&ctx, target, &set_screen, &set_status)
|
||||
}
|
||||
(None, false) => connect::initiate(&ctx, target, &set_screen, &set_status),
|
||||
}
|
||||
}
|
||||
Ok(PlanOutcome::Connect(p)) => dial_link(&ctx, &p, &set_screen, &set_status),
|
||||
// The link named a saved, pinned host by its LABEL or its ADDRESS rather than
|
||||
// by its record id. This app registers the `punktfunk` scheme (AppxManifest's
|
||||
// windows.protocol / the installer's URL Protocol key), so any web page can
|
||||
// hand us such a URL, and both of those references are guessable — it may not
|
||||
// dial on its own. Arm the confirmation instead; OK runs `dial_link` on the
|
||||
// very same plan, so the confirmed link and an id-referenced one are one code
|
||||
// path. Deliberately NOT the PIN ceremony below: this host is already pinned,
|
||||
// and re-pairing it would throw that pin away.
|
||||
Ok(PlanOutcome::ConfirmConnect(p)) => set_link_confirm.call(Some(p)),
|
||||
// Known but never pinned, or not known at all: a link may not pair and may not
|
||||
// trust on its own, so it opens the ordinary PIN ceremony seeded with what the
|
||||
// link CLAIMED — name shown as claimed, the fingerprint pre-filling the pin so
|
||||
@@ -729,19 +713,110 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
Screen::Stream => stream::session_page(ctx, &hud),
|
||||
};
|
||||
|
||||
// The "Open this link?" confirmation for a guessable-reference link (see `link_confirm`).
|
||||
// It lives at ROOT, not on a page: a link can arrive over WM_COPYDATA while any screen is
|
||||
// up, and a WinUI ContentDialog is a popup rather than a visual child, so it rides above
|
||||
// whatever is showing. Same discipline as the shell's other dialogs — ALWAYS MOUNTED, with
|
||||
// `is_open` doing the arming, in a stable trailing slot (unmounting a ContentDialog trips
|
||||
// the reactor backend's phantom-child bookkeeping; see hosts.rs's forget confirmation).
|
||||
let link_dialog: Element = {
|
||||
let pending = link_confirm;
|
||||
// Name the host AND the game, because that is the whole point of asking: it's what
|
||||
// lets someone tell their own shortcut from a link a web page just handed them.
|
||||
let content = pending
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
let mut s = format!(
|
||||
"A link asks to connect to {} ({}).",
|
||||
p.host.name, p.host.addr
|
||||
);
|
||||
if let Some(id) = &p.launch {
|
||||
s.push_str(&format!(
|
||||
"\n\nIt also asks the host to launch \u{201c}{id}\u{201d}."
|
||||
));
|
||||
}
|
||||
s.push_str(
|
||||
"\n\nIt names the host by its label or address, which anything that can open \
|
||||
a link could guess. Shortcuts made in Punktfunk name the host's id and \
|
||||
connect without asking.",
|
||||
);
|
||||
s
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (ctx2, ss, st, sc) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
set_link_confirm.clone(),
|
||||
);
|
||||
ContentDialog::new("Open this link?")
|
||||
.content(content)
|
||||
.primary_button_text("Connect")
|
||||
.close_button_text("Cancel")
|
||||
.is_open(pending.is_some())
|
||||
.on_closed(move |r: ContentDialogResult| {
|
||||
sc.call(None);
|
||||
// Cancel (and Escape, which WinUI also reports as `None`) does nothing at all.
|
||||
if r == ContentDialogResult::Primary
|
||||
&& let Some(plan) = &pending
|
||||
{
|
||||
dial_link(&ctx2, plan, &ss, &st);
|
||||
}
|
||||
})
|
||||
.into()
|
||||
};
|
||||
|
||||
// The Stream screen is a plain status card (the session child owns the real stream window);
|
||||
// it's shown without the navigation entrance tween. Everything else slides + fades in.
|
||||
if matches!(screen, Screen::Stream) {
|
||||
return body;
|
||||
let page: Element = if matches!(screen, Screen::Stream) {
|
||||
body
|
||||
} else {
|
||||
let offset = (1.0 - progress) * 22.0;
|
||||
border(body)
|
||||
.opacity(progress)
|
||||
.margin(Thickness {
|
||||
left: 0.0,
|
||||
top: offset,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.into()
|
||||
};
|
||||
grid(vec![page, link_dialog]).into()
|
||||
}
|
||||
|
||||
/// Run a resolved link plan: the same four calls a host tile's click makes, so a link gets the
|
||||
/// identical wake, trust and error surfaces rather than a second connect path of its own. Shared
|
||||
/// by the two outcomes that dial — `PlanOutcome::Connect` (the link named the stable record id)
|
||||
/// and a confirmed `PlanOutcome::ConfirmConnect` — so the confirmation is one click in front of
|
||||
/// this, never a second implementation of it.
|
||||
fn dial_link(
|
||||
ctx: &Arc<AppCtx>,
|
||||
plan: &pf_client_core::orchestrate::ConnectPlan,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
let target = Target {
|
||||
name: plan.host.name.clone(),
|
||||
addr: plan.host.addr.clone(),
|
||||
port: plan.host.port,
|
||||
fp_hex: plan.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
mac: plan.host.mac.clone(),
|
||||
mgmt_port: plan.host.mgmt_port,
|
||||
profile: plan.profile_override.clone(),
|
||||
launch: None, // routed explicitly below (initiate_launch*)
|
||||
};
|
||||
// With a MAC it takes the dial first wake path, so a sleeping host wakes instead of
|
||||
// erroring — exactly what clicking its tile would do. The link's `launch=` id must reach
|
||||
// the session (`--launch`) — this used to drop it, so a game link opened a plain desktop
|
||||
// session.
|
||||
match (plan.launch.clone(), plan.wake && !target.mac.is_empty()) {
|
||||
(Some(id), true) => {
|
||||
connect::initiate_launch_waking(ctx, target, id, set_screen, set_status);
|
||||
}
|
||||
(Some(id), false) => connect::initiate_launch(ctx, target, id, set_screen, set_status),
|
||||
(None, true) => connect::initiate_waking(ctx, target, set_screen, set_status),
|
||||
(None, false) => connect::initiate(ctx, target, set_screen, set_status),
|
||||
}
|
||||
let offset = (1.0 - progress) * 22.0;
|
||||
border(body)
|
||||
.opacity(progress)
|
||||
.margin(Thickness {
|
||||
left: 0.0,
|
||||
top: offset,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -487,9 +487,11 @@ struct OverrideFlags {
|
||||
codec: bool,
|
||||
hdr_enabled: bool,
|
||||
enable_444: bool,
|
||||
ten_bit_sdr: bool,
|
||||
compositor: bool,
|
||||
audio_channels: bool,
|
||||
audio_format: bool,
|
||||
keep_host_audio: bool,
|
||||
mic_enabled: bool,
|
||||
echo_cancel: bool,
|
||||
touch_mode: bool,
|
||||
@@ -523,9 +525,11 @@ impl OverrideFlags {
|
||||
codec: o.codec.is_some(),
|
||||
hdr_enabled: o.hdr_enabled.is_some(),
|
||||
enable_444: o.enable_444.is_some(),
|
||||
ten_bit_sdr: o.ten_bit_sdr.is_some(),
|
||||
compositor: o.compositor.is_some(),
|
||||
audio_channels: o.audio_channels.is_some(),
|
||||
audio_format: o.audio_format.is_some(),
|
||||
keep_host_audio: o.keep_host_audio.is_some(),
|
||||
mic_enabled: o.mic_enabled.is_some(),
|
||||
echo_cancel: o.echo_cancel.is_some(),
|
||||
touch_mode: o.touch_mode.is_some(),
|
||||
@@ -919,6 +923,9 @@ pub(crate) fn settings_page(
|
||||
let hdr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.hdr_enabled, |s, on| {
|
||||
s.hdr_enabled = on
|
||||
});
|
||||
let ten_bit_sdr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.ten_bit_sdr, |s, on| {
|
||||
s.ten_bit_sdr = on
|
||||
});
|
||||
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
|
||||
s.enable_444 = on
|
||||
});
|
||||
@@ -1066,6 +1073,10 @@ pub(crate) fn settings_page(
|
||||
let format_combo = setting_combo(ctx, scope, (rev, set_rev), af_names, af_i, |s, i| {
|
||||
s.audio_format = AUDIO_FORMATS[i].0.to_string();
|
||||
});
|
||||
let keep_host_audio_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.keep_host_audio, |s, on| {
|
||||
s.keep_host_audio = on
|
||||
});
|
||||
let mic_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.mic_enabled, |s, on| {
|
||||
s.mic_enabled = on
|
||||
});
|
||||
@@ -1231,6 +1242,17 @@ pub(crate) fn settings_page(
|
||||
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
|
||||
codec \u{2014} other encoders stream 4:2:0.",
|
||||
),
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"ten_bit_sdr",
|
||||
"10-bit SDR",
|
||||
over.ten_bit_sdr,
|
||||
ten_bit_sdr_toggle,
|
||||
"Smoother gradients without HDR \u{2014} the picture is encoded at \
|
||||
10-bit precision. Needs an NVIDIA host; HDR takes over when it \
|
||||
engages.",
|
||||
),
|
||||
],
|
||||
None,
|
||||
));
|
||||
@@ -1541,6 +1563,17 @@ pub(crate) fn settings_page(
|
||||
rate; the stats overlay names what the session actually got.",
|
||||
)
|
||||
}),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"keep_host_audio",
|
||||
"Keep host audio playing",
|
||||
over.keep_host_audio,
|
||||
keep_host_audio_toggle,
|
||||
"The host\u{2019}s own speakers or headphones keep playing while you \
|
||||
stream \u{2014} both ends hear the same audio. Needs a host on 0.32 \
|
||||
or newer.",
|
||||
)),
|
||||
// The endpoint picks are facts about THIS device's hardware — never
|
||||
// per profile, like Decoder/GPU.
|
||||
(!profile_mode)
|
||||
|
||||
@@ -21,32 +21,18 @@
|
||||
#[allow(dead_code)]
|
||||
mod logfile;
|
||||
|
||||
// The hand-off itself, verbatim the shell's `--console` flag — including the CREATE_NO_WINDOW
|
||||
// the console-subsystem session binary needs from a GUI parent. Kept in one file precisely
|
||||
// because the two copies of this hand-off had already drifted apart on that flag.
|
||||
#[cfg(windows)]
|
||||
#[path = "../couch.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod couch;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
logfile::init();
|
||||
// The session binary ships beside us in the package; fall back to PATH for a dev run.
|
||||
let session = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|e| e.with_file_name("punktfunk-session.exe"))
|
||||
.filter(|p| p.exists())
|
||||
.unwrap_or_else(|| "punktfunk-session".into());
|
||||
|
||||
let mut cmd = std::process::Command::new(session);
|
||||
cmd.arg("--browse");
|
||||
if !std::env::args().any(|a| a == "--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(_) => std::process::exit(1),
|
||||
}
|
||||
couch::run_browse();
|
||||
}
|
||||
|
||||
/// The workspace builds on Linux/macOS too; there is nothing to launch there.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The couch/HTPC hand-off: run the session binary's `--browse` mode (the complete
|
||||
//! controller-driven client — host list, discovery, PIN pairing, settings, Wake-on-LAN,
|
||||
//! library) and mirror its exit code.
|
||||
//!
|
||||
//! Shared by BOTH couch entry points — `punktfunk-console.exe`, which needs its own
|
||||
//! executable because an MSIX `<Application>` cannot pass arguments, and this shell's
|
||||
//! `--console` flag — so the spawn flags below are stated once. They were stated in neither
|
||||
//! until 2026-08-27, which is why both Start-menu tiles opened a black console window that
|
||||
//! then sat behind the couch UI for the whole session.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// The session binary: installed next to us (the MSIX layout and dev `target\…` runs both
|
||||
/// land on the sibling), else `PATH`.
|
||||
pub(crate) fn session_binary() -> PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let sibling = exe.with_file_name("punktfunk-session.exe");
|
||||
if sibling.exists() {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
"punktfunk-session".into()
|
||||
}
|
||||
|
||||
/// Run `punktfunk-session --browse` (fullscreen unless `--windowed`) and exit with the
|
||||
/// child's code, so whatever supervises this process sees the real result. Never returns.
|
||||
pub(crate) fn run_browse() -> ! {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
// `punktfunk-session` keeps the CONSOLE subsystem for its stdout contract, and both couch
|
||||
// entry points are GUI processes with no console to lend it — so without this flag Windows
|
||||
// mints one, and the couch UI comes up in front of a black terminal window.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--browse");
|
||||
// A couch UI is fullscreen unless explicitly told otherwise.
|
||||
if !std::env::args().any(|a| a == "--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
cmd.stdin(Stdio::null())
|
||||
// Nothing here parses the stdout contract (no `--json-status`), but `match_window`
|
||||
// reports the settled window size on stdout REGARDLESS — and with no console the
|
||||
// handle it would inherit is invalid, which panics the child mid-stream on the first
|
||||
// report. A sink that goes nowhere is the difference between quiet and a crash.
|
||||
.stdout(Stdio::null())
|
||||
// Piped through the log tee: a couch launch (Start-menu tile, Steam shortcut) has no
|
||||
// console either, so the session's whole receive/decode/present log would otherwise
|
||||
// evaporate exactly when a user hits something worth reporting.
|
||||
.stderr(Stdio::piped())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
// Spawn (not `status()`) so the stderr pipe can be drained into the client log.
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
crate::logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(e) => {
|
||||
eprintln!("could not start the console UI: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,8 +146,10 @@ impl Write for Tee {
|
||||
}
|
||||
|
||||
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
|
||||
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
|
||||
/// pipe (child exit).
|
||||
/// interleave mid-line with the shell's own — and into the client log ring, so a "Send logs
|
||||
/// to host" bundle from this shell carries the session's whole receive/decode/present trail
|
||||
/// (the file half of exactly that rationale is this module's opening doc). Returns
|
||||
/// immediately; the thread dies with the pipe (child exit).
|
||||
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-session-log".into())
|
||||
@@ -157,6 +159,7 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
let mut tee = Tee;
|
||||
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
|
||||
let _ = tee.write_all(line.as_bytes());
|
||||
pf_client_core::logring::note(line.trim_end().to_string());
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
|
||||
+29
-29
@@ -22,6 +22,9 @@
|
||||
|
||||
#[cfg(windows)]
|
||||
mod app;
|
||||
// The `--console` couch hand-off, shared verbatim with `punktfunk-console.exe`.
|
||||
#[cfg(windows)]
|
||||
mod couch;
|
||||
// `punktfunk://` activation: single instance, hand-off, and the positional URL parse
|
||||
// (design/client-deep-links.md §4.2).
|
||||
#[cfg(windows)]
|
||||
@@ -63,14 +66,32 @@ fn main() {
|
||||
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
|
||||
// a GUI/MSIX launch has no console, so without the file the client side of any field report
|
||||
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
|
||||
// Plus the in-process ring (`pf_client_core::logring`, DEBUG+ regardless of RUST_LOG) that
|
||||
// "Send logs to host" uploads — the env filter scopes the visible layer only: the ring
|
||||
// exists precisely for the diagnostics nobody enabled before the bug happened. The spawned
|
||||
// session's stderr joins the ring in `logfile::forward_child_stderr`, so a bundle carries
|
||||
// the stream's trail too.
|
||||
logfile::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_writer(logfile::tee)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
{
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::Layer;
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_ansi(false)
|
||||
.with_writer(logfile::tee)
|
||||
.with_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
),
|
||||
)
|
||||
.with(
|
||||
pf_client_core::logring::RingLayer
|
||||
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
if let Some(p) = logfile::path() {
|
||||
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
|
||||
}
|
||||
@@ -114,28 +135,7 @@ fn main() {
|
||||
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
|
||||
// its exit code, so anything supervising this process sees the real result.
|
||||
if flag("--console") {
|
||||
let mut cmd = std::process::Command::new(spawn::session_binary());
|
||||
cmd.arg("--browse");
|
||||
// A couch UI is fullscreen unless explicitly told otherwise.
|
||||
if !flag("--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
|
||||
// (Start-menu tile, Steam shortcut) has no console to inherit either.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(e) => {
|
||||
eprintln!("could not start the console UI: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
couch::run_browse();
|
||||
}
|
||||
|
||||
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
|
||||
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
|
||||
|
||||
// The session binary's location: ONE resolver, shared with the couch entry points
|
||||
// (`crate::couch`), which the standalone `punktfunk-console.exe` bin includes by path.
|
||||
use crate::couch::session_binary;
|
||||
use std::io::BufRead as _;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -116,18 +119,6 @@ pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The session binary: installed next to the shell (the MSIX layout and dev
|
||||
/// `target\…` runs both land on the sibling), else `PATH`.
|
||||
pub(crate) fn session_binary() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let sibling = exe.with_file_name("punktfunk-session.exe");
|
||||
if sibling.exists() {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
"punktfunk-session".into()
|
||||
}
|
||||
|
||||
/// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to
|
||||
/// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel
|
||||
/// can kill it. `launch` carries a library title id for the host to launch during the
|
||||
|
||||
@@ -1187,4 +1187,56 @@ mod tests {
|
||||
Some(PlanError::NoFrame)
|
||||
);
|
||||
}
|
||||
|
||||
/// A truncated access unit never panics the decode thread.
|
||||
///
|
||||
/// `plan_au` degrades every malformation it knows about to [`PlanWarning::TruncatedAu`]
|
||||
/// or [`PlanError`], and `pf-vkdecode` re-validates OBU ranges on top — but the AV1
|
||||
/// `obu_size` bound lives in the vendored parser, and until PROVENANCE.md deviation 14
|
||||
/// it was missing: an AU cut mid-OBU leaves a final OBU declaring more payload than
|
||||
/// remains, and the unchecked slice aborted the calling thread. That reaches all three
|
||||
/// native rungs, which re-export this planner, and is exactly the shape
|
||||
/// `PUNKTFUNK_AU_FAULT=truncate` injects.
|
||||
///
|
||||
/// The contract asserted here is the crate's stated posture, not a specific verdict:
|
||||
/// a short AU is a plan error or a warning, and whatever plans do come back stay
|
||||
/// inside the bytes handed in.
|
||||
#[test]
|
||||
fn a_truncated_access_unit_is_a_plan_error_not_a_panic() {
|
||||
let mut planned = 0usize;
|
||||
let mut rejected = 0usize;
|
||||
|
||||
for packet in IvfIterator::new(AV1_25FPS).take(12) {
|
||||
for denom in [2usize, 3, 4, 8] {
|
||||
let cut = packet.len() - packet.len() / denom;
|
||||
// A fresh planner per cut: the claim is that a short unit fails cleanly on
|
||||
// its own terms, not that a planner carries state across one.
|
||||
let mut planner = Av1Planner::new();
|
||||
match planner.plan_au(&packet[..cut]) {
|
||||
Ok(plans) => {
|
||||
planned += 1;
|
||||
for plan in &plans {
|
||||
for tile in &plan.tiles {
|
||||
assert!(
|
||||
tile.data.start <= tile.data.end && tile.data.end <= cut,
|
||||
"tile range {:?} escapes a {cut}-byte truncated unit",
|
||||
tile.data
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => rejected += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
planned + rejected == 48,
|
||||
"every cut must reach a verdict; got {planned} planned + {rejected} rejected"
|
||||
);
|
||||
assert!(
|
||||
rejected > 0,
|
||||
"no truncated unit was rejected - the test proves nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+174
-12
@@ -1921,19 +1921,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // sps_video_parameter_set_id
|
||||
s.bits(3, 0); // sps_max_sub_layers_minus1
|
||||
s.bit(1); // sps_temporal_id_nesting_flag
|
||||
|
||||
// profile_tier_level(1, 0): general_profile_space u(2), tier u(1),
|
||||
// profile_idc u(5), 32 compatibility flags, progressive/interlaced/
|
||||
// non-packed/frame-only, 43 constraint/reserved bits (all zero for every
|
||||
// profile branch the parser takes), inbld/reserved bit, level u(8).
|
||||
/// profile_tier_level()'s general block: general_profile_space u(2), tier u(1),
|
||||
/// profile_idc u(5), 32 compatibility flags, progressive/interlaced/non-packed/
|
||||
/// frame-only, 43 constraint/reserved bits (all zero for every profile branch the
|
||||
/// parser takes), inbld/reserved bit, level u(8). The per-sub-layer tail follows
|
||||
/// only when max_sub_layers_minus1 > 0.
|
||||
fn ptl_general(s: &mut BitSink, profile_idc: u8, level_idc: u32) {
|
||||
s.bits(2, 0);
|
||||
s.bit(0);
|
||||
s.bits(5, u32::from(o.profile_idc));
|
||||
s.bits(5, u32::from(profile_idc));
|
||||
s.bits(32, 0);
|
||||
s.bit(1); // general_progressive_source_flag
|
||||
s.bit(0); // general_interlaced_source_flag
|
||||
@@ -1942,7 +1938,15 @@ mod tests {
|
||||
s.bits(31, 0);
|
||||
s.bits(12, 0); // 43 zero bits total
|
||||
s.bit(0); // general_inbld_flag / reserved
|
||||
s.bits(8, o.level_idc); // general_level_idc
|
||||
s.bits(8, level_idc); // general_level_idc
|
||||
}
|
||||
|
||||
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // sps_video_parameter_set_id
|
||||
s.bits(3, 0); // sps_max_sub_layers_minus1
|
||||
s.bit(1); // sps_temporal_id_nesting_flag
|
||||
ptl_general(&mut s, o.profile_idc, o.level_idc);
|
||||
|
||||
s.ue(0); // sps_seq_parameter_set_id
|
||||
s.ue(o.chroma_format_idc);
|
||||
@@ -3579,4 +3583,162 @@ mod tests {
|
||||
assert!(plan.picture.is_idr);
|
||||
assert!(plan.warnings.is_empty(), "{:?}", plan.warnings);
|
||||
}
|
||||
|
||||
// ------- vendored-parser bounds regressions (deviations 9-11) -------
|
||||
|
||||
const VPS_NUT: u8 = 32;
|
||||
const SPS_NUT: u8 = 33;
|
||||
const PPS_NUT: u8 = 34;
|
||||
|
||||
/// Trailing bits, so the parser keeps reading past the guarded field instead of
|
||||
/// stopping short — a truncated NALU would be an error for the wrong reason.
|
||||
fn padded(mut s: BitSink) -> Vec<u8> {
|
||||
for _ in 0..64 {
|
||||
s.bits(8, 0xff);
|
||||
}
|
||||
s.finish()
|
||||
}
|
||||
|
||||
/// Deviation 9: `{vps,sps}_max_sub_layers_minus1` is u(3), so 7 is representable,
|
||||
/// but 7.4.3.1/7.4.3.2 stop at 6 — and the sub-layer arrays the parser then walks
|
||||
/// are six and seven deep. Both param sets are now a parse error, not a panic.
|
||||
/// This is also what keeps the planner's own
|
||||
/// `max_num_reorder_pics[max_sub_layers_minus1]` reads in bounds.
|
||||
#[test]
|
||||
fn a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic() {
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // vps_video_parameter_set_id
|
||||
s.bit(1); // vps_base_layer_internal_flag
|
||||
s.bit(1); // vps_base_layer_available_flag
|
||||
s.bits(6, 0); // vps_max_layers_minus1
|
||||
s.bits(3, 7); // vps_max_sub_layers_minus1 — one past the spec's 6
|
||||
s.bit(1); // vps_temporal_id_nesting_flag
|
||||
s.bits(16, 0xffff); // vps_reserved_0xffff_16bits
|
||||
ptl_general(&mut s, 1, 120);
|
||||
let vps = h265_nalu(VPS_NUT, &padded(s));
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&vps),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // sps_video_parameter_set_id
|
||||
s.bits(3, 7); // sps_max_sub_layers_minus1
|
||||
s.bit(1); // sps_temporal_id_nesting_flag
|
||||
ptl_general(&mut s, 1, 120);
|
||||
let sps = h265_nalu(SPS_NUT, &padded(s));
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&sps),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// The PPS fields ahead of the two this section attacks, all zero.
|
||||
fn pps_prefix() -> BitSink {
|
||||
let mut s = BitSink::new();
|
||||
s.ue(0); // pps_pic_parameter_set_id
|
||||
s.ue(0); // pps_seq_parameter_set_id
|
||||
s.bits(2, 0); // dependent_slice_segments_enabled / output_flag_present
|
||||
s.bits(3, 0); // num_extra_slice_header_bits
|
||||
s.bits(2, 0); // sign_data_hiding_enabled / cabac_init_present
|
||||
s.ue(0); // num_ref_idx_l0_default_active_minus1
|
||||
s.ue(0); // num_ref_idx_l1_default_active_minus1
|
||||
s.se(0); // init_qp_minus26
|
||||
s.bits(3, 0); // constrained_intra_pred / transform_skip / cu_qp_delta_enabled
|
||||
s.se(0); // pps_cb_qp_offset
|
||||
s.se(0); // pps_cr_qp_offset
|
||||
s.bits(4, 0); // chroma_qp_offsets / weighted_pred / weighted_bipred / bypass
|
||||
s
|
||||
}
|
||||
|
||||
/// The PPS fields after the tile block, all zero, plus rbsp_trailing_bits().
|
||||
fn pps_tail(mut s: BitSink) -> Vec<u8> {
|
||||
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
|
||||
s.bits(2, 0); // pps_scaling_list_data_present / lists_modification_present
|
||||
s.ue(0); // log2_parallel_merge_level_minus2
|
||||
s.bits(2, 0); // slice_segment_header_extension / pps_extension_present
|
||||
s.finish()
|
||||
}
|
||||
|
||||
/// Deviation 10: equation 7-42 subtracts `scaling_list_pred_matrix_id_delta` from
|
||||
/// matrixId in u32. Unbounded, it underflows into an out-of-bounds read of the
|
||||
/// six-entry scaling lists; 7.4.5 caps it at matrixId / (sizeId == 3 ? 3 : 1).
|
||||
#[test]
|
||||
fn a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic() {
|
||||
let mut s = pps_prefix();
|
||||
s.bits(2, 0); // tiles_enabled / entropy_coding_sync_enabled
|
||||
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
|
||||
s.bit(1); // pps_scaling_list_data_present_flag
|
||||
s.bit(0); // scaling_list_pred_mode_flag[0][0]
|
||||
s.ue(1); // scaling_list_pred_matrix_id_delta[0][0] — refMatrixId = 0 - 1
|
||||
let mut au = synth_sps(&SpsOpts::default());
|
||||
au.extend(h265_nalu(PPS_NUT, &padded(s)));
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&au),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Deviation 11: the tile counts were bounded by the picture's CTB size only, so a
|
||||
/// wide-enough SPS let them run past the width and height arrays. Table A.8 caps
|
||||
/// them at 20 columns and 22 rows for every level.
|
||||
#[test]
|
||||
fn a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic() {
|
||||
// 2048x2048 luma with 64x64 CTBs: 32 CTBs each way, so the picture bound
|
||||
// alone would admit 31 tile columns and rows.
|
||||
let sps = SpsOpts {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
..Default::default()
|
||||
};
|
||||
for (columns, rows) in [(25, 0), (0, 25)] {
|
||||
let mut s = pps_prefix();
|
||||
s.bit(1); // tiles_enabled_flag
|
||||
s.bit(0); // entropy_coding_sync_enabled_flag
|
||||
s.ue(columns); // num_tile_columns_minus1
|
||||
s.ue(rows); // num_tile_rows_minus1
|
||||
s.bit(1); // uniform_spacing_flag
|
||||
let mut au = synth_sps(&sps);
|
||||
au.extend(h265_nalu(PPS_NUT, &padded(s)));
|
||||
assert!(
|
||||
matches!(H265Planner::new().plan_au(&au), Err(PlanError::Parse(_))),
|
||||
"{columns} columns / {rows} rows must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tile ceiling's two downstream sites, both reached from one slice header:
|
||||
/// the entry-point maximum multiplied the two tile counts in u8 (20 x 22 overflows
|
||||
/// it), and `entry_point_offset_minus1` is 32 deep however large that maximum is.
|
||||
#[test]
|
||||
fn a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic() {
|
||||
let sps = SpsOpts {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
..Default::default()
|
||||
};
|
||||
let mut s = pps_prefix();
|
||||
s.bit(1); // tiles_enabled_flag
|
||||
s.bit(0); // entropy_coding_sync_enabled_flag
|
||||
s.ue(19); // num_tile_columns_minus1 — Table A.8's ceiling, and legal here
|
||||
s.ue(21); // num_tile_rows_minus1
|
||||
s.bit(1); // uniform_spacing_flag
|
||||
s.bit(0); // loop_filter_across_tiles_enabled_flag
|
||||
let mut au = synth_sps(&sps);
|
||||
au.extend(h265_nalu(PPS_NUT, &pps_tail(s)));
|
||||
|
||||
let mut s = BitSink::new();
|
||||
s.bit(1); // first_slice_segment_in_pic_flag
|
||||
s.bit(0); // no_output_of_prior_pics_flag
|
||||
s.ue(0); // slice_pic_parameter_set_id
|
||||
s.ue(2); // slice_type: I
|
||||
s.se(0); // slice_qp_delta
|
||||
s.ue(35); // num_entry_point_offsets — 440 tiles would allow it, 32 slots do not
|
||||
au.extend(h265_nalu(IDR_W_RADL, &padded(s)));
|
||||
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&au),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,5 +105,136 @@ in the future."
|
||||
AV1 has its own `read_su`, and the H.26x `se(v)` callers all pass positive widths — so
|
||||
it is left to upstream rather than widened into this deviation.
|
||||
|
||||
9. `src/codec/h265/parser.rs` — `parse_vps` and `parse_sps`: reject
|
||||
`{vps,sps}_max_sub_layers_minus1 > 6` immediately after the read. The element is
|
||||
`u(3)`, so 7 is representable, but 7.4.3.1 and 7.4.3.2 both bound it at 6 — and
|
||||
every array the parser then walks with it is sized for the spec, not for the field:
|
||||
`profile_tier_level()`'s `sub_layer_*` flags are `[_; 6]` and the sub-layer ordering
|
||||
arrays are `[_; 7]`. A ~20-byte VPS NALU with the field set to 7 panicked inside
|
||||
`parse_profile_tier_level` with an index-out-of-bounds before any picture was
|
||||
decoded, on every reconnect. Bound at the spec's 6, not at the arrays' 5/6, because
|
||||
the two agree there — a conformant stream is never refused. This is also what keeps
|
||||
punktfunk's own `sps.max_num_reorder_pics[max_sub_layers_minus1]` and
|
||||
`sps.max_dec_pic_buffering_minus1[max_sub_layers_minus1]` reads in bounds
|
||||
(`pf-bitstream` h265.rs, and the `pf-vaadec` / `pf-dxvadec` / `pf-vkdecode` picture
|
||||
builders downstream of it) — they all take their `Sps` from this parser, so the
|
||||
parse-time check is the single choke point and none of them needs its own guard.
|
||||
Regression-tested in `pf-bitstream`
|
||||
(`a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
10. `src/codec/h265/parser.rs` — `parse_scaling_list_data`: read
|
||||
`scaling_list_pred_matrix_id_delta` with `read_ue_max(matrixId / factor)` instead of
|
||||
an unbounded `read_ue`, which is exactly the range 7.4.5 permits (`0` to
|
||||
`matrixId / ( sizeId == 3 ? 3 : 1 )`). Equation 7-42 subtracts
|
||||
`delta * factor` from `matrixId` in `u32`: unbounded it underflows — a debug panic
|
||||
on the subtraction, and in release a `~4e9` index into the six-entry
|
||||
`scaling_list_{4x4,8x8,16x16,32x32}`. Reachable from a PPS with
|
||||
`pps_scaling_list_data_present_flag` set, or the equivalent SPS flag. `factor` moved
|
||||
a few lines up so the bound and equation 7-42 share one definition; the same bound
|
||||
also makes `delta * factor` unable to overflow. Regression-tested in `pf-bitstream`
|
||||
(`a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
11. `src/codec/h265/parser.rs` — the tile syntax, three edits, all one defect. `parse_pps`
|
||||
bounded `num_tile_{columns,rows}_minus1` by the picture only
|
||||
(`pic_{width,height}_in_ctbs_y - 1`, which reaches 2110 on a legal SPS) while using
|
||||
them to index `column_width_minus1` / `row_height_minus1`. A PPS on a 2048x2048 SPS
|
||||
asking for 26 tile columns panicked. Annex A is the real bound: A.4.1 requires
|
||||
`num_tile_columns_minus1 < MaxTileCols` and `num_tile_rows_minus1 < MaxTileRows`,
|
||||
and Table A.8 peaks at 20 and 22 (levels 6, 6.1, 6.2). So:
|
||||
|
||||
- new `MAX_TILE_COLUMNS` / `MAX_TILE_ROWS` consts (20, 22), and the counts are read
|
||||
with `min(picture bound, const - 1)`;
|
||||
- the arrays grew from `[u32; 19]` / `[u32; 21]` to those consts. Upstream sized them
|
||||
one short of Table A.8 — the code stores the running remainder in
|
||||
`column_width_minus1[num_tile_columns_minus1]`, so the last tile needs a slot —
|
||||
which means bounding at the arrays would have refused a conformant 20-column
|
||||
stream. Growing by one entry each costs nothing and lets the guard be the spec's
|
||||
number rather than an implementation artefact. The upstream test asserting
|
||||
`[0; 19]` / `[0; 21]` follows the consts now.
|
||||
|
||||
Raising the ceiling to the spec's exposed two further panics downstream, in
|
||||
`parse_slice_header`'s entry-point block (both reachable before this change too, at
|
||||
the arrays' old 19x21 ceiling):
|
||||
|
||||
- the `num_entry_point_offsets` maximum computed
|
||||
`(num_tile_columns_minus1 + 1) * (num_tile_rows_minus1 + 1) - 1` in `u8`, which
|
||||
overflows above 256 tiles — 20x22 is 440. Widened to `u32`, matching the sibling
|
||||
branch two lines down;
|
||||
- `num_entry_point_offsets` was then bounded by that maximum while
|
||||
`entry_point_offset_minus1` is `[u32; 32]`, so a slice claiming 35 entry points
|
||||
indexed past it. Clamped to the array, the same way deviation 7 handles the
|
||||
long-term arrays. 7.4.7.1 puts no 32-entry cap on the element, so this refuses a
|
||||
conformant stream with more than 32 entry points — notably 4K wavefront
|
||||
(`entropy_coding_sync_enabled_flag`) streams, which carry one offset per CTB row.
|
||||
An error beats a panic, but the real fix is upstream sizing that array from the
|
||||
stream.
|
||||
|
||||
Regression-tested in `pf-bitstream`
|
||||
(`a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic`,
|
||||
`a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
12. `src/codec/av1/parser.rs` — `parse_tile_info`: the two non-uniform tile loops
|
||||
(`uniform_tile_spacing_flag == 0`) run until `start_sb` reaches `sb_cols` / `sb_rows`
|
||||
while filling `width_in_sbs_minus_1` / `height_in_sbs_minus_1`, which are
|
||||
`MAX_TILE_COLS` / `MAX_TILE_ROWS` (64) deep. Each iteration advances `start_sb` by at
|
||||
least one superblock, so a frame wide or tall enough — 4096 mi columns is 256
|
||||
superblocks — walks 256 entries into a 64-entry array. The uniform branch already
|
||||
checks `tile_cols > MAX_TILE_COLS` after the fact and is genuinely bounded before it
|
||||
(`tile_cols_log2 <= max_log2_tile_cols <= 6`); the non-uniform branch had neither.
|
||||
Guarded at the top of each loop body, returning the same
|
||||
`"Invalid tile_{cols,rows} {n}"` the uniform branch does. 64 is the spec's own
|
||||
ceiling (`MAX_TILE_COLS` / `MAX_TILE_ROWS` in 3, and a conformance requirement on
|
||||
`TileCols` / `TileRows` in 5.11.1), so it is both the array bound and the legal one.
|
||||
Regression-tested in the file's own test module
|
||||
(`more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
13. `src/codec/h264/parser.rs` — `parse_sps`: reject a picture whose macroblock count
|
||||
overflows `u32`, with the `checked_mul` idiom the frame-crop validation a few lines
|
||||
below already uses. `max_dpb_frames()` computes
|
||||
`max_dpb_mbs / (width_mb * height_mb)` (A.3.1); both dimensions are `ue(v)` read into
|
||||
`u16`, so each reaches 65536 macroblocks and their product reaches 2^33. In debug
|
||||
that is a multiply-overflow panic, in release it wraps — 65536 x 65536 wraps to
|
||||
exactly zero — and the division that follows panics on a zero divisor. `max_dpb_frames()`
|
||||
returns `usize`, not `Result`, and the DPB and `max_num_order_frames()` both call it,
|
||||
so the check belongs at the parse boundary where an `Err` is available. Bounded at
|
||||
the arithmetic limit rather than Table A-1's `MaxFS`: the level tables are the only
|
||||
range H.264 gives these elements, this parser enforces no other level conformance at
|
||||
parse time, and hardware decoders routinely accept a stream whose level_idc
|
||||
understates its resolution. Regression-tested in the file's own test module
|
||||
(`a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
14. `src/codec/av1/parser.rs` — `read_obu`: bound `obu_size` against the buffer before it is
|
||||
used to slice. `obu_size` is a leb128 read out of the stream (`read_leb128()? as usize`,
|
||||
so anything up to `u32::MAX`) and nothing ties it to the bytes actually present; the OBU
|
||||
was then built with an unchecked `&data[start_offset..start_offset + obu_size]`. Any
|
||||
access unit whose last OBU declares more payload than remains — a truncated AU, or simply
|
||||
an over-declared size — panicked with `range end index .. out of range for slice of length
|
||||
..`. That is a bounds check, not arithmetic, so it panics in release too (the workspace
|
||||
leaves `overflow-checks` off, which is why the parser's other unchecked accumulations
|
||||
merely wrap), and it aborts whichever thread is decoding.
|
||||
|
||||
Blast radius is every native AV1 rung: `pf-vkdecode`, `pf-dxvadec` and `pf-vaadec` are all
|
||||
re-exports of `pf_bitstream::av1::Av1Planner`, whose `plan_au` hands raw access-unit bytes
|
||||
straight to this function. Reachable from the project's own `PUNKTFUNK_AU_FAULT=truncate`
|
||||
injector — whose `FaultMode::Truncate` docs reason only about Annex-B, where a NALU carries
|
||||
no length, while AV1 OBUs do — and from any AU delivered short over the wire.
|
||||
|
||||
This was a gap in an otherwise consistent posture rather than a missing idea: `plan_au`
|
||||
degrades every *other* malformation to `PlanWarning::TruncatedAu` or `PlanError::Parse`,
|
||||
and `pf-vkdecode` re-validates `obu.end > au.len()` one layer up. Guarded with
|
||||
`checked_add` plus a length compare, returning the same `String` error the rest of the
|
||||
parser uses; the computed end is reused for `bytes_used` so the slice and the advance can
|
||||
no longer disagree. Regression-tested in the file's own test module
|
||||
(`an_obu_declaring_more_bytes_than_are_present_is_a_parse_error_not_a_panic`, which
|
||||
reproduces the original panic exactly when the guard is reverted) and at the planner
|
||||
boundary in `pf-bitstream`
|
||||
(`av1::tests::a_truncated_access_unit_is_a_plan_error_not_a_panic`).
|
||||
**Not filed upstream.**
|
||||
|
||||
Re-sync procedure: fetch the AOSP tree, re-apply this trim, diff `codec/` +
|
||||
`bitstream_utils.rs` (expect near-zero conflicts), update the commit pin above.
|
||||
|
||||
+102
-3
@@ -1850,10 +1850,25 @@ impl Parser {
|
||||
assert!(reader.0.position() % 8 == 0);
|
||||
let start_offset: usize = (reader.0.position() / 8).try_into().unwrap();
|
||||
|
||||
// `obu_size` was read off the wire as a leb128 and is bounded only by `u32::MAX`; nothing
|
||||
// ties it to how many bytes are actually present. Bound it against the buffer BEFORE it is
|
||||
// used to slice, or a truncated (or simply over-declared) OBU panics with `range end index
|
||||
// .. out of range` — an abort of whatever thread is decoding. See PROVENANCE.md deviation 14.
|
||||
let obu_end = start_offset
|
||||
.checked_add(obu_size)
|
||||
.ok_or::<String>("obu_size overflows the access unit offset".into())?;
|
||||
if obu_end > data.len() {
|
||||
return Err(format!(
|
||||
"obu_size {} overruns the access unit: {} bytes present after the OBU header",
|
||||
obu_size,
|
||||
data.len().saturating_sub(start_offset)
|
||||
));
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Identified OBU type {:?}, data size: {}, obu_size: {}",
|
||||
header.obu_type,
|
||||
start_offset + obu_size,
|
||||
obu_end,
|
||||
obu_size
|
||||
);
|
||||
|
||||
@@ -1872,8 +1887,8 @@ impl Parser {
|
||||
|
||||
Ok(ObuAction::Process(Obu {
|
||||
header,
|
||||
data: Cow::from(&data[start_offset..start_offset + obu_size]),
|
||||
bytes_used: start_offset + obu_size,
|
||||
data: Cow::from(&data[start_offset..obu_end]),
|
||||
bytes_used: obu_end,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2361,6 +2376,10 @@ impl Parser {
|
||||
let mut i = 0;
|
||||
|
||||
while start_sb < sb_cols {
|
||||
if i >= MAX_TILE_COLS {
|
||||
return Err(format!("Invalid tile_cols {}", i + 1));
|
||||
}
|
||||
|
||||
self.mi_col_starts[i] = start_sb << sb_shift;
|
||||
|
||||
let max_width = std::cmp::min(sb_cols - start_sb, max_tile_width_sb);
|
||||
@@ -2387,6 +2406,10 @@ impl Parser {
|
||||
let mut start_sb = 0;
|
||||
let mut i = 0;
|
||||
while start_sb < sb_rows {
|
||||
if i >= MAX_TILE_ROWS {
|
||||
return Err(format!("Invalid tile_rows {}", i + 1));
|
||||
}
|
||||
|
||||
self.mi_row_starts[i] = start_sb << sb_shift;
|
||||
let max_height = std::cmp::min(sb_rows - start_sb, max_tile_height_sb);
|
||||
ti.height_in_sbs_minus_1[i] = r.read_ns(max_height.try_into().unwrap())?;
|
||||
@@ -4291,4 +4314,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// punktfunk deviation 12: the non-uniform tile loops are bounded by the frame's
|
||||
/// superblock count, not by the 64-entry `width_in_sbs_minus_1` /
|
||||
/// `height_in_sbs_minus_1` they fill, so a frame made of one-superblock tiles
|
||||
/// walked off both. MAX_TILE_COLS / MAX_TILE_ROWS are the spec's own ceiling.
|
||||
#[test]
|
||||
fn more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic() {
|
||||
use crate::codec::av1::parser::{SequenceHeaderObu, TileInfo};
|
||||
use crate::codec::av1::reader::Reader;
|
||||
use std::rc::Rc;
|
||||
|
||||
// All zeroes: uniform_tile_spacing_flag = 0, then every ns() read decodes to
|
||||
// a one-superblock tile.
|
||||
let data = [0u8; 128];
|
||||
|
||||
// 4096 mi columns is 256 superblocks, so the column loop runs 256 times.
|
||||
let mut parser = Parser::default();
|
||||
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
|
||||
parser.mi_cols = 4096;
|
||||
parser.mi_rows = 4096;
|
||||
let err = parser
|
||||
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
|
||||
.unwrap_err();
|
||||
assert!(err.starts_with("Invalid tile_cols"), "{err}");
|
||||
|
||||
// Four superblocks wide: the column loop finishes, the row loop overruns.
|
||||
let mut parser = Parser::default();
|
||||
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
|
||||
parser.mi_cols = 64;
|
||||
parser.mi_rows = 4096;
|
||||
let err = parser
|
||||
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
|
||||
.unwrap_err();
|
||||
assert!(err.starts_with("Invalid tile_rows"), "{err}");
|
||||
}
|
||||
|
||||
/// An OBU whose declared `obu_size` runs past the bytes present is a parse error,
|
||||
/// not a panic (PROVENANCE.md deviation 14).
|
||||
///
|
||||
/// `obu_size` is a leb128 read straight out of the stream and bounded only by
|
||||
/// `u32::MAX`; nothing ties it to the length of the buffer handed in. Cutting a real
|
||||
/// access unit mid-OBU therefore leaves a final OBU declaring more payload than
|
||||
/// remains, and the unchecked slice used to abort the calling thread with
|
||||
/// `range end index .. out of range for slice of length ..`.
|
||||
#[test]
|
||||
fn an_obu_declaring_more_bytes_than_are_present_is_a_parse_error_not_a_panic() {
|
||||
let mut overruns = 0usize;
|
||||
|
||||
for packet in IvfIterator::new(STREAM_TEST_25_FPS).take(8) {
|
||||
// Three cuts per unit so the walk is guaranteed to land inside an OBU
|
||||
// rather than exactly on a boundary.
|
||||
for denom in [2usize, 3, 4] {
|
||||
let cut = packet.len() - packet.len() / denom;
|
||||
let mut parser = Parser::default();
|
||||
let mut consumed = 0usize;
|
||||
|
||||
while consumed < cut {
|
||||
match parser.read_obu(&packet[..cut][consumed..]) {
|
||||
Ok(ObuAction::Process(obu)) => consumed += obu.bytes_used,
|
||||
Ok(ObuAction::Drop(n)) => consumed += usize::try_from(n).unwrap(),
|
||||
Err(e) => {
|
||||
if e.contains("overruns the access unit") {
|
||||
overruns += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
overruns > 0,
|
||||
"no cut reached the obu_size bound - the test proves nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2102,6 +2102,13 @@ impl Parser {
|
||||
sps.pic_height_in_map_units_minus1 = r.read_ue()?;
|
||||
sps.frame_mbs_only_flag = r.read_bit()?;
|
||||
|
||||
// max_dpb_frames() divides MaxDpbMbs by the frame's macroblock count (A.3.1).
|
||||
// ue(v) admits 65536 macroblocks in each direction, and the u32 product of the
|
||||
// two wraps to zero long before that.
|
||||
let _ = (sps.width() / 16)
|
||||
.checked_mul(sps.height() / 16)
|
||||
.ok_or::<String>("Invalid picture size in macroblocks".into())?;
|
||||
|
||||
if !sps.frame_mbs_only_flag {
|
||||
sps.mb_adaptive_frame_field_flag = r.read_bit()?;
|
||||
}
|
||||
@@ -3059,4 +3066,43 @@ mod tests {
|
||||
assert_eq!(MaxLongTermFrameIdx::Idx(24), 24);
|
||||
assert!(MaxLongTermFrameIdx::Idx(24) < 25);
|
||||
}
|
||||
|
||||
/// punktfunk deviation 13: `max_dpb_frames()` divides MaxDpbMbs by the frame's
|
||||
/// macroblock count (A.3.1), a u32 product that wraps to zero at the widest
|
||||
/// picture `ue(v)` admits — 65536 x 65536 macroblocks is exactly 2^32. The SPS is
|
||||
/// refused at parse time now, so the division always has a divisor.
|
||||
#[test]
|
||||
fn a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic() {
|
||||
use crate::codec::h264::nalu_writer::NaluWriter;
|
||||
|
||||
let mut buf = Vec::<u8>::new();
|
||||
{
|
||||
let mut w = NaluWriter::new(&mut buf, true);
|
||||
w.write_header(3, 7).unwrap(); // nal_ref_idc = 3, SPS
|
||||
w.write_u(8, 66u32).unwrap(); // profile_idc: Baseline, so no chroma block
|
||||
w.write_u(8, 0u32).unwrap(); // constraint flags + reserved_zero_2bits
|
||||
w.write_u(8, 51u32).unwrap(); // level_idc: 5.1
|
||||
w.write_ue(0u32).unwrap(); // seq_parameter_set_id
|
||||
w.write_ue(0u32).unwrap(); // log2_max_frame_num_minus4
|
||||
w.write_ue(2u32).unwrap(); // pic_order_cnt_type: 2, nothing follows
|
||||
w.write_ue(1u32).unwrap(); // max_num_ref_frames
|
||||
w.write_f(1, 0u32).unwrap(); // gaps_in_frame_num_value_allowed_flag
|
||||
w.write_ue(65535u32).unwrap(); // pic_width_in_mbs_minus1
|
||||
w.write_ue(65535u32).unwrap(); // pic_height_in_map_units_minus1
|
||||
w.write_f(1, 1u32).unwrap(); // frame_mbs_only_flag
|
||||
w.write_f(1, 1u32).unwrap(); // direct_8x8_inference_flag
|
||||
w.write_f(1, 0u32).unwrap(); // frame_cropping_flag
|
||||
w.write_f(1, 0u32).unwrap(); // vui_parameters_present_flag
|
||||
w.write_f(1, 1u32).unwrap(); // rbsp_stop_one_bit
|
||||
while !w.aligned() {
|
||||
w.write_f(1, 0u32).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let mut cursor = Cursor::new(buf.as_slice());
|
||||
let nalu = Nalu::next(&mut cursor).unwrap();
|
||||
assert!(matches!(nalu.header.type_, NaluType::Sps));
|
||||
let err = Parser::default().parse_sps(&nalu).unwrap_err();
|
||||
assert!(err.starts_with("Invalid picture size"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
+55
-10
@@ -42,6 +42,11 @@ const MAX_SHORT_TERM_REF_PIC_SETS: usize = 65;
|
||||
// 7.4.3.2.1:
|
||||
const MAX_LONG_TERM_REF_PIC_SETS: usize = 32;
|
||||
|
||||
// Table A.8: MaxTileCols and MaxTileRows peak at 20 and 22 at level 6.2, and A.4.1
|
||||
// makes those a bitstream conformance requirement for every level.
|
||||
const MAX_TILE_COLUMNS: usize = 20;
|
||||
const MAX_TILE_ROWS: usize = 22;
|
||||
|
||||
// From table 7-5.
|
||||
const DEFAULT_SCALING_LIST_0: [u8; 16] = [16; 16];
|
||||
|
||||
@@ -1239,10 +1244,10 @@ pub struct Pps {
|
||||
pub uniform_spacing_flag: bool,
|
||||
/// `column_width_minus1[ i ]` plus 1 specifies the width of the i-th tile
|
||||
/// column in units of CTBs.
|
||||
pub column_width_minus1: [u32; 19],
|
||||
pub column_width_minus1: [u32; MAX_TILE_COLUMNS],
|
||||
/// `row_height_minus1[ i ]` plus 1 specifies the height of the i-th tile row
|
||||
/// in units of CTBs.
|
||||
pub row_height_minus1: [u32; 21],
|
||||
pub row_height_minus1: [u32; MAX_TILE_ROWS],
|
||||
/// When set, specifies that in-loop filtering operations may be performed
|
||||
/// across tile boundaries in pictures referring to the PPS. When not set,
|
||||
/// specifies that in-loop filtering operations are not performed across
|
||||
@@ -2238,6 +2243,15 @@ impl Parser {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// u(3) can say 7, but 7.4.3.1 stops at 6 — and 7 walks off every sub-layer
|
||||
// array below, starting with profile_tier_level()'s six-deep flags.
|
||||
if vps.max_sub_layers_minus1 > 6 {
|
||||
return Err(format!(
|
||||
"Invalid max_sub_layers_minus1 {}",
|
||||
vps.max_sub_layers_minus1
|
||||
));
|
||||
}
|
||||
|
||||
r.skip_bits(16)?; // vps_reserved_0xffff_16bits
|
||||
|
||||
let ptl = &mut vps.profile_tier_level;
|
||||
@@ -2606,12 +2620,16 @@ impl Parser {
|
||||
// in Table 7-5 and Table 7-6 for i = 0..Min( 63, ( 1 << ( 4 + (
|
||||
// sizeId << 1 ) ) ) − 1 ).
|
||||
if !scaling_list_pred_mode_flag {
|
||||
let scaling_list_pred_matrix_id_delta: u32 = r.read_ue()?;
|
||||
// Equation 7-42's factor. 7.4.5 bounds the delta by
|
||||
// matrixId / factor, which is what keeps refMatrixId at or above
|
||||
// zero — unbounded it underflows into an out-of-bounds read.
|
||||
let factor: u32 = if size_id == 3 { 3 } else { 1 };
|
||||
let scaling_list_pred_matrix_id_delta: u32 =
|
||||
r.read_ue_max(matrix_id as u32 / factor)?;
|
||||
if scaling_list_pred_matrix_id_delta == 0 {
|
||||
Self::fill_default_scaling_list(sl, size_id, matrix_id);
|
||||
} else {
|
||||
// Equation 7-42
|
||||
let factor = if size_id == 3 { 3 } else { 1 };
|
||||
let ref_matrix_id =
|
||||
matrix_id as u32 - scaling_list_pred_matrix_id_delta * factor;
|
||||
if size_id == 0 {
|
||||
@@ -3104,6 +3122,14 @@ impl Parser {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// See parse_vps(): 7.4.3.2 bounds this at 6, u(3) does not.
|
||||
if sps.max_sub_layers_minus1 > 6 {
|
||||
return Err(format!(
|
||||
"Invalid max_sub_layers_minus1 {}",
|
||||
sps.max_sub_layers_minus1
|
||||
));
|
||||
}
|
||||
|
||||
Self::parse_profile_tier_level(
|
||||
&mut sps.profile_tier_level,
|
||||
&mut r,
|
||||
@@ -3485,8 +3511,16 @@ impl Parser {
|
||||
|
||||
// A mix of the rbsp data and the algorithm in 6.5.1
|
||||
if pps.tiles_enabled_flag {
|
||||
pps.num_tile_columns_minus1 = r.read_ue_max(sps.pic_width_in_ctbs_y - 1)?;
|
||||
pps.num_tile_rows_minus1 = r.read_ue_max(sps.pic_height_in_ctbs_y - 1)?;
|
||||
// 7.4.3.3.1 bounds these by the picture, Table A.8 bounds them by the
|
||||
// level — and the level cap is the one the arrays below are sized for.
|
||||
pps.num_tile_columns_minus1 = r.read_ue_max(std::cmp::min(
|
||||
sps.pic_width_in_ctbs_y - 1,
|
||||
MAX_TILE_COLUMNS as u32 - 1,
|
||||
))?;
|
||||
pps.num_tile_rows_minus1 = r.read_ue_max(std::cmp::min(
|
||||
sps.pic_height_in_ctbs_y - 1,
|
||||
MAX_TILE_ROWS as u32 - 1,
|
||||
))?;
|
||||
pps.uniform_spacing_flag = r.read_bit()?;
|
||||
if !pps.uniform_spacing_flag {
|
||||
pps.column_width_minus1[usize::from(pps.num_tile_columns_minus1)] =
|
||||
@@ -4122,12 +4156,21 @@ impl Parser {
|
||||
let max = if !pps.tiles_enabled_flag && pps.entropy_coding_sync_enabled_flag {
|
||||
sps.pic_height_in_ctbs_y - 1
|
||||
} else if pps.tiles_enabled_flag && !pps.entropy_coding_sync_enabled_flag {
|
||||
u32::from((pps.num_tile_columns_minus1 + 1) * (pps.num_tile_rows_minus1 + 1) - 1)
|
||||
// Widened: Table A.8 permits 20 x 22 tiles, whose product does not fit
|
||||
// the u8 the tile counts are stored in.
|
||||
(u32::from(pps.num_tile_columns_minus1) + 1)
|
||||
* (u32::from(pps.num_tile_rows_minus1) + 1)
|
||||
- 1
|
||||
} else {
|
||||
(u32::from(pps.num_tile_columns_minus1) + 1) * sps.pic_height_in_ctbs_y - 1
|
||||
};
|
||||
|
||||
hdr.num_entry_point_offsets = r.read_ue_max(max)?;
|
||||
// 7.4.7.1 puts no 32-entry cap on num_entry_point_offsets, but
|
||||
// entry_point_offset_minus1 is that deep, so the array is the real bound.
|
||||
hdr.num_entry_point_offsets = r.read_ue_max(std::cmp::min(
|
||||
max,
|
||||
hdr.entry_point_offset_minus1.len() as u32 - 1,
|
||||
))?;
|
||||
if hdr.num_entry_point_offsets > 0 {
|
||||
hdr.offset_len_minus1 = r.read_ue_max(31)?;
|
||||
for i in 0..hdr.num_entry_point_offsets as usize {
|
||||
@@ -4189,6 +4232,8 @@ mod tests {
|
||||
use crate::codec::h265::parser::NaluType;
|
||||
use crate::codec::h265::parser::Parser;
|
||||
use crate::codec::h265::parser::SliceType;
|
||||
use crate::codec::h265::parser::MAX_TILE_COLUMNS;
|
||||
use crate::codec::h265::parser::MAX_TILE_ROWS;
|
||||
|
||||
const STREAM_BEAR: &[u8] = include_bytes!("test_data/bear.h265");
|
||||
const STREAM_BEAR_NUM_NALUS: usize = 35;
|
||||
@@ -4718,8 +4763,8 @@ mod tests {
|
||||
assert_eq!(pps.num_tile_rows_minus1, 0);
|
||||
assert_eq!(pps.num_tile_columns_minus1, 0);
|
||||
assert!(pps.uniform_spacing_flag);
|
||||
assert_eq!(pps.column_width_minus1, [0; 19]);
|
||||
assert_eq!(pps.row_height_minus1, [0; 21]);
|
||||
assert_eq!(pps.column_width_minus1, [0; MAX_TILE_COLUMNS]);
|
||||
assert_eq!(pps.row_height_minus1, [0; MAX_TILE_ROWS]);
|
||||
assert!(pps.loop_filter_across_slices_enabled_flag);
|
||||
assert!(pps.loop_filter_across_tiles_enabled_flag);
|
||||
assert!(!pps.deblocking_filter_control_present_flag);
|
||||
|
||||
@@ -667,7 +667,8 @@ pub fn open_virtual_output(
|
||||
pub fn open_idd_push(
|
||||
target: pf_frame::dxgi::WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_hdr: bool,
|
||||
ten_bit_sdr: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
@@ -678,7 +679,8 @@ pub fn open_idd_push(
|
||||
idd_push::IddPushCapturer::open(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_hdr,
|
||||
ten_bit_sdr,
|
||||
want_444,
|
||||
pyrowave,
|
||||
keepalive,
|
||||
|
||||
@@ -390,14 +390,41 @@ float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
||||
}
|
||||
";
|
||||
|
||||
/// The 10-bit **SDR** pass PS ([`HdrRgb10Converter::new_sdr_expand`]) — full-res, samples the
|
||||
/// 8-bit BGRA slot and writes the SAME sRGB values into the packed 10-bit target. No colour math
|
||||
/// on purpose: the UNORM sample→write roundtrip IS the 8→10 expansion (code 255/255 lands on
|
||||
/// 1023/1023), and the transfer stays sRGB/BT.709 exactly as the 8-bit SDR path treats BGRA —
|
||||
/// the depth gain is the ENCODER's (Main10 coding precision), not the source's.
|
||||
const SDR_RGB10_PS: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
SamplerState sm : register(s0);
|
||||
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
||||
return float4(tx.Sample(sm, uv).rgb, 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
impl HdrRgb10Converter {
|
||||
/// The HDR pass: FP16 scRGB in, PQ-encoded BT.2020 RGB out.
|
||||
pub(crate) fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
Self::from_ps(
|
||||
device,
|
||||
HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON),
|
||||
)
|
||||
}
|
||||
|
||||
/// The 10-bit **SDR** pass: BGRA in, the same sRGB values out at 10-bit UNORM (see
|
||||
/// [`SDR_RGB10_PS`]). Identical plumbing — only the pixel shader differs — so the two
|
||||
/// depth paths share the VS/sampler/draw and cannot drift.
|
||||
pub(crate) fn new_sdr_expand(device: &ID3D11Device) -> Result<Self> {
|
||||
Self::from_ps(device, SDR_RGB10_PS.to_string())
|
||||
}
|
||||
|
||||
fn from_ps(device: &ID3D11Device, src: String) -> Result<Self> {
|
||||
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader`
|
||||
// receives `s!()` literals (its contract). Each created COM interface owns its own
|
||||
// reference, and no raw pointer outlives the call that produced it.
|
||||
unsafe {
|
||||
let src = HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON);
|
||||
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let psb = compile_shader(&src, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
|
||||
@@ -416,13 +416,20 @@ pub struct IddPushCapturer {
|
||||
/// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
|
||||
/// (and so stale-ring publishes are rejected).
|
||||
generation: u32,
|
||||
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Gates the
|
||||
/// composition depth: a 10-bit client PROACTIVELY enables advanced color at `open` (HDR without a
|
||||
/// manual toggle); an SDR-only client forces it OFF and the descriptor poller PINS it there, so a
|
||||
/// client that advertised SDR ("HDR off") is never handed the in-band PQ upgrade the pixel-format-
|
||||
/// driven encoder would otherwise stamp from an HDR composition. (An HDR-negotiated H.26x session
|
||||
/// still follows a host-side "Use HDR" flip; all clients decode Main10 + auto-detect PQ from the VUI.)
|
||||
client_10bit: bool,
|
||||
/// The session negotiated **HDR** (client advertised `VIDEO_CAP_HDR` and the handshake said
|
||||
/// yes — no longer merely `bit_depth >= 10`, which the 10-bit SDR path below also reaches).
|
||||
/// Gates the composition depth: an HDR session PROACTIVELY enables advanced color at `open`
|
||||
/// (HDR without a manual toggle); any other session forces it OFF and the descriptor poller
|
||||
/// PINS it there, so a client that did not ask for HDR is never handed the in-band PQ
|
||||
/// upgrade the pixel-format-driven encoder would otherwise stamp from an HDR composition.
|
||||
/// (An HDR-negotiated H.26x session still follows a host-side "Use HDR" flip; all clients
|
||||
/// decode Main10 + auto-detect PQ from the VUI.)
|
||||
want_hdr: bool,
|
||||
/// The session negotiated 10-bit WITHOUT HDR (`OutputFormat::ten_bit_sdr`): the BGRA slot is
|
||||
/// expanded 8→10 bit into the packed RGB10 output ([`PixelFormat::Rgb10a2Sdr`]) so NVENC
|
||||
/// encodes Main10 under the ordinary BT.709 SDR VUI. The display's colour state is never
|
||||
/// touched — `want_hdr` above stays false, advanced colour stays pinned off.
|
||||
ten_bit_sdr: bool,
|
||||
/// The DISPLAY's CURRENT HDR state (from `advanced_color_enabled`) — the user can flip "Use HDR" in
|
||||
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
|
||||
/// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]).
|
||||
@@ -502,6 +509,17 @@ pub struct IddPushCapturer {
|
||||
/// ever read (µs) while the host starved since then. Rolled at every fresh frame.
|
||||
offered_at_fresh: u64,
|
||||
max_hb_age_us: u64,
|
||||
/// The damage witness feeding [`stall::StallEvidence::cursor_moved_px`]: the OS cursor's
|
||||
/// last sampled position (`None` until `GetCursorPos` first succeeds), the px accumulated
|
||||
/// since the last fresh frame, and one pending sample's delta held back a call so the
|
||||
/// stall-ending frame's own cursor move never counts into the gap it ended (the fold
|
||||
/// happens at the top of the NEXT `try_consume`, by which point this call demonstrably
|
||||
/// did not consume a fresh frame — a fresh frame resets both). Sampled at most every
|
||||
/// [`Self::CURSOR_WITNESS_INTERVAL`]; user32 reads, never the display-config lock.
|
||||
cursor_last: Option<(i32, i32)>,
|
||||
cursor_gap_px: u32,
|
||||
cursor_pending_px: u32,
|
||||
cursor_sampled_at: Instant,
|
||||
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
|
||||
/// every stall report so the verdict matrix can name the disturbance class. `None` when
|
||||
/// `PUNKTFUNK_STALL_PROBES=0` opted the box out (the engine costs standing threads); the
|
||||
@@ -532,6 +550,11 @@ pub struct IddPushCapturer {
|
||||
/// session negotiated 4:4:4 — the full-chroma twin of [`Self::hdr_p010_conv`]. Rebuilt with the
|
||||
/// ring on a mode/HDR flip.
|
||||
hdr_rgb10_conv: Option<HdrRgb10Converter>,
|
||||
/// BGRA slot → packed 10-bit RGB by plain 8→10 expansion (`HdrRgb10Converter::new_sdr_expand`
|
||||
/// — same sRGB values at UNORM precision), used on a 10-bit **SDR** session
|
||||
/// ([`Self::ten_bit_sdr`], both 4:2:0 and 4:4:4 — NVENC does the CSC/subsampling either
|
||||
/// way). Built lazily like its HDR twin above.
|
||||
sdr_rgb10_conv: Option<HdrRgb10Converter>,
|
||||
last_seq: u64,
|
||||
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
|
||||
status_logged: bool,
|
||||
@@ -655,8 +678,8 @@ impl IddPushCapturer {
|
||||
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
|
||||
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
|
||||
/// SM-side CSC, because the video processor can only produce subsampled output). The
|
||||
/// composition depth DOES follow the session's negotiated `client_10bit` — pinned at open
|
||||
/// (`open.rs`, the `!client_10bit` force-off and the 10-bit enable) and re-pinned every sample
|
||||
/// composition depth DOES follow the session's negotiated `want_hdr` — pinned at open
|
||||
/// (`open.rs`, the `!want_hdr` force-off and the 10-bit enable) and re-pinned every sample
|
||||
/// by [`Self::poll_display_hdr`], because a PQ stream sent to a client that advertised SDR-only
|
||||
/// lands on an SDR desktop and blows out. (The older note here claimed the opposite — that the
|
||||
/// advertised `VIDEO_CAP_10BIT` was ignored because clients under-report it. That reasoning
|
||||
@@ -684,6 +707,11 @@ impl IddPushCapturer {
|
||||
return (DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2);
|
||||
}
|
||||
(DXGI_FORMAT_P010, PixelFormat::P010)
|
||||
} else if self.ten_bit_sdr {
|
||||
// 10-bit SDR (either chroma): the BGRA slot expanded 8→10 into packed RGB. The
|
||||
// format is the SDR twin of `Rgb10a2` — NVENC ingests it as ABGR10 and encodes
|
||||
// Main10 under the BT.709 VUI its CSC follows (the SDR 4:4:4 precedent below).
|
||||
(DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2Sdr)
|
||||
} else if self.want_444 {
|
||||
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra)
|
||||
} else {
|
||||
@@ -816,9 +844,10 @@ impl IddPushCapturer {
|
||||
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||
self.hdr_p010_conv = None;
|
||||
self.hdr_rgb10_conv = None;
|
||||
self.sdr_rgb10_conv = None;
|
||||
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
|
||||
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
|
||||
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
|
||||
// want_hdr=true but HDR couldn't enable at open) reused the stale SDR converter against
|
||||
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
|
||||
// builds when None, so it must be reset here like its siblings.
|
||||
self.pyro_conv = None;
|
||||
@@ -859,12 +888,12 @@ impl IddPushCapturer {
|
||||
// is never recreated at the wrong format):
|
||||
// - a PyroWave session: its encoder was opened for fixed plane formats (R8 SDR / R16 HDR),
|
||||
// so it can't follow a flip the way H.26x re-inits do;
|
||||
// - ANY SDR-negotiated session (`!client_10bit`, either codec): a host-side flip to HDR
|
||||
// - ANY SDR-negotiated session (`!want_hdr`, either codec): a host-side flip to HDR
|
||||
// must not promote the stream to P010 PQ behind a client that advertised SDR-only.
|
||||
// An HDR-negotiated H.26x session is NOT pinned — it still follows a host "Use HDR" flip in
|
||||
// either direction (its encoder re-inits on the depth change).
|
||||
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
|
||||
let want = self.client_10bit;
|
||||
if (self.pyrowave || !self.want_hdr) && now.hdr != self.want_hdr {
|
||||
let want = self.want_hdr;
|
||||
// A display that refuses the pin refuses it on every 250 ms sample — past
|
||||
// [`Self::HDR_PIN_EAGER`] consecutive failures the write+read-back re-fires only on
|
||||
// every [`Self::HDR_PIN_RETRY_EVERY`]th sample (~4 s), instead of 4 CCD writes +
|
||||
@@ -876,7 +905,7 @@ impl IddPushCapturer {
|
||||
|| self.desc_seq % Self::HDR_PIN_RETRY_EVERY == 0
|
||||
{
|
||||
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s
|
||||
// `bool` and then write `now.hdr = self.client_10bit` — substituting the DESIRED
|
||||
// `bool` and then write `now.hdr = self.want_hdr` — substituting the DESIRED
|
||||
// state for the observed one, which broke in both directions on a display that
|
||||
// cannot be flipped (the state this file already logs as "Downgrade point D" at
|
||||
// open):
|
||||
@@ -907,7 +936,7 @@ impl IddPushCapturer {
|
||||
observed_hdr = ?observed,
|
||||
set_advanced_color_returned = requested,
|
||||
pyrowave = self.pyrowave,
|
||||
client_10bit = self.client_10bit,
|
||||
want_hdr = self.want_hdr,
|
||||
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
|
||||
it actually composes instead (a physical display forcing HDR, or a driver that \
|
||||
refuses the flip). The stream's depth will not match the negotiation; the \
|
||||
@@ -1113,6 +1142,12 @@ impl IddPushCapturer {
|
||||
self.height,
|
||||
)?);
|
||||
}
|
||||
} else if self.ten_bit_sdr {
|
||||
// 10-bit SDR (4:2:0 AND 4:4:4): one full-res 8→10 expansion pass to packed RGB;
|
||||
// NVENC does the RGB→YUV CSC + any subsampling under the BT.709 VUI.
|
||||
if self.sdr_rgb10_conv.is_none() {
|
||||
self.sdr_rgb10_conv = Some(HdrRgb10Converter::new_sdr_expand(&self.device)?);
|
||||
}
|
||||
} else if self.want_444 {
|
||||
// Full-chroma passthrough — no conversion resources to build.
|
||||
} else if self.video_conv.is_none() {
|
||||
@@ -1441,11 +1476,47 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Damage-witness sample cadence — coarse enough to cost nothing (two user32 reads at
|
||||
/// 125 Hz worst case), fine enough that a ≥150 ms hole gets many samples.
|
||||
const CURSOR_WITNESS_INTERVAL: Duration = Duration::from_millis(8);
|
||||
|
||||
/// The damage witness (see the `cursor_*` field docs): fold the PREVIOUS call's pending
|
||||
/// delta into the gap accumulator — if that call had consumed a fresh frame, the fresh-frame
|
||||
/// bookkeeping would have zeroed the pending, so whatever survives belongs to the gap — then
|
||||
/// take a fresh rate-limited `GetCursorPos` sample into the pending slot. The one-call lag is
|
||||
/// what keeps the stall-ending frame's own cursor move out of the gap it ended.
|
||||
///
|
||||
/// `GetCursorPos` is global, not per-display: a delta of 0 therefore proves the cursor sat
|
||||
/// still EVERYWHERE (the demotion direction is strict), while a delta > 0 on a
|
||||
/// parallel-displays host may be a sibling display's motion — that direction only ever
|
||||
/// upholds today's CONTENT-SILENCE labeling, never worsens it.
|
||||
// ponytail: no per-target rect filter (needs a cached CCD rect); add one if parallel-display
|
||||
// hosts ever show false CONTENT-SILENCE convictions from sibling-cursor motion.
|
||||
fn sample_cursor_witness(&mut self) {
|
||||
self.cursor_gap_px = self.cursor_gap_px.saturating_add(self.cursor_pending_px);
|
||||
self.cursor_pending_px = 0;
|
||||
if self.cursor_sampled_at.elapsed() < Self::CURSOR_WITNESS_INTERVAL {
|
||||
return;
|
||||
}
|
||||
self.cursor_sampled_at = Instant::now();
|
||||
let mut pos = POINT::default();
|
||||
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
|
||||
if unsafe { GetCursorPos(&mut pos) }.is_ok() {
|
||||
if let Some((x, y)) = self.cursor_last {
|
||||
self.cursor_pending_px = pos.x.abs_diff(x).saturating_add(pos.y.abs_diff(y));
|
||||
}
|
||||
self.cursor_last = Some((pos.x, pos.y));
|
||||
}
|
||||
}
|
||||
|
||||
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
self.log_driver_status_once();
|
||||
// The secure-desktop guard first: while UAC/Winlogon is up there may be NO fresh frames
|
||||
// at all — this edge is what brings them back.
|
||||
self.poll_secure_desktop();
|
||||
// The stall damage witness — before any early return, so gaps of every shape accumulate
|
||||
// cursor motion (or certify its absence).
|
||||
self.sample_cursor_witness();
|
||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||
self.poll_display_hdr();
|
||||
// Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed
|
||||
@@ -1631,6 +1702,17 @@ impl IddPushCapturer {
|
||||
let (y_rtv, uv_rtv) = rtvs.as_ref().expect("P010 out slot has plane RTVs");
|
||||
conv.convert(&self.context, src, y_rtv, uv_rtv, self.width, self.height)?;
|
||||
}
|
||||
} else if self.ten_bit_sdr {
|
||||
// 10-bit SDR: BGRA slot → packed 10-bit RGB, a plain 8→10 expansion (same
|
||||
// sRGB values at UNORM precision). NVENC ingests it as ABGR10 and encodes
|
||||
// Main10 under the BT.709 VUI — the CSC and any subsampling are NVENC's,
|
||||
// exactly like the SDR 4:4:4 passthrough below, one bit-depth up.
|
||||
if let Some(conv) = self.sdr_rgb10_conv.as_ref() {
|
||||
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
|
||||
let (_, _, rtv) = out.as_ref().expect("out ring");
|
||||
let rtv = rtv.as_ref().expect("Rgb10a2Sdr out slot has an RTV");
|
||||
conv.convert(&self.context, src, rtv, self.width, self.height)?;
|
||||
}
|
||||
} else if self.want_444 {
|
||||
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
|
||||
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
|
||||
@@ -1709,6 +1791,9 @@ impl IddPushCapturer {
|
||||
.map(|(from, p)| p.window(from, now)),
|
||||
etw,
|
||||
etw_counts,
|
||||
// The gap accumulator only — this call's own pending sample (the stall-ending
|
||||
// frame's move) is still unfolded and gets discarded by the reset below.
|
||||
cursor_moved_px: self.cursor_last.map(|_| self.cursor_gap_px),
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
@@ -1734,6 +1819,10 @@ impl IddPushCapturer {
|
||||
self.offered_at_fresh = offered;
|
||||
}
|
||||
self.max_hb_age_us = 0;
|
||||
// Damage witness rolls with the other per-gap trackers; the pending sample is the
|
||||
// ending frame's own move — discarded, never folded (see `sample_cursor_witness`).
|
||||
self.cursor_gap_px = 0;
|
||||
self.cursor_pending_px = 0;
|
||||
}
|
||||
// Build the frame. For PyroWave the encode input is the Y plane
|
||||
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
|
||||
@@ -2137,13 +2226,20 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
|
||||
/// return what each `note_fresh` produced.
|
||||
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> {
|
||||
/// return what each `note_fresh` produced, paired with the metronome's read for the stalls
|
||||
/// (fed as non-damage-idle, the way `report` feeds every display-evidence stall).
|
||||
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<(Stall, Option<Duration>)>> {
|
||||
let base = Instant::now();
|
||||
let mut w = StallWatch::new();
|
||||
offsets_ms
|
||||
.iter()
|
||||
.map(|ms| w.note_fresh(base + Duration::from_millis(*ms)))
|
||||
.map(|ms| {
|
||||
let at = base + Duration::from_millis(*ms);
|
||||
w.note_fresh(at).map(|s| {
|
||||
let period = w.cycle(at, false);
|
||||
(s, period)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -2160,9 +2256,9 @@ mod tests {
|
||||
t.push(604);
|
||||
let out = watch_run(&t);
|
||||
assert!(out[..20].iter().all(Option::is_none));
|
||||
let stall = out[20].as_ref().expect("hole after active flow is a stall");
|
||||
let (stall, period) = out[20].as_ref().expect("hole after active flow is a stall");
|
||||
assert_eq!(stall.gap.as_millis(), 300);
|
||||
assert!(stall.metronomic.is_none(), "one stall is not a cycle");
|
||||
assert!(period.is_none(), "one stall is not a cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2272,11 +2368,11 @@ mod tests {
|
||||
flow(&mut t, cycle * 4_000, 232); // last frame at cycle*4000 + 3696
|
||||
}
|
||||
let out = watch_run(&t);
|
||||
let stalls: Vec<&Stall> = out.iter().flatten().collect();
|
||||
let stalls: Vec<&(Stall, Option<Duration>)> = out.iter().flatten().collect();
|
||||
assert_eq!(stalls.len(), 4, "each cycle boundary is one stall");
|
||||
assert!(stalls[..3].iter().all(|s| s.metronomic.is_none()));
|
||||
assert!(stalls[..3].iter().all(|(_, period)| period.is_none()));
|
||||
let period = stalls[3]
|
||||
.metronomic
|
||||
.1
|
||||
.expect("the 4th evenly-spaced event completes the metronome streak");
|
||||
assert!(
|
||||
(period.as_secs_f64() - 4.0).abs() < 0.3,
|
||||
@@ -2284,6 +2380,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A damage-idle hole must not advance the metronome: the same four evenly-spaced stalls
|
||||
/// as [`metronomic_stalls_self_diagnose`], but with one classified damage-idle — the beat
|
||||
/// never completes, because a hand/input pause is not display-disturbance evidence.
|
||||
#[test]
|
||||
fn damage_idle_stalls_do_not_feed_the_metronome() {
|
||||
let base = Instant::now();
|
||||
let mut w = StallWatch::new();
|
||||
let mut periods = Vec::new();
|
||||
for cycle in 0..5u64 {
|
||||
let mut t = Vec::new();
|
||||
flow(&mut t, cycle * 4_000, 232);
|
||||
for ms in t {
|
||||
let at = base + Duration::from_millis(ms);
|
||||
if let Some(_stall) = w.note_fresh(at) {
|
||||
// The 2nd stall reads damage-idle (cursor sat still on a dwm-only desktop).
|
||||
let damage_idle = periods.len() == 1;
|
||||
periods.push(w.cycle(at, damage_idle));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(periods.len(), 4);
|
||||
assert!(
|
||||
periods.iter().all(Option::is_none),
|
||||
"a skipped beat must break the streak: {periods:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_swallows_the_recreate_gap() {
|
||||
// Active flow, then a ring recreate (reset), then flow resumes 800 ms later — the resume
|
||||
@@ -2305,6 +2428,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The repeated-stall (non-metronomic) WARN's window arithmetic: fires at the third reported
|
||||
/// stall inside 60 s, stays quiet through the 300 s re-warn spacing, and re-arms on a fresh
|
||||
/// burst after old entries age out.
|
||||
#[test]
|
||||
fn stall_rate_warn_window_and_rewarn() {
|
||||
let base = Instant::now();
|
||||
let at = |s: u64| base + Duration::from_secs(s);
|
||||
let mut w = StallWatch::new();
|
||||
assert_eq!(w.note_for_rate_warn(at(0)), None);
|
||||
assert_eq!(w.note_for_rate_warn(at(10)), None);
|
||||
assert_eq!(
|
||||
w.note_for_rate_warn(at(20)),
|
||||
Some(3),
|
||||
"third stall in 60 s warns"
|
||||
);
|
||||
assert_eq!(
|
||||
w.note_for_rate_warn(at(30)),
|
||||
None,
|
||||
"inside the re-warn spacing the arm stays quiet"
|
||||
);
|
||||
// A fresh burst well past the spacing: the old entries have aged out of the window,
|
||||
// so it takes a full RATE_MIN_STALLS again — and then warns again.
|
||||
assert_eq!(w.note_for_rate_warn(at(400)), None);
|
||||
assert_eq!(w.note_for_rate_warn(at(401)), None);
|
||||
assert_eq!(
|
||||
w.note_for_rate_warn(at(402)),
|
||||
Some(3),
|
||||
"re-warns after the spacing"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`stall::attribute`]'s verdict table — the Branch-1/Branch-2 fork, per evidence shape.
|
||||
#[test]
|
||||
fn stall_attribution_verdicts() {
|
||||
@@ -2318,6 +2472,7 @@ mod tests {
|
||||
probes: None,
|
||||
etw: None,
|
||||
etw_counts: None,
|
||||
cursor_moved_px: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
@@ -2345,7 +2500,7 @@ mod tests {
|
||||
#[test]
|
||||
fn stall_classification_matrix() {
|
||||
use super::dxgkrnl_etw::EtwWindowCounts;
|
||||
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
|
||||
use super::stall::{ProbeWindow, StallClass, StallVerdict};
|
||||
let gap = Duration::from_millis(600);
|
||||
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
|
||||
fence_max_us: fence,
|
||||
@@ -2358,7 +2513,20 @@ mod tests {
|
||||
queue_adds,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
flow_dwm_only: false,
|
||||
};
|
||||
// Every case below predates the damage witness — `cursor_moved_px = None` keeps the
|
||||
// pre-witness classification, which is exactly what these cases assert. The witness's
|
||||
// own matrix (the damage-idle split) is `damage_idle_split` below. A nested fn (not a
|
||||
// closure): each call site's temporaries need their own lifetime.
|
||||
fn classify(
|
||||
gap: Duration,
|
||||
verdict: &StallVerdict,
|
||||
p: Option<&ProbeWindow>,
|
||||
c: Option<&EtwWindowCounts>,
|
||||
) -> StallClass {
|
||||
super::stall::classify(gap, verdict, p, c, None)
|
||||
}
|
||||
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
|
||||
assert_eq!(
|
||||
classify(
|
||||
@@ -2510,4 +2678,58 @@ mod tests {
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
}
|
||||
|
||||
/// The damage witness's split of CONTENT-SILENCE (the 2026-08-27 field reattribution): a
|
||||
/// present-free hole on a dwm-only desktop with a stationary cursor is an input/hand pause
|
||||
/// (DAMAGE-IDLE), a moving cursor keeps it CONTENT-SILENCE (damage existed, nothing
|
||||
/// composed — the display-stack conviction), and a game session (flow not dwm-only) is
|
||||
/// never demoted regardless of the cursor.
|
||||
#[test]
|
||||
fn damage_idle_split() {
|
||||
use super::dxgkrnl_etw::EtwWindowCounts;
|
||||
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
|
||||
let gap = Duration::from_millis(600);
|
||||
let healthy = ProbeWindow {
|
||||
fence_max_us: Some(16_000),
|
||||
dwm_tick_frozen_us: Some(20_000),
|
||||
dwm_flush_max_us: Some(30_000),
|
||||
..ProbeWindow::default()
|
||||
};
|
||||
let counts = |dwm_only: bool| EtwWindowCounts {
|
||||
presents: 0,
|
||||
queue_adds: 0,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
flow_dwm_only: dwm_only,
|
||||
};
|
||||
let run = |dwm_only: bool, moved: Option<u32>| {
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&healthy),
|
||||
Some(&counts(dwm_only)),
|
||||
moved,
|
||||
)
|
||||
};
|
||||
assert_eq!(run(true, Some(0)), StallClass::DamageIdle);
|
||||
assert_eq!(run(true, Some(312)), StallClass::ContentSilence);
|
||||
// A game presented in the lookback: its hole is real evidence even with a still cursor.
|
||||
assert_eq!(run(false, Some(0)), StallClass::ContentSilence);
|
||||
// No witness (pre-witness build / GetCursorPos failing): pre-witness behavior.
|
||||
assert_eq!(run(true, None), StallClass::ContentSilence);
|
||||
// The witness never overrules a harder conviction: stalled fences stay ADAPTER-FREEZE.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&ProbeWindow {
|
||||
fence_max_us: Some(400_000),
|
||||
..ProbeWindow::default()
|
||||
}),
|
||||
Some(&counts(true)),
|
||||
Some(0),
|
||||
),
|
||||
StallClass::AdapterFreeze
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,18 @@ impl EtwWatch {
|
||||
.copied()
|
||||
.collect()
|
||||
};
|
||||
let counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
|
||||
let mut counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
|
||||
// Pre-hole flow attribution (damage-idle discriminator): when every present in the
|
||||
// lookback window came from dwm.exe, the flow the stall watch gated on was pure desktop
|
||||
// composition — on the IDD-push desktop that means cursor/UI damage, the kind that
|
||||
// legitimately stops the instant input stops. A game presenting anywhere in the lookback
|
||||
// keeps this false, so a game's real holes are never demoted. Name resolution runs here
|
||||
// (a handful of `OpenProcess` calls per stall report), off the consumer callback.
|
||||
let lookback_pids = lookback_present_pids(&events, from_q, duration_qpc(LOOKBACK, freq));
|
||||
counts.flow_dwm_only = !lookback_pids.is_empty()
|
||||
&& lookback_pids
|
||||
.iter()
|
||||
.all(|&pid| process_name(pid).is_some_and(|n| n.eq_ignore_ascii_case("dwm.exe")));
|
||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||
let mut parts = Vec::new();
|
||||
for (start_id, stop_id, label) in [
|
||||
@@ -521,6 +532,29 @@ pub(super) struct EtwWindowCounts {
|
||||
/// Queue-stream liveness inside [`LOOKBACK`] before the hole (`BltQueueAddEntry` or
|
||||
/// `BltQueueCompleteIndirectPresent` — either proves the witness works).
|
||||
pub(super) queue_history: bool,
|
||||
/// Every present in the lookback window came from `dwm.exe` (and there was at least one):
|
||||
/// the pre-hole flow was pure desktop composition — cursor/UI damage — not a game. Set by
|
||||
/// [`EtwWatch::window_report`] (name resolution lives there, not in the pure tick math);
|
||||
/// the damage-idle demotion in `stall::classify` requires it, so a game session's holes
|
||||
/// are never demoted no matter what the cursor did.
|
||||
pub(super) flow_dwm_only: bool,
|
||||
}
|
||||
|
||||
/// The distinct pids that presented (DXGI 42/55) inside the lookback window `[from_q -
|
||||
/// lookback_q, from_q]` — the pre-hole flow's presenters. Pure tick math (no name resolution),
|
||||
/// factored out of [`EtwWatch::window_report`] so the windowing is unit-testable.
|
||||
fn lookback_present_pids(events: &[(i64, u16, u32)], from_q: i64, lookback_q: i64) -> Vec<u32> {
|
||||
let mut pids = Vec::new();
|
||||
for &(ts, id, pid) in events {
|
||||
if matches!(id, DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID)
|
||||
&& ts >= from_q.saturating_sub(lookback_q)
|
||||
&& ts <= from_q
|
||||
&& !pids.contains(&pid)
|
||||
{
|
||||
pids.push(pid);
|
||||
}
|
||||
}
|
||||
pids
|
||||
}
|
||||
|
||||
/// Enable `guid` on `session` with a kernel-side event-id allowlist. `true` on success.
|
||||
@@ -645,6 +679,8 @@ mod tests {
|
||||
queue_adds: 1,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
// Set by `window_report` (needs name resolution), never by the tick math.
|
||||
flow_dwm_only: false,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -679,4 +715,21 @@ mod tests {
|
||||
let c = count_window(&[ev(0, DXGI_PRESENT_ID)], 3, to, i64::MAX);
|
||||
assert!(c.present_history);
|
||||
}
|
||||
|
||||
/// [`lookback_present_pids`]'s windowing: presenters strictly from the lookback window
|
||||
/// (dedup'd), never from inside or after the hole — an in-hole presenter is not "the flow
|
||||
/// the stall watch gated on".
|
||||
#[test]
|
||||
fn lookback_presenters_come_from_before_the_hole() {
|
||||
let (from, lb) = (1_000i64, 500i64);
|
||||
let events = [
|
||||
(600, DXGI_PRESENT_ID, 7u32), // lookback, pid 7
|
||||
(700, DXGI_PRESENT_MPO_ID, 7u32), // lookback, pid 7 again (dedup)
|
||||
(800, DXGI_PRESENT_ID, 9u32), // lookback, pid 9
|
||||
(900, BLT_ADD_ID, 11u32), // lookback, but not a present
|
||||
(1_100, DXGI_PRESENT_ID, 13u32), // inside the hole — excluded
|
||||
];
|
||||
assert_eq!(lookback_present_pids(&events, from, lb), vec![7, 9]);
|
||||
assert!(lookback_present_pids(&events[4..], from, lb).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,8 @@ impl IddPushCapturer {
|
||||
pub fn open(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_hdr: bool,
|
||||
ten_bit_sdr: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
@@ -176,7 +177,8 @@ impl IddPushCapturer {
|
||||
match Self::open_inner(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_hdr,
|
||||
ten_bit_sdr,
|
||||
want_444,
|
||||
pyrowave,
|
||||
sender,
|
||||
@@ -195,7 +197,8 @@ impl IddPushCapturer {
|
||||
fn open_inner(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_hdr: bool,
|
||||
ten_bit_sdr: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
sender: crate::FrameChannelSender,
|
||||
@@ -218,7 +221,8 @@ impl IddPushCapturer {
|
||||
match Self::open_on(
|
||||
target.clone(),
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_hdr,
|
||||
ten_bit_sdr,
|
||||
want_444,
|
||||
pyrowave,
|
||||
luid,
|
||||
@@ -253,7 +257,8 @@ impl IddPushCapturer {
|
||||
Self::open_on(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_hdr,
|
||||
ten_bit_sdr,
|
||||
want_444,
|
||||
pyrowave,
|
||||
drv,
|
||||
@@ -270,7 +275,8 @@ impl IddPushCapturer {
|
||||
fn open_on(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_hdr: bool,
|
||||
ten_bit_sdr: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
luid: LUID,
|
||||
@@ -338,7 +344,7 @@ impl IddPushCapturer {
|
||||
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
|
||||
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
|
||||
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
|
||||
if !client_10bit {
|
||||
if !want_hdr {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||
let settle = Instant::now();
|
||||
while settle.elapsed() < Duration::from_millis(250) {
|
||||
@@ -372,8 +378,8 @@ impl IddPushCapturer {
|
||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||
let enabled_hdr = client_10bit
|
||||
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||
let enabled_hdr =
|
||||
want_hdr && pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||
if enabled_hdr {
|
||||
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
|
||||
@@ -399,7 +405,7 @@ impl IddPushCapturer {
|
||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
|
||||
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
|
||||
// SDR unconditionally: `want_hdr` gates HDR so a client that advertised SDR-only is
|
||||
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
|
||||
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
|
||||
// Keep the raw observation so Downgrade point D below can say whether the read reported
|
||||
@@ -407,13 +413,13 @@ impl IddPushCapturer {
|
||||
// causes and different fixes.
|
||||
let observed_hdr =
|
||||
pf_win_display::win_display::advanced_color_enabled(target.target_id);
|
||||
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
|
||||
let display_hdr = want_hdr && (enabled_hdr || observed_hdr.unwrap_or(false));
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
// BT.709, so the client's label overstates the stream until the descriptor poller sees
|
||||
// HDR come on. Loud, because every frame of this session is affected.
|
||||
if client_10bit && !display_hdr {
|
||||
if want_hdr && !display_hdr {
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
want_hdr = true,
|
||||
@@ -586,7 +592,8 @@ impl IddPushCapturer {
|
||||
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
mode = format!("{w}x{h}"),
|
||||
display_hdr,
|
||||
client_10bit,
|
||||
want_hdr,
|
||||
ten_bit_sdr,
|
||||
want_444,
|
||||
ring_fp16 = display_hdr,
|
||||
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
|
||||
@@ -610,7 +617,8 @@ impl IddPushCapturer {
|
||||
height: h,
|
||||
slots,
|
||||
generation,
|
||||
client_10bit,
|
||||
want_hdr,
|
||||
ten_bit_sdr,
|
||||
display_hdr,
|
||||
hdr_pin_warned: false,
|
||||
hdr_pin_failures: 0,
|
||||
@@ -639,6 +647,10 @@ impl IddPushCapturer {
|
||||
stall_watch: StallWatch::new(),
|
||||
offered_at_fresh: 0,
|
||||
max_hb_age_us: 0,
|
||||
cursor_last: None,
|
||||
cursor_gap_px: 0,
|
||||
cursor_pending_px: 0,
|
||||
cursor_sampled_at: Instant::now(),
|
||||
probes: pf_host_config::config()
|
||||
.stall_probes
|
||||
.then(super::probes::acquire),
|
||||
@@ -648,6 +660,7 @@ impl IddPushCapturer {
|
||||
video_conv: None,
|
||||
hdr_p010_conv: None,
|
||||
hdr_rgb10_conv: None,
|
||||
sdr_rgb10_conv: None,
|
||||
last_seq: 0,
|
||||
last_present: None,
|
||||
status_logged: false,
|
||||
@@ -672,6 +685,15 @@ impl IddPushCapturer {
|
||||
// it back to the caller to retire or reuse the display (audit §5.1).
|
||||
_keepalive: Box::new(()),
|
||||
};
|
||||
// The two REALTIME GPU-priority opt-ins, stamped once per capture session so EVERY
|
||||
// field log self-describes its posture — the stall WARNs repeat them, but only when
|
||||
// they fire, and the 7700 XT case (2026-08-26) showed a stalling log where they
|
||||
// never did (design: windows-amd-host-program §3.1 Gap B).
|
||||
tracing::info!(
|
||||
rt_gpu_driver = super::stall::rt_gpu_driver_posture(),
|
||||
rt_gpu_host = super::stall::rt_gpu_host_posture(),
|
||||
"GPU-priority posture for this capture session"
|
||||
);
|
||||
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
|
||||
// from the blend (which holds the ring slot's keyed mutex — see
|
||||
// `refresh_sdr_white_scale`). No-op on an SDR composition.
|
||||
|
||||
@@ -5,12 +5,14 @@ use super::*;
|
||||
|
||||
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
||||
/// desktop was actively composing right beforehand (see [`StallWatch`]).
|
||||
///
|
||||
/// The metronome is NOT fed here — [`StallWatch::report`] feeds it after classification, so a
|
||||
/// damage-idle hole (cursor stationary on a dwm-only desktop: an input/hand pause, not a display
|
||||
/// stall — the 2026-08-27 NVIDIA-laptop field case was ~30 of these misread as a 1.87 s display
|
||||
/// metronome) never contributes to the beat that the METRONOMIC warns blame on display hardware.
|
||||
pub(super) struct Stall {
|
||||
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
||||
pub(super) gap: Duration,
|
||||
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
||||
/// [`pf_frame::metronome::Metronome`]).
|
||||
pub(super) metronomic: Option<Duration>,
|
||||
}
|
||||
|
||||
/// One degraded stretch, summarized at recovery ([`StallWatch::take_recovery`]). Per-hole stall
|
||||
@@ -59,6 +61,14 @@ pub(super) struct StallEvidence {
|
||||
/// display path dropped composed frames; both silent = the content stopped presenting.
|
||||
/// `None` when the ETW session is unavailable.
|
||||
pub(super) etw_counts: Option<super::dxgkrnl_etw::EtwWindowCounts>,
|
||||
/// How far the OS cursor moved (px, |dx|+|dy| summed over samples) DURING the hole — the
|
||||
/// damage witness. On the composited-cursor desktop the pointer is the damage source, so
|
||||
/// `Some(0)` says the content had nothing to compose (input/hand pause: damage-idle) while
|
||||
/// `Some(n>0)` says damage existed and DWM composed none of it — a positive display-stack
|
||||
/// conviction. `None` = never sampled (`GetCursorPos` failing / pre-witness build); the
|
||||
/// classifier then behaves as before this field existed. The stall-ending frame's own
|
||||
/// cursor move is deliberately NOT counted (see the capturer's fold-on-next-call sampler).
|
||||
pub(super) cursor_moved_px: Option<u32>,
|
||||
}
|
||||
|
||||
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
|
||||
@@ -129,9 +139,13 @@ pub(super) enum StallClass {
|
||||
CompositorBlocked,
|
||||
/// Engines alive, DWM's clock ticking, driver drained E_PENDING, and the ETW present witness
|
||||
/// saw (essentially) NO swapchain presents from ANY process across the hole: the content
|
||||
/// stopped presenting — no damage, DWM correctly composed nothing (a game hitch, a loading
|
||||
/// screen, a menu). Benign for the display path; the content side is where to look if the
|
||||
/// user FELT it.
|
||||
/// stopped presenting — no damage, DWM correctly composed nothing. A ONE-OFF here is benign
|
||||
/// (a game hitch, a loading screen, a menu). But this class is also what a frozen *presenter*
|
||||
/// looks like (disturbance-immunity Flavor 3: the display stack — win32k CCD lock, UMD
|
||||
/// serialization against display events, vblank-wait limiters — stops the content's present
|
||||
/// loop), and every probe we run sits at the host's elevated GPU priority, so normal-band
|
||||
/// starvation reads healthy. REPEATED holes under active load do NOT exonerate the display
|
||||
/// path — the repeated-stall / metronomic WARNs carry that triage.
|
||||
ContentSilence,
|
||||
/// Engines alive, DWM ticking, driver drained E_PENDING — and the ETW present witness saw
|
||||
/// presents FLOWING through the hole while the virtual display's kernel queue
|
||||
@@ -139,6 +153,13 @@ pub(super) enum StallClass {
|
||||
/// them before our swap-chain. The real display-path bug class — never yet observed in the
|
||||
/// field; a report with this label (counts attached) is the specimen we want.
|
||||
FrameGeneration,
|
||||
/// A CONTENT-SILENCE hole whose damage witness says there was nothing to compose: the
|
||||
/// pre-hole flow was dwm.exe-only (cursor/UI damage, no game presenting) AND the cursor sat
|
||||
/// still through the hole. That is an input/hand pause — client-radio holes, Wi-Fi
|
||||
/// power-save/scan cycles, or simply a resting hand — not a display disturbance. Excluded
|
||||
/// from the metronome and both repeated-stall warns; the 2026-08-27 NVIDIA-laptop field
|
||||
/// case was 30/30 of these blamed on the dark laptop panel.
|
||||
DamageIdle,
|
||||
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
|
||||
Unattributed,
|
||||
}
|
||||
@@ -155,16 +176,54 @@ impl std::fmt::Display for StallClass {
|
||||
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
|
||||
}
|
||||
Self::ContentSilence => {
|
||||
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; not the display path)"
|
||||
"CONTENT-SILENCE (no swapchain presents from any process across the hole — the content stopped presenting; a one-off is a game hitch/menu, but REPEATED holes under load can equally be the display stack freezing the presenter)"
|
||||
}
|
||||
Self::FrameGeneration => {
|
||||
"FRAME-GENERATION (presents FLOWED while the virtual display's kernel queue starved — the OS display path dropped composed frames)"
|
||||
}
|
||||
Self::DamageIdle => {
|
||||
"DAMAGE-IDLE (dwm-only flow and the cursor sat still through the hole — nothing was dirty, so DWM correctly composed nothing; an input/hand pause, not a display stall)"
|
||||
}
|
||||
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The vdisplay driver's GPU-priority lever (`PFVD_RT_GPU` / legacy opt-out `PFVD_NO_RT_GPU`) as
|
||||
/// configured in THIS process's environment (machine env; the WUDFHost driver process resolves
|
||||
/// the pair the same way, so this read mirrors what the driver decided — modulo a machine env
|
||||
/// edited after either process started, which a restart heals). The RX 9070 XT field A/B
|
||||
/// (2026-08-12) convicted EXACTLY this lever's REALTIME rung of the metronomic stall signature,
|
||||
/// so every stall-triage line must say whether it is engaged before anyone chases display
|
||||
/// hardware.
|
||||
pub(super) fn rt_gpu_driver_posture() -> &'static str {
|
||||
if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
|
||||
"off (PFVD_NO_RT_GPU)"
|
||||
} else {
|
||||
match std::env::var_os("PFVD_RT_GPU") {
|
||||
None => "off (default)",
|
||||
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
|
||||
Some(_) => "REALTIME (PFVD_RT_GPU)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's own GPU scheduling-priority policy (`PUNKTFUNK_GPU_PRIORITY_CLASS`) as prose —
|
||||
/// [`rt_gpu_driver_posture`]'s twin for the second convicted REALTIME lever (the `auto` gate's
|
||||
/// HIGH→REALTIME upgrade, ~3.6 s beat in the same A/B).
|
||||
pub(super) fn rt_gpu_host_posture() -> &'static str {
|
||||
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||
.ok()
|
||||
.as_deref()
|
||||
{
|
||||
Some("off") => "off",
|
||||
Some("normal") => "normal",
|
||||
Some("realtime") => "REALTIME (pinned)",
|
||||
Some("auto") => "auto (gated REALTIME upgrade)",
|
||||
_ => "high (default)",
|
||||
}
|
||||
}
|
||||
|
||||
/// How many window presents acquit the content: ≥8 presents across the hole mirrors
|
||||
/// [`attribute`]'s offered-frames bar and [`StallWatch::RECENT`]'s sustained-flow definition —
|
||||
/// a caret blink or a stall-ending frame stays under it, a game presenting through the hole
|
||||
@@ -187,6 +246,7 @@ pub(super) fn classify(
|
||||
verdict: &StallVerdict,
|
||||
probes: Option<&ProbeWindow>,
|
||||
etw_counts: Option<&super::dxgkrnl_etw::EtwWindowCounts>,
|
||||
cursor_moved_px: Option<u32>,
|
||||
) -> StallClass {
|
||||
match verdict {
|
||||
StallVerdict::WorkerStalled => return StallClass::OursWorker,
|
||||
@@ -212,6 +272,16 @@ pub(super) fn classify(
|
||||
Some(c) if c.present_history => {
|
||||
if c.presents >= PRESENTS_ACQUIT_CONTENT {
|
||||
StallClass::FrameGeneration
|
||||
} else if c.flow_dwm_only && cursor_moved_px == Some(0) {
|
||||
// The damage witness closes the CONTENT-SILENCE ambiguity from BOTH sides:
|
||||
// dwm-only flow means the damage source was the cursor, and a cursor that
|
||||
// sat still through the hole means DWM had nothing to compose — the hole is
|
||||
// an input/hand pause, not a display stall. (A cursor that MOVED through a
|
||||
// present-free hole stays CONTENT-SILENCE and, repeated, is the display
|
||||
// stack freezing the presenter — the warns say so.) Both conditions are
|
||||
// required: a game session (flow not dwm-only) is never demoted, and a
|
||||
// missing witness (`None`) keeps the pre-witness behavior.
|
||||
StallClass::DamageIdle
|
||||
} else {
|
||||
StallClass::ContentSilence
|
||||
}
|
||||
@@ -300,14 +370,21 @@ pub(super) struct StallWatch {
|
||||
/// whole session's beat, not just the stall that tripped the metronome.
|
||||
verdicts: [u32; 4],
|
||||
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
|
||||
/// compositor-blocked, content-silence, frame-generation, unattributed) — the verdict
|
||||
/// matrix's session summary.
|
||||
classes: [u32; 7],
|
||||
/// compositor-blocked, content-silence, frame-generation, damage-idle, unattributed) — the
|
||||
/// verdict matrix's session summary.
|
||||
classes: [u32; 8],
|
||||
/// The degraded stretch currently being accumulated, opened by a reported stall and fed by
|
||||
/// every stall-sized hole until sustained flow returns.
|
||||
episode: Option<Episode>,
|
||||
/// A closed episode's summary, parked for the caller ([`Self::take_recovery`]).
|
||||
pending_recovery: Option<Recovery>,
|
||||
/// Instants of REPORTED stalls inside the last [`Self::RATE_WINDOW`] — the repeated-stall
|
||||
/// (non-metronomic) WARN's evidence. The metronome needs a stable period; the 2026-08-26
|
||||
/// 7700 XT field case showed 6 stall-sized holes in 8 s with none, and its log carried zero
|
||||
/// triage guidance as a result.
|
||||
rate_window: std::collections::VecDeque<Instant>,
|
||||
/// When the repeated-stall WARN last fired (spacing: [`Self::RATE_REWARN`]).
|
||||
last_rate_warn: Option<Instant>,
|
||||
}
|
||||
|
||||
impl StallWatch {
|
||||
@@ -327,6 +404,14 @@ impl StallWatch {
|
||||
/// Episodes with fewer holes than this dissolve silently — the single stall's own report
|
||||
/// line already covers them.
|
||||
const EPISODE_MIN_HOLES: u32 = 2;
|
||||
/// Rolling window for the repeated-stall (non-metronomic) WARN.
|
||||
const RATE_WINDOW: Duration = Duration::from_secs(60);
|
||||
/// Reported stalls inside [`Self::RATE_WINDOW`] that make the session WARN-worthy — well
|
||||
/// above the ~1-per-minute a busy desktop legitimately produces, well under a degraded
|
||||
/// session's dozens.
|
||||
const RATE_MIN_STALLS: usize = 3;
|
||||
/// Re-WARN spacing for the rate arm (the metronomic arms pace themselves via the metronome).
|
||||
const RATE_REWARN: Duration = Duration::from_secs(300);
|
||||
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
@@ -335,12 +420,63 @@ impl StallWatch {
|
||||
seen: 0,
|
||||
with_os_events: 0,
|
||||
verdicts: [0; 4],
|
||||
classes: [0; 7],
|
||||
classes: [0; 8],
|
||||
episode: None,
|
||||
pending_recovery: None,
|
||||
rate_window: std::collections::VecDeque::new(),
|
||||
last_rate_warn: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one REPORTED stall at `now` into the rate window; `Some(count)` exactly when the
|
||||
/// repeated-stall WARN is due (≥ [`Self::RATE_MIN_STALLS`] inside [`Self::RATE_WINDOW`],
|
||||
/// spaced by [`Self::RATE_REWARN`]). Pure — unit-tested beside the verdict tests.
|
||||
pub(super) fn note_for_rate_warn(&mut self, now: Instant) -> Option<usize> {
|
||||
self.rate_window.push_back(now);
|
||||
while let Some(front) = self.rate_window.front() {
|
||||
if now.duration_since(*front) > Self::RATE_WINDOW {
|
||||
self.rate_window.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.rate_window.len() < Self::RATE_MIN_STALLS {
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.last_rate_warn
|
||||
.is_some_and(|t| now.duration_since(t) < Self::RATE_REWARN)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.last_rate_warn = Some(now);
|
||||
Some(self.rate_window.len())
|
||||
}
|
||||
|
||||
/// The session's per-verdict tally as one log token ([`StallVerdict`] order).
|
||||
fn verdict_tally(&self) -> String {
|
||||
format!(
|
||||
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
|
||||
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
|
||||
)
|
||||
}
|
||||
|
||||
/// The session's per-class tally as one log token ([`StallClass`] order).
|
||||
fn class_tally(&self) -> String {
|
||||
format!(
|
||||
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
|
||||
content-silence {}, frame-generation {}, damage-idle {}, unattributed {}",
|
||||
self.classes[0],
|
||||
self.classes[1],
|
||||
self.classes[2],
|
||||
self.classes[3],
|
||||
self.classes[4],
|
||||
self.classes[5],
|
||||
self.classes[6],
|
||||
self.classes[7]
|
||||
)
|
||||
}
|
||||
|
||||
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
||||
/// the reset the first post-recreate frame would read as one). An open episode is closed and
|
||||
/// summarized: its holes predate the recreate and are real evidence.
|
||||
@@ -418,10 +554,21 @@ impl StallWatch {
|
||||
if !was_active || gap < Self::STALL_MIN {
|
||||
return None;
|
||||
}
|
||||
Some(Stall {
|
||||
gap,
|
||||
metronomic: self.cadence.note(now),
|
||||
})
|
||||
// The metronome is fed in [`Self::report`], AFTER classification — a damage-idle hole
|
||||
// must never advance the beat the METRONOMIC warns blame on display hardware.
|
||||
Some(Stall { gap })
|
||||
}
|
||||
|
||||
/// Feed one classified stall into the metronome — `Some(mean period)` when it completes a
|
||||
/// metronomic cycle. Damage-idle stalls are NOT fed: an input/hand pause repeating on the
|
||||
/// user's cadence must not fabricate the display-disturbance beat (the 2026-08-27 field
|
||||
/// case fitted a "1.87 s display metronome" to what were pauses in the client's input).
|
||||
/// Split from [`Self::report`] so the metronome integration stays unit-testable.
|
||||
pub(super) fn cycle(&mut self, now: Instant, damage_idle: bool) -> Option<Duration> {
|
||||
if damage_idle {
|
||||
return None;
|
||||
}
|
||||
self.cadence.note(now)
|
||||
}
|
||||
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
|
||||
/// metronomic — name the class of disturbance and its cures.
|
||||
@@ -458,6 +605,7 @@ impl StallWatch {
|
||||
&verdict,
|
||||
evidence.probes.as_ref(),
|
||||
evidence.etw_counts.as_ref(),
|
||||
evidence.cursor_moved_px,
|
||||
);
|
||||
self.classes[match class {
|
||||
StallClass::OursWorker => 0,
|
||||
@@ -466,8 +614,15 @@ impl StallWatch {
|
||||
StallClass::CompositorBlocked => 3,
|
||||
StallClass::ContentSilence => 4,
|
||||
StallClass::FrameGeneration => 5,
|
||||
StallClass::Unattributed => 6,
|
||||
StallClass::DamageIdle => 6,
|
||||
StallClass::Unattributed => 7,
|
||||
}] += 1;
|
||||
// Damage-idle holes are real delivery holes (the episode/recovery summaries still count
|
||||
// them) but they are NOT display-disturbance evidence: they must not advance the
|
||||
// metronome, trip either repeated-stall warn, or put a connected-inactive display on
|
||||
// trial. The per-stall line below still carries their full evidence.
|
||||
let damage_idle = class == StallClass::DamageIdle;
|
||||
let metronomic = self.cycle(now, damage_idle);
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
// at debug level, and the web-console debug ring captures these.
|
||||
@@ -483,12 +638,49 @@ impl StallWatch {
|
||||
// inside the gap window. presents≥bar with adds≈0 = FRAME-GENERATION conviction.
|
||||
etw_presents = evidence.etw_counts.map(|c| c.presents),
|
||||
etw_queue_adds = evidence.etw_counts.map(|c| c.queue_adds),
|
||||
// The damage witness: 0 on a dwm-only desktop = the hole had nothing to compose
|
||||
// (input/hand pause); >0 with no presents = damage existed and the display stack
|
||||
// composed none of it — a positive conviction the old CONTENT-SILENCE could not make.
|
||||
cursor_moved_px_during_gap = evidence.cursor_moved_px,
|
||||
flow_dwm_only = evidence.etw_counts.map(|c| c.flow_dwm_only),
|
||||
offered_during_gap = evidence.offered_delta,
|
||||
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
|
||||
"IDD-push capture stall — the desktop was composing at speed, then the ring \
|
||||
delivered no frame for the gap; the class names the leg that lost them"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
// The repeated-stall arm: the metronome needs a stable period, but a session losing
|
||||
// frames to 150+ ms holes every few seconds without one (the 2026-08-26 7700 XT case)
|
||||
// deserves the same triage payload — otherwise the log's only guidance is per-stall
|
||||
// DEBUG lines nobody is told to read. Skipped when THIS stall completed a metronomic
|
||||
// cycle (the arms below carry strictly richer prose) and for damage-idle holes (an
|
||||
// input/hand pause repeated 30 times is still not a display problem).
|
||||
if metronomic.is_none() && !damage_idle {
|
||||
if let Some(stalls_in_window) = self.note_for_rate_warn(now) {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_physicals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
suspects.join(", ")
|
||||
};
|
||||
tracing::warn!(
|
||||
stalls_in_window = stalls_in_window as u64,
|
||||
os_correlated = format!("{}/{}", self.with_os_events, self.seen),
|
||||
connected_inactive = %suspects,
|
||||
rt_gpu_driver = rt_gpu_driver_posture(),
|
||||
rt_gpu_host = rt_gpu_host_posture(),
|
||||
verdicts = %self.verdict_tally(),
|
||||
classes = %self.class_tally(),
|
||||
"capture stalls are REPEATING without a stable period — same triage as the \
|
||||
metronomic class: if rt_gpu_driver or rt_gpu_host shows a REALTIME opt-in, \
|
||||
clear it first (unset PFVD_RT_GPU / set PUNKTFUNK_GPU_PRIORITY_CLASS=high); \
|
||||
then a connected-but-inactive display's standby servicing (see \
|
||||
connected_inactive), then display-poller software (the SteelSeries GG / \
|
||||
SignalRGB class). A content-silence class tally does NOT exonerate the \
|
||||
display stack — a frozen presenter reads identically (Flavor 3)"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(period) = metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_physicals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
@@ -497,21 +689,8 @@ impl StallWatch {
|
||||
};
|
||||
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||||
// The session's attribution in one token: which leg the evidence convicted, per stall.
|
||||
let verdict_tally = format!(
|
||||
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
|
||||
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
|
||||
);
|
||||
let class_tally = format!(
|
||||
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
|
||||
content-silence {}, frame-generation {}, unattributed {}",
|
||||
self.classes[0],
|
||||
self.classes[1],
|
||||
self.classes[2],
|
||||
self.classes[3],
|
||||
self.classes[4],
|
||||
self.classes[5],
|
||||
self.classes[6]
|
||||
);
|
||||
let verdict_tally = self.verdict_tally();
|
||||
let class_tally = self.class_tally();
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
// driver. Different classes, different cures — say which one this box has.
|
||||
@@ -533,33 +712,11 @@ impl StallWatch {
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
|
||||
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
|
||||
// the same way, so this read mirrors what the driver decided — modulo a machine
|
||||
// env edited after either process started, which a restart heals). The RX 9070
|
||||
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
|
||||
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
|
||||
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
|
||||
// say whether either lever is engaged before anyone chases display hardware.
|
||||
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
|
||||
"off (PFVD_NO_RT_GPU)"
|
||||
} else {
|
||||
match std::env::var_os("PFVD_RT_GPU") {
|
||||
None => "off (default)",
|
||||
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
|
||||
Some(_) => "REALTIME (PFVD_RT_GPU)",
|
||||
}
|
||||
};
|
||||
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||
.ok()
|
||||
.as_deref()
|
||||
{
|
||||
Some("off") => "off",
|
||||
Some("normal") => "normal",
|
||||
Some("realtime") => "REALTIME (pinned)",
|
||||
Some("auto") => "auto (gated REALTIME upgrade)",
|
||||
_ => "high (default)",
|
||||
};
|
||||
// The two REALTIME GPU-priority opt-ins (see the posture helpers' docs for the
|
||||
// 2026-08-12 A/B that convicted both) — a log carrying this warning must say
|
||||
// whether either lever is engaged before anyone chases display hardware.
|
||||
let rt_gpu_driver = rt_gpu_driver_posture();
|
||||
let rt_gpu_host = rt_gpu_host_posture();
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
@@ -569,22 +726,27 @@ impl StallWatch {
|
||||
verdicts = %verdict_tally,
|
||||
classes = %class_tally,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
|
||||
rt_gpu_host shows a REALTIME opt-in, clear it (unset PFVD_RT_GPU / \
|
||||
set PUNKTFUNK_GPU_PRIORITY_CLASS=high) — a punktfunk process holding \
|
||||
the disturbance is BELOW Windows (damage-idle holes — cursor \
|
||||
stationary on a dwm-only desktop, i.e. input/hand pauses — are \
|
||||
already excluded from this beat; see cursor_moved_px_during_gap on \
|
||||
the per-stall lines). FIRST: if rt_gpu_driver or rt_gpu_host shows a \
|
||||
REALTIME opt-in, clear it (unset PFVD_RT_GPU / set \
|
||||
PUNKTFUNK_GPU_PRIORITY_CLASS=high) — a punktfunk process holding \
|
||||
REALTIME GPU priority is the field-proven amplifier of exactly this \
|
||||
signature on AMD. Otherwise: the GPU driver servicing a \
|
||||
signature on AMD, and every pre-0.28 field metronome ran with it \
|
||||
default-on. Otherwise: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
clock (try a different refresh rate). If connected_inactive lists a \
|
||||
display, its standby servicing is the prime suspect. For a LAPTOP \
|
||||
PANEL (the exclusive isolate deactivated it — the dark-but-connected \
|
||||
head is itself the disturbance on hybrid laptops): keep it active \
|
||||
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
|
||||
For an external display: unplug it at the GPU, disable its OSD auto \
|
||||
input scan (TVs: instant-on/quick-start + CEC off), use an \
|
||||
HPD-holding adapter/dummy, or keep it active while streaming"
|
||||
display, its standby servicing is a suspect — cursor motion through \
|
||||
the holes is what convicts the display stack. For an external \
|
||||
display: keep it active while streaming, disable its OSD auto input \
|
||||
scan (TVs: instant-on/quick-start + CEC off), unplug it at the GPU, \
|
||||
or use an HPD-holding adapter/dummy. For a LAPTOP PANEL: keep it \
|
||||
active with `topology: primary` (the dark-but-connected-head \
|
||||
hypothesis has no confirmed post-0.28 case — verify with the cursor \
|
||||
witness before chasing it)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,16 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
# `logring::RingLayer` — the ONE tracing layer feeding the ring, shared by the session binary
|
||||
# and both desktop shells (it lived in clients/session, which left the GTK and WinUI shells
|
||||
# with no ring and so no "Send logs to host"). Minimal features: the layer only needs the
|
||||
# `Layer` trait; the bins bring their own full-featured tracing-subscriber, and cargo's
|
||||
# feature union means this adds nothing they didn't already compile. `tracing-log` is the
|
||||
# bridge-normalization half (see the layer's docs) and rides tracing-subscriber's default
|
||||
# `tracing-log` feature in every bin anyway. Android/Apple builds are untouched — this is
|
||||
# the desktop target block.
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] }
|
||||
tracing-log = "0.2"
|
||||
# Stable ids for profiles and host records (profiles.rs) — the OS RNG only, same version the
|
||||
# workspace already resolves for punktfunk-core. No uuid crate: the v4 layout is four lines.
|
||||
rand = "0.9"
|
||||
@@ -224,6 +234,11 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
# `video_vaapi_native::parity`. A DEV dependency, so no shipped binary gains anything —
|
||||
# which is also part of why the VAAPI readback cannot reach the production video path.
|
||||
sha2 = "0.11"
|
||||
# `logring`'s bridged-decoder test plants `log`-crate records through the tracing-log
|
||||
# bridge and installs a registry subscriber — the full-featured halves the shipping layer
|
||||
# deliberately doesn't need.
|
||||
log = "0.4"
|
||||
tracing-subscriber = { version = "0.3" }
|
||||
|
||||
[features]
|
||||
# PyroWave client decode ships in every default build (flatpak included; pyrowave-sys is a
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user