Compare commits
@@ -31,6 +31,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# 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
|
||||
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
|
||||
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
|
||||
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
|
||||
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
|
||||
- name: Publish the stable update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
VER="${TAG#v}"
|
||||
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
|
||||
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
grep -qi "$SHA" /tmp/installer.sha256 || {
|
||||
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
|
||||
exit 1
|
||||
}
|
||||
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
- name: Post release announcement to Discord
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -210,6 +210,13 @@ jobs:
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
|
||||
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
|
||||
# per-partition-set), which is the standing packaging verdict behind the on-device
|
||||
# distrobox build (scripts/steamdeck/, see scripts/steamdeck/README.md). That flow builds
|
||||
# its own HDR gamescope too. packaging/arch/build-sysext.sh remains a by-hand tool for the
|
||||
# Deck CLIENT image and for operators who accept the prebuilt-host trade-off.
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -147,15 +147,16 @@ jobs:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
|
||||
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
|
||||
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
|
||||
# three; leaving punktfunk-cli out here made it fall over on `install: No such file or
|
||||
# FOUR binaries ship in the client .deb, so all four are built here: the GTK shell,
|
||||
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect),
|
||||
# punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper
|
||||
# behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four;
|
||||
# leaving punktfunk-cli out here made it fall over on `install: No such file or
|
||||
# directory`, because its build-if-missing guard only tested the first two and so decided
|
||||
# everything was already built. The HOST is built separately in the build-publish-host
|
||||
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||
cargo build --release --locked \
|
||||
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
|
||||
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
|
||||
@@ -348,6 +348,21 @@ jobs:
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "web console smoke (bun): /login -> $code"
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
|
||||
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
|
||||
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
|
||||
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
|
||||
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
|
||||
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
|
||||
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
|
||||
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
|
||||
# The throw is the point: never silently ship a console-less installer again.
|
||||
- name: Export the console output dir (cache hit or fresh build)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path 'web\.output\server\index.mjs')) {
|
||||
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
|
||||
}
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build plugin/script runner bundle (bun)
|
||||
@@ -368,16 +383,40 @@ jobs:
|
||||
}
|
||||
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
# The UMDF drivers build IN-TREE inside the pack step (a relocated CARGO_TARGET_DIR
|
||||
# breaks wdk-build's manifest walk), and checkout's clean wipes that tree every run —
|
||||
# ~1 min of rebuild for crates that rarely change. Cache the in-tree target; cargo's
|
||||
# own fingerprints decide what's still fresh after a restore.
|
||||
- name: Cache drivers workspace target (built in-tree by the pack step)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: packaging/windows/drivers/target
|
||||
key: drivers-target-${{ hashFiles('packaging/windows/drivers/**', 'crates/pf-driver-proto/**') }}
|
||||
restore-keys: drivers-target-
|
||||
# NOT cached, and it must stay that way: the UMDF drivers build IN-TREE inside the pack
|
||||
# step (a relocated CARGO_TARGET_DIR breaks wdk-build's manifest walk), and act rotates
|
||||
# the job workspace path (~/.cache/act/<hash>/hostexecutor) between runs. A cargo target
|
||||
# dir restored under a DIFFERENT absolute path brings state that points at the old one:
|
||||
# measured 2026-07-30, `pf-umdf-util` died with 14 × "unable to create file lock (os
|
||||
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
|
||||
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
|
||||
# workspace-relative target.
|
||||
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
|
||||
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
|
||||
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
|
||||
# all of these, so here a missing input is a build failure rather than a quietly smaller
|
||||
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
|
||||
- name: Verify every installer payload is present
|
||||
shell: pwsh
|
||||
run: |
|
||||
$need = @(
|
||||
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
|
||||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||||
)
|
||||
$missing = @()
|
||||
foreach ($x in $need) {
|
||||
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
|
||||
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
|
||||
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
|
||||
else { Write-Output "payload OK - $($x.n) -> $full" }
|
||||
}
|
||||
if ($missing.Count) {
|
||||
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
|
||||
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
|
||||
}
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
@@ -463,6 +502,36 @@ jobs:
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
|
||||
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
|
||||
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
|
||||
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
|
||||
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
|
||||
canary-manifest:
|
||||
needs: package
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish the canary update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
|
||||
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
|
||||
@@ -139,16 +139,20 @@ jobs:
|
||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||
# for the shell, which has no features).
|
||||
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
|
||||
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
|
||||
# RUN the binary (help contract), as the session's contract_smoke runs the session —
|
||||
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
|
||||
- name: Build
|
||||
shell: pwsh
|
||||
run: |
|
||||
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
@@ -156,9 +160,9 @@ jobs:
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
|
||||
+55
-11
@@ -1,10 +1,10 @@
|
||||
# Contributing to punktfunk
|
||||
# Contributing to Punktfunk
|
||||
|
||||
Thanks for your interest in contributing!
|
||||
|
||||
## Licensing of contributions (inbound = outbound)
|
||||
|
||||
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||
Punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||
|
||||
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
||||
@@ -28,6 +28,28 @@ If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0
|
||||
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
||||
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The Rust toolchain is **pinned exactly** in `rust-toolchain.toml`; rustup installs it for you the
|
||||
first time you build, so don't override it — a different rustc reformats files nobody touched.
|
||||
|
||||
The workspace links real system libraries, so a bare `cargo build --workspace` fails on a stock
|
||||
machine. The authoritative list is what CI installs, in `ci/rust-ci.Dockerfile` — on **Ubuntu 26.04**,
|
||||
which is what gets you FFmpeg 8:
|
||||
|
||||
```sh
|
||||
sudo apt install build-essential clang libclang-dev pkg-config cmake \
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
|
||||
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
|
||||
libgl-dev libegl-dev libgbm-dev \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
libvulkan-dev
|
||||
```
|
||||
|
||||
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
|
||||
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
|
||||
PipeWire — and is not a substitute for the list above.)
|
||||
|
||||
## Before you push
|
||||
|
||||
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
||||
@@ -38,18 +60,40 @@ on formatting alone:
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
```
|
||||
|
||||
Then the usual full pass:
|
||||
Then the usual full pass. Use `--locked` as CI does — otherwise a silent `Cargo.lock` update can pass
|
||||
locally and fail CI:
|
||||
|
||||
```sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
```
|
||||
|
||||
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
|
||||
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||
Two more gates that only apply to some changes:
|
||||
|
||||
See the [README's Build & test section](README.md#build--test-from-source) and
|
||||
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
|
||||
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||
- **Touched `web/` or `docs-site/`?** CI builds and typechecks both. Run, in that directory:
|
||||
```sh
|
||||
bun install && bun run build && bun run lint
|
||||
```
|
||||
Build first — it generates the API client / MDX typegen that the typecheck imports.
|
||||
- **Touched Windows- or Linux-gated code from another OS?** `scripts/xcheck.sh windows` (or
|
||||
`linux`) type-checks and lints that platform's `#[cfg(target_os = …)]` code in about a second,
|
||||
instead of waiting for the CI job that compiles it.
|
||||
|
||||
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
|
||||
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
|
||||
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
|
||||
and copy the snapshot the docs site serves:
|
||||
|
||||
```sh
|
||||
cargo run -p punktfunk-host -- openapi > api/openapi.json
|
||||
cp api/openapi.json docs-site/public/openapi.json
|
||||
```
|
||||
|
||||
Match the surrounding code's comment density and naming. Commit messages end with the
|
||||
`Co-Authored-By` trailer (see `git log`).
|
||||
|
||||
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
|
||||
commands (the FEC loss harness, the standalone C-ABI proof) and
|
||||
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
|
||||
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||
|
||||
Generated
+52
-35
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2880,6 +2880,7 @@ dependencies = [
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pf-ffvk",
|
||||
"pf-update-check",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
@@ -2896,7 +2897,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2914,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2935,7 +2936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2959,7 +2960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2968,7 +2969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2980,7 +2981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2994,11 +2995,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3027,14 +3028,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3047,9 +3048,29 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3082,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3094,7 +3115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3302,7 +3323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3313,7 +3334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3329,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3346,18 +3367,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"glib-build-tools",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"relm4",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3366,7 +3382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3386,7 +3402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3418,7 +3434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3454,6 +3470,7 @@ dependencies = [
|
||||
"pf-host-config",
|
||||
"pf-inject",
|
||||
"pf-paths",
|
||||
"pf-update-check",
|
||||
"pf-vdisplay",
|
||||
"pf-win-display",
|
||||
"pf-zerocopy",
|
||||
@@ -3502,7 +3519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3516,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3539,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+3
-1
@@ -12,6 +12,8 @@ members = [
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pf-paths",
|
||||
"crates/pf-update",
|
||||
"crates/pf-update-check",
|
||||
"crates/pf-host-config",
|
||||
"crates/pf-gpu",
|
||||
"crates/pf-zerocopy",
|
||||
@@ -51,7 +53,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
|
||||
<img src="assets/punktfunk-logo.svg" alt="Punktfunk" width="320" />
|
||||
</p>
|
||||
|
||||
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
||||
@@ -18,7 +18,7 @@ access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
||||
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
||||
|
||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
Punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
||||
@@ -43,8 +43,13 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
||||
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
||||
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
||||
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
|
||||
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
|
||||
encoder exists as a fallback.)
|
||||
~1.3 ms cross-machine on a LAN. (On Linux AMD/Intel, Vulkan Video for HEVC and AV1 with VAAPI for
|
||||
H.264 and as the fallback; a GPU-less software H.264 encoder exists as a last resort.)
|
||||
- **A library that fills itself.** Steam and non-Steam titles show up as a grid on every client, and
|
||||
plugins add their own sources — ROM Manager (your ROM collection, matched to installed emulators),
|
||||
Playnite, VirtualHere. Install them from the console's **Plugins** page or with
|
||||
`punktfunk-host plugins add`. See
|
||||
[Plugins](https://docs.punktfunk.unom.io/docs/plugins).
|
||||
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
|
||||
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
|
||||
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
||||
@@ -58,12 +63,12 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). Hardware decode and HDR10 present validated on glass on NVIDIA and Intel, including HDR pass-through on the Intel D3D11VA path |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, game library, virtual-display presets, plugin store, GPU selection, performance capture graphs, live host logs, host updates |
|
||||
|
||||
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
|
||||
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
|
||||
@@ -81,36 +86,76 @@ Both run from **one process**: bare `punktfunk-host serve` is the **secure nativ
|
||||
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||
|
||||
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
|
||||
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
|
||||
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
|
||||
## Install the host
|
||||
|
||||
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
||||
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; on
|
||||
SteamOS the host is built on-device by a script instead, and a Windows host ships as a signed
|
||||
installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `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) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [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) |
|
||||
| **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) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
|
||||
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
|
||||
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
|
||||
installed first:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
|
||||
# (on Bazzite take host.env.bazzite instead)
|
||||
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
|
||||
|
||||
systemctl --user enable --now punktfunk-host # the streaming host
|
||||
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
|
||||
```
|
||||
|
||||
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
|
||||
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
|
||||
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
|
||||
line before the replacement — the install guide has the snippet). Then open
|
||||
`https://<host-ip>:47992` and pair.
|
||||
|
||||
How the virtual display and input are wired up depends on your desktop — see
|
||||
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway).
|
||||
|
||||
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
|
||||
nothing to run by hand — open the web console and pair. Use
|
||||
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
|
||||
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
|
||||
`setup.exe` over the old install; uninstall from Add/Remove Programs.
|
||||
|
||||
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
The console's **Host** page also shows when a newer host is out, along with the exact command for
|
||||
how *this* box was installed (or a one-click **Update now** on Windows) — see
|
||||
[Updating the host](https://docs.punktfunk.unom.io/docs/updating). To remove it again, or to go back
|
||||
to an earlier version, see [Uninstalling](https://docs.punktfunk.unom.io/docs/uninstall) and
|
||||
[Release Channels](https://docs.punktfunk.unom.io/docs/channels#pin-a-version-or-roll-back).
|
||||
|
||||
## Connect a client
|
||||
|
||||
| Streaming to… | Use |
|
||||
|---|---|
|
||||
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
|
||||
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||
| Linux desktop / laptop | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||
| Steam Deck | The **Decky plugin** in Gaming Mode — it launches the client for you ([Steam Deck](https://docs.punktfunk.unom.io/docs/steam-deck)); in Desktop Mode, the Flatpak directly |
|
||||
| Android phone or TV | The **Android app** (`clients/android`) |
|
||||
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
||||
| Scripts, automation, another launcher | **`punktfunk`** — the headless CLI shipped in the Linux client packages (`punktfunk pair`, `punktfunk hosts list --json`, `punktfunk launch <host>`) |
|
||||
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
|
||||
|
||||
Each client discovers hosts on the network automatically and does a one-time
|
||||
@@ -122,7 +167,7 @@ Each client discovers hosts on the network automatically and does a one-time
|
||||
For development, or as an install fallback where no package is available:
|
||||
|
||||
```sh
|
||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
|
||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, the `punktfunk` CLI, probe (Linux & macOS)
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
@@ -154,11 +199,14 @@ clients/
|
||||
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
|
||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||
cli/ punktfunk, the headless client CLI — pair · hosts · wake · library · launch · punktfunk:// links
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · library · displays · plugins · GPUs · performance · logs · updates
|
||||
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
sdk/ `@punktfunk/host` — TypeScript management-API client + event stream (Effect)
|
||||
plugin-kit/ `@punktfunk/plugin-kit` — the plugin authoring kit (bun / TypeScript)
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite sysext + bootc · Windows installer + drivers · winget · Nix · gamescope
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
@@ -195,7 +243,7 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Third-party components
|
||||
|
||||
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||
@@ -203,7 +251,7 @@ notice ship in the installed `licenses/` folder).
|
||||
|
||||
### Trademarks
|
||||
|
||||
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||
Punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
|
||||
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
|
||||
here only to describe interoperability.
|
||||
|
||||
+12
-3
@@ -1,8 +1,17 @@
|
||||
# Security Policy
|
||||
|
||||
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||
Punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||
machine, so we take security reports seriously and appreciate responsible disclosure.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
|
||||
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
|
||||
we don't backport to older minor versions, so the supported versions are the latest stable release
|
||||
and the current canary build. If you're on an older build, please check that the issue still
|
||||
reproduces on the latest stable before reporting it. See
|
||||
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
@@ -14,7 +23,7 @@ exposes other users before a fix exists.
|
||||
|
||||
The more of this you can give us, the faster we can act:
|
||||
|
||||
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
|
||||
- The component and version (e.g. `punktfunk-host 0.22.3`, Windows or Linux, which client).
|
||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||
admin, a paired client, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
@@ -98,4 +107,4 @@ pursue legal action against researchers who:
|
||||
- give us reasonable time to remediate before public disclosure,
|
||||
- don't exfiltrate more data than needed to demonstrate the issue.
|
||||
|
||||
Thank you for helping keep punktfunk and its users safe.
|
||||
Thank you for helping keep Punktfunk and its users safe.
|
||||
|
||||
+25
-4
@@ -18,6 +18,7 @@ VENDORED THIRD-PARTY SOURCE (inside first-party crates)
|
||||
Vulkan-Headers (vendored, crates/pyrowave-sys) — https://github.com/KhronosGroup/Vulkan-Headers
|
||||
Font Awesome Free brand icons (vendored, assets/os-icons) — https://fontawesome.com
|
||||
Simple Icons (vendored, assets/os-icons) — https://simpleicons.org
|
||||
Bazzite logo (vendored, assets/os-icons) — https://github.com/ublue-os/bazzite
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
MANIFEST (crate version — SPDX license — source)
|
||||
@@ -2930,6 +2931,25 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (bazzite.txt) applies to: Bazzite logo (vendored, assets/os-icons)
|
||||
----------------------------------------------------------------------------
|
||||
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
|
||||
the Bazzite source repository (repo_content/Bazzite.svg).
|
||||
|
||||
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
|
||||
|
||||
Licensed under the Apache License, Version 2.0,
|
||||
https://www.apache.org/licenses/LICENSE-2.0.
|
||||
|
||||
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
|
||||
gradient and decorative overlays were dropped, and the path was translated and scaled
|
||||
into a 24x24 box with a monochrome fill (fill="currentColor").
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE) applies to: bindgen 0.72.1
|
||||
----------------------------------------------------------------------------
|
||||
@@ -6283,8 +6303,8 @@ the following restrictions:
|
||||
----------------------------------------------------------------------------
|
||||
The following license (font-awesome-brands.txt) applies to: Font Awesome Free brand icons (vendored, assets/os-icons)
|
||||
----------------------------------------------------------------------------
|
||||
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
|
||||
opensuse in assets/os-icons/) are from Font Awesome Free.
|
||||
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
|
||||
assets/os-icons/) are from Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
@@ -12997,8 +13017,9 @@ SOFTWARE.
|
||||
----------------------------------------------------------------------------
|
||||
The following license (simple-icons.txt) applies to: Simple Icons (vendored, assets/os-icons)
|
||||
----------------------------------------------------------------------------
|
||||
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
|
||||
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
|
||||
are from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
|
||||
+430
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.21.0"
|
||||
"version": "0.22.3"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -3432,6 +3432,142 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/apply": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Apply the available update",
|
||||
"description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.",
|
||||
"operationId": "applyUpdate",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApplyRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Apply started — poll `GET /update/status`",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/check": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Check for updates now",
|
||||
"description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.",
|
||||
"operationId": "forceUpdateCheck",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Update checks are disabled on this host",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "A forced check ran less than 30 s ago",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Update-check status",
|
||||
"description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.",
|
||||
"operationId": "getUpdateStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current update-check state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -3764,6 +3900,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApplyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApprovePending": {
|
||||
"type": "object",
|
||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||
@@ -4799,6 +4944,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).",
|
||||
"required": [
|
||||
"version",
|
||||
"channel",
|
||||
"install_kind",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "The channel it was announced on (`stable` | `canary`)."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.available"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The newer release's version string."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.",
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.applied"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7040,6 +7238,233 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateJobInfo": {
|
||||
"type": "object",
|
||||
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||
"required": [
|
||||
"target_version",
|
||||
"stage",
|
||||
"received_bytes",
|
||||
"started_unix"
|
||||
],
|
||||
"properties": {
|
||||
"received_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"stage": {
|
||||
"type": "string",
|
||||
"description": "`downloading` | `verifying` | `applying` | `restarting`."
|
||||
},
|
||||
"started_unix": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"target_version": {
|
||||
"type": "string",
|
||||
"description": "The version being installed."
|
||||
},
|
||||
"total_bytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateManifestInfo": {
|
||||
"type": "object",
|
||||
"description": "One channel's manifest facts, as much as the console renders.",
|
||||
"required": [
|
||||
"version",
|
||||
"serial",
|
||||
"published_at",
|
||||
"notes_url",
|
||||
"stale"
|
||||
],
|
||||
"properties": {
|
||||
"notes_url": {
|
||||
"type": "string",
|
||||
"description": "Release-notes link (pinned to our forge by the manifest validator)."
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"description": "RFC-3339 publish time (display only)."
|
||||
},
|
||||
"serial": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Publish serial (unix seconds) — monotonic per channel.",
|
||||
"minimum": 0
|
||||
},
|
||||
"stale": {
|
||||
"type": "boolean",
|
||||
"description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The released version this manifest announces."
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateResultInfo": {
|
||||
"type": "object",
|
||||
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
|
||||
"required": [
|
||||
"ok",
|
||||
"from",
|
||||
"to",
|
||||
"finished_unix"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"finished_unix": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"log_path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The installer's own log file on this host, for diagnosis."
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stage": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The stage that failed; absent on success."
|
||||
},
|
||||
"staged": {
|
||||
"type": "boolean",
|
||||
"description": "Applied but activates on the next reboot (rpm-ostree)."
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateStatus": {
|
||||
"type": "object",
|
||||
"description": "The full update-check state for this host.",
|
||||
"required": [
|
||||
"install_kind",
|
||||
"channel",
|
||||
"current_version",
|
||||
"apply",
|
||||
"channel_hint",
|
||||
"check_disabled",
|
||||
"available",
|
||||
"not_published"
|
||||
],
|
||||
"properties": {
|
||||
"apply": {
|
||||
"type": "string",
|
||||
"description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)."
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)."
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Release channel this install follows: `stable` | `canary`."
|
||||
},
|
||||
"channel_hint": {
|
||||
"type": "string",
|
||||
"description": "The copy-pastable update command for this install kind."
|
||||
},
|
||||
"check_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)."
|
||||
},
|
||||
"current_version": {
|
||||
"type": "string",
|
||||
"description": "The running host version."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
|
||||
},
|
||||
"job": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateJobInfo",
|
||||
"description": "The apply in flight, if any."
|
||||
}
|
||||
]
|
||||
},
|
||||
"last_checked_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When the last successful check happened (unix seconds).",
|
||||
"minimum": 0
|
||||
},
|
||||
"last_error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the last check failed, verbatim, if it did."
|
||||
},
|
||||
"last_result": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateResultInfo",
|
||||
"description": "Outcome of the most recent apply attempt."
|
||||
}
|
||||
]
|
||||
},
|
||||
"manifest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateManifestInfo",
|
||||
"description": "The last verified manifest, if any check has succeeded."
|
||||
}
|
||||
]
|
||||
},
|
||||
"not_published": {
|
||||
"type": "boolean",
|
||||
"description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error."
|
||||
},
|
||||
"opt_in_hint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -7110,6 +7535,10 @@
|
||||
{
|
||||
"name": "store",
|
||||
"description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"
|
||||
},
|
||||
{
|
||||
"name": "update",
|
||||
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
|
||||
the Bazzite source repository (repo_content/Bazzite.svg).
|
||||
|
||||
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
|
||||
|
||||
Licensed under the Apache License, Version 2.0,
|
||||
https://www.apache.org/licenses/LICENSE-2.0.
|
||||
|
||||
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
|
||||
gradient and decorative overlays were dropped, and the path was translated and scaled
|
||||
into a 24x24 box with a monochrome fill (fill="currentColor").
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
@@ -1,5 +1,5 @@
|
||||
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
|
||||
opensuse in assets/os-icons/) are from Font Awesome Free.
|
||||
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
|
||||
assets/os-icons/) are from Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
|
||||
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
|
||||
are from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
|
||||
+32
-10
@@ -7,7 +7,7 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
|
||||
|
||||
| token | mark | source |
|
||||
|---|---|---|
|
||||
| `windows` | Windows | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `windows` | Windows (the current four-pane mark, no perspective skew) | own geometry |
|
||||
| `apple` | Apple (also `macos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `linux` | Tux | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `steam` | Steam (also `steamos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
||||
@@ -17,14 +17,36 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
|
||||
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
|
||||
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
|
||||
| `debian` | Debian | Simple Icons (CC0 1.0) |
|
||||
| `bazzite` | Bazzite | ublue-os/bazzite (Apache-2.0) |
|
||||
| `cachyos` | CachyOS | Simple Icons (CC0 1.0) |
|
||||
| `nobara` | Nobara | Simple Icons (CC0 1.0, slug `nobaralinux`) |
|
||||
|
||||
Distros with no file here (Bazzite, CachyOS, Nobara, Pop!_OS, Mint, …) are intentional:
|
||||
the host advertises the full chain (`linux/fedora/bazzite`), and clients walk it
|
||||
most-specific-first, so they degrade to the family mark and finally to Tux.
|
||||
The last three are **distro leaves, not families**: a chain walks most-specific-first, so
|
||||
`linux/fedora/bazzite` would otherwise draw the Fedora mark. They earn their own art because
|
||||
"a Bazzite box" and "a Fedora box" are different machines to the person reading the card, and
|
||||
they are what this project's hosts actually run. Every other distro with no file here (Pop!_OS,
|
||||
Mint, …) still degrades to its family's mark and finally to Tux — that fallback is the design,
|
||||
not a gap.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved.
|
||||
Licensing: attribution notices live in `LICENSES/` and are folded into
|
||||
`THIRD-PARTY-NOTICES.txt` by `scripts/gen-third-party-notices.py`. The marks are
|
||||
trademarks of their respective owners; they are used here nominatively — to *identify*
|
||||
the operating system a host runs, the standard practice in this ecosystem — and imply no
|
||||
affiliation or endorsement.
|
||||
Windows is the one mark drawn here rather than sourced: every icon set that ships a "Windows"
|
||||
brand glyph still carries the **Windows 8/10 flag with the perspective skew**, which reads as
|
||||
dated next to the flat four-pane mark Microsoft has used since Windows 11. Four equal squares
|
||||
(11.377 + 1.246 gap) is the current proportion.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Because
|
||||
those viewBoxes are not all square, a client must letterbox rather than stretch — see the aspect
|
||||
note in `clients/android/.../components/OsIcons.kt`.
|
||||
|
||||
## Regenerating the per-client derivatives
|
||||
|
||||
`bash scripts/gen-os-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||
that inline it (web console, Decky plugin, Android). Adding a **new** token also means adding it
|
||||
to each client's shipped-token list — the script prints that checklist too.
|
||||
|
||||
## Licensing
|
||||
|
||||
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||
are used here nominatively — to *identify* the operating system a host runs, the standard
|
||||
practice in this ecosystem — and imply no affiliation or endorsement.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>
|
||||
|
After Width: | Height: | Size: 523 B |
@@ -0,0 +1,2 @@
|
||||
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1,2 @@
|
||||
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>
|
||||
|
After Width: | Height: | Size: 681 B |
@@ -1,2 +1,2 @@
|
||||
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
||||
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 323 B |
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -149,13 +150,14 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
|
||||
// capture claims even those away), so it's enumerated on the capture side — USB device
|
||||
// list + bonded BLE — and re-checked on USB hot-plug.
|
||||
var sc2Generation by remember { mutableIntStateOf(0) }
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
|
||||
// row supplements the PadRow below with the capture status + the USB grant.
|
||||
var usbGeneration by remember { mutableIntStateOf(0) }
|
||||
DisposableEffect(Unit) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
|
||||
}
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
val sc2Probe = remember { Sc2Capture(context) }
|
||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(sc2Generation) {
|
||||
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(usbGeneration) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) sc2Probe.pairedBleAddress() else null
|
||||
}
|
||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||
val dsUsb = remember(usbGeneration) {
|
||||
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
|
||||
.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
}
|
||||
|
||||
Group("Gamepads") {
|
||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||
dsUsb?.let { DsRow(it) }
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
@@ -319,6 +328,104 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask
|
||||
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
|
||||
* refreshes whichever dialog was answered.
|
||||
*/
|
||||
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
|
||||
|
||||
/**
|
||||
* The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the
|
||||
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
|
||||
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
|
||||
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
|
||||
* itself only runs inside a stream, so at menu time this card is pure status.
|
||||
*/
|
||||
@Composable
|
||||
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
val context = LocalContext.current
|
||||
val settingOn = remember { SettingsStore(context).load().dsCapture }
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
|
||||
val model = DsDevice.modelFor(usbDev.productId)
|
||||
val label = when (model) {
|
||||
DsDevice.Model.DUALSENSE -> "DualSense"
|
||||
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
|
||||
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
|
||||
null -> return
|
||||
}
|
||||
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
|
||||
// this receiver only updates the card).
|
||||
val action = DS_USB_PERMISSION_ACTION
|
||||
DisposableEffect(usbDev) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) {
|
||||
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
|
||||
}
|
||||
}
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
android.content.IntentFilter(action),
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Wired (USB)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
!settingOn -> Text(
|
||||
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
|
||||
"passthrough (USB)\" to capture it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
!permitted -> {
|
||||
Text(
|
||||
"Needs USB access — grant it now and streams capture the pad silently.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = {
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
android.app.PendingIntent.getBroadcast(
|
||||
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
|
||||
android.content.Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
android.app.PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
|
||||
@@ -395,6 +395,11 @@ private fun buildSettingsRows(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
toggle(
|
||||
"echoCancel", null, "Echo cancellation",
|
||||
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
choice(
|
||||
"padType", "Controllers", "Controller type",
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
@@ -45,14 +46,40 @@ suspend fun connectToHost(
|
||||
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
|
||||
// sockets) — must be applied before connect, since sockets are tagged at creation.
|
||||
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
|
||||
val multiSlice = VideoDecoders.multiSliceTolerant()
|
||||
val partialFrame = VideoDecoders.partialFrameCapable()
|
||||
// Slice-progressive delivery: decoder truth AND the async decode loop — the legacy
|
||||
// sync loop feeds whole AUs only, so parts must never arrive when it is selected.
|
||||
val frameParts = settings.lowLatencyMode && partialFrame
|
||||
val codecBits = VideoDecoders.decodableCodecBits()
|
||||
// Automatic codec (P5, measured NP3 ↔ RTX 4090): AV1 beat HEVC by ~1.2 ms end-to-end at
|
||||
// identical conditions, so under "Automatic" this device prefers AV1 when it hardware-
|
||||
// decodes it (the AV1 bit is only ever set for a real, non-blocked hardware decoder) AND
|
||||
// it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice overlap
|
||||
// AV1 cannot ride (AV1 has no slices; the host's chunked poll never arms). The host
|
||||
// honors the preference only inside the probed shared codec set, so an AV1-less encoder
|
||||
// still resolves HEVC. An explicit user choice always wins unchanged.
|
||||
val preferredCodec = settings.preferredCodec().takeIf { it != 0 }
|
||||
?: if (codecBits and 4 != 0 && !partialFrame) 4 else 0
|
||||
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline
|
||||
// is client-inert unless BOTH probes pass — this line says which decoder failed one.
|
||||
Log.i(
|
||||
"pf.caps",
|
||||
VideoDecoders.capsReport() +
|
||||
" → multiSlice=$multiSlice parts=$frameParts prefer=$preferredCodec" +
|
||||
" (lowLatency=${settings.lowLatencyMode})",
|
||||
)
|
||||
NativeBridge.nativeConnect(
|
||||
host, port, w, h, hz,
|
||||
identity.certPem, identity.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels,
|
||||
hdrEnabled, multiSlice,
|
||||
frameParts,
|
||||
settings.audioChannels,
|
||||
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
|
||||
// the user's soft codec preference — the host resolves the emitted codec from both.
|
||||
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
|
||||
// the soft codec preference (user choice, or the Automatic AV1 rule above) — the
|
||||
// host resolves the emitted codec from both.
|
||||
codecBits, preferredCodec, timeoutMs,
|
||||
launch,
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
@@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() {
|
||||
private var sc2Receiver: BroadcastReceiver? = null
|
||||
private var sc2PermissionAsked = false
|
||||
|
||||
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
|
||||
* Controllers screen's explicit button). */
|
||||
private var dsPermissionAsked = false
|
||||
|
||||
/**
|
||||
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
|
||||
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
|
||||
@@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() {
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
sc2PermissionAsked = false // a fresh attach may ask once again
|
||||
startSc2MenuNav()
|
||||
dsPermissionAsked = false
|
||||
maybeAskDsPermission()
|
||||
}
|
||||
SC2_MENU_PERMISSION -> {
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
@@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
startSc2MenuNav()
|
||||
maybeAskDsPermission()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() {
|
||||
sc2MenuActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while
|
||||
* the app is open, or the app coming to the foreground with one already plugged in — at most
|
||||
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
|
||||
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
|
||||
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
|
||||
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
|
||||
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
|
||||
* open; a deny leaves that card's explicit button as the re-ask.
|
||||
*/
|
||||
private fun maybeAskDsPermission() {
|
||||
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
|
||||
if (dsPermissionAsked) return
|
||||
if (!SettingsStore(this).load().dsCapture) return
|
||||
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val dev = usbManager.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
} ?: return
|
||||
if (usbManager.hasPermission(dev)) return
|
||||
dsPermissionAsked = true
|
||||
usbManager.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
|
||||
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
|
||||
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
|
||||
@@ -405,8 +444,8 @@ class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
|
||||
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
|
||||
*/
|
||||
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||
if (highRefreshModeId == 0) return
|
||||
@@ -415,6 +454,64 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
|
||||
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
|
||||
* pulldown), else the highest available. Same-resolution modes only.
|
||||
*
|
||||
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
|
||||
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
|
||||
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
|
||||
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
|
||||
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
|
||||
* the pin (the pre-pin behaviour).
|
||||
*/
|
||||
fun setStreamDisplayMode(hz: Int) {
|
||||
if (hz <= 0) {
|
||||
setConsoleHighRefreshRate(false)
|
||||
return
|
||||
}
|
||||
val target = streamModeFor(hz) ?: return
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
|
||||
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
|
||||
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
|
||||
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
|
||||
* supported-modes list is not override-filtered. `0` when unresolvable.
|
||||
*/
|
||||
fun streamPanelFps(hz: Int): Int =
|
||||
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
|
||||
|
||||
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
|
||||
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
|
||||
if (hz <= 0) return null
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
val current = disp?.mode ?: return null
|
||||
val sameRes = disp.supportedModes.filter {
|
||||
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
|
||||
}
|
||||
fun multiple(rate: Float): Int {
|
||||
val k = (rate / hz).toInt()
|
||||
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
|
||||
}
|
||||
return sameRes.minWithOrNull(
|
||||
compareBy(
|
||||
{
|
||||
when {
|
||||
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
|
||||
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
|
||||
else -> 2 // no relation — prefer highest so at least nothing is halved
|
||||
}
|
||||
},
|
||||
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
|
||||
@@ -38,6 +38,7 @@ data class SettingsOverlay(
|
||||
val compositor: Int? = null,
|
||||
val audioChannels: Int? = null,
|
||||
val micEnabled: Boolean? = null,
|
||||
val echoCancel: Boolean? = null,
|
||||
val touchMode: TouchMode? = null,
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
@@ -48,6 +49,9 @@ data class SettingsOverlay(
|
||||
* else, but here it is the one knob a marginal link wants turned off per host.
|
||||
*/
|
||||
val lowLatencyMode: Boolean? = null,
|
||||
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
|
||||
val presentPriority: String? = null,
|
||||
val smoothBuffer: Int? = null,
|
||||
/**
|
||||
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
|
||||
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
|
||||
@@ -67,12 +71,15 @@ data class SettingsOverlay(
|
||||
compositor = compositor ?: base.compositor,
|
||||
audioChannels = audioChannels ?: base.audioChannels,
|
||||
micEnabled = micEnabled ?: base.micEnabled,
|
||||
echoCancel = echoCancel ?: base.echoCancel,
|
||||
touchMode = touchMode ?: base.touchMode,
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -98,12 +105,15 @@ data class SettingsOverlay(
|
||||
compositor = if (after.compositor != before.compositor) after.compositor else compositor,
|
||||
audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels,
|
||||
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
|
||||
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
|
||||
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
|
||||
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
|
||||
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -121,12 +131,15 @@ data class SettingsOverlay(
|
||||
"compositor" -> copy(compositor = null)
|
||||
"audio_channels" -> copy(audioChannels = null)
|
||||
"mic_enabled" -> copy(micEnabled = null)
|
||||
"echo_cancel" -> copy(echoCancel = null)
|
||||
"touch_mode" -> copy(touchMode = null)
|
||||
"mouse_mode" -> copy(mouseMode = null)
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
"smooth_buffer" -> copy(smoothBuffer = null)
|
||||
else -> this
|
||||
}
|
||||
|
||||
@@ -141,12 +154,15 @@ data class SettingsOverlay(
|
||||
if (compositor != null) add("compositor")
|
||||
if (audioChannels != null) add("audio_channels")
|
||||
if (micEnabled != null) add("mic_enabled")
|
||||
if (echoCancel != null) add("echo_cancel")
|
||||
if (touchMode != null) add("touch_mode")
|
||||
if (mouseMode != null) add("mouse_mode")
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
if (smoothBuffer != null) add("smooth_buffer")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,12 +185,15 @@ data class SettingsOverlay(
|
||||
compositor?.let { j.put("compositor", it) }
|
||||
audioChannels?.let { j.put("audio_channels", it) }
|
||||
micEnabled?.let { j.put("mic_enabled", it) }
|
||||
echoCancel?.let { j.put("echo_cancel", it) }
|
||||
touchMode?.let { j.put("touch_mode", it.name) }
|
||||
mouseMode?.let { j.put("mouse_mode", it.storedName) }
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
smoothBuffer?.let { j.put("smooth_buffer", it) }
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -185,8 +204,9 @@ data class SettingsOverlay(
|
||||
/** Keys this build models; everything else in a stored overlay is carried through. */
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
|
||||
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
|
||||
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
|
||||
@@ -200,6 +220,7 @@ data class SettingsOverlay(
|
||||
compositor = j.optIntOrNull("compositor"),
|
||||
audioChannels = j.optIntOrNull("audio_channels"),
|
||||
micEnabled = j.optBooleanOrNull("mic_enabled"),
|
||||
echoCancel = j.optBooleanOrNull("echo_cancel"),
|
||||
touchMode = j.optStringOrNull("touch_mode")
|
||||
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
|
||||
mouseMode = j.optStringOrNull("mouse_mode")
|
||||
@@ -209,6 +230,8 @@ data class SettingsOverlay(
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
presentPriority = j.optStringOrNull("present_priority"),
|
||||
smoothBuffer = j.optIntOrNull("smooth_buffer"),
|
||||
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
@@ -41,6 +42,15 @@ data class Settings(
|
||||
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
|
||||
val codec: String = "auto",
|
||||
val micEnabled: Boolean = false,
|
||||
/**
|
||||
* Cancel acoustic echo on the mic uplink (plus noise suppression): the capture opens under
|
||||
* the VoiceCommunication preset so the HAL's own AEC/NS process it, with the Java effects
|
||||
* attached as a backstop where available. On by default — a phone/tablet plays the game audio
|
||||
* out of the same device its mic hears, so without this the host hears its own stream back.
|
||||
* Turn off for a headset-only setup where the untouched full-band capture sounds better.
|
||||
* Only meaningful while [micEnabled] is on.
|
||||
*/
|
||||
val echoCancel: Boolean = true,
|
||||
/**
|
||||
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
|
||||
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
|
||||
@@ -83,6 +93,19 @@ data class Settings(
|
||||
* feeds a queue that only grows.
|
||||
*/
|
||||
val lowLatencyMode: Boolean = true,
|
||||
/**
|
||||
* The timeline presenter's intent — the cross-client `present_priority` pair (the Apple
|
||||
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
|
||||
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
|
||||
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
|
||||
* display latency per buffered frame. Anything unrecognized resolves to latency.
|
||||
*/
|
||||
val presentPriority: String = "latency",
|
||||
/**
|
||||
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
|
||||
* Only meaningful when [presentPriority] is `"smooth"`.
|
||||
*/
|
||||
val smoothBuffer: Int = 0,
|
||||
/**
|
||||
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
||||
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
||||
@@ -110,6 +133,18 @@ data class Settings(
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
|
||||
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
|
||||
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
|
||||
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
|
||||
* platform API for any of them). ON by default — it engages only when such a pad is attached
|
||||
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
|
||||
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
|
||||
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -184,6 +219,7 @@ class SettingsStore(context: Context) {
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
|
||||
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
|
||||
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
|
||||
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
|
||||
@@ -201,9 +237,12 @@ class SettingsStore(context: Context) {
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -226,14 +265,18 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -251,6 +294,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_ECHO_CANCEL = "echo_cancel"
|
||||
const val K_STATS_VERBOSITY = "stats_verbosity"
|
||||
|
||||
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
|
||||
@@ -271,9 +315,12 @@ class SettingsStore(context: Context) {
|
||||
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
|
||||
*/
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_PRESENT_PRIORITY = "present_priority"
|
||||
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
@@ -285,14 +332,31 @@ class SettingsStore(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The display to probe for capability/mode queries: the context's own display when it is already
|
||||
* associated with one, else the DEFAULT display via [DisplayManager]. A `punktfunk://` deep-link
|
||||
* COLD start can reach the connect before the activity is attached to its display —
|
||||
* `context.display` then throws, and the old `false`/1080p60 fallbacks silently downgraded the
|
||||
* whole session (no HDR advertised / non-native mode) with nothing in the log. The default
|
||||
* display IS the panel on phones and TVs; the activity-display distinction only matters on
|
||||
* multi-display setups, where the attached path still wins whenever it is available.
|
||||
*/
|
||||
private fun probeDisplay(context: Context): Display? =
|
||||
runCatching { context.display }.getOrNull()
|
||||
?: runCatching {
|
||||
context.getSystemService(DisplayManager::class.java)
|
||||
?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
}.getOrNull().also {
|
||||
if (it != null) Log.i("punktfunk", "display probe: context unattached — using DEFAULT_DISPLAY")
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's native display mode as a landscape `(width, height, hz)` — the long edge is the
|
||||
* width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read.
|
||||
* [context] must be a visual (Activity) context.
|
||||
* width, since we stream a desktop. Falls back to 1920×1080@60 if no display can be read at all
|
||||
* (see [probeDisplay] for the cold-start fallback that makes that a last resort).
|
||||
*/
|
||||
fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
// getDisplay() throws on a non-visual context rather than returning null — guard it.
|
||||
val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60)
|
||||
val display = probeDisplay(context) ?: return Triple(1920, 1080, 60)
|
||||
val mode = display.mode
|
||||
val w = mode.physicalWidth
|
||||
val h = mode.physicalHeight
|
||||
@@ -307,7 +371,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
* capability gate the Apple/Windows clients apply.
|
||||
*/
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||
val display = probeDisplay(context)
|
||||
if (display == null) {
|
||||
// Distinguishable from a real SDR verdict — a silent `false` here cost an HDR session.
|
||||
Log.w("punktfunk", "display HDR probe: no display reachable — advertising SDR")
|
||||
return false
|
||||
}
|
||||
val types = buildSet {
|
||||
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
|
||||
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
|
||||
@@ -491,6 +560,29 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
||||
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
||||
|
||||
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
|
||||
* Unrecognized values resolve to latency — same rule as the Apple client. */
|
||||
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
|
||||
|
||||
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
|
||||
val PRESENT_PRIORITY_OPTIONS = listOf(
|
||||
"latency" to "Lowest latency",
|
||||
"smooth" to "Smoothness",
|
||||
)
|
||||
|
||||
/** (frames, label) for the smoothness-buffer picker; each buffered frame ≈ one refresh interval
|
||||
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
|
||||
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||
val periodMs = 1000.0 / maxOf(24, hz)
|
||||
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
|
||||
return listOf(
|
||||
0 to "Automatic",
|
||||
1 to "1 frame (${cost(1)})",
|
||||
2 to "2 frames (${cost(2)})",
|
||||
3 to "3 frames (${cost(3)})",
|
||||
)
|
||||
}
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -673,12 +673,22 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
// Only codecs this device can actually decode are offered — a preference the client never
|
||||
// advertises would be a dead setting (see [codecOptionsFor]).
|
||||
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
|
||||
// Mirror the Automatic AV1 rule in HostConnect (hardware AV1 AND no partial-frame
|
||||
// support) so the picker says what "Automatic" actually does on THIS device.
|
||||
val autoPrefersAv1 = remember {
|
||||
VideoDecoders.decodableCodecBits() and 4 != 0 && !VideoDecoders.partialFrameCapable()
|
||||
}
|
||||
SettingDropdown(
|
||||
label = "Video codec",
|
||||
options = codecOptionsFor(s.codec, av1Capable),
|
||||
selected = s.codec,
|
||||
field = "codec",
|
||||
caption = "A preference — the host falls back if it can't encode this one.",
|
||||
caption = if (autoPrefersAv1) {
|
||||
"A preference — the host falls back if it can't encode this one. " +
|
||||
"Automatic prefers AV1 on this device."
|
||||
} else {
|
||||
"A preference — the host falls back if it can't encode this one."
|
||||
},
|
||||
) { c -> update(s.copy(codec = c)) }
|
||||
|
||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
|
||||
@@ -711,6 +721,26 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
field = "low_latency_mode",
|
||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||
)
|
||||
// The timeline presenter's intent — the Apple client's "Prioritize" pair, same stored
|
||||
// values, so a profile written on one platform means the same thing here.
|
||||
SettingDropdown(
|
||||
label = "Prioritize",
|
||||
options = PRESENT_PRIORITY_OPTIONS,
|
||||
selected = if (s.presentPriority == "smooth") "smooth" else "latency",
|
||||
field = "present_priority",
|
||||
caption = "Lowest latency shows each frame the moment it can reach the panel; " +
|
||||
"Smoothness buffers a little to absorb network jitter.",
|
||||
) { v -> update(s.copy(presentPriority = v)) }
|
||||
if (s.presentPriority == "smooth") {
|
||||
SettingDropdown(
|
||||
label = "Smoothness buffer",
|
||||
options = smoothBufferOptions(if (s.hz > 0) s.hz else nhz),
|
||||
selected = if (s.smoothBuffer in 1..3) s.smoothBuffer else 0,
|
||||
field = "smooth_buffer",
|
||||
caption = "Each buffered frame absorbs one refresh of jitter and adds one of " +
|
||||
"display latency — the cost shown is at the session's refresh rate.",
|
||||
) { v -> update(s.copy(smoothBuffer = v)) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
|
||||
@@ -774,6 +804,14 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
field = "mic_enabled",
|
||||
onCheckedChange = onMicChange,
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Echo cancellation",
|
||||
subtitle = "Filters the stream's own audio out of the mic pickup",
|
||||
checked = s.echoCancel,
|
||||
enabled = s.micEnabled,
|
||||
field = "echo_cancel",
|
||||
onCheckedChange = { on -> update(s.copy(echoCancel = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,6 +854,15 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
checked = s.sc2Capture,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
// the CONTROLLER's own motors/LEDs, not this device's.
|
||||
ToggleRow(
|
||||
title = "DualSense / DualShock passthrough (USB)",
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ import kotlin.math.roundToInt
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 26-double
|
||||
* layout from [NativeBridge.nativeVideoStats]:
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 33-double
|
||||
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms]`.
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
|
||||
* is length-guarded, so an older native lib simply omits the lines it can't feed.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
@@ -46,12 +48,20 @@ internal fun StatsOverlay(
|
||||
* common case: no profile) the line is exactly what it always was.
|
||||
*/
|
||||
profileName: String? = null,
|
||||
/**
|
||||
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
|
||||
* it sits below the stream rate — the "an OEM governor ignored the mode pin" tell, which
|
||||
* otherwise reads as inexplicable judder and an extra refresh of latency.
|
||||
*/
|
||||
panelHz: Float = 0f,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
val hz = s[8].toInt()
|
||||
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
|
||||
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val lost = s[9].toLong()
|
||||
@@ -65,12 +75,12 @@ internal fun StatsOverlay(
|
||||
val profileTag = profileName?.let { " · $it" }.orEmpty()
|
||||
// Compact: everything the glance-value needs on one line, nothing else.
|
||||
if (verbosity == StatsVerbosity.COMPACT) {
|
||||
statLine(compactLine(s, latValid) + profileTag, Color.White)
|
||||
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
|
||||
return@Column
|
||||
}
|
||||
|
||||
statLine(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
|
||||
Color.White,
|
||||
)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
@@ -104,8 +114,47 @@ internal fun StatsOverlay(
|
||||
} else {
|
||||
"host+network ${"%.1f".format(s[14])}"
|
||||
}
|
||||
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
|
||||
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
|
||||
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
|
||||
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
|
||||
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
|
||||
// dropping/serializing, an fps deficit is upstream.
|
||||
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
|
||||
val displayTerm = when {
|
||||
dispValid && split ->
|
||||
" + display ${"%.1f".format(s[23])} " +
|
||||
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
|
||||
dispValid -> " + display ${"%.1f".format(s[23])}"
|
||||
else -> ""
|
||||
}
|
||||
val presents = if (s.size >= 30 && s[29] != 0.0) {
|
||||
" · presents ${s[28].toInt()}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
// P3 decode split (s[30]/s[31]): `feed` = received→queued (hand-off + input-slot
|
||||
// wait) + `codec` = queued→decoded (codec-pure) — rendered when a sample landed.
|
||||
val decodeTerm = if (s.size >= 33 && (s[30] > 0 || s[31] > 0)) {
|
||||
"decode ${"%.1f".format(s[15])} " +
|
||||
"(feed ${"%.1f".format(s[30])} + codec ${"%.1f".format(s[31])})"
|
||||
} else {
|
||||
"decode ${"%.1f".format(s[15])}"
|
||||
}
|
||||
statLine(
|
||||
"= $hostTerms + $decodeTerm$displayTerm$presents",
|
||||
Color.White,
|
||||
)
|
||||
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
|
||||
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
|
||||
// applies the same shave so iPhone↔Android HUD numbers compare directly.
|
||||
if (dispValid && hz > 0) {
|
||||
val shave = 2000.0 / hz
|
||||
statLine(
|
||||
"≈ Apple-HUD equiv: end-to-end " +
|
||||
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
|
||||
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)",
|
||||
Color(0xFFA8D8B8),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
@@ -151,12 +200,17 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
val fec = s[20].toLong()
|
||||
val frames = s[21].toLong()
|
||||
if (lost == 0L && skipped == 0L && fec == 0L) return null
|
||||
// The overflow subset of `skipped` (s[32]): whole AUs dropped before feeding — the decoder
|
||||
// fell behind. Absent (0 / old layout) the plain count keeps meaning benign pacing drops.
|
||||
val overflow = if (s.size >= 33) s[32].toLong() else 0L
|
||||
return buildList {
|
||||
if (lost > 0) {
|
||||
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
|
||||
add("lost $lost (${"%.1f".format(pct)}%)")
|
||||
}
|
||||
if (skipped > 0) add("skipped $skipped")
|
||||
if (skipped > 0) {
|
||||
add(if (overflow > 0) "skipped $skipped (⚠ $overflow overflow)" else "skipped $skipped")
|
||||
}
|
||||
if (fec > 0) add("FEC $fec")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@ import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
@@ -25,12 +29,20 @@ import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -41,6 +53,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -54,6 +67,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
@@ -62,6 +76,7 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -77,7 +92,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
// The View hosting this composition — the one that receives the stream's touch/pointer events
|
||||
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
|
||||
// requested.
|
||||
val composeView = androidx.compose.ui.platform.LocalView.current
|
||||
val window = activity?.window
|
||||
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
|
||||
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
|
||||
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
|
||||
val controller = remember(window) {
|
||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||
}
|
||||
@@ -88,6 +110,38 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
// The Java AEC/NS pair backstopping the native VoiceCommunication capture preset, hung off the
|
||||
// audio session id `nativeStartMic` returns. Attached in surfaceCreated (where the mic starts)
|
||||
// and released on every path that stops the mic — the surface teardown AND the final dispose —
|
||||
// so a surface recreate re-attaches to the fresh stream instead of leaking effect engines.
|
||||
// All three touch points run on the main thread; a plain list is race-free.
|
||||
val micEffects = remember { mutableListOf<AudioEffect>() }
|
||||
|
||||
// In-stream mic mute. Per SESSION and never persisted (no setting backs it): a new stream
|
||||
// always starts unmuted. The authoritative flag lives on the native handle, which is why a mute
|
||||
// survives the mic stop/start a surface recreate performs — this state is the UI's mirror of
|
||||
// it, and survives the same recreate because the composition outlives the surface.
|
||||
var micMuted by remember(handle) { mutableStateOf(false) }
|
||||
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
|
||||
// rather than one that lies about a mic being heard.
|
||||
var micRunning by remember(handle) { mutableStateOf(false) }
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
|
||||
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
|
||||
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
|
||||
var micHint by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(micHint) {
|
||||
if (micHint != null) {
|
||||
delay(1600)
|
||||
micHint = null
|
||||
}
|
||||
}
|
||||
// The one place mute is toggled — Compose state + the native flag, always together.
|
||||
val setMicMuted = { muted: Boolean ->
|
||||
micMuted = muted
|
||||
NativeBridge.nativeSetMicMuted(handle, muted)
|
||||
}
|
||||
|
||||
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||
@@ -99,6 +153,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var codecLabel by remember { mutableStateOf("") }
|
||||
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
|
||||
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
|
||||
var panelHz by remember { mutableStateOf(0f) }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
@@ -120,6 +177,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
|
||||
// The decoder is fixed for the session; fetch its label once it's resolved.
|
||||
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
|
||||
}
|
||||
@@ -226,13 +284,39 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val priorSoftInput = window?.attributes?.softInputMode
|
||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
|
||||
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
|
||||
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
|
||||
// region can never show anything wrong. Captured + restored like the rest of the window
|
||||
// state so the menus keep their platform-default behaviour.
|
||||
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.attributes?.layoutInDisplayCutoutMode
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.let { w ->
|
||||
w.attributes = w.attributes.apply {
|
||||
layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
|
||||
// The prior request is captured and restored on the way out.
|
||||
//
|
||||
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
|
||||
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
|
||||
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
|
||||
// the lock is purely a phone-ergonomics choice.
|
||||
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
|
||||
val priorOrientation = activity?.requestedOrientation
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
if (compactDevice) {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
@@ -248,6 +332,16 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
router.onMicChord = {
|
||||
if (micRunning) {
|
||||
val next = !micMuted
|
||||
setMicMuted(next)
|
||||
micHint = if (next) "Microphone muted" else "Microphone live"
|
||||
}
|
||||
}
|
||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
||||
@@ -304,7 +398,30 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
|
||||
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
|
||||
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
|
||||
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
|
||||
if (isTv) {
|
||||
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
|
||||
} else {
|
||||
activity?.setStreamDisplayMode(streamHz)
|
||||
}
|
||||
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
|
||||
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
|
||||
// Undone by passing 0 on the way out (API 30+).
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
|
||||
}
|
||||
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
|
||||
// panel, but the platform separately down-rates a quiet app's choreographer stream
|
||||
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
|
||||
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
|
||||
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
|
||||
// that braces. Reset to no-preference on the way out.
|
||||
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
|
||||
composeView.requestedFrameRate = streamHz.toFloat()
|
||||
}
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||
@@ -367,14 +484,61 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
|
||||
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
|
||||
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
|
||||
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
|
||||
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
|
||||
usbDev != null -> {
|
||||
// One-time system dialog; capture engages on grant (Android remembers the
|
||||
// grant for as long as the device stays attached).
|
||||
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != action) return
|
||||
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
|
||||
}
|
||||
}
|
||||
dsUsbReceiver = receiver
|
||||
ContextCompat.registerReceiver(
|
||||
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
PendingIntent.getBroadcast(
|
||||
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
|
||||
Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.onMicChord = null // same: no mute toggle on buttons released during teardown
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
||||
@@ -388,8 +552,19 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||
activity?.startSc2MenuNav()
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 35) {
|
||||
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
|
||||
}
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
|
||||
window?.let { w ->
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -400,6 +575,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
activity?.requestedOrientation =
|
||||
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
||||
releaseMicEffects(micEffects)
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
@@ -480,12 +656,48 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
lowLatencyMode,
|
||||
choice?.lowLatencyFeature ?: false,
|
||||
isTv,
|
||||
initialSettings.presentPriorityWire(),
|
||||
initialSettings.smoothBuffer,
|
||||
// The panel's own refresh — from the mode TABLE (streamPanelFps),
|
||||
// because display.refreshRate reports a per-uid override, not the
|
||||
// panel. Fallback: the (possibly lying) live rate.
|
||||
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||
if (micWanted) {
|
||||
val sessionId =
|
||||
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
|
||||
if (initialSettings.echoCancel) {
|
||||
attachMicEffects(sessionId, micEffects)
|
||||
}
|
||||
// Did a capture actually open? That — not the setting — is what
|
||||
// puts the mute control on screen. A restart after a surface
|
||||
// recreate comes back already muted if the user muted: the flag
|
||||
// lives on the session handle, so nothing has to be re-applied.
|
||||
micRunning = NativeBridge.nativeMicActive(handle)
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
// policy (FIXED_SOURCE; ALWAYS only on the TV low-latency path —
|
||||
// phones stay seamless so a re-hint can never force a mode flicker).
|
||||
if (streamHz > 0) runCatching {
|
||||
holder.surface.setFrameRate(
|
||||
streamHz.toFloat(),
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
if (isTv && lowLatencyMode) {
|
||||
Surface.CHANGE_FRAME_RATE_ALWAYS
|
||||
} else {
|
||||
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
// Surface gone (backgrounding, or on the way out). Stop the threads that
|
||||
@@ -493,7 +705,12 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// DisposableEffect has closed it, the handle is freed; dereferencing it
|
||||
// here is the use-after-free that crashed on back-navigation.
|
||||
if (!closed.get()) {
|
||||
releaseMicEffects(micEffects)
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
// No capture, no control — but the MUTE state is deliberately left
|
||||
// standing (native keeps it on the handle), so the restart in
|
||||
// surfaceCreated brings the user's choice back with it.
|
||||
micRunning = false
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
}
|
||||
@@ -508,6 +725,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
stats?.let {
|
||||
StatsOverlay(
|
||||
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
|
||||
panelHz,
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
@@ -567,9 +785,106 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
},
|
||||
)
|
||||
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
|
||||
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
|
||||
// costs the stream that small corner of touch area, which is why it exists only while a
|
||||
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
|
||||
// control there, and a focusable button would fight the game for the D-pad.
|
||||
if (micRunning && (micMuted || !isTv)) {
|
||||
MicMuteControl(
|
||||
muted = micMuted,
|
||||
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the Java echo-canceller + noise-suppressor pair to the mic stream's audio session — the
|
||||
* backstop for HALs whose VoiceCommunication capture path doesn't cancel on its own (the native
|
||||
* side already opened the stream under that preset). [sessionId] `<= 0` means native allocated no
|
||||
* session (echo cancellation off, or the preset fell back to the plain open), so there is nothing
|
||||
* to hang an effect on. Created effects land in [into] for [releaseMicEffects]; `create()`
|
||||
* returning null (unsupported / claimed) is quietly nothing — the HAL preset still does its part.
|
||||
* Needs no extra permission: the effect APIs attach to our own recording session.
|
||||
*/
|
||||
private fun attachMicEffects(sessionId: Int, into: MutableList<AudioEffect>) {
|
||||
if (sessionId <= 0) return
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
AcousticEchoCanceler.create(sessionId)?.let { it.setEnabled(true); into.add(it) }
|
||||
}
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
NoiseSuppressor.create(sessionId)?.let { it.setEnabled(true); into.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Release every attached mic effect engine. Idempotent — the list is cleared, and both stop
|
||||
* paths (surface teardown, final dispose) may call it in either order. */
|
||||
private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
|
||||
effects.forEach { runCatching { it.release() } }
|
||||
effects.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
|
||||
* uplink is live, a red **Muted** badge while it isn't — so the state that matters is the loud one,
|
||||
* readable at couch distance and impossible to mistake for the stream's own picture.
|
||||
*
|
||||
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
|
||||
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
|
||||
* caller's decision — it means a capture is genuinely running.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
|
||||
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
// Spoken state first, then the action — a talkback user needs to know they are muted
|
||||
// before they need to know how to stop being muted.
|
||||
contentDescription = if (muted) {
|
||||
"Microphone muted. Activate to unmute."
|
||||
} else {
|
||||
"Microphone live. Activate to mute."
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
if (muted) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Muted", color = Color.White, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
|
||||
* *muted*, but nothing on screen says *un*muted — and "did that press do anything?" is the whole
|
||||
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
|
||||
* in-stream cues; the caller clears it after a beat.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
||||
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
||||
|
||||
@@ -14,7 +14,11 @@ private const val PEN_TOUCHING = 2f
|
||||
private const val PEN_BARREL1 = 4f
|
||||
private const val PEN_BARREL2 = 8f
|
||||
private const val STRIDE = 10
|
||||
private const val MAX_SAMPLES = 8
|
||||
// Ceiling on samples per emit, NOT the wire batch size: the JNI layer splits an over-8 run into
|
||||
// consecutive wire batches (never truncates — a long historical run means the UI thread hitched,
|
||||
// which is exactly when dropping its head would notch the stroke). 64 samples ≈ >250 ms of
|
||||
// 240 Hz history; anything past that clamp is a pathological stall, not stroke geometry.
|
||||
private const val MAX_SAMPLES = 64
|
||||
|
||||
/**
|
||||
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
|
||||
@@ -117,7 +121,8 @@ internal class StylusStream(private val handle: Long) {
|
||||
NativeBridge.nativeSendPen(handle, last, 1)
|
||||
}
|
||||
|
||||
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
|
||||
/** Historical (coalesced) samples oldest-first, then the current one — one emit; the JNI
|
||||
* layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */
|
||||
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
|
||||
val history = minOf(me.historySize, MAX_SAMPLES - 1)
|
||||
var count = 0
|
||||
|
||||
@@ -105,8 +105,20 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
||||
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
|
||||
}
|
||||
c.positionChanged() ->
|
||||
ids[c.id]?.let {
|
||||
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
|
||||
ids[c.id]?.let { id ->
|
||||
// Batched MotionEvents coalesce intermediate points into the
|
||||
// historical list — forward them in order so a fast swipe keeps
|
||||
// its real curvature on the host (usually empty during a stream:
|
||||
// unbuffered dispatch is requested, so this costs nothing).
|
||||
for (hs in c.historical) {
|
||||
NativeBridge.nativeSendTouch(
|
||||
handle, id, 1,
|
||||
hs.position.x.roundToInt().coerceIn(0, sw - 1),
|
||||
hs.position.y.roundToInt().coerceIn(0, sh - 1),
|
||||
sw, sh,
|
||||
)
|
||||
}
|
||||
NativeBridge.nativeSendTouch(handle, id, 1, x, y, sw, sh)
|
||||
}
|
||||
}
|
||||
c.consume()
|
||||
@@ -289,7 +301,10 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
accY -= outY
|
||||
}
|
||||
} else {
|
||||
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
|
||||
// Direct: cursor follows the finger — historical points first (batched
|
||||
// MotionEvent samples), so the host cursor traces the finger's real path.
|
||||
for (hs in p.historical) moveAbs(hs.position.x, hs.position.y)
|
||||
moveAbs(p.position.x, p.position.y)
|
||||
}
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.discovery.osIconTokens
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
|
||||
@@ -14,18 +15,19 @@ import io.unom.punktfunk.kit.discovery.osIconTokens
|
||||
* ship, so an unknown distro degrades to its family's mark and finally to Tux; null means
|
||||
* "no icon", rendering the card exactly as before the field existed.
|
||||
*
|
||||
* Path data is vendored from the assets/os-icons masters (Font Awesome Free brands
|
||||
* CC BY 4.0 + Simple Icons CC0 — provenance in that directory's README); Material ships
|
||||
* no brand icons. Hand-kept as raw SVG path strings (one line each) rather than
|
||||
* transcribed ImageVector DSL — [PathParser] builds the vector once, then it's cached.
|
||||
* Path data is vendored from the assets/os-icons masters (per-mark provenance and licensing
|
||||
* in that directory's README; `bash scripts/gen-os-icons.sh <token>` prints a master's
|
||||
* viewport + path ready to paste); Material ships no brand icons. Hand-kept as raw SVG path
|
||||
* strings (one line each) rather than transcribed ImageVector DSL — [PathParser] builds the
|
||||
* vector once, then it's cached.
|
||||
*/
|
||||
private class OsGlyph(val viewportWidth: Float, val viewportHeight: Float, val d: String)
|
||||
|
||||
private val GLYPHS: Map<String, OsGlyph> = mapOf(
|
||||
"windows" to OsGlyph(
|
||||
viewportWidth = 448f,
|
||||
viewportHeight = 512f,
|
||||
d = "M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z",
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
|
||||
),
|
||||
"apple" to OsGlyph(
|
||||
viewportWidth = 384f,
|
||||
@@ -72,8 +74,28 @@ private val GLYPHS: Map<String, OsGlyph> = mapOf(
|
||||
viewportHeight = 512f,
|
||||
d = "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
|
||||
),
|
||||
// The gaming distros get their own mark rather than their family's: "a Bazzite box" and
|
||||
// "a Fedora box" are different machines to the person reading the card.
|
||||
"bazzite" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
|
||||
),
|
||||
"cachyos" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
|
||||
),
|
||||
"nobara" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
|
||||
),
|
||||
)
|
||||
|
||||
/** Longest edge of a built mark, in dp — the box callers size the [Icon] to. */
|
||||
private const val GLYPH_DP = 24f
|
||||
|
||||
private val built = mutableMapOf<String, ImageVector>()
|
||||
|
||||
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
|
||||
@@ -82,11 +104,18 @@ fun resolveOsIcon(chain: String): ImageVector? =
|
||||
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
|
||||
}
|
||||
|
||||
private fun OsGlyph.build(token: String): ImageVector =
|
||||
ImageVector.Builder(
|
||||
private fun OsGlyph.build(token: String): ImageVector {
|
||||
// The intrinsic size has to carry the VIEWPORT'S ASPECT RATIO, not a fixed square:
|
||||
// a VectorPainter maps the viewport onto the default size with independent x and y
|
||||
// scales, so declaring a 448x512 mark as 24x24 dp stretches it horizontally — which is
|
||||
// exactly how Tux and the Apple mark used to come out on a phone. Scaling the longest
|
||||
// edge to GLYPH_DP instead keeps the ratio, and Icon() paints with ContentScale.Fit, so
|
||||
// the mark letterboxes inside whatever box the caller sized us to.
|
||||
val longest = max(viewportWidth, viewportHeight)
|
||||
return ImageVector.Builder(
|
||||
name = "OsIcon.$token",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
defaultWidth = (GLYPH_DP * viewportWidth / longest).dp,
|
||||
defaultHeight = (GLYPH_DP * viewportHeight / longest).dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
).apply {
|
||||
@@ -96,3 +125,4 @@ private fun OsGlyph.build(token: String): ImageVector =
|
||||
fill = SolidColor(Color.Black),
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.components.resolveOsIcon
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the host card's OS marks (`components/OsIcons.kt`). Run:
|
||||
* `./gradlew :app:testDebugUnitTest`.
|
||||
*
|
||||
* The aspect assertions are the point: a [androidx.compose.ui.graphics.vector.VectorPainter] maps
|
||||
* the viewport onto the vector's default size with independent x and y scales, so a mark whose
|
||||
* default size does not carry its viewport's ratio renders STRETCHED — silently, with no crash and
|
||||
* no warning. That is exactly how Tux and the Apple mark used to look on a phone.
|
||||
*/
|
||||
class OsIconsTest {
|
||||
/** `ImageVector.name` is "OsIcon.<token>" — the only handle on which mark got resolved. */
|
||||
private fun markOf(chain: String) = resolveOsIcon(chain)?.name?.removePrefix("OsIcon.")
|
||||
|
||||
@Test
|
||||
fun defaultSizeCarriesTheViewportAspect() {
|
||||
for (chain in listOf("windows", "linux", "opensuse", "steam", "apple", "bazzite")) {
|
||||
val v = resolveOsIcon(chain)
|
||||
assertNotNull("no mark for $chain", v)
|
||||
v!!
|
||||
assertEquals(
|
||||
"$chain default size must keep the viewport ratio",
|
||||
v.viewportWidth / v.viewportHeight,
|
||||
v.defaultWidth.value / v.defaultHeight.value,
|
||||
0.001f,
|
||||
)
|
||||
assertEquals(
|
||||
"$chain longest edge must be the 24dp box",
|
||||
24f,
|
||||
maxOf(v.defaultWidth.value, v.defaultHeight.value),
|
||||
0.001f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tallMarkIsNarrowerThanItsBox() {
|
||||
// Tux is 448x512, so a correct build is 21x24dp — 24x24 would be the stretched bug.
|
||||
val tux = resolveOsIcon("linux")!!
|
||||
assertEquals(21f, tux.defaultWidth.value, 0.001f)
|
||||
assertEquals(24f, tux.defaultHeight.value, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gamingDistrosResolveToTheirOwnMark() {
|
||||
// The whole reason these three ship art: without it they'd draw their family's mark.
|
||||
assertEquals("bazzite", markOf("linux/fedora/bazzite"))
|
||||
assertEquals("cachyos", markOf("linux/arch/cachyos"))
|
||||
assertEquals("nobara", markOf("linux/rhel/nobara"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownDistroStillDegradesThroughItsFamily() {
|
||||
assertEquals("fedora", markOf("linux/fedora/somethingnew"))
|
||||
assertEquals("linux", markOf("linux/frontier/chimera"))
|
||||
assertEquals("steam", markOf("linux/arch/steamos")) // brand alias
|
||||
assertEquals("apple", markOf("macos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noChainMeansNoMark() {
|
||||
assertNull(resolveOsIcon(""))
|
||||
assertNull(resolveOsIcon("!!!"))
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,8 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
|
||||
/**
|
||||
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB — stream mode only.
|
||||
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
|
||||
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
|
||||
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
|
||||
* phone, plus gyro + touchpad the standard path never captured.
|
||||
*
|
||||
* Unlike [Sc2Capture] there is no raw passthrough — the host's DualSense/DS4 backends consume
|
||||
* only typed events — and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
|
||||
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
|
||||
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
|
||||
* engage (toggle off, permission denied, Bluetooth).
|
||||
*
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
|
||||
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
|
||||
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
|
||||
* itself if the poll thread stalls — the engine's explicit zeros remain the real stop mechanism.
|
||||
*/
|
||||
class DsCapture(
|
||||
context: Context,
|
||||
private val router: GamepadRouter,
|
||||
) : GamepadFeedback.PadFeedbackSink {
|
||||
private val usb = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = TAG,
|
||||
threadName = "pf-ds-usb",
|
||||
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
|
||||
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
|
||||
// class check already leaves them (and the pad's headset routing) to Android; the
|
||||
// single HID interface is the only claim.
|
||||
),
|
||||
::onReport,
|
||||
::onLinkClosed,
|
||||
)
|
||||
|
||||
@Volatile private var model: DsDevice.Model? = null
|
||||
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||
|
||||
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||
private val state = DsDevice.State()
|
||||
private var wireButtons = 0
|
||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||
private val lastTouchActive = BooleanArray(2)
|
||||
private val lastTouchX = IntArray(2) { -1 }
|
||||
private val lastTouchY = IntArray(2) { -1 }
|
||||
|
||||
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
|
||||
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
|
||||
// rumble, before any host Led lands) doesn't black the bar out.
|
||||
@Volatile private var ds4Low = 0
|
||||
@Volatile private var ds4High = 0
|
||||
@Volatile private var ds4Rgb = 0x000040
|
||||
|
||||
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
|
||||
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@Volatile private var backstop: Runnable? = null
|
||||
|
||||
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||
|
||||
/**
|
||||
* Start capturing [dev] (permission already granted). Claims the HID interface — the kernel
|
||||
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
|
||||
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
|
||||
* than waiting for the system's removal callback — so the freed wire index is deterministic
|
||||
* for this capture's ExternalPad instead of racing the first report against the callback. A
|
||||
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
|
||||
* reopens a slot on its next input event, so over-matching self-heals.
|
||||
*/
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (model != null) return false
|
||||
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||
if (!usb.start(dev)) return false
|
||||
model = m
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
|
||||
}
|
||||
// Release the firmware's lightbar animation once so host lightbar writes take effect
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
// ---- link callbacks (link thread) ----
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||
var changed = state.buttons xor wireButtons
|
||||
while (changed != 0) {
|
||||
val bit = changed and -changed // lowest changed bit
|
||||
p.button(bit, state.buttons and bit != 0)
|
||||
changed = changed and bit.inv()
|
||||
}
|
||||
wireButtons = state.buttons
|
||||
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
||||
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
||||
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
||||
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
||||
axis(p, Gamepad.AXIS_LT, state.lt)
|
||||
axis(p, Gamepad.AXIS_RT, state.rt)
|
||||
}
|
||||
|
||||
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
||||
if (lastAxis[id] == v) return
|
||||
lastAxis[id] = v
|
||||
p.axis(id, v)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
|
||||
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
|
||||
*/
|
||||
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
|
||||
for (f in 0 until 2) {
|
||||
if (state.touchActive[f]) {
|
||||
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
|
||||
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
|
||||
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
|
||||
p.touch(f, true, x, y)
|
||||
lastTouchActive[f] = true
|
||||
lastTouchX[f] = x
|
||||
lastTouchY[f] = y
|
||||
}
|
||||
} else if (lastTouchActive[f]) {
|
||||
p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
lastTouchActive[f] = false
|
||||
}
|
||||
}
|
||||
p.motion(state.gyro, state.accel)
|
||||
}
|
||||
|
||||
private fun releaseSlot() {
|
||||
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
|
||||
val p = pad
|
||||
if (p != null) {
|
||||
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
}
|
||||
p?.close()
|
||||
pad = null
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
lastTouchActive.fill(false)
|
||||
lastTouchX.fill(-1)
|
||||
lastTouchY.fill(-1)
|
||||
}
|
||||
|
||||
// ---- PadFeedbackSink (feedback poll threads) ----
|
||||
|
||||
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
}
|
||||
}
|
||||
|
||||
override fun led(pad: Int, r: Int, g: Int, b: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Rgb = (r shl 16) or (g shl 8) or b
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
override fun playerLeds(pad: Int, bits: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
|
||||
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
|
||||
}
|
||||
|
||||
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
ds4Low,
|
||||
ds4High,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
)
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = 0
|
||||
ds4High = 0
|
||||
DsDevice.ds4Report(
|
||||
0,
|
||||
0,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
)
|
||||
} else {
|
||||
DsDevice.ds5RumbleReport(m, 0, 0)
|
||||
}
|
||||
|
||||
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
|
||||
private fun armBackstop(ms: Long) {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private fun disarmBackstop() {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
backstop = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
|
||||
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
|
||||
* as-is passthrough, nothing rides the wire raw here — the host's DualSense/DS4 backends consume
|
||||
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
|
||||
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
|
||||
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
|
||||
* USB output reports itself.
|
||||
*
|
||||
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
|
||||
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
|
||||
* `dualsense_proto.rs` / `dualshock4_proto.rs` — this file is the byte-exact inverse of those
|
||||
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
|
||||
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
|
||||
* reaches this code — an uncaptured pad stays on the ordinary InputDevice path.
|
||||
*/
|
||||
object DsDevice {
|
||||
const val VID_SONY = 0x054C
|
||||
const val PID_DUALSENSE = 0x0CE6
|
||||
const val PID_DUALSENSE_EDGE = 0x0DF2
|
||||
const val PID_DUALSHOCK4_V1 = 0x05C4
|
||||
const val PID_DUALSHOCK4_V2 = 0x09CC
|
||||
|
||||
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
|
||||
|
||||
/**
|
||||
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
|
||||
* the physical one), its output-report size (the descriptor-declared size the firmware
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
|
||||
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||
* onto the wire's 0..65535 space.
|
||||
*/
|
||||
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
|
||||
}
|
||||
|
||||
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
|
||||
fun modelFor(pid: Int): Model? = when (pid) {
|
||||
PID_DUALSENSE -> Model.DUALSENSE
|
||||
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
|
||||
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
|
||||
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
|
||||
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
|
||||
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
|
||||
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
|
||||
*/
|
||||
class State {
|
||||
var buttons = 0
|
||||
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255
|
||||
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
|
||||
val accel = IntArray(3)
|
||||
val touchActive = BooleanArray(2)
|
||||
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
|
||||
val touchY = IntArray(2)
|
||||
}
|
||||
|
||||
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
|
||||
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
|
||||
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
|
||||
private const val DS5_INPUT_ID = 0x01
|
||||
// report[8] high nibble (`dualsense_proto::btn0`).
|
||||
private const val DS5_SQUARE = 0x10
|
||||
private const val DS5_CROSS = 0x20
|
||||
private const val DS5_CIRCLE = 0x40
|
||||
private const val DS5_TRIANGLE = 0x80
|
||||
// report[9] (`btn1`).
|
||||
private const val DS5_L1 = 0x01
|
||||
private const val DS5_R1 = 0x02
|
||||
private const val DS5_CREATE = 0x10
|
||||
private const val DS5_OPTIONS = 0x20
|
||||
private const val DS5_L3 = 0x40
|
||||
private const val DS5_R3 = 0x80
|
||||
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
|
||||
private const val DS5_PS = 0x01
|
||||
private const val DS5_TOUCHPAD = 0x02
|
||||
private const val DS5_MUTE = 0x04
|
||||
private const val EDGE_FN_LEFT = 0x10
|
||||
private const val EDGE_FN_RIGHT = 0x20
|
||||
private const val EDGE_BACK_LEFT = 0x40
|
||||
private const val EDGE_BACK_RIGHT = 0x80
|
||||
|
||||
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
|
||||
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
|
||||
// [35..43) two touch points (same 4-byte packing as the DS5).
|
||||
private const val DS4_L1 = 0x01
|
||||
private const val DS4_R1 = 0x02
|
||||
private const val DS4_SHARE = 0x10
|
||||
private const val DS4_OPTIONS = 0x20
|
||||
private const val DS4_L3 = 0x40
|
||||
private const val DS4_R3 = 0x80
|
||||
private const val DS4_PS = 0x01
|
||||
private const val DS4_TOUCHPAD = 0x02
|
||||
|
||||
/**
|
||||
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
|
||||
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
|
||||
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
|
||||
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
|
||||
*/
|
||||
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||
if (model == Model.DUALSHOCK4) {
|
||||
parseDs4(report, len, out)
|
||||
} else {
|
||||
parseDs5(model, report, len, out)
|
||||
}
|
||||
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
out.lt = u8(r, 5)
|
||||
out.rt = u8(r, 6)
|
||||
val b8 = u8(r, 8)
|
||||
val b9 = u8(r, 9)
|
||||
val b10 = u8(r, 10)
|
||||
var w = hatBits(b8 and 0x0F)
|
||||
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
// L2/R2 digital bits ride the analog axes instead (wire convention).
|
||||
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
|
||||
if (model == Model.DUALSENSE_EDGE) {
|
||||
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
|
||||
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
|
||||
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
|
||||
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
|
||||
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
|
||||
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
|
||||
}
|
||||
out.buttons = w
|
||||
if (len >= 28) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
|
||||
}
|
||||
if (len >= 41) {
|
||||
unpackTouch(r, 33, out, 0)
|
||||
unpackTouch(r, 37, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
val b5 = u8(r, 5)
|
||||
val b6 = u8(r, 6)
|
||||
val b7 = u8(r, 7)
|
||||
out.lt = u8(r, 8)
|
||||
out.rt = u8(r, 9)
|
||||
var w = hatBits(b5 and 0x0F)
|
||||
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
out.buttons = w
|
||||
if (len >= 25) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
|
||||
}
|
||||
if (len >= 43) {
|
||||
unpackTouch(r, 35, out, 0)
|
||||
unpackTouch(r, 39, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
|
||||
private fun hatBits(h: Int): Int = when (h) {
|
||||
0 -> Gamepad.BTN_DPAD_UP
|
||||
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
|
||||
2 -> Gamepad.BTN_DPAD_RIGHT
|
||||
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
|
||||
4 -> Gamepad.BTN_DPAD_DOWN
|
||||
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
|
||||
6 -> Gamepad.BTN_DPAD_LEFT
|
||||
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One 4-byte touch point (shared DS5/DS4 packing — `dualsense_proto::pack_touch`): byte0
|
||||
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
|
||||
*/
|
||||
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
|
||||
val b0 = u8(r, o)
|
||||
out.touchActive[slot] = b0 and 0x80 == 0
|
||||
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
|
||||
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
|
||||
}
|
||||
|
||||
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
|
||||
|
||||
private fun i16(r: ByteArray, o: Int): Int =
|
||||
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
|
||||
|
||||
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
|
||||
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
|
||||
private fun stickX(raw: Int): Int = raw * 257 - 32768
|
||||
|
||||
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
|
||||
|
||||
// ---- Output reports ----
|
||||
//
|
||||
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
|
||||
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
|
||||
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
|
||||
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
|
||||
|
||||
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
|
||||
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
|
||||
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
|
||||
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
|
||||
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
|
||||
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
|
||||
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
|
||||
private const val DS5_FLAG0_R2_EFFECT = 0x04
|
||||
private const val DS5_FLAG0_L2_EFFECT = 0x08
|
||||
private const val DS5_FLAG1_LIGHTBAR = 0x04
|
||||
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
|
||||
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
|
||||
private const val DS5_FLAG2_VIBRATION2 = 0x04
|
||||
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
|
||||
|
||||
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
|
||||
const val TRIGGER_EFFECT_LEN = 11
|
||||
|
||||
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
|
||||
|
||||
/**
|
||||
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
|
||||
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect — the same
|
||||
* init both hid-playstation and SDL send on open. No-op fields otherwise.
|
||||
*/
|
||||
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
|
||||
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
|
||||
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
|
||||
* light/right — the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
|
||||
* trigger block from the wire (`HidOutput::Trigger` — the game's bytes verbatim), copied to
|
||||
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
|
||||
*/
|
||||
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
|
||||
val at = if (which == 1) 11 else 22
|
||||
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
|
||||
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
|
||||
System.arraycopy(effect, 0, it, at, n)
|
||||
}
|
||||
|
||||
/** DS5/Edge lightbar RGB. */
|
||||
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
|
||||
it[45] = r.toByte()
|
||||
it[46] = g.toByte()
|
||||
it[47] = b.toByte()
|
||||
}
|
||||
|
||||
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
|
||||
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
|
||||
it[44] = (bits and 0x1F).toByte()
|
||||
}
|
||||
|
||||
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
|
||||
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
|
||||
// [6..9) RGB, [9]/[10] blink on/off.
|
||||
private const val DS4_FLAG0_MOTORS = 0x01
|
||||
private const val DS4_FLAG0_LED = 0x02
|
||||
|
||||
/**
|
||||
* One full-state DS4 write: motors + lightbar together, both flags set — the composed-state
|
||||
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
|
||||
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
|
||||
*/
|
||||
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||
// vibrator path's toAmplitude).
|
||||
private fun amp8(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,42 @@ class GamepadFeedback(
|
||||
private val router: GamepadRouter?,
|
||||
private val deviceVibrator: Vibrator? = null,
|
||||
) {
|
||||
/**
|
||||
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
|
||||
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
|
||||
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
|
||||
* resolves null and the platform paths no-op) — the link renders instead, by composing USB
|
||||
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
|
||||
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
|
||||
* Invoked on the feedback poll threads; implementations must be thread-safe.
|
||||
*/
|
||||
interface PadFeedbackSink {
|
||||
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
|
||||
* invoked while true. Racing a pad close is fine — a late render is a harmless no-op. */
|
||||
fun ownsPad(pad: Int): Boolean
|
||||
|
||||
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
|
||||
* [backstopMs] as the self-termination net — see [GamepadFeedback.renderRumble]). */
|
||||
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
|
||||
|
||||
/** Lightbar RGB. */
|
||||
fun led(pad: Int, r: Int, g: Int, b: Int)
|
||||
|
||||
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
|
||||
fun playerLeds(pad: Int, bits: Int)
|
||||
|
||||
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
|
||||
* block (mode byte + parameters) exactly as the game wrote it host-side. */
|
||||
fun trigger(pad: Int, which: Int, effect: ByteArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
|
||||
* [onHidRaw]; cleared before the poll threads stop.
|
||||
*/
|
||||
@Volatile
|
||||
var sink: PadFeedbackSink? = null
|
||||
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
@@ -221,6 +257,12 @@ class GamepadFeedback(
|
||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||
// already decided the bind, and the user opted in.
|
||||
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
||||
// A captured pad's link renders on the physical controller itself (its slot has no
|
||||
// InputDevice, so the vibrator bind below would resolve null and drop the command).
|
||||
sink?.takeIf { it.ownsPad(pad) }?.let {
|
||||
it.rumble(pad, low, high, durationMs)
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
@@ -313,23 +355,36 @@ class GamepadFeedback(
|
||||
val g = buf.get().toInt() and 0xFF
|
||||
val b = buf.get().toInt() and 0xFF
|
||||
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.led(pad, r, g, b)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
}
|
||||
TAG_PLAYER_LEDS -> {
|
||||
val bits = buf.get().toInt() and 0x1F
|
||||
val player = playerIndexForBits(bits)
|
||||
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.playerLeds(pad, bits)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
}
|
||||
TAG_TRIGGER -> {
|
||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null && effLen > 0) {
|
||||
// A captured DualSense: the raw trigger block replays onto the physical pad.
|
||||
val effect = ByteArray(effLen)
|
||||
buf.get(effect)
|
||||
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
|
||||
s.trigger(pad, which, effect)
|
||||
} else {
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No platform adaptive-trigger API — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
TAG_HID_RAW -> {
|
||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||
|
||||
@@ -65,6 +65,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
var onExitArmed: ((armed: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* chord adds a meaning to them rather than swallowing them, exactly as the exit chord does.
|
||||
*/
|
||||
var onMicChord: (() -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
@@ -108,14 +118,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
val wasHeld = slot.held
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
|
||||
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
|
||||
// that leaves the whole chord held. Any other button pressed while Select + Y are down
|
||||
// fails the middle test, so the toggle happens once per chord, not once per press.
|
||||
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -210,6 +229,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||
fun close() = closeSlot(syntheticId)
|
||||
}
|
||||
@@ -228,6 +265,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
|
||||
* detaches the kernel driver, so the system's own removal callback would close it moments
|
||||
* later anyway — doing it at claim time makes the freed wire index deterministic for the
|
||||
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
|
||||
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
|
||||
* slot on its next input event). Main thread, like the hot-plug callbacks.
|
||||
*/
|
||||
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
@@ -323,6 +370,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
const val EXIT_HOLD_MS = 1000L
|
||||
|
||||
/**
|
||||
* Mic-mute chord: Select + Y. Y is deliberately NOT one of [EXIT_CHORD]'s buttons, so no
|
||||
* way of reaching the exit chord can pass through this one on the way (and vice versa) —
|
||||
* and Select is a menu button rather than a twitch action, which makes the pair unlikely
|
||||
* to occur inside real play.
|
||||
*/
|
||||
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
|
||||
* interface(s) — `force = true` detaches the kernel/OS driver, so a captured pad can't
|
||||
* double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest] read loop, and
|
||||
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
|
||||
* else EP0 `SET_REPORT`).
|
||||
*
|
||||
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
|
||||
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence —
|
||||
* the SC2's lizard-mode refresh; a DualSense needs none).
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (an SC2 on-glass round tripped exactly this — a 5 s silence heuristic firing on an idle pad).
|
||||
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||
*/
|
||||
class HidUsbLink(
|
||||
private val context: Context,
|
||||
private val config: Config,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
/**
|
||||
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
|
||||
* HID/vendor-class interfaces get claimed (the class check itself is built in) — e.g. the SC2
|
||||
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
|
||||
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
|
||||
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
|
||||
*/
|
||||
class Config(
|
||||
val tag: String,
|
||||
val threadName: String,
|
||||
val deviceMatch: (UsbDevice) -> Boolean,
|
||||
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
|
||||
val keepAliveFeatures: List<ByteArray> = emptyList(),
|
||||
val keepAliveMs: Long = 0,
|
||||
)
|
||||
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||
private class Claim(
|
||||
val iface: UsbInterface,
|
||||
val epIn: UsbEndpoint,
|
||||
val epOut: UsbEndpoint?,
|
||||
) {
|
||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||
var inReq: UsbRequest? = null
|
||||
var outReq: UsbRequest? = null
|
||||
var outBusy = false
|
||||
var reports = 0L
|
||||
}
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var device: UsbDevice? = null
|
||||
private var claims: List<Claim> = emptyList()
|
||||
|
||||
/** The claim whose IN endpoint last produced data — where output/feature writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
"USB link up: PID=0x%04x ifaces=%s".format(
|
||||
dev.productId,
|
||||
claimed.joinToString {
|
||||
"%d(in=0x%02x out=%s)".format(
|
||||
it.iface.id, it.epIn.address,
|
||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
detachReceiver = receiver
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
if (config.keepAliveFeatures.isNotEmpty()) {
|
||||
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
}
|
||||
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
|
||||
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional — the fallback is
|
||||
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
|
||||
* from Android's own input stack while captured.
|
||||
*/
|
||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||
val out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (!config.ifaceFilter(dev, iface)) continue
|
||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||
iface.interfaceClass == 0xFF
|
||||
if (!hidOrVendor) continue
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val ep = iface.getEndpoint(e)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
if (conn.claimInterface(iface, true)) {
|
||||
out.add(Claim(iface, inEp, outEp))
|
||||
} else {
|
||||
Log.w(config.tag, "could not claim iface ${iface.id}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||
*/
|
||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||
val live = claims.filter { c ->
|
||||
val req = UsbRequest()
|
||||
if (!req.initialize(conn, c.epIn)) {
|
||||
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||
return@filter false
|
||||
}
|
||||
req.clientData = c
|
||||
c.inReq = req
|
||||
c.epOut?.let { ep ->
|
||||
val o = UsbRequest()
|
||||
if (o.initialize(conn, ep)) {
|
||||
o.clientData = c
|
||||
c.outReq = o
|
||||
} else {
|
||||
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||
}
|
||||
}
|
||||
c.inBuf.clear()
|
||||
req.queue(c.inBuf)
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
|
||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||
try {
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
|
||||
now - lastKeepAlive >= config.keepAliveMs
|
||||
) {
|
||||
// Refresh the firmware settings on the streaming interface (else every live
|
||||
// one, before a streaming interface is known) — replaying also repairs state
|
||||
// some other consumer changed after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) sendKeepAlive(conn, target.iface.id)
|
||||
else live.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
lastKeepAlive = now
|
||||
}
|
||||
// Submit the next pending OUT report on the active (else first) interface.
|
||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||
if (outTarget != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS)
|
||||
} catch (_: TimeoutException) {
|
||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||
// detach broadcast is the real signal.
|
||||
errorsSince = 0L
|
||||
continue
|
||||
}
|
||||
if (done == null) {
|
||||
// Hard error. On a real unplug these storm continuously (the detach
|
||||
// broadcast usually beats us to it); tolerate transient ones.
|
||||
if (errorsSince == 0L) errorsSince = now
|
||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
errorsSince = 0L
|
||||
val claim = done.clientData as? Claim ?: continue
|
||||
if (done === claim.inReq) {
|
||||
val n = claim.inBuf.position()
|
||||
if (n > 0) {
|
||||
claim.inBuf.flip()
|
||||
claim.inBuf.get(scratch, 0, n)
|
||||
if (claim.reports++ == 0L) {
|
||||
Log.i(
|
||||
config.tag,
|
||||
"first report on iface %d: id=0x%02x len=%d".format(
|
||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||
),
|
||||
)
|
||||
}
|
||||
activeClaim = claim
|
||||
onReport(scratch, n)
|
||||
}
|
||||
claim.inBuf.clear()
|
||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
for (c in claims) {
|
||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||
c.inReq = null
|
||||
c.outReq = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
|
||||
* queue — for a teardown write that must land while the reader thread is stopping and the
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
claims = emptyList()
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
device = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,14 @@ object NativeBridge {
|
||||
compositorPref: Int,
|
||||
gamepadPref: Int,
|
||||
hdrEnabled: Boolean,
|
||||
/** Every decoder this device would use tolerates multi-slice AUs
|
||||
* ([VideoDecoders.multiSliceTolerant]) — advertises `VIDEO_CAP_MULTI_SLICE`; false keeps
|
||||
* the host at single-slice frames (the safe pre-0.17 wire shape). */
|
||||
multiSliceOk: Boolean,
|
||||
/** Every decoder this device would use accepts partial-frame input
|
||||
* ([VideoDecoders.partialFrameCapable]) — opts into slice-progressive delivery (the
|
||||
* decode loop then feeds slices with `BUFFER_FLAG_PARTIAL_FRAME` as they arrive). */
|
||||
framePartsOk: Boolean,
|
||||
audioChannels: Int,
|
||||
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
|
||||
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
|
||||
@@ -184,10 +192,12 @@ object NativeBridge {
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
|
||||
* handshake, so it is known before the first frame — the stream view sizes itself to THIS
|
||||
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
|
||||
* Cheap; UI-safe.
|
||||
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
|
||||
* Resolved at the handshake, so it is known before the first frame — the stream view sizes
|
||||
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
|
||||
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
|
||||
* (an older native lib returns only `[width, height]` — index defensively). Fixed for the
|
||||
* session; read once. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSize(handle: Long): IntArray?
|
||||
|
||||
@@ -204,11 +214,13 @@ object NativeBridge {
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
|
||||
* the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's
|
||||
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
|
||||
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
|
||||
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
|
||||
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
|
||||
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
|
||||
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
|
||||
* hint otherwise). No-op if already started.
|
||||
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
|
||||
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) — the Apple
|
||||
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
|
||||
*/
|
||||
external fun nativeStartVideo(
|
||||
handle: Long,
|
||||
@@ -217,6 +229,11 @@ object NativeBridge {
|
||||
lowLatencyMode: Boolean,
|
||||
lowLatencyFeature: Boolean,
|
||||
isTv: Boolean,
|
||||
presentPriority: Int,
|
||||
smoothBuffer: Int,
|
||||
/** The display mode's own refresh rate (0 = unknown) — the latch grid the presenter
|
||||
* subdivides onto when the platform down-rates the app's choreographer stream. */
|
||||
panelFps: Int,
|
||||
)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
@@ -231,11 +248,12 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* Returns 33 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
* e2eDispP50Ms, e2eDispP95Ms]`
|
||||
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
@@ -246,7 +264,12 @@ object NativeBridge {
|
||||
* `display` stage from the OnFrameRendered render timestamps — when `dispValid` is 1.0 the
|
||||
* headline becomes the directly-measured capture→displayed pair at 24/25, tiled by
|
||||
* `host+network` + `decode` + `display` (23), and when 0.0 the HUD falls back to the
|
||||
* capture→decoded headline at 2/3 without the `display` term).
|
||||
* capture→decoded headline at 2/3 without the `display` term; 26–29 split the `display`
|
||||
* term the timeline presenter owns — `pace` = decoded→release, `latch` = release→displayed,
|
||||
* the window's on-glass confirm count, and whether the presenter is active at all; 30/31
|
||||
* split `decode` (15) the same way — `feed` = received→queued (hand-off + input-slot wait),
|
||||
* `codec` = queued→decoded, the decoder's own time; 32 is the parked-AU overflow subset of
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
@@ -271,15 +294,53 @@ object NativeBridge {
|
||||
external fun nativeStopAudio(handle: Long)
|
||||
|
||||
/**
|
||||
* Start mic uplink: AAudio input → Opus (48 kHz stereo, 20 ms) → host (`send_mic` / 0xCB), all in
|
||||
* Rust. No-op if already running. The caller MUST hold RECORD_AUDIO; otherwise the AAudio input
|
||||
* stream fails to open and the rest of the session keeps streaming.
|
||||
* Start mic uplink: AAudio input → Opus (48 kHz mono, 10 ms) → host (`send_mic` / 0xCB), all in
|
||||
* Rust. [echoCancel] opens the capture under the VoiceCommunication preset (the HAL's own echo
|
||||
* canceller / noise suppressor) and allocates an audio session id; the return value is that id
|
||||
* (`> 0`) so the caller can attach the Java [android.media.audiofx.AcousticEchoCanceler] /
|
||||
* [android.media.audiofx.NoiseSuppressor] as a backstop — `0` when none was allocated
|
||||
* (echoCancel off, the device refused the preset and the open fell back to the plain path, or
|
||||
* the mic failed entirely). No-op if already running (returns the running capture's id). The
|
||||
* caller MUST hold RECORD_AUDIO; otherwise the AAudio input stream fails to open and the rest
|
||||
* of the session keeps streaming.
|
||||
*/
|
||||
external fun nativeStartMic(handle: Long)
|
||||
external fun nativeStartMic(handle: Long, echoCancel: Boolean): Int
|
||||
|
||||
/** Stop + join the mic thread and close the AAudio input stream. No-op on `0`. */
|
||||
/**
|
||||
* Stop + join the mic thread and close the AAudio input stream. No-op on `0`. Leaves the
|
||||
* session's mute state ([nativeSetMicMuted]) alone — a surface recreate stops and restarts the
|
||||
* mic, and a user who muted must stay muted through it.
|
||||
*/
|
||||
external fun nativeStopMic(handle: Long)
|
||||
|
||||
/**
|
||||
* Mute/unmute the mic uplink mid-stream. Muting does NOT stop the capture: the AAudio input
|
||||
* stream, the input preset it settled on and its primed buffers stay as they are, and the
|
||||
* encode loop drops each 10 ms frame instead of encoding + sending it — so room audio is never
|
||||
* encoded and nothing goes on the wire, while a toggle costs an atomic store and takes effect
|
||||
* on the next 10 ms boundary (a stop/start would re-run the preset fallback ladder and re-prime
|
||||
* buffers every time).
|
||||
*
|
||||
* Sticky for the SESSION — the flag lives on the handle, not on the capture — so the mic
|
||||
* restart a surface recreate performs comes back muted, with no window for an unmuted frame to
|
||||
* escape; a fresh session always starts unmuted. Nothing here is persisted. No-op on `0`.
|
||||
* Cheap (one atomic store); UI-safe.
|
||||
*
|
||||
* One honest consequence of keeping the stream open: the platform's own recording indicator
|
||||
* stays lit while muted, because the mic really is still open. What stops is the encode and the
|
||||
* send — no captured audio leaves the process.
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
* on the user's setting: a device that refused every AAudio input rung (or a missing
|
||||
* RECORD_AUDIO grant) then shows no control instead of a lie about a mic being heard. `false`
|
||||
* on a `0` handle. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeMicActive(handle: Long): Boolean
|
||||
|
||||
// ---- Input: Kotlin captures, Rust forwards to the host (send_input) ----
|
||||
|
||||
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
||||
@@ -407,6 +468,30 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||
|
||||
/**
|
||||
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
|
||||
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
|
||||
* are normalized 0..65535 in SCREEN convention (+y down — the wire's fixed meaning); active
|
||||
* false lifts the finger. Send on change only — the host holds per-slot state.
|
||||
*/
|
||||
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
|
||||
|
||||
/**
|
||||
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
|
||||
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units — the host passes
|
||||
* them straight into the virtual DualSense report. Called at the pad's report rate.
|
||||
*/
|
||||
external fun nativeSendPadMotion(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
gyroPitch: Int,
|
||||
gyroYaw: Int,
|
||||
gyroRoll: Int,
|
||||
accelX: Int,
|
||||
accelY: Int,
|
||||
accelZ: Int,
|
||||
)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
|
||||
* dongle (`1304`/`1305`). Claims the controller interface(s) — detaching the OS input stack, so
|
||||
* the pad can't double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest]
|
||||
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
|
||||
* writes (Steam's rumble output reports / settings feature reports) back to the device.
|
||||
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
|
||||
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
|
||||
* SC2-specific:
|
||||
*
|
||||
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
|
||||
* HID interface each, and there is no way to know which slot a controller bonded to — claiming
|
||||
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
|
||||
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
||||
* streams state becomes the write target for rumble/settings.
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
|
||||
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
|
||||
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
|
||||
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence — the generic link's keep-alive.
|
||||
*/
|
||||
class Sc2UsbLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
context: Context,
|
||||
onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
onClosed: () -> Unit,
|
||||
) {
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||
private class Claim(
|
||||
val iface: UsbInterface,
|
||||
val epIn: UsbEndpoint,
|
||||
val epOut: UsbEndpoint?,
|
||||
) {
|
||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||
var inReq: UsbRequest? = null
|
||||
var outReq: UsbRequest? = null
|
||||
var outBusy = false
|
||||
var reports = 0L
|
||||
}
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var device: UsbDevice? = null
|
||||
private var claims: List<Claim> = emptyList()
|
||||
|
||||
/** The claim whose IN endpoint last produced data — where rumble/settings writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — only
|
||||
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
|
||||
* returns ANY completed request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
private val link = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = "Sc2UsbLink",
|
||||
threadName = "pf-sc2-usb",
|
||||
deviceMatch = {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
},
|
||||
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
|
||||
ifaceFilter = { dev, iface ->
|
||||
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
|
||||
},
|
||||
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
|
||||
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
|
||||
),
|
||||
onReport,
|
||||
onClosed,
|
||||
)
|
||||
|
||||
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
}
|
||||
fun findDevice(): UsbDevice? = link.findDevice()
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(TAG, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
running = true
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
|
||||
dev.productId,
|
||||
claimed.joinToString {
|
||||
"%d(in=0x%02x out=%s)".format(
|
||||
it.iface.id, it.epIn.address,
|
||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
detachReceiver = receiver
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
claimed.forEach { configureInputMode(conn, it.iface.id) }
|
||||
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
|
||||
* of a Puck's controller slots (interfaces 2..5 — the controller may be bonded to any of
|
||||
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
|
||||
* Android's own input stack while captured.
|
||||
*/
|
||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||
val dongle = dev.productId != Sc2Device.PID_WIRED
|
||||
val out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
|
||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||
iface.interfaceClass == 0xFF
|
||||
if (!hidOrVendor) continue
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val ep = iface.getEndpoint(e)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
if (conn.claimInterface(iface, true)) {
|
||||
out.add(Claim(iface, inEp, outEp))
|
||||
} else {
|
||||
Log.w(TAG, "could not claim iface ${iface.id}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||
*/
|
||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||
val live = claims.filter { c ->
|
||||
val req = UsbRequest()
|
||||
if (!req.initialize(conn, c.epIn)) {
|
||||
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||
return@filter false
|
||||
}
|
||||
req.clientData = c
|
||||
c.inReq = req
|
||||
c.epOut?.let { ep ->
|
||||
val o = UsbRequest()
|
||||
if (o.initialize(conn, ep)) {
|
||||
o.clientData = c
|
||||
c.outReq = o
|
||||
} else {
|
||||
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||
}
|
||||
}
|
||||
c.inBuf.clear()
|
||||
req.queue(c.inBuf)
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
Log.e(TAG, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastLizard = android.os.SystemClock.elapsedRealtime()
|
||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||
try {
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||
// Refresh both required firmware modes. The raw-joystick setting is normally
|
||||
// persistent, but replaying it also repairs a host/driver that enabled ADC
|
||||
// coordinates after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) configureInputMode(conn, target.iface.id)
|
||||
else live.forEach { configureInputMode(conn, it.iface.id) }
|
||||
lastLizard = now
|
||||
}
|
||||
// Submit the next pending OUT report on the active (else first) interface.
|
||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||
if (outTarget != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS)
|
||||
} catch (_: TimeoutException) {
|
||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||
// detach broadcast is the real signal.
|
||||
errorsSince = 0L
|
||||
continue
|
||||
}
|
||||
if (done == null) {
|
||||
// Hard error. On a real unplug these storm continuously (the detach
|
||||
// broadcast usually beats us to it); tolerate transient ones.
|
||||
if (errorsSince == 0L) errorsSince = now
|
||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
errorsSince = 0L
|
||||
val claim = done.clientData as? Claim ?: continue
|
||||
if (done === claim.inReq) {
|
||||
val n = claim.inBuf.position()
|
||||
if (n > 0) {
|
||||
claim.inBuf.flip()
|
||||
claim.inBuf.get(scratch, 0, n)
|
||||
if (claim.reports++ == 0L) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 first report on iface %d: id=0x%02x len=%d".format(
|
||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||
),
|
||||
)
|
||||
}
|
||||
activeClaim = claim
|
||||
onReport(scratch, n)
|
||||
}
|
||||
claim.inBuf.clear()
|
||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
for (c in claims) {
|
||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||
c.inReq = null
|
||||
c.outReq = null
|
||||
}
|
||||
}
|
||||
fun start(dev: UsbDevice): Boolean = link.start(dev)
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
|
||||
* rumble & friends — the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
|
||||
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
||||
* report, id byte first, exactly as hidapi framed it host-side.
|
||||
* rumble & friends), kind 1 = feature report. [data] is the full report, id byte first,
|
||||
* exactly as hidapi framed it host-side.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the host re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
}
|
||||
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
|
||||
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
|
||||
}
|
||||
|
||||
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
|
||||
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
claims = emptyList()
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
device = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2UsbLink"
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
|
||||
fun stop() = link.stop()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,81 @@ object VideoDecoders {
|
||||
fun decodableCodecBits(): Int =
|
||||
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
|
||||
|
||||
/**
|
||||
* Whether EVERY decoder this device would use tolerates multi-slice access units — the
|
||||
* `VIDEO_CAP_MULTI_SLICE` advertisement (decoder truth, per the 0.17.0 field regression:
|
||||
* Amlogic HEVC decoders wedge the whole DEVICE on multi-slice frames — Chromecast with
|
||||
* Google TV, onn 4K — which is why Moonlight requests single-slice for every hardware
|
||||
* decoder). We advertise per-family instead: tolerant unless the pick for ANY advertised
|
||||
* codec is an Amlogic decoder or can't be inspected (a null pick = the platform default —
|
||||
* uninspectable, so conservatively single-slice). Probed once at connect time; the host
|
||||
* defaults to >1 slice only toward clients that set the bit.
|
||||
*/
|
||||
fun multiSliceTolerant(): Boolean {
|
||||
val mimes = buildList {
|
||||
add("video/avc")
|
||||
add("video/hevc")
|
||||
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||
}
|
||||
return mimes.all { mime ->
|
||||
val name = pickDecoder(mime)?.name?.lowercase() ?: return@all false
|
||||
!name.startsWith("omx.amlogic") && !name.startsWith("c2.amlogic")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when EVERY decoder this device would stream with supports partial-frame input
|
||||
* (MediaCodec `FEATURE_PartialFrame`): the client may then opt into slice-progressive
|
||||
* delivery and feed slices with `BUFFER_FLAG_PARTIAL_FRAME` ahead of the AU's tail.
|
||||
* Codec selection happens after connect, so all advertised mimes must qualify — the same
|
||||
* conservative shape as [multiSliceTolerant] (an unprobeable pick disqualifies).
|
||||
*/
|
||||
fun partialFrameCapable(): Boolean {
|
||||
val mimes = buildList {
|
||||
add("video/avc")
|
||||
add("video/hevc")
|
||||
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||
}
|
||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
.getOrNull() ?: return false
|
||||
return mimes.all { mime ->
|
||||
val pick = pickDecoder(mime)?.name ?: return@all false
|
||||
val info = infos.firstOrNull { it.name == pick } ?: return@all false
|
||||
runCatching {
|
||||
info.getCapabilitiesForType(mime)
|
||||
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line per-mime probe readout for the connect log (`adb logcat -s pf.caps`): which
|
||||
* decoder each advertised mime resolves to and whether it declares `FEATURE_PartialFrame` —
|
||||
* the P2 slice-pipeline gate that is otherwise invisible until a stream behaves differently.
|
||||
*/
|
||||
fun capsReport(): String {
|
||||
val mimes = buildList {
|
||||
add("video/avc")
|
||||
add("video/hevc")
|
||||
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||
}
|
||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
.getOrNull() ?: return "codec list unavailable"
|
||||
return mimes.joinToString(" ") { mime ->
|
||||
val pick = pickDecoder(mime)?.name
|
||||
val partial = pick?.let { p ->
|
||||
infos.firstOrNull { it.name == p }?.let { info ->
|
||||
runCatching {
|
||||
info.getCapabilitiesForType(mime)
|
||||
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
"${mime.removePrefix("video/")}=${pick ?: "platform-default"}" +
|
||||
" partialFrame=${partial ?: "?"}"
|
||||
}
|
||||
}
|
||||
|
||||
fun pickDecoder(mime: String): DecoderChoice? {
|
||||
if (mime.isEmpty()) return null
|
||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM tests of the Sony USB report codec ([DsDevice]) — the byte-exact inverse of the
|
||||
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
|
||||
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DsDeviceTest {
|
||||
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
// Sticks centred, hat neutral (8).
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[8] = 0x08
|
||||
// Touch points inactive (bit7 set).
|
||||
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[5] = 0x08
|
||||
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
// ---- input parse ----
|
||||
|
||||
@Test
|
||||
fun ds5ButtonsMapPositionally() {
|
||||
val s = DsDevice.State()
|
||||
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
|
||||
val r = ds5Report {
|
||||
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
|
||||
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
|
||||
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
|
||||
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
|
||||
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
|
||||
assertEquals(expected, s.buttons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5SticksInvertYAndCoverTheFullRange() {
|
||||
val s = DsDevice.State()
|
||||
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
|
||||
val r = ds5Report {
|
||||
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
|
||||
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
|
||||
it[5] = 0x40; it[6] = 0xFF.toByte()
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(-32768, s.lsX)
|
||||
assertEquals(32767, s.lsY) // device up → wire +32767
|
||||
assertEquals(32767, s.rsX)
|
||||
assertEquals(-32768, s.rsY) // device down → wire −32768
|
||||
assertEquals(0x40, s.lt)
|
||||
assertEquals(0xFF, s.rt)
|
||||
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
|
||||
val c = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
|
||||
assertEquals(128, c.lsX)
|
||||
assertEquals(-129, c.lsY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5MotionAndTouchUnpack() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds5Report {
|
||||
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
|
||||
it[16] = 0x02; it[17] = 0x01
|
||||
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
|
||||
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
|
||||
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
|
||||
it[33] = 0x05
|
||||
it[34] = 0x7F
|
||||
it[35] = (0x07 or (0x07 shl 4)).toByte()
|
||||
it[36] = 0x43
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(0x0102, s.gyro[0])
|
||||
assertEquals(-2, s.accel[2])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(1919, s.touchX[0])
|
||||
assertEquals(1079, s.touchY[0])
|
||||
assertFalse(s.touchActive[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun edgePaddlesParseOnlyOnTheEdge() {
|
||||
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
|
||||
val edge = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
|
||||
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
|
||||
assertEquals(
|
||||
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
|
||||
edge.buttons,
|
||||
)
|
||||
val plain = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
|
||||
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4LayoutDiffersWhereItShould() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds4Report {
|
||||
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
|
||||
it[6] = (0x10 or 0x20).toByte() // share | options
|
||||
it[7] = 0x03 // PS | touchpad click
|
||||
it[8] = 0x11 // L2 analog
|
||||
it[9] = 0x99.toByte() // R2 analog
|
||||
// gyro yaw at 15.. (second i16 of 13..19).
|
||||
it[15] = 0x34; it[16] = 0x12
|
||||
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
|
||||
it[35] = 0x03
|
||||
it[36] = 0x64
|
||||
it[37] = 0xD0.toByte()
|
||||
it[38] = 0x3A
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
|
||||
assertEquals(
|
||||
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
|
||||
s.buttons,
|
||||
)
|
||||
assertEquals(0x11, s.lt)
|
||||
assertEquals(0x99, s.rt)
|
||||
assertEquals(0x1234, s.gyro[1])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(100, s.touchX[0])
|
||||
assertEquals(941, s.touchY[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsForeignAndShortReports() {
|
||||
val s = DsDevice.State()
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||
}
|
||||
|
||||
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||
|
||||
@Test
|
||||
fun ds5RumbleReportFlagsAndMotors() {
|
||||
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
|
||||
assertEquals(48, r.size)
|
||||
assertEquals(0x02, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
|
||||
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
|
||||
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
|
||||
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
|
||||
// A nonzero amplitude never collapses to motor 0.
|
||||
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
|
||||
// The Edge's output report is the 64-byte variant.
|
||||
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5TriggerReportPlacesTheBlockPerSide() {
|
||||
val effect = ByteArray(11) { (it + 1).toByte() }
|
||||
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
|
||||
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
|
||||
assertEquals(1, r2[11].toInt()) // block at [11..22)
|
||||
assertEquals(11, r2[21].toInt())
|
||||
assertEquals(0, r2[22].toInt())
|
||||
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
|
||||
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
|
||||
assertEquals(1, l2[22].toInt()) // block at [22..33)
|
||||
assertEquals(11, l2[32].toInt())
|
||||
// Oversized wire effects clamp to the 11-byte hardware block.
|
||||
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
|
||||
assertEquals(0, big[22].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5LightbarPlayerLedsAndInit() {
|
||||
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
|
||||
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
|
||||
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
|
||||
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
|
||||
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
|
||||
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
|
||||
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
|
||||
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
|
||||
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4ReportIsAFullStateWrite() {
|
||||
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
|
||||
assertEquals(32, r.size)
|
||||
assertEquals(0x05, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
|
||||
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
|
||||
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
|
||||
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
|
||||
assertEquals(0, r[9].toInt()) // blink untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelResolution() {
|
||||
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
|
||||
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
|
||||
assertEquals(null, DsDevice.modelFor(0x1234))
|
||||
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,13 @@ use std::time::{Duration, Instant};
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::vsync::{now_monotonic_ns, VsyncClock};
|
||||
use super::{
|
||||
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
|
||||
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
|
||||
@@ -55,6 +57,8 @@ enum DecodeEvent {
|
||||
},
|
||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||
FormatChanged,
|
||||
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
|
||||
Vsync,
|
||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||
Error { fatal: bool },
|
||||
}
|
||||
@@ -78,6 +82,9 @@ pub(super) fn run_async(
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -196,9 +203,33 @@ pub(super) fn run_async(
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let meter = Arc::new(PresentMeter::new());
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
|
||||
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
|
||||
// legacy release-immediately path for a rebuild-free on-device A/B.
|
||||
let mut presenter = if presenter_disabled_by_sysprop() {
|
||||
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
|
||||
None
|
||||
} else {
|
||||
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||
log::info!(
|
||||
"decode: presenter = timeline ({})",
|
||||
match priority {
|
||||
PresentPriority::Latency => "lowest latency".to_string(),
|
||||
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
|
||||
}
|
||||
);
|
||||
Some(Presenter::new(priority))
|
||||
};
|
||||
stats.set_presenter_active(presenter.is_some());
|
||||
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
|
||||
// the same event channel. The Sender parks here until that moment.
|
||||
let mut vsync: Option<VsyncClock> = None;
|
||||
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
|
||||
|
||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||
let feeder = {
|
||||
@@ -238,6 +269,8 @@ pub(super) fn run_async(
|
||||
|
||||
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
||||
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
||||
// Phase-lock v3: per-AU arrival stamps for the circular arrival-lead report (drained 1 Hz).
|
||||
let mut arrival_stamps: Vec<i128> = Vec::new();
|
||||
let mut ready: Vec<OutputReady> = Vec::new();
|
||||
let mut applied_ds: Option<DataSpace> = None;
|
||||
let mut fed: u64 = 0;
|
||||
@@ -245,6 +278,12 @@ pub(super) fn run_async(
|
||||
let mut discarded: u64 = 0;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// Slice-progressive continuity ledger (see `PartFeed`).
|
||||
let mut part_open: Option<PartFeed> = None;
|
||||
// Queued-instant stamps (pts → realtime ns at the AU's LAST piece entering the codec) — the
|
||||
// P3 decode-split ledger: `feed` = received→queued, `codec` = queued→decoded. Always on
|
||||
// (one vDSO clock read per AU); consumed by `present_ready`.
|
||||
let mut queued_stamps: VecDeque<(u64, i128)> = VecDeque::new();
|
||||
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||
@@ -277,6 +316,7 @@ pub(super) fn run_async(
|
||||
};
|
||||
let work_t0 = Instant::now();
|
||||
let mut fmt_dirty = false;
|
||||
let mut vsync_tick = false;
|
||||
let mut aus_dropped: u64 = 0;
|
||||
if let Some(ev) = ev0 {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
@@ -285,9 +325,11 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
@@ -299,12 +341,19 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
if vsync_tick {
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.on_vsync();
|
||||
}
|
||||
}
|
||||
stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
@@ -315,8 +364,12 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut fed,
|
||||
&mut oversized_dropped,
|
||||
&mut part_open,
|
||||
&mut queued_stamps,
|
||||
&mut gate,
|
||||
);
|
||||
let had_output = !ready.is_empty();
|
||||
let rendered_before = rendered;
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
@@ -324,16 +377,92 @@ pub(super) fn run_async(
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
&mut queued_stamps,
|
||||
&meter,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut presenter,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
|
||||
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
// CIRCULAR mean + coherence of the ARRIVAL lead — each AU's reassembly stamp
|
||||
// against the panel's latch grid — because arrival is the phase the host actually
|
||||
// controls; the v2 latch statistic measured downstream of the decoder pipeline,
|
||||
// which absorbed the actuation (on-glass 2026-07-31). Timestamps convert
|
||||
// monotonic→realtime→host — the skew offset lives client-side.
|
||||
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
|
||||
let period = c.panel_period_ns().max(c.period_ns());
|
||||
if period > 0 {
|
||||
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
|
||||
let mono_now = now_monotonic_ns();
|
||||
let real_now = now_realtime_ns();
|
||||
let leads_us: Vec<u64> = arrival_stamps
|
||||
.iter()
|
||||
.map(|&r_ns| {
|
||||
let arrival_mono = mono_now as i128 - (real_now - r_ns);
|
||||
((t.expected_present_ns as i128 - arrival_mono)
|
||||
.rem_euclid(period as i128)
|
||||
/ 1000) as u64
|
||||
})
|
||||
.collect();
|
||||
arrival_stamps.clear();
|
||||
if let Some((lead_mean_ns, coherence)) =
|
||||
punktfunk_core::phase::circular_latch(&leads_us, period)
|
||||
{
|
||||
log::info!(
|
||||
target: "pf.phase",
|
||||
"arrival lead circ={:.2}ms coh={}",
|
||||
lead_mean_ns as f64 / 1e6,
|
||||
coherence
|
||||
);
|
||||
let latch_real_ns =
|
||||
real_now + (t.expected_present_ns - mono_now) as i128;
|
||||
let latch_host_ns = (latch_real_ns
|
||||
+ clock_offset.load(Ordering::Relaxed) as i128)
|
||||
.max(0) as u64;
|
||||
client.report_phase(
|
||||
latch_host_ns,
|
||||
period.clamp(0, u32::MAX as i64) as u32,
|
||||
1_000_000, // skew residual — conservative 1 ms
|
||||
lead_mean_ns.min(u32::MAX as u64) as u32,
|
||||
coherence,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let presented_now = rendered > rendered_before;
|
||||
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
|
||||
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
|
||||
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
|
||||
if had_output && vsync.is_none() {
|
||||
if let Some(tx) = vsync_tx.take() {
|
||||
vsync = VsyncClock::start(
|
||||
panel_hz,
|
||||
Box::new(move || {
|
||||
let _ = tx.send(DecodeEvent::Vsync);
|
||||
}),
|
||||
);
|
||||
if vsync.is_none() {
|
||||
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
if had_output {
|
||||
if presented_now {
|
||||
if !hint_tried {
|
||||
hint_tried = true;
|
||||
let tids = client.hot_thread_ids();
|
||||
@@ -420,6 +549,10 @@ pub(super) fn run_async(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.release_all(&codec); // hand every held output buffer back before the codec stops
|
||||
}
|
||||
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
|
||||
let _ = codec.stop();
|
||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||
if let Some(j) = feeder {
|
||||
@@ -448,6 +581,9 @@ fn feeder_loop(
|
||||
) {
|
||||
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
|
||||
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
|
||||
let mut last_phase_ack: Option<i32> = None;
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
@@ -455,11 +591,15 @@ fn feeder_loop(
|
||||
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
// Slice-progressive parts repeat their AU's index — note it once, on the
|
||||
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||
// counting AUs.
|
||||
let au_first = frame.part.is_none_or(|p| p.first);
|
||||
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
if stats.enabled() || measure_decode {
|
||||
if (stats.enabled() || measure_decode) && frame.complete {
|
||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||
// here would fold the hand-off queue wait into the network latency figure
|
||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
||||
@@ -482,7 +622,10 @@ fn feeder_loop(
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
// On a parts stream the completing delivery carries only the AU's
|
||||
// suffix — its offset restores the full AU byte count for bitrate.
|
||||
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
|
||||
stats.note_received(au_len, lat_us, clock_offset != 0);
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
@@ -490,6 +633,17 @@ fn feeder_loop(
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
// Phase-lock closed-loop readout: the host's applied hold rides the
|
||||
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
|
||||
// once a second). None = a host without the tail (pre-phase-lock).
|
||||
if t.applied_phase_ns != last_phase_ack {
|
||||
log::info!(
|
||||
target: "pf.phase",
|
||||
"host applied_phase={:?}us",
|
||||
t.applied_phase_ns.map(|n| n / 1000)
|
||||
);
|
||||
last_phase_ack = t.applied_phase_ns;
|
||||
}
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
@@ -520,9 +674,11 @@ fn dispatch_event(
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
fmt_dirty: &mut bool,
|
||||
vsync_tick: &mut bool,
|
||||
fatal: &mut bool,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
arrival_stamps: &mut Vec<i128>,
|
||||
) -> bool {
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
@@ -531,9 +687,27 @@ fn dispatch_event(
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||
if f.complete {
|
||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
}
|
||||
// Phase-lock v3 sensor: the ARRIVAL stamp (reassembly completion, realtime) — the
|
||||
// phase the host actually controls. The latch-based v2 sensor measured downstream
|
||||
// of the decoder pipeline, which absorbed the host's actuation (on-glass 07-31).
|
||||
// Phase sensor: AU completion is the arrival the host's hold actually moves —
|
||||
// prefix parts would smear the phase toward the first slice's landing.
|
||||
if f.complete {
|
||||
arrival_stamps.push(if f.received_ns > 0 {
|
||||
f.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
});
|
||||
if arrival_stamps.len() > 256 {
|
||||
arrival_stamps.remove(0);
|
||||
}
|
||||
}
|
||||
pending_aus.push_back(f);
|
||||
if pending_aus.len() > FRAME_PARK_CAP {
|
||||
@@ -552,6 +726,7 @@ fn dispatch_event(
|
||||
decoded_ns,
|
||||
}),
|
||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||
DecodeEvent::Vsync => *vsync_tick = true,
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
*fatal = true;
|
||||
@@ -565,10 +740,36 @@ fn dispatch_event(
|
||||
false
|
||||
}
|
||||
|
||||
/// `AMEDIACODEC_BUFFER_FLAG_PARTIAL_FRAME` (NDK ≥ 26, gated by the Kotlin
|
||||
/// `FEATURE_PartialFrame` probe): this input buffer is a PIECE of an AU — the codec assembles
|
||||
/// pieces until a buffer WITHOUT the flag closes the AU.
|
||||
const BUFFER_FLAG_PARTIAL_FRAME: u32 = 8;
|
||||
|
||||
/// The slice-progressive feed's open access unit: parts already queued into the codec under
|
||||
/// [`BUFFER_FLAG_PARTIAL_FRAME`], awaiting the rest. Loop-local — a codec rebuild tears the
|
||||
/// whole loop down, so the state can never outlive the codec instance it fed.
|
||||
pub(super) struct PartFeed {
|
||||
index: u32,
|
||||
/// The AU byte offset the next part must carry — a mismatch means the hand-off dropped a
|
||||
/// piece (memory cap / jump-to-live clear) and the AU is unrecoverable.
|
||||
expected: usize,
|
||||
/// The dead-close pts: an abandoned AU is CLOSED with an empty non-PARTIAL buffer at its
|
||||
/// own pts — the codec then emits (concealed garbage) at that pts, which the reanchor
|
||||
/// freeze gate withholds from glass while the keyframe request recovers the chain. No
|
||||
/// mid-stream codec flush needed.
|
||||
pts_us: u64,
|
||||
}
|
||||
|
||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
||||
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||
///
|
||||
/// Slice-progressive deliveries ([`Frame::part`]) feed as they arrive: every piece rides
|
||||
/// [`BUFFER_FLAG_PARTIAL_FRAME`] except the AU's last, all at the AU's pts. `part_open` is the
|
||||
/// continuity ledger — any break (gap, orphan, oversize) abandons the AU per [`PartFeed::pts_us`]'s
|
||||
/// close contract and re-syncs at the next `first`.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; the split ledger threads through like the gate
|
||||
fn feed_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
@@ -576,11 +777,50 @@ fn feed_ready(
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
fed: &mut u64,
|
||||
oversized_dropped: &mut u64,
|
||||
part_open: &mut Option<PartFeed>,
|
||||
queued_stamps: &mut VecDeque<(u64, i128)>,
|
||||
gate: &mut ReanchorGate,
|
||||
) {
|
||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||
let idx = free_inputs.pop_front().unwrap();
|
||||
let frame = pending_aus.pop_front().unwrap();
|
||||
let pts_us = frame.pts_ns / 1000;
|
||||
let (first, last, offset) = match frame.part {
|
||||
None => (true, true, 0usize),
|
||||
Some(p) => (p.first, p.last, p.offset as usize),
|
||||
};
|
||||
// Continuity ledger. `continues` = this piece extends the open AU exactly;
|
||||
// anything else with an AU open means that AU died mid-flight and must be closed
|
||||
// (empty non-PARTIAL buffer at ITS pts) before this frame may touch the codec.
|
||||
let continues = part_open
|
||||
.as_ref()
|
||||
.is_some_and(|o| frame.frame_index == o.index && offset == o.expected && !first);
|
||||
if !continues {
|
||||
if let Some(o) = part_open.take() {
|
||||
// Spend THIS slot on the close; the current frame re-queues for the next one.
|
||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, 0, o.pts_us, 0) {
|
||||
log::warn!("decode: close of abandoned partial AU {}: {e}", o.index);
|
||||
}
|
||||
log::warn!(
|
||||
"decode: partial AU {} abandoned mid-feed — closed empty, requesting keyframe",
|
||||
o.index
|
||||
);
|
||||
// The close makes the codec emit concealed garbage at the dead pts — freeze it
|
||||
// off the glass until the recovery keyframe re-anchors.
|
||||
gate.arm(Instant::now());
|
||||
let _ = client.request_keyframe();
|
||||
pending_aus.push_front(frame);
|
||||
continue;
|
||||
}
|
||||
// No AU open: an orphan non-first piece lost its head upstream — discard and
|
||||
// re-sync at the next `first` (the recovery request rides the same loss).
|
||||
if !first {
|
||||
free_inputs.push_front(idx);
|
||||
gate.arm(Instant::now());
|
||||
let _ = client.request_keyframe();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
@@ -597,6 +837,16 @@ fn feed_ready(
|
||||
*oversized_dropped
|
||||
);
|
||||
let _ = client.request_keyframe();
|
||||
if frame.part.is_some() {
|
||||
gate.arm(Instant::now());
|
||||
// Pieces already queued can't be unqueued: poison the ledger so the next
|
||||
// delivery mismatches and takes the close-empty path above.
|
||||
*part_open = Some(PartFeed {
|
||||
index: frame.frame_index,
|
||||
expected: usize::MAX,
|
||||
pts_us,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let n = au.len();
|
||||
@@ -605,21 +855,51 @@ fn feed_ready(
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||
}
|
||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
|
||||
let flags = if last { 0 } else { BUFFER_FLAG_PARTIAL_FRAME };
|
||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, flags) {
|
||||
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
||||
if frame.part.is_some() && !last {
|
||||
// The piece never reached the codec — same unrecoverable-AU shape as oversize.
|
||||
*part_open = Some(PartFeed {
|
||||
index: frame.frame_index,
|
||||
expected: usize::MAX,
|
||||
pts_us,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
*fed += 1;
|
||||
// `fed` counts ACCESS UNITS toward the HUD's fed/decoded balance — the closing
|
||||
// piece (or a whole AU) bumps it. The queued stamp marks the same instant (the AU
|
||||
// is fully in the codec's hands): the P3 decode split measures `codec` from here,
|
||||
// so a slice-progressive head start shows up as codec-pure shrink.
|
||||
if last {
|
||||
*fed += 1;
|
||||
queued_stamps.push_back((pts_us, now_realtime_ns()));
|
||||
if queued_stamps.len() > IN_FLIGHT_CAP {
|
||||
queued_stamps.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
}
|
||||
*part_open = if last {
|
||||
None
|
||||
} else {
|
||||
Some(PartFeed {
|
||||
index: frame.frame_index,
|
||||
expected: offset + n,
|
||||
pts_us,
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
||||
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
||||
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
||||
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
||||
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
||||
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
||||
/// drained.
|
||||
/// Route the ready outputs toward glass, recording each one's decode-split + e2e first. With the
|
||||
/// timeline presenter (default): fold each output
|
||||
/// through the re-anchor gate in pts order, hand the approved ones to the presenter's store
|
||||
/// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and
|
||||
/// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop):
|
||||
/// present only the NEWEST ready output immediately and release the rest unrendered — the
|
||||
/// original policy. Every dequeued buffer, rendered or not, is the HUD's `decoded` measurement
|
||||
/// point (it finished decoding either way); samples are recorded in pts order so the receipt-map
|
||||
/// eviction stays monotonic. `ready` is drained.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||
fn present_ready(
|
||||
codec: &MediaCodec,
|
||||
@@ -628,8 +908,11 @@ fn present_ready(
|
||||
ready: &mut Vec<OutputReady>,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
queued_stamps: &mut VecDeque<(u64, i128)>,
|
||||
meter: &PresentMeter,
|
||||
clock_offset: i64,
|
||||
tracker: &DisplayTracker,
|
||||
presenter: &mut Option<Presenter>,
|
||||
rendered: &mut u64,
|
||||
discarded: &mut u64,
|
||||
gate: &mut ReanchorGate,
|
||||
@@ -638,50 +921,85 @@ fn present_ready(
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
|
||||
// while visible) — both consume the receipt map, so enter for either.
|
||||
if stats.enabled() || measure_decode {
|
||||
// Pair each output's decode stage (the ABR decode signal + the HUD histogram consume the
|
||||
// receipt map; the P3 split's codec-pure half needs only the queued stamp, so it records
|
||||
// even with both off — that keeps the 1 Hz pf.present mirror HUD-off readable).
|
||||
{
|
||||
let want_stage = stats.enabled() || measure_decode;
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
);
|
||||
let received_ns = if want_stage {
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let queued = take_stamp(queued_stamps, o.pts_us);
|
||||
let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64);
|
||||
let feed_us = match (queued, received_ns) {
|
||||
(Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64),
|
||||
_ => None,
|
||||
};
|
||||
// Always-on e2e for the 1 Hz pf.present mirror (same formula + clamp as the HUD's
|
||||
// capture→decoded headline in `note_decoded_pts`).
|
||||
let e2e_ns = o.decoded_ns + clock_offset as i128 - o.pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
meter.note_decode(feed_us, codec_us, e2e_us);
|
||||
if let Some(c) = codec_us {
|
||||
stats.note_decode_split(feed_us, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||
// so the two-mark re-anchor count stays correct; a `false` verdict is withheld concealment (the
|
||||
// SurfaceView keeps the last rendered frame frozen on).
|
||||
let now = Instant::now();
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
if stats.enabled() {
|
||||
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
for o in ready.drain(..) {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
|
||||
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
|
||||
skipped += dropped;
|
||||
*discarded += dropped;
|
||||
} else {
|
||||
if let Err(e) = codec.release_output_buffer_by_index(o.index, false) {
|
||||
log::warn!("decode: release_output_buffer_by_index({}): {e}", o.index);
|
||||
}
|
||||
}
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
o.index
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let last = ready.len() - 1;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
tracker.note_rendered(o.pts_us, o.decoded_ns, now_realtime_ns());
|
||||
}
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
o.index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,32 +35,37 @@ pub(super) struct DisplayTracker {
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||
/// callback early-outs) while the overlay is hidden.
|
||||
rendered: Mutex<VecDeque<(u64, i128)>>,
|
||||
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
|
||||
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
/// `(pts_us, decoded_real_ns, released_real_ns)` of frames released with `render = true`, in
|
||||
/// release order, awaiting their callback. Pushed on EVERY render (no HUD gate — the ring is
|
||||
/// a 64-tuple bound and the latch metric wants to exist when nobody is watching).
|
||||
rendered: Mutex<VecDeque<(u64, i128, i128)>>,
|
||||
}
|
||||
|
||||
impl DisplayTracker {
|
||||
pub(super) fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
meter,
|
||||
rendered: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
||||
/// Caller gates on the HUD being visible.
|
||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
||||
/// Park one just-rendered frame's `(pts, decoded stamp, release stamp)` for the render
|
||||
/// callback to pair — the release stamp is the latch metric's start (release→displayed).
|
||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128, released_ns: i128) {
|
||||
let mut g = self
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.push_back((pts_us, decoded_ns));
|
||||
g.push_back((pts_us, decoded_ns, released_ns));
|
||||
if g.len() > RENDERED_CAP {
|
||||
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
||||
}
|
||||
@@ -152,36 +157,42 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
|
||||
// the codec exists, and the codec is what delivers this call.
|
||||
let t = unsafe { &*(userdata as *const DisplayTracker) };
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
||||
}
|
||||
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
||||
let pts_us = media_time_us.max(0) as u64;
|
||||
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
||||
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
||||
// discipline as `note_decoded_pts`.
|
||||
let mut decoded_ns = None;
|
||||
// dropped by the platform) — same monotonic-eviction discipline as `note_decoded_pts`.
|
||||
let mut paired = None;
|
||||
{
|
||||
let mut g = t
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while let Some(&(p, d)) = g.front() {
|
||||
while let Some(&(p, d, r)) = g.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own callback
|
||||
}
|
||||
g.pop_front();
|
||||
if p == pts_us {
|
||||
decoded_ns = Some(d);
|
||||
paired = Some((d, r));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
|
||||
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
|
||||
// window), and one such sample would poison every max/percentile it lands in.
|
||||
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
|
||||
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
|
||||
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||
t.meter.note_latch(latch_us);
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the skew math + the stats lock
|
||||
}
|
||||
let e2e_ns =
|
||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us);
|
||||
t.stats.note_displayed(e2e_us, display_us, latch_us);
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
|
||||
@@ -20,7 +20,8 @@ pub(super) fn now_realtime_ns() -> i128 {
|
||||
/// entries older than it are evicted (decode order == input order here — low-latency, no
|
||||
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||
/// stamp (async loop).
|
||||
/// stamp (async loop). Returns the receipt stamp it paired (if any) so the caller can split the
|
||||
/// `decode` stage further (feed wait vs codec-pure) without re-walking the map.
|
||||
pub(super) fn note_decoded_pts(
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
@@ -29,7 +30,7 @@ pub(super) fn note_decoded_pts(
|
||||
clock_offset: i64,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) {
|
||||
) -> Option<i128> {
|
||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||
let mut received_ns = None;
|
||||
while let Some(&(p, r)) = in_flight.front() {
|
||||
@@ -61,6 +62,24 @@ pub(super) fn note_decoded_pts(
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
received_ns
|
||||
}
|
||||
|
||||
/// The queued-instant stamp for a decoded output, keyed by the echoed `presentationTimeUs` — the
|
||||
/// same monotonic evict-as-you-go pairing as [`take_flags`], over an `(pts_us, realtime_ns)` map
|
||||
/// (the feed side stamps each AU as its last piece enters the codec). A miss returns `None` —
|
||||
/// the split is simply not recorded for that frame.
|
||||
pub(super) fn take_stamp(map: &mut VecDeque<(u64, i128)>, pts_us: u64) -> Option<i128> {
|
||||
while let Some(&(p, t)) = map.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
map.pop_front();
|
||||
if p == pts_us {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
mod async_loop;
|
||||
mod display;
|
||||
mod latency;
|
||||
mod presenter;
|
||||
mod setup;
|
||||
mod sync_loop;
|
||||
mod vsync;
|
||||
|
||||
use async_loop::run_async;
|
||||
pub(crate) use setup::{codec_label, codec_mime};
|
||||
@@ -106,6 +108,17 @@ pub(crate) struct DecodeOptions {
|
||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||
pub is_tv: bool,
|
||||
/// The user's presentation intent (`present_priority` setting): 0 = lowest latency
|
||||
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
|
||||
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
|
||||
pub present_priority: i32,
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
|
||||
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
|
||||
//!
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
//! identical to the legacy path — and only the budget prediction uses the measured period.
|
||||
//!
|
||||
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
|
||||
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
|
||||
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
|
||||
//! (off = the synchronous pre-overhaul loop, no presenter at all).
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::display::DisplayTracker;
|
||||
use super::latency::now_realtime_ns;
|
||||
use super::vsync::VsyncShared;
|
||||
|
||||
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
|
||||
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
|
||||
/// present). A present closer than this is treated as missed and the next one is targeted. This
|
||||
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
|
||||
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
|
||||
/// vsync later, the exact cost the deadline gate paid on every frame.
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
/// setprop + stream restart, no rebuild. Unset/invalid = `None` = the adaptive default.
|
||||
fn latch_margin_ns() -> Option<i64> {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.latch_margin_us".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
if n > 0 {
|
||||
if let Ok(us) = std::str::from_utf8(&buf[..n as usize])
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
{
|
||||
if (0..=8_000).contains(&us) {
|
||||
return Some(us * 1_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
|
||||
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
|
||||
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) enum PresentPriority {
|
||||
/// Newest-wins, release the instant the budget opens. The default.
|
||||
Latency,
|
||||
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
|
||||
/// added display latency per slot, which the metrics show rather than hide.
|
||||
Smooth { buffer: usize },
|
||||
}
|
||||
|
||||
impl PresentPriority {
|
||||
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
|
||||
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
|
||||
if priority != 1 {
|
||||
return PresentPriority::Latency;
|
||||
}
|
||||
let b = if (1..=3).contains(&buffer) {
|
||||
buffer as usize
|
||||
} else {
|
||||
2
|
||||
};
|
||||
PresentPriority::Smooth { buffer: b }
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded output buffer held for presentation.
|
||||
struct HeldFrame {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
|
||||
decoded_ns: i128,
|
||||
}
|
||||
|
||||
/// The one-in-flight glass budget.
|
||||
struct InFlight {
|
||||
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
|
||||
/// `release + period` on the fallback path.
|
||||
reopen_at_ns: i64,
|
||||
released_at_ns: i64,
|
||||
}
|
||||
|
||||
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
|
||||
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
latch_us: Vec<u64>,
|
||||
displays: u64,
|
||||
/// The `decode` stage's feed split, received→queued µs (P3 science: hand-off + input-slot
|
||||
/// wait). Empty when no receipt stamp matched (HUD off and ABR not measuring decode).
|
||||
feed_us: Vec<u64>,
|
||||
/// The codec-pure half, queued→decoded µs, measured from the AU's LAST piece — always on,
|
||||
/// so a HUD-off logcat A/B still reads the decoder's own time.
|
||||
codec_us: Vec<u64>,
|
||||
/// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason:
|
||||
/// the wireless A/B's headline without having to reach the on-screen HUD.
|
||||
e2e_us: Vec<u64>,
|
||||
}
|
||||
|
||||
impl PresentMeter {
|
||||
pub(super) fn new() -> PresentMeter {
|
||||
PresentMeter {
|
||||
inner: Mutex::new(PresentMeterInner {
|
||||
latch_us: Vec::with_capacity(256),
|
||||
displays: 0,
|
||||
feed_us: Vec::with_capacity(256),
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.displays += 1;
|
||||
if let Some(l) = latch_us {
|
||||
if g.latch_us.len() < 4096 {
|
||||
g.latch_us.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
pub(super) fn note_decode(
|
||||
&self,
|
||||
feed_us: Option<u64>,
|
||||
codec_us: Option<u64>,
|
||||
e2e_us: Option<u64>,
|
||||
) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = feed_us {
|
||||
if g.feed_us.len() < 4096 {
|
||||
g.feed_us.push(f);
|
||||
}
|
||||
}
|
||||
if let Some(c) = codec_us {
|
||||
if g.codec_us.len() < 4096 {
|
||||
g.codec_us.push(c);
|
||||
}
|
||||
}
|
||||
if let Some(e) = e2e_us {
|
||||
if g.e2e_us.len() < 4096 {
|
||||
g.e2e_us.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise
|
||||
fn drain(&self) -> (Vec<u64>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let displays = g.displays;
|
||||
g.displays = 0;
|
||||
(
|
||||
std::mem::take(&mut g.latch_us),
|
||||
displays,
|
||||
std::mem::take(&mut g.feed_us),
|
||||
std::mem::take(&mut g.codec_us),
|
||||
std::mem::take(&mut g.e2e_us),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
|
||||
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
|
||||
if v.is_empty() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
v.sort_unstable();
|
||||
let p50 = v[v.len() / 2] as f64 / 1000.0;
|
||||
let max = *v.last().unwrap() as f64 / 1000.0;
|
||||
(p50, max)
|
||||
}
|
||||
|
||||
pub(super) struct Presenter {
|
||||
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
|
||||
fifo_capacity: usize,
|
||||
frames: VecDeque<HeldFrame>,
|
||||
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
|
||||
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
|
||||
prerolled: bool,
|
||||
inflight: Option<InFlight>,
|
||||
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
|
||||
vsync_tick: bool,
|
||||
// -- 1 Hz pf-present window, always on --
|
||||
released: u64,
|
||||
paced_drops: u64,
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
/// release with NO lead on the NP3 — the fixed 2.5 ms was pure display latency) and
|
||||
/// widens by measurement: `paced` misses in a 1 Hz window push it +500 µs toward
|
||||
/// [`LATCH_MARGIN_NS`], the pre-sweep known-safe ceiling. A sysprop override PINS it.
|
||||
margin_ns: i64,
|
||||
/// `debug.punktfunk.latch_margin_us` was set — the margin is pinned, never adapted.
|
||||
margin_pinned: bool,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
pub(super) fn new(priority: PresentPriority) -> Presenter {
|
||||
let pinned = latch_margin_ns();
|
||||
let (margin_ns, margin_pinned) = match pinned {
|
||||
Some(ns) => (ns, true),
|
||||
None => (0, false),
|
||||
};
|
||||
log::info!(
|
||||
"presenter: latch margin {}us{}",
|
||||
margin_ns / 1_000,
|
||||
if margin_pinned {
|
||||
" (debug.punktfunk.latch_margin_us pin)"
|
||||
} else {
|
||||
" (adaptive — widens on latch misses)"
|
||||
}
|
||||
);
|
||||
Presenter {
|
||||
fifo_capacity: match priority {
|
||||
PresentPriority::Latency => 0,
|
||||
PresentPriority::Smooth { buffer } => buffer,
|
||||
},
|
||||
frames: VecDeque::new(),
|
||||
prerolled: false,
|
||||
inflight: None,
|
||||
vsync_tick: false,
|
||||
released: 0,
|
||||
paced_drops: 0,
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
margin_pinned,
|
||||
}
|
||||
}
|
||||
|
||||
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
|
||||
/// FIFO's drain pace.
|
||||
pub(super) fn on_vsync(&mut self) {
|
||||
self.vsync_tick = true;
|
||||
}
|
||||
|
||||
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
|
||||
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
|
||||
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
|
||||
pub(super) fn submit(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) -> u64 {
|
||||
let mut dropped = 0u64;
|
||||
if self.fifo_capacity == 0 {
|
||||
while let Some(stale) = self.frames.pop_front() {
|
||||
release_unrendered(codec, stale.index);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
self.frames.push_back(HeldFrame {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
});
|
||||
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
|
||||
if let Some(stale) = self.frames.pop_front() {
|
||||
release_unrendered(codec, stale.index);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
self.paced_drops += dropped;
|
||||
dropped
|
||||
}
|
||||
|
||||
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
|
||||
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
|
||||
/// `true` when a frame was released to glass this call.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
|
||||
pub(super) fn pump(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
|
||||
if let Some(f) = &self.inflight {
|
||||
if now_mono_ns >= f.reopen_at_ns {
|
||||
self.inflight = None;
|
||||
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
|
||||
self.forced += 1;
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
} else {
|
||||
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
|
||||
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
|
||||
// the previous frame persists on glass, a repeat by omission, while headroom
|
||||
// rebuilds). Everything is gated on the tick so an idle stream neither counts
|
||||
// underflows nor churns the preroll flag 200×/s.
|
||||
if !self.vsync_tick {
|
||||
return false;
|
||||
}
|
||||
if !self.prerolled {
|
||||
if self.frames.len() < self.fifo_capacity {
|
||||
return false;
|
||||
}
|
||||
self.prerolled = true;
|
||||
}
|
||||
if self.frames.is_empty() {
|
||||
self.prerolled = false;
|
||||
self.dry += 1;
|
||||
self.vsync_tick = false; // this tick's drain ran (and found nothing)
|
||||
return false;
|
||||
}
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
_ => self.frames.push_front(frame),
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Release: timeline-timed when the clock has one, ASAP otherwise.
|
||||
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
|
||||
let released = match target {
|
||||
Some(t) => codec
|
||||
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
|
||||
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
|
||||
None => codec
|
||||
.release_output_buffer_by_index(frame.index, true)
|
||||
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
|
||||
};
|
||||
self.vsync_tick = false;
|
||||
if released.is_err() {
|
||||
return false; // the buffer is gone either way; nothing to book-keep
|
||||
}
|
||||
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
|
||||
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
|
||||
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
|
||||
// next release can target the NEXT refresh. Not the platform `deadline` (with the
|
||||
// aggressive present gate it can already be in the past — an instant reopen would let
|
||||
// two releases pile onto the same vsync) and not the present time itself (a period too
|
||||
// late — it would cap the sustainable rate at roughly half the panel).
|
||||
let reopen_at_ns = target
|
||||
.map(|t| t.expected_present_ns - self.margin_ns)
|
||||
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
|
||||
self.inflight = Some(InFlight {
|
||||
reopen_at_ns,
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
self.pace_us.push(pace_us);
|
||||
}
|
||||
stats.note_release(pace_us);
|
||||
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
|
||||
true
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
release_unrendered(codec, f.index);
|
||||
}
|
||||
self.inflight = None;
|
||||
}
|
||||
|
||||
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
///
|
||||
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
|
||||
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
|
||||
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
|
||||
pub(super) fn flush_log(
|
||||
&mut self,
|
||||
meter: &PresentMeter,
|
||||
clock: Option<&VsyncShared>,
|
||||
) -> Option<(u64, u16)> {
|
||||
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
|
||||
return None;
|
||||
}
|
||||
self.last_flush = Instant::now();
|
||||
let (latch, displays, feed, codec, e2e) = meter.drain();
|
||||
if self.released == 0 && displays == 0 {
|
||||
return None; // idle stream — nothing worth a line
|
||||
}
|
||||
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
|
||||
let (feed_p50, feed_max) = p50_max_ms(feed);
|
||||
let (codec_p50, codec_max) = p50_max_ms(codec);
|
||||
let (e2e_p50, e2e_max) = p50_max_ms(e2e);
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
vsyncMs={:.2} panelMs={:.2}",
|
||||
self.released,
|
||||
displays,
|
||||
self.paced_drops,
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
latch_max,
|
||||
feed_p50,
|
||||
feed_max,
|
||||
codec_p50,
|
||||
codec_max,
|
||||
e2e_p50,
|
||||
e2e_max,
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ms,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||
self.paced_drops,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
fn release_unrendered(codec: &MediaCodec, index: usize) {
|
||||
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
|
||||
log::warn!("presenter: release_output_buffer({index}, false): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
|
||||
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
|
||||
pub(super) fn presenter_disabled_by_sysprop() -> bool {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.presenter".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
n > 0 && &buf[..n as usize] == b"arrival"
|
||||
}
|
||||
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
|
||||
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
||||
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
||||
/// phone. Falls through to the 2-arg hint on API 30.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
||||
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
|
||||
/// phones/tablets and the universal fallback.
|
||||
///
|
||||
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
|
||||
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
|
||||
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
|
||||
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
|
||||
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
|
||||
///
|
||||
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
||||
/// decline.
|
||||
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
if lib.is_null() {
|
||||
return false;
|
||||
}
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
||||
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
|
||||
const FIXED_SOURCE: i8 = 1;
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
|
||||
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
if is_tv {
|
||||
let sym = libc::dlsym(
|
||||
lib,
|
||||
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
);
|
||||
if !sym.is_null() {
|
||||
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
|
||||
}
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
return false; // device API < 30 — no per-surface frame-rate hint
|
||||
}
|
||||
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ pub(super) fn run_sync(
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
|
||||
present_priority: _,
|
||||
smooth_buffer: _,
|
||||
panel_hz: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -181,7 +185,11 @@ pub(super) fn run_sync(
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
std::sync::Arc::new(super::presenter::PresentMeter::new()),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||
@@ -598,7 +606,7 @@ fn drain(
|
||||
Ok(()) if held_present => {
|
||||
rendered = 1;
|
||||
if let Some((pts_us, decoded_ns)) = meta {
|
||||
tracker.note_rendered(pts_us, decoded_ns);
|
||||
tracker.note_rendered(pts_us, decoded_ns, super::latency::now_realtime_ns());
|
||||
}
|
||||
}
|
||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! The vsync clock behind the timeline presenter: an `AChoreographer` thread publishing the
|
||||
//! panel's vsync grid + upcoming frame timelines, and pulsing the decode loop's event channel so
|
||||
//! a frame parked on a closed glass budget gets its retry tick.
|
||||
//!
|
||||
//! On API 33+ the thread rides `AChoreographer_postVsyncCallback`, whose callback payload carries
|
||||
//! the platform's FRAME TIMELINES — for each upcoming refresh, when SurfaceFlinger expects to
|
||||
//! present and the deadline by which a frame must be submitted to make it. That pair is exactly
|
||||
//! what `AMediaCodec_releaseOutputBufferAtTime` wants as its target. On 31/32 the older
|
||||
//! `postFrameCallback64` supplies only the vsync instant; the presenter then releases ASAP
|
||||
//! (identical to the legacy path) and uses the measured period purely to predict the latch for
|
||||
//! its glass budget.
|
||||
//!
|
||||
//! Every `AChoreographer_*` symbol is dlsym-resolved from `libandroid.so` (mirrors
|
||||
//! [`super::setup::try_set_frame_rate`]): several sit above the crate's API floor, and one hard
|
||||
//! import of a too-new symbol fails `System.loadLibrary` on every older device.
|
||||
//!
|
||||
//! Started LAZILY on the first decoded frame (the Apple deadline presenter's bootstrap lesson:
|
||||
//! an eagerly started clock ticks uselessly for the whole connect window), stopped + joined via
|
||||
//! [`VsyncClock`]'s `Drop`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// `CLOCK_MONOTONIC` now in nanoseconds — the clock AChoreographer stamps its timelines on and
|
||||
/// the one `AMediaCodec_releaseOutputBufferAtTime` compares against (`System.nanoTime` basis).
|
||||
/// Distinct from the stats path's `CLOCK_REALTIME`: presenter scheduling stays monotonic.
|
||||
pub(super) fn now_monotonic_ns() -> i64 {
|
||||
let mut ts = libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
}
|
||||
|
||||
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
|
||||
/// frame, and the last instant it can be submitted to make that present. Monotonic ns.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FrameTimeline {
|
||||
pub expected_present_ns: i64,
|
||||
pub deadline_ns: i64,
|
||||
}
|
||||
|
||||
/// State the choreographer thread publishes and the decode loop reads. All monotonic ns.
|
||||
pub(super) struct VsyncShared {
|
||||
stop: AtomicBool,
|
||||
/// The latest vsync callback's frame time (0 = no callback yet).
|
||||
last_vsync_ns: AtomicI64,
|
||||
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
|
||||
///
|
||||
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
|
||||
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
|
||||
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
|
||||
timelines: Mutex<Vec<FrameTimeline>>,
|
||||
}
|
||||
|
||||
impl VsyncShared {
|
||||
/// The measured vsync period, or 0 while unmeasured.
|
||||
pub(super) fn period_ns(&self) -> i64 {
|
||||
self.period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
|
||||
pub(super) fn panel_period_ns(&self) -> i64 {
|
||||
self.panel_period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
|
||||
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
|
||||
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
|
||||
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
|
||||
///
|
||||
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
|
||||
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
|
||||
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
|
||||
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
|
||||
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
|
||||
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
|
||||
/// frame presents one vsync later — exactly what the conservative gate always paid.
|
||||
///
|
||||
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
|
||||
/// at the app's assigned render rate, but the panel latches at its own — when the app is
|
||||
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
|
||||
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
|
||||
/// by whole panel periods (while its present still clears the margin) restores the true
|
||||
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
|
||||
/// a no-op.
|
||||
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
|
||||
let mut t = {
|
||||
let g = self
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let found = g
|
||||
.iter()
|
||||
.find(|t| t.expected_present_ns > now_ns + margin_ns)
|
||||
.copied();
|
||||
match found {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let last = g.last().copied()?;
|
||||
let period = self.period_ns();
|
||||
if period <= 0 {
|
||||
return None;
|
||||
}
|
||||
// All stored timelines have passed — step the last one forward whole
|
||||
// periods until its present clears `now + margin` again.
|
||||
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
|
||||
let k = behind / period + 1;
|
||||
FrameTimeline {
|
||||
expected_present_ns: last.expected_present_ns + k * period,
|
||||
deadline_ns: last.deadline_ns + k * period,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let panel = self.panel_period_ns.load(Ordering::Relaxed);
|
||||
if panel > 0 {
|
||||
while t.expected_present_ns - panel > now_ns + margin_ns {
|
||||
t.deadline_ns -= panel;
|
||||
t.expected_present_ns -= panel;
|
||||
}
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dlsym'd AChoreographer surface ----
|
||||
|
||||
type PostFrameCallback64 =
|
||||
unsafe extern "C" fn(*mut c_void, unsafe extern "C" fn(i64, *mut c_void), *mut c_void);
|
||||
type PostVsyncCallback = unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, *mut c_void),
|
||||
*mut c_void,
|
||||
);
|
||||
|
||||
struct ChoreoApi {
|
||||
get_instance: unsafe extern "C" fn() -> *mut c_void,
|
||||
/// API 33: vsync callback with frame-timeline payload. Preferred.
|
||||
post_vsync: Option<PostVsyncCallback>,
|
||||
/// API 29 fallback: frame callback with only the vsync instant.
|
||||
post_frame64: Option<PostFrameCallback64>,
|
||||
// AChoreographerFrameCallbackData accessors (API 33; present iff `post_vsync` is).
|
||||
fcd_frame_time: Option<unsafe extern "C" fn(*const c_void) -> i64>,
|
||||
fcd_timelines_len: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||
fcd_preferred_index: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||
fcd_expected_present: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||
fcd_deadline: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||
}
|
||||
|
||||
impl ChoreoApi {
|
||||
/// Resolve from `libandroid.so`. `None` when even the baseline symbols are missing.
|
||||
fn resolve() -> Option<ChoreoApi> {
|
||||
// SAFETY: dlopen of the always-mapped libandroid.so (refcount bump, never closed); each
|
||||
// dlsym is null-checked before the transmute to its fn-pointer type.
|
||||
unsafe {
|
||||
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
let sym = |name: &std::ffi::CStr| {
|
||||
let p = libc::dlsym(lib, name.as_ptr());
|
||||
(!p.is_null()).then_some(p)
|
||||
};
|
||||
let get_instance = sym(c"AChoreographer_getInstance")?;
|
||||
let post_vsync = sym(c"AChoreographer_postVsyncCallback");
|
||||
let post_frame64 = sym(c"AChoreographer_postFrameCallback64");
|
||||
post_vsync.or(post_frame64)?; // neither post entry point — no clock on this device
|
||||
Some(ChoreoApi {
|
||||
get_instance: std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn() -> *mut c_void,
|
||||
>(get_instance),
|
||||
post_vsync: post_vsync.map(|p| std::mem::transmute::<*mut c_void, PostVsyncCallback>(p)),
|
||||
post_frame64: post_frame64
|
||||
.map(|p| std::mem::transmute::<*mut c_void, PostFrameCallback64>(p)),
|
||||
fcd_frame_time: sym(c"AChoreographerFrameCallbackData_getFrameTimeNanos").map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> i64>(p)
|
||||
}),
|
||||
fcd_timelines_len: sym(c"AChoreographerFrameCallbackData_getFrameTimelinesLength")
|
||||
.map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(
|
||||
p,
|
||||
)
|
||||
}),
|
||||
fcd_preferred_index: sym(
|
||||
c"AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex",
|
||||
)
|
||||
.map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(p)
|
||||
}),
|
||||
fcd_expected_present: sym(
|
||||
c"AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos",
|
||||
)
|
||||
.map(|p| {
|
||||
std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||
>(p)
|
||||
}),
|
||||
fcd_deadline: sym(c"AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos")
|
||||
.map(|p| {
|
||||
std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||
>(p)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a callback invocation needs. Owned by the choreographer thread's stack; callbacks
|
||||
/// only ever fire inside that thread's looper poll, so the borrow can't outlive the thread.
|
||||
struct CallbackCtx {
|
||||
api: ChoreoApi,
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
/// Common tail of both callback flavours: update the grid estimate, publish, pulse, re-arm.
|
||||
fn tick(&self, frame_time_ns: i64, timelines: Vec<FrameTimeline>) {
|
||||
let prev = self
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
|
||||
let spacing = if timelines.len() >= 2 {
|
||||
timelines[1].expected_present_ns - timelines[0].expected_present_ns
|
||||
} else {
|
||||
0
|
||||
};
|
||||
log::info!(
|
||||
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
|
||||
if prev > 0 {
|
||||
(frame_time_ns - prev) as f64 / 1e6
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
timelines.len(),
|
||||
spacing as f64 / 1e6,
|
||||
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
|
||||
);
|
||||
}
|
||||
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
|
||||
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
|
||||
let mut period = 0i64;
|
||||
if timelines.len() >= 2 {
|
||||
period = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
} else if prev > 0 {
|
||||
period = frame_time_ns - prev;
|
||||
}
|
||||
if (2_000_000..=42_000_000).contains(&period) {
|
||||
let old = self.shared.period_ns.load(Ordering::Relaxed);
|
||||
let smoothed = if old > 0 {
|
||||
(old * 7 + period) / 8
|
||||
} else {
|
||||
period
|
||||
};
|
||||
self.shared.period_ns.store(smoothed, Ordering::Relaxed);
|
||||
}
|
||||
if !timelines.is_empty() {
|
||||
let mut g = self
|
||||
.shared
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*g = timelines;
|
||||
}
|
||||
(self.on_tick)();
|
||||
if !self.shared.stop.load(Ordering::Relaxed) {
|
||||
self.repost();
|
||||
}
|
||||
}
|
||||
|
||||
fn repost(&self) {
|
||||
// SAFETY: `choreographer` is this thread's instance; the ctx pointer stays valid for the
|
||||
// thread's life and callbacks only fire on this thread (see the struct doc).
|
||||
unsafe {
|
||||
let ud = self as *const CallbackCtx as *mut c_void;
|
||||
if let Some(post) = self.api.post_vsync {
|
||||
post(self.choreographer, on_vsync, ud);
|
||||
} else if let Some(post) = self.api.post_frame64 {
|
||||
post(self.choreographer, on_frame64, ud);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API 33+ trampoline: harvest the frame timelines, then the common tick. Panic-free (an unwind
|
||||
/// out of an `extern "C"` fn aborts).
|
||||
unsafe extern "C" fn on_vsync(data: *const c_void, ud: *mut c_void) {
|
||||
// SAFETY: `ud` is the thread's `CallbackCtx`, alive for the whole poll loop (see struct doc).
|
||||
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||
let api = &ctx.api;
|
||||
let (mut frame_time, mut timelines) = (now_monotonic_ns(), Vec::new());
|
||||
// SAFETY: `data` is the platform's callback payload, valid for this invocation; the accessors
|
||||
// were resolved together with `post_vsync` (same API level) and are only called when present.
|
||||
unsafe {
|
||||
if let Some(f) = api.fcd_frame_time {
|
||||
frame_time = f(data);
|
||||
}
|
||||
if let (Some(len_f), Some(pref_f), Some(exp_f), Some(dl_f)) = (
|
||||
api.fcd_timelines_len,
|
||||
api.fcd_preferred_index,
|
||||
api.fcd_expected_present,
|
||||
api.fcd_deadline,
|
||||
) {
|
||||
let len = len_f(data).min(8);
|
||||
// From the PREFERRED index on: earlier timelines are ones the platform already
|
||||
// considers missed for a frame starting now.
|
||||
let start = pref_f(data).min(len);
|
||||
timelines = (start..len)
|
||||
.map(|i| FrameTimeline {
|
||||
expected_present_ns: exp_f(data, i),
|
||||
deadline_ns: dl_f(data, i),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
ctx.tick(frame_time, timelines);
|
||||
}
|
||||
|
||||
/// API 29 fallback trampoline: vsync instant only.
|
||||
unsafe extern "C" fn on_frame64(frame_time_ns: i64, ud: *mut c_void) {
|
||||
// SAFETY: `ud` is the thread's `CallbackCtx` (see `on_vsync`).
|
||||
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||
ctx.tick(frame_time_ns, Vec::new());
|
||||
}
|
||||
|
||||
/// The clock: a dedicated looper thread the choreographer calls back on. Dropping stops + joins.
|
||||
pub(super) struct VsyncClock {
|
||||
shared: Arc<VsyncShared>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
let api = ChoreoApi::resolve()?;
|
||||
let timelines_live = api.post_vsync.is_some();
|
||||
let shared = Arc::new(VsyncShared {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
let thread_shared = shared.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-vsync".into())
|
||||
.spawn(move || {
|
||||
let looper = ndk::looper::ThreadLooper::prepare();
|
||||
// SAFETY: getInstance on a thread with a prepared looper returns this thread's
|
||||
// choreographer (never null once a looper exists).
|
||||
let choreographer = unsafe { (api.get_instance)() };
|
||||
if choreographer.is_null() {
|
||||
log::warn!("vsync: AChoreographer_getInstance returned null — no clock");
|
||||
return;
|
||||
}
|
||||
let ctx = CallbackCtx {
|
||||
api,
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
// case teardown waits one timeout out. Callbacks fire inside poll_once_timeout.
|
||||
while !ctx.shared.stop.load(Ordering::Relaxed) {
|
||||
let _ = looper.poll_once_timeout(Duration::from_millis(250));
|
||||
}
|
||||
// `ctx` drops here — after the loop, so no queued callback can outlive it (they
|
||||
// only ever fire inside this thread's poll).
|
||||
})
|
||||
.ok()?;
|
||||
log::info!(
|
||||
"vsync: choreographer clock started ({})",
|
||||
if timelines_live {
|
||||
"frame timelines"
|
||||
} else {
|
||||
"frame callback fallback"
|
||||
}
|
||||
);
|
||||
Some(VsyncClock {
|
||||
shared,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn shared(&self) -> &Arc<VsyncShared> {
|
||||
&self.shared
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VsyncClock {
|
||||
fn drop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
//! Android microphone uplink (android-only): capture mic PCM via AAudio (LowLatency **input**),
|
||||
//! Opus-encode 20 ms stereo frames, and push them to the host over the connector's mic plane
|
||||
//! Opus-encode 10 ms mono frames, and push them to the host over the connector's mic plane
|
||||
//! (`send_mic` → 0xCB datagram). The mirror of [`crate::audio`] in reverse: AAudio's realtime input
|
||||
//! callback hands captured interleaved f32 to a channel; a worker thread we own does the Opus
|
||||
//! encode + send (encoding is too heavy for the realtime callback, exactly as decode is on the
|
||||
//! playback side). Like the playback path, the realtime callback is allocation-free: captured
|
||||
//! bursts are copied into pre-allocated buffers from a recycle free-list (pool empty = drop the
|
||||
//! chunk, never allocate on the capture thread). Format matches the host decoder + the Linux
|
||||
//! client: 48 kHz **stereo**, 20 ms, Opus VOIP.
|
||||
//! callback hands captured f32 to a channel; a worker thread we own does the Opus encode + send
|
||||
//! (encoding is too heavy for the realtime callback, exactly as decode is on the playback side).
|
||||
//! Like the playback path, the realtime callback is allocation-free: captured bursts are copied
|
||||
//! into pre-allocated buffers from a recycle free-list (pool empty = drop the chunk, never
|
||||
//! allocate on the capture thread). Format: 48 kHz **mono**, 10 ms, Opus VOIP with in-band FEC —
|
||||
//! the host decodes any Opus frame ≤ 120 ms with its stereo decoder (mono packets upmix), so this
|
||||
//! needs no protocol change; speech gains nothing from stereo, and the shorter frame shaves a
|
||||
//! buffering interval off the uplink.
|
||||
//!
|
||||
//! **Mute** is a flag the encode loop reads per 10 ms frame, never a stream teardown: the AAudio
|
||||
//! input stream, the input-preset ladder it settled on and its primed buffers all survive a
|
||||
//! mute/unmute untouched, so toggling costs an atomic load and nothing else.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
||||
AudioStream, AudioStreamBuilder,
|
||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode,
|
||||
AudioSharingMode, AudioStream, AudioStreamBuilder, SessionId,
|
||||
};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::collections::VecDeque;
|
||||
@@ -20,31 +26,57 @@ use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TryS
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CHANNELS: usize = 2;
|
||||
const CHANNELS: usize = 1;
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// 20 ms per channel @ 48 kHz — the Linux client's frame; the host accepts ≤ 120 ms.
|
||||
const FRAME_SAMPLES: usize = 960;
|
||||
/// 10 ms per channel @ 48 kHz — half the desktop clients' 20 ms frame, trading a little Opus
|
||||
/// header overhead for one less buffered interval; the host accepts ≤ 120 ms.
|
||||
const FRAME_SAMPLES: usize = 480;
|
||||
/// Captured-chunk hand-off depth (each ~ one burst); drops on overflow (best-effort uplink).
|
||||
/// Bursts are sized in frames, so the wall-time depth is unchanged by the stereo→mono move.
|
||||
const RING_CHUNKS: usize = 64;
|
||||
/// Free-list buffer capacity, in interleaved f32 samples: comfortably above a LowLatency input
|
||||
/// burst (typically ≤ ~480 frames). A device with larger bursts costs each buffer a one-time grow
|
||||
/// on the capture thread, after which the steady state is allocation-free again.
|
||||
const CHUNK_CAP_SAMPLES: usize = 1920; // 20 ms stereo
|
||||
/// Opus VOIP target bitrate (speech; tunable).
|
||||
const MIC_BITRATE: i32 = 64_000;
|
||||
/// burst (typically ≤ ~480 frames — mono, so samples = frames). A device with larger bursts costs
|
||||
/// each buffer a one-time grow on the capture thread, after which the steady state is
|
||||
/// allocation-free again.
|
||||
const CHUNK_CAP_SAMPLES: usize = 960; // 20 ms mono — the same wall-time as the old stereo value
|
||||
/// Opus VOIP target bitrate (mono speech; tunable).
|
||||
const MIC_BITRATE: i32 = 48_000;
|
||||
/// Encode-side self-heal threshold, in queued 10 ms frames (~60 ms): waking to more than this
|
||||
/// means the uplink stalled — and because the capture callback drops the NEWEST chunk when the
|
||||
/// channel is full, a stall otherwise converts to standing mic delay that never drains (real-time
|
||||
/// playback host-side never makes time back up). Skip to the newest few frames instead.
|
||||
const BACKLOG_MAX_FRAMES: usize = 6;
|
||||
/// What a self-heal keeps: ~20 ms of the freshest audio (one audible blip, live again).
|
||||
const BACKLOG_KEEP_FRAMES: usize = 2;
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio input stream + the encode thread.
|
||||
pub struct MicCapture {
|
||||
_stream: AudioStream, // dropping it stops + closes the AAudio input stream
|
||||
/// The audio-session id AAudio allocated (`> 0`) when echo cancellation asked for one — the
|
||||
/// hook Kotlin hangs the Java `AcousticEchoCanceler`/`NoiseSuppressor` on. `0` = none.
|
||||
session_id: i32,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MicCapture {
|
||||
/// Open AAudio (LowLatency, 48 kHz/stereo/f32) for **input** with a realtime callback that
|
||||
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. `None` on
|
||||
/// failure (the caller leaves the rest of the session streaming).
|
||||
pub fn start(client: Arc<NativeClient>) -> Option<MicCapture> {
|
||||
/// Open AAudio (LowLatency, 48 kHz/mono/f32) for **input** with a realtime callback that
|
||||
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. With
|
||||
/// `echo_cancel` the stream opens under the `VoiceCommunication` input preset — the HAL's own
|
||||
/// echo canceller / noise suppressor on the capture path (the default `VoiceRecognition`
|
||||
/// preset deliberately bypasses them, which is why the host used to hear its own stream back
|
||||
/// from a speaker-playing phone) — and allocates an audio session id for Kotlin's Java-effect
|
||||
/// backstop. `None` on failure (the caller leaves the rest of the session streaming).
|
||||
///
|
||||
/// `muted` is the SESSION's live mic-mute flag (owned by `SessionHandle`, not by this capture),
|
||||
/// honoured per frame by [`encode_loop`]. Sharing it rather than owning it is what makes mute
|
||||
/// survive the mic stop/start a surface recreate performs — and means a capture started while
|
||||
/// muted never encodes its first frame, so there is no window for one to escape.
|
||||
pub fn start(
|
||||
client: Arc<NativeClient>,
|
||||
echo_cancel: bool,
|
||||
muted: Arc<AtomicBool>,
|
||||
) -> Option<MicCapture> {
|
||||
let captured = Arc::new(AtomicU64::new(0));
|
||||
// Chunks discarded on the capture thread (free-list empty / encoder lagging); logged
|
||||
// throttled from the encode worker.
|
||||
@@ -52,7 +84,9 @@ impl MicCapture {
|
||||
|
||||
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
|
||||
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
|
||||
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||
let try_open = |sharing: AudioSharingMode,
|
||||
voice: bool|
|
||||
-> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
Receiver<Vec<f32>>,
|
||||
SyncSender<Vec<f32>>,
|
||||
@@ -99,13 +133,25 @@ impl MicCapture {
|
||||
AudioCallbackResult::Continue
|
||||
};
|
||||
|
||||
let stream = AudioStreamBuilder::new()?
|
||||
// NOTE: no `.frames_per_data_callback(...)`: AAudio's own docs call leaving it unset
|
||||
// the lowest-latency path (the callback then runs at the device's optimal burst,
|
||||
// while pinning a size inserts an adaptation buffer), and the encode side re-chunks
|
||||
// to 10 ms frames regardless of how the bursts arrive.
|
||||
let mut builder = AudioStreamBuilder::new()?
|
||||
.direction(AudioDirection::Input)
|
||||
.sample_rate(SAMPLE_RATE)
|
||||
.channel_count(CHANNELS as i32)
|
||||
.format(AudioFormat::PCM_Float)
|
||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||
.sharing_mode(sharing)
|
||||
.sharing_mode(sharing);
|
||||
if voice {
|
||||
// VoiceCommunication routes the capture through the HAL's AEC/NS; the allocated
|
||||
// session id (`None` = allocate) is what Kotlin attaches the Java effects to.
|
||||
builder = builder
|
||||
.input_preset(AudioInputPreset::VoiceCommunication)
|
||||
.session_id(None);
|
||||
}
|
||||
let stream = builder
|
||||
.data_callback(Box::new(callback))
|
||||
.error_callback(Box::new(|_s, e| {
|
||||
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
|
||||
@@ -114,21 +160,52 @@ impl MicCapture {
|
||||
Ok((stream, rx, free_tx))
|
||||
};
|
||||
|
||||
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to Shared
|
||||
// when the device refuses (no MMAP, mic claimed, …). The started-log below prints the mode
|
||||
// the device actually GRANTED (`share=`).
|
||||
let (stream, rx, free_tx) = match try_open(AudioSharingMode::Exclusive) {
|
||||
Ok(opened) => opened,
|
||||
Err(e) => {
|
||||
log::info!("mic: Exclusive open failed ({e}) — retrying Shared");
|
||||
match try_open(AudioSharingMode::Shared) {
|
||||
Ok(opened) => opened,
|
||||
Err(e) => {
|
||||
log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}");
|
||||
return None;
|
||||
}
|
||||
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to
|
||||
// Shared when the device refuses (no MMAP, mic claimed, …); and each sharing mode with
|
||||
// the voice preset before without it, because some HALs reject VoiceCommunication (or a
|
||||
// session id) outright and a mic without echo cancellation still beats no mic. The
|
||||
// ladder's last rungs are exactly the preset-less open this always did. The started-log
|
||||
// below prints what the device actually GRANTED (`share=`/`session=`).
|
||||
let attempts: &[(AudioSharingMode, bool)] = if echo_cancel {
|
||||
&[
|
||||
(AudioSharingMode::Exclusive, true),
|
||||
(AudioSharingMode::Shared, true),
|
||||
(AudioSharingMode::Exclusive, false),
|
||||
(AudioSharingMode::Shared, false),
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
(AudioSharingMode::Exclusive, false),
|
||||
(AudioSharingMode::Shared, false),
|
||||
]
|
||||
};
|
||||
let mut opened = None;
|
||||
for &(sharing, voice) in attempts {
|
||||
match try_open(sharing, voice) {
|
||||
Ok(o) => {
|
||||
opened = Some(o);
|
||||
break;
|
||||
}
|
||||
Err(e) => log::info!(
|
||||
"mic: open {sharing:?}{} failed ({e}) — trying the next fallback",
|
||||
if voice { "+VoiceCommunication" } else { "" },
|
||||
),
|
||||
}
|
||||
}
|
||||
let (stream, rx, free_tx) = match opened {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
log::error!("mic: open_stream (RECORD_AUDIO granted?): every mode refused");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// The session id AAudio actually allocated (only a voice rung asks for one): `> 0` is the
|
||||
// handle Kotlin hangs the Java AcousticEchoCanceler/NoiseSuppressor off as the HAL
|
||||
// preset's backstop; `0` = none, nothing to attach.
|
||||
let session_id = match stream.session_id() {
|
||||
SessionId::Allocated(id) => id.get(),
|
||||
SessionId::None => 0,
|
||||
};
|
||||
|
||||
if let Err(e) = stream.request_start() {
|
||||
@@ -136,7 +213,7 @@ impl MicCapture {
|
||||
return None;
|
||||
}
|
||||
log::info!(
|
||||
"mic: AAudio input started rate={} ch={} fmt={:?} share={:?}",
|
||||
"mic: AAudio input started rate={} ch={} fmt={:?} share={:?} session={session_id}",
|
||||
stream.sample_rate(),
|
||||
stream.channel_count(),
|
||||
stream.format(),
|
||||
@@ -147,15 +224,21 @@ impl MicCapture {
|
||||
let sd = shutdown.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-mic".into())
|
||||
.spawn(move || encode_loop(client, rx, free_tx, sd, captured, dropped))
|
||||
.spawn(move || encode_loop(client, rx, free_tx, sd, muted, captured, dropped))
|
||||
.ok();
|
||||
|
||||
Some(MicCapture {
|
||||
_stream: stream,
|
||||
session_id,
|
||||
shutdown,
|
||||
join,
|
||||
})
|
||||
}
|
||||
|
||||
/// The audio-session id AAudio allocated (`> 0`; see [`MicCapture::start`]), `0` = none.
|
||||
pub fn session_id(&self) -> i32 {
|
||||
self.session_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MicCapture {
|
||||
@@ -168,20 +251,29 @@ impl Drop for MicCapture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 20 ms stereo frames → `send_mic`.
|
||||
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 10 ms mono frames → `send_mic`.
|
||||
/// Drained chunk buffers go back to the callback's free-list; the encode scratch is reused across
|
||||
/// frames (only the packet Vec handed to `send_mic` is allocated per frame — it's sent away owned).
|
||||
///
|
||||
/// While `muted` is set a formed frame is dropped instead of encoded (see the frame loop) — the
|
||||
/// capture side keeps running exactly as it does unmuted, so nothing about the stream, its ring or
|
||||
/// its backlog behaviour changes across a toggle.
|
||||
fn encode_loop(
|
||||
client: Arc<NativeClient>,
|
||||
rx: Receiver<Vec<f32>>,
|
||||
free_tx: SyncSender<Vec<f32>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
muted: Arc<AtomicBool>,
|
||||
captured: Arc<AtomicU64>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
) {
|
||||
// Fold this Opus-encode/uplink thread into the client's hot-thread set so the ADPF session the
|
||||
// decode thread opens keeps mic encode on a fast core too (the playback side's decode_loop
|
||||
// does the same). No-op below API 33.
|
||||
client.register_hot_thread();
|
||||
let mut enc = match opus::Encoder::new(
|
||||
SAMPLE_RATE as u32,
|
||||
opus::Channels::Stereo,
|
||||
opus::Channels::Mono,
|
||||
opus::Application::Voip,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
@@ -191,13 +283,21 @@ fn encode_loop(
|
||||
}
|
||||
};
|
||||
let _ = enc.set_bitrate(opus::Bitrate::Bits(MIC_BITRATE));
|
||||
// Speech tuning: complexity 5 roughly halves encode cost for no audible loss at this rate,
|
||||
// and in-band FEC at an assumed 10% loss lets the host's decoder reconstruct a dropped
|
||||
// datagram from its successor instead of playing a hole (the uplink is fire-and-forget).
|
||||
let _ = enc.set_complexity(5);
|
||||
let _ = enc.set_inband_fec(true);
|
||||
let _ = enc.set_packet_loss_perc(10);
|
||||
|
||||
let frame = FRAME_SAMPLES * CHANNELS;
|
||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(frame * 4);
|
||||
let mut pcm = vec![0f32; frame]; // reusable encode scratch (one 20 ms frame)
|
||||
let mut out = vec![0u8; 4000]; // max Opus packet for a 20 ms frame fits easily
|
||||
let mut pcm = vec![0f32; frame]; // reusable encode scratch (one 10 ms frame)
|
||||
let mut out = vec![0u8; 4000]; // max Opus packet for a 10 ms frame fits easily
|
||||
let mut seq: u32 = 0;
|
||||
let mut sent: u64 = 0;
|
||||
let mut stale: u64 = 0; // frames shed by the backlog self-heal (see BACKLOG_MAX_FRAMES)
|
||||
let mut muted_frames: u64 = 0; // frames dropped unencoded because the user muted
|
||||
let mut peak = 0f32; // loudest |sample| since the last log — tells speech from silence
|
||||
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
@@ -207,11 +307,41 @@ fn encode_loop(
|
||||
// callback's free-list (dropped only if the pool is momentarily full).
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
// Drain whatever else queued while we were away, so a post-stall backlog lands as
|
||||
// ONE lump the self-heal below can size up — chunk-at-a-time it would be encoded
|
||||
// (and inflicted on the host as standing delay) before it ever looked deep.
|
||||
while let Ok(mut chunk) = rx.try_recv() {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue, // wake to re-check shutdown
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// Self-heal the latency ratchet: a stall (scheduler hiccup, a slow send) queues stale
|
||||
// audio, and every ms of it would ride the stream as mic delay for the rest of the
|
||||
// session. Jump to the newest ~20 ms (one audible blip), counting the shed.
|
||||
if ring.len() > BACKLOG_MAX_FRAMES * frame {
|
||||
let excess = ring.len() - BACKLOG_KEEP_FRAMES * frame;
|
||||
ring.drain(..excess);
|
||||
stale += (excess / frame) as u64;
|
||||
}
|
||||
while ring.len() >= frame {
|
||||
// Muted: drop the frame at the last point before it would become an Opus packet —
|
||||
// room audio is never encoded and nothing goes on the wire. `seq` does NOT advance:
|
||||
// it numbers the datagrams the host de-jitters, and that side reads a seq jump as
|
||||
// loss (conceal + a counted gap) where a mute is a pause. Freezing it means the
|
||||
// frame after an unmute continues the chain, which is what the host's own
|
||||
// `reset_stream` doc calls for and what the desktop uplink does. (Encoding silence
|
||||
// instead would keep a pointless uplink and a host-side ring alive for the whole
|
||||
// mute.) `peak` is the loudest sample the UPLINK carried since the last log, so a
|
||||
// dropped frame resets rather than raises it.
|
||||
if muted.load(Ordering::Relaxed) {
|
||||
ring.drain(..frame);
|
||||
muted_frames += 1;
|
||||
peak = 0.0;
|
||||
continue;
|
||||
}
|
||||
for (dst, src) in pcm.iter_mut().zip(ring.drain(..frame)) {
|
||||
*dst = src;
|
||||
}
|
||||
@@ -227,9 +357,10 @@ fn encode_loop(
|
||||
let _ = client.send_mic(seq, pts, out[..len].to_vec());
|
||||
seq = seq.wrapping_add(1);
|
||||
sent += 1;
|
||||
if sent % 250 == 0 {
|
||||
if sent % 500 == 0 {
|
||||
log::info!(
|
||||
"mic: sent={sent} captured_frames={} dropped_chunks={} peak={peak:.3}",
|
||||
"mic: sent={sent} captured_frames={} dropped_chunks={} \
|
||||
stale_frames={stale} muted_frames={muted_frames} peak={peak:.3}",
|
||||
captured.load(Ordering::Relaxed),
|
||||
dropped.load(Ordering::Relaxed),
|
||||
);
|
||||
@@ -241,7 +372,8 @@ fn encode_loop(
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={})",
|
||||
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={} stale_frames={stale} \
|
||||
muted_frames={muted_frames})",
|
||||
captured.load(Ordering::Relaxed),
|
||||
dropped.load(Ordering::Relaxed),
|
||||
);
|
||||
|
||||
@@ -83,6 +83,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten
|
||||
punktfunk_core::transport::set_dscp_default(enabled != 0);
|
||||
}
|
||||
|
||||
/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the
|
||||
/// Kotlin `FEATURE_PartialFrame` probe said no — the rebuild-free on-glass experiment for a
|
||||
/// decoder that may accept `BUFFER_FLAG_PARTIAL_FRAME` without declaring the feature (the NP3's
|
||||
/// c2.qti decoders declare nothing). Android-only; everywhere else the probe verdict stands.
|
||||
#[cfg(target_os = "android")]
|
||||
fn force_parts_sysprop() -> bool {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.force_parts".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
n > 0 && std::str::from_utf8(&buf[..n as usize]).unwrap_or("").trim() == "1"
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn force_parts_sysprop() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
|
||||
/// deviceName): Long`.
|
||||
@@ -115,6 +137,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
compositor_pref: jint,
|
||||
gamepad_pref: jint,
|
||||
hdr_enabled: jboolean,
|
||||
multi_slice_ok: jboolean,
|
||||
frame_parts_ok: jboolean,
|
||||
audio_channels: jint,
|
||||
video_codecs: jint,
|
||||
preferred_codec: jint,
|
||||
@@ -153,6 +177,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
} else {
|
||||
Some((cert, key))
|
||||
};
|
||||
// Slice-progressive parts, by decoder truth (Kotlin's FEATURE_PartialFrame probe) — with a
|
||||
// sysprop escape hatch for the on-glass science question the probe can't answer: does the
|
||||
// decoder ACTUALLY choke on BUFFER_FLAG_PARTIAL_FRAME input, or does it merely not declare
|
||||
// the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec
|
||||
// that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.)
|
||||
let force_parts = force_parts_sysprop();
|
||||
let frame_parts = frame_parts_ok != 0 || force_parts;
|
||||
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is
|
||||
// inert client-side unless BOTH probes pass — this line is the one place that says which.
|
||||
log::info!(
|
||||
target: "pf.caps",
|
||||
"decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}",
|
||||
multi_slice_ok != 0,
|
||||
frame_parts_ok != 0,
|
||||
if force_parts { " (FORCED by sysprop)" } else { "" },
|
||||
hdr_enabled != 0,
|
||||
video_codecs,
|
||||
);
|
||||
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -182,11 +224,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
|
||||
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
|
||||
// metadata (see crate::decode).
|
||||
if hdr_enabled != 0 {
|
||||
// 10-bit/HDR by panel truth (above) + multi-slice by DECODER truth: Kotlin probes every
|
||||
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
|
||||
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
|
||||
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
|
||||
(if hdr_enabled != 0 {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}) | (if multi_slice_ok != 0 {
|
||||
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
|
||||
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
|
||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||
@@ -213,9 +263,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||
None,
|
||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
||||
0,
|
||||
// No CLIENT_CAP_CURSOR: this client does not render the host cursor locally (no
|
||||
// shape/state planes in the jni surface) — advertising it would stream cursor-less.
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
// loop feeds them with BUFFER_FLAG_PARTIAL_FRAME.
|
||||
frame_parts,
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
@@ -233,6 +291,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -185,7 +185,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupport
|
||||
/// Floats per sample in the `nativeSendPen` flat array.
|
||||
const PEN_JNI_STRIDE: usize = 10;
|
||||
|
||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
|
||||
/// Sample ceiling per `nativeSendPen` call: over-cap runs are SPLIT into consecutive ≤8-sample
|
||||
/// `send_pen` batches (the send_pen contract — never truncated), so this only bounds the stack
|
||||
/// buffer. 64 samples ≈ >250 ms of 240 Hz history = a pathological UI-thread stall.
|
||||
const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
|
||||
|
||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus emit of STATE-FULL
|
||||
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
|
||||
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
|
||||
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
|
||||
@@ -203,53 +208,56 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
if handle == 0 || count <= 0 {
|
||||
return;
|
||||
}
|
||||
let count = (count as usize).min(PEN_BATCH_MAX);
|
||||
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
|
||||
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
|
||||
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
|
||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
||||
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
||||
return; // short array — a bridge bug, never worth a crash on the input path
|
||||
}
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return; // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_pen(&batch[..count]);
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
|
||||
let n = run.len() / PEN_JNI_STRIDE;
|
||||
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return; // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
}
|
||||
let _ = h.client.send_pen(&batch[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
||||
@@ -406,3 +414,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
|
||||
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
|
||||
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
finger: jint,
|
||||
active: jboolean,
|
||||
x: jint,
|
||||
y: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::Touchpad {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
finger: (finger as u32 & 0x1) as u8,
|
||||
active: active != 0,
|
||||
x: (x as i64).clamp(0, 65535) as u16,
|
||||
y: (y as i64).clamp(0, 65535) as u16,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
|
||||
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
gyro_pitch: jint,
|
||||
gyro_yaw: jint,
|
||||
gyro_roll: jint,
|
||||
accel_x: jint,
|
||||
accel_y: jint,
|
||||
accel_z: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::Motion {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
|
||||
accel: [c(accel_x), c(accel_y), c(accel_z)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
/// recreate, and a mute the user set must come back with it — with no window in which the
|
||||
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
|
||||
/// starts unmuted.
|
||||
pub mic_muted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
|
||||
@@ -10,11 +10,13 @@ use jni::JNIEnv;
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
|
||||
/// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
|
||||
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
|
||||
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
|
||||
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||||
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
|
||||
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
|
||||
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
|
||||
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
|
||||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
@@ -27,6 +29,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
low_latency_mode: jboolean,
|
||||
ll_feature: jboolean,
|
||||
is_tv: jboolean,
|
||||
present_priority: jni::sys::jint,
|
||||
smooth_buffer: jni::sys::jint,
|
||||
panel_fps: jni::sys::jint,
|
||||
) {
|
||||
use super::VideoThread;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -70,6 +75,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
ll_feature: ll_feature != 0,
|
||||
low_latency_mode: low_latency_mode != 0,
|
||||
is_tv: is_tv != 0,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -169,11 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 26 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
/// e2eDispP50Ms, e2eDispP95Ms]`
|
||||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
@@ -186,7 +195,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
||||
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
||||
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
||||
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
|
||||
/// the capture→decoded headline at 2/3; 26–29 are the timeline presenter's split of the `display`
|
||||
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
|
||||
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
|
||||
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
|
||||
/// is active this session; 30/31 are the `decode` stage's split p50s — `feed` =
|
||||
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
|
||||
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
|
||||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||||
/// newest-wins pacing)), or `null` when no decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
@@ -210,7 +227,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 26] = [
|
||||
let buf: [f64; 33] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -251,6 +268,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
snap.display_p50_ms,
|
||||
snap.e2e_disp_p50_ms,
|
||||
snap.e2e_disp_p95_ms,
|
||||
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
|
||||
// release→displayed), the window's on-glass confirm count, and whether the presenter
|
||||
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
|
||||
snap.pace_p50_ms,
|
||||
snap.latch_p50_ms,
|
||||
snap.presents as f64,
|
||||
if h.stats.presenter_active() { 1.0 } else { 0.0 },
|
||||
// The `decode` stage's split (P3 science): feed = received→queued (hand-off +
|
||||
// input-slot wait), codec = queued→decoded (codec-pure) — and the parked-AU
|
||||
// overflow subset of `skipped` (decoder-health vs benign pacing drops).
|
||||
snap.feed_p50_ms,
|
||||
snap.codec_p50_ms,
|
||||
snap.skipped_overflow as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
@@ -264,10 +294,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||||
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
|
||||
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
|
||||
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
|
||||
/// on the host build too. Cheap; safe on the UI thread.
|
||||
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
|
||||
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
|
||||
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
|
||||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||
/// build too. Cheap; safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
@@ -281,7 +313,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mode = h.client.mode();
|
||||
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
|
||||
let buf: [i32; 3] = [
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
mode.refresh_hz as i32,
|
||||
];
|
||||
let arr = match env.new_int_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
@@ -365,33 +401,49 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
||||
/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no
|
||||
/// permission) leaves the rest of the session streaming.
|
||||
/// `NativeBridge.nativeStartMic(handle, echoCancel): Int` — start mic capture (AAudio input →
|
||||
/// Opus → host `send_mic`). `echoCancel` opens the capture under the `VoiceCommunication` preset
|
||||
/// (the HAL's echo canceller / noise suppressor) and allocates an audio session id; the return
|
||||
/// value is that id (`> 0`), so Kotlin can attach the Java `AcousticEchoCanceler`/`NoiseSuppressor`
|
||||
/// as a backstop — `0` when none was allocated (echoCancel off, the preset fell back to the plain
|
||||
/// open, a `0` handle, or the mic failed entirely). Already running (a surface recreate) returns
|
||||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||||
/// the rest of the session streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
echo_cancel: jboolean,
|
||||
) -> jni::sys::jint {
|
||||
if handle == 0 {
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.mic.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return; // already capturing
|
||||
if let Some(m) = guard.as_ref() {
|
||||
return m.session_id(); // already capturing — same stream, same session
|
||||
}
|
||||
match crate::mic::MicCapture::start(h.client.clone()) {
|
||||
Some(m) => *guard = Some(m),
|
||||
None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"),
|
||||
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
|
||||
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
|
||||
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
|
||||
Some(m) => {
|
||||
let session_id = m.session_id();
|
||||
*guard = Some(m);
|
||||
session_id
|
||||
}
|
||||
None => {
|
||||
log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
|
||||
/// stream (without closing the session). No-op on `0`.
|
||||
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
|
||||
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
@@ -407,3 +459,59 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
/// it settled on and its primed buffers all stay exactly as they are, and the encode loop simply
|
||||
/// drops each 10 ms frame instead of encoding + sending it. A stop/start would re-run the preset
|
||||
/// fallback ladder and re-prime buffers on every toggle — hundreds of ms, and possibly a different
|
||||
/// rung (echo cancellation silently lost). This way a toggle costs one atomic store here and one
|
||||
/// relaxed load per frame there, and takes effect on the very next 10 ms boundary.
|
||||
///
|
||||
/// Sticky for the SESSION (the flag lives on the handle, not on the capture), so the mic restart a
|
||||
/// surface recreate performs comes back muted with no window for an unmuted frame to escape; a
|
||||
/// fresh session always starts unmuted. No-op on `0`. Not android-gated — pure `jni` + an atomic
|
||||
/// store, so it links on the host build too.
|
||||
///
|
||||
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
|
||||
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
|
||||
/// no captured audio leaves the process.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
muted: jboolean,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.mic_muted
|
||||
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeMicActive(handle): Boolean` — is a mic capture actually RUNNING? `true` only
|
||||
/// between a `nativeStartMic` that opened a stream and the matching `nativeStopMic`. The in-stream
|
||||
/// mute control is offered on this evidence rather than on the user's setting, so a device that
|
||||
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
|
||||
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
jboolean::from(h.mic.lock().unwrap().is_some())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ pub struct VideoStats {
|
||||
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||
/// Off until Kotlin shows the HUD.
|
||||
enabled: AtomicBool,
|
||||
/// Whether the timeline presenter is active this session (async loop, not sysprop-disabled) —
|
||||
/// stats index 29, so the HUD can label the display split it is (or isn't) getting.
|
||||
presenter_active: AtomicBool,
|
||||
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
|
||||
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
|
||||
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
|
||||
@@ -67,9 +70,29 @@ struct Inner {
|
||||
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
|
||||
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
|
||||
e2e_disp_us: Vec<u64>,
|
||||
/// The `display` stage's presenter split, decoded→release (pace wait: store + glass budget),
|
||||
/// µs. Empty on the legacy release-immediately path.
|
||||
pace_us: Vec<u64>,
|
||||
/// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs —
|
||||
/// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame.
|
||||
latch_us: Vec<u64>,
|
||||
/// The `decode` stage's feed split, received→queued (hand-off + input-slot wait), µs. Empty
|
||||
/// when no receipt stamp matched (HUD off and ABR not measuring decode).
|
||||
feed_us: Vec<u64>,
|
||||
/// The other half, queued→decoded (codec-pure: the decoder's own time on the AU, measured
|
||||
/// from its LAST piece so a slice-progressive head start shows up as a shrink here), µs.
|
||||
codec_us: Vec<u64>,
|
||||
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs-
|
||||
/// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps
|
||||
/// deficit is upstream.
|
||||
presents: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
||||
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
||||
skipped: u64,
|
||||
/// The subset of `skipped` that was parked-AU OVERFLOW (the decoder fell behind and whole
|
||||
/// AUs were dropped before feeding) — a decoder-health signal, vs the benign newest-wins
|
||||
/// pacing majority. Always ≤ `skipped`.
|
||||
skipped_overflow: u64,
|
||||
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
|
||||
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
|
||||
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
|
||||
@@ -101,6 +124,18 @@ pub struct Snapshot {
|
||||
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
|
||||
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
|
||||
pub disp_valid: bool,
|
||||
/// The `display` stage's presenter split p50s (ms): `pace` = decoded→release (store + glass
|
||||
/// budget), `latch` = release→displayed (SurfaceFlinger). 0.0 when no sample landed (legacy
|
||||
/// path / no render callbacks).
|
||||
pub pace_p50_ms: f64,
|
||||
pub latch_p50_ms: f64,
|
||||
/// The `decode` stage's split p50s (ms): `feed` = received→queued (hand-off + input-slot
|
||||
/// wait), `codec` = queued→decoded (codec-pure, from the AU's last piece). 0.0 when no
|
||||
/// sample landed (sync loop / no receipt stamps).
|
||||
pub feed_p50_ms: f64,
|
||||
pub codec_p50_ms: f64,
|
||||
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks).
|
||||
pub presents: u64,
|
||||
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||
pub host_p50_ms: f64,
|
||||
@@ -115,6 +150,8 @@ pub struct Snapshot {
|
||||
pub lost: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
|
||||
pub skipped: u64,
|
||||
/// The parked-AU overflow subset of `skipped` (decoder fell behind; ≤ `skipped`).
|
||||
pub skipped_overflow: u64,
|
||||
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
|
||||
pub fec: u64,
|
||||
}
|
||||
@@ -132,6 +169,7 @@ impl VideoStats {
|
||||
pub fn new() -> VideoStats {
|
||||
VideoStats {
|
||||
enabled: AtomicBool::new(false),
|
||||
presenter_active: AtomicBool::new(false),
|
||||
decoder: Mutex::new(None),
|
||||
inner: Mutex::new(Inner {
|
||||
window_start: Instant::now(),
|
||||
@@ -144,7 +182,13 @@ impl VideoStats {
|
||||
decode_us: Vec::with_capacity(256),
|
||||
display_us: Vec::with_capacity(256),
|
||||
e2e_disp_us: Vec::with_capacity(256),
|
||||
pace_us: Vec::with_capacity(256),
|
||||
latch_us: Vec::with_capacity(256),
|
||||
feed_us: Vec::with_capacity(256),
|
||||
codec_us: Vec::with_capacity(256),
|
||||
presents: 0,
|
||||
skipped: 0,
|
||||
skipped_overflow: 0,
|
||||
last_dropped_total: 0,
|
||||
last_fec_total: 0,
|
||||
skew_corrected: false,
|
||||
@@ -160,6 +204,18 @@ impl VideoStats {
|
||||
self.enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Record whether the timeline presenter runs this session (decode thread, once at start).
|
||||
// Set only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn set_presenter_active(&self, on: bool) {
|
||||
self.presenter_active.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the timeline presenter is active (stats index 29).
|
||||
pub fn presenter_active(&self) -> bool {
|
||||
self.presenter_active.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
||||
@@ -181,7 +237,13 @@ impl VideoStats {
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.pace_us.clear();
|
||||
g.latch_us.clear();
|
||||
g.feed_us.clear();
|
||||
g.codec_us.clear();
|
||||
g.presents = 0;
|
||||
g.skipped = 0;
|
||||
g.skipped_overflow = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
}
|
||||
@@ -275,6 +337,45 @@ impl VideoStats {
|
||||
g.skipped += n;
|
||||
}
|
||||
|
||||
/// Record parked-AU OVERFLOW drops (whole AUs dropped before feeding — the decoder fell
|
||||
/// behind). Counts into `skipped` too, plus the overflow-only counter, so the HUD can tell
|
||||
/// benign newest-wins pacing from a decoder that can't keep up.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_skipped_overflow(&self, n: u64) {
|
||||
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.skipped += n;
|
||||
g.skipped_overflow += n;
|
||||
}
|
||||
|
||||
/// Record one decoded frame's `decode`-stage split: `feed` = received→queued (hand-off +
|
||||
/// input-slot wait; absent when no receipt stamp matched) and `codec` = queued→decoded
|
||||
/// (codec-pure, measured from the AU's LAST piece — a slice-progressive head start shows
|
||||
/// as a shrink here), both µs.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_decode_split(&self, feed_us: Option<u64>, codec_us: u64) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = feed_us {
|
||||
g.feed_us.push(f);
|
||||
}
|
||||
g.codec_us.push(codec_us);
|
||||
}
|
||||
|
||||
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||
/// this pts predates the HUD being shown).
|
||||
@@ -298,13 +399,19 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
|
||||
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
|
||||
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
|
||||
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
|
||||
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
|
||||
/// realtime clock): its capture→displayed `end-to-end` sample, its decoded→displayed
|
||||
/// `display` stage sample, and the presenter split's `latch` half (release→displayed) — any
|
||||
/// may be absent (the e2e clamp rejected an out-of-range value, or the release record for
|
||||
/// this pts was already evicted). Fired from the codec's render-callback thread, not the
|
||||
/// decode thread — the lock makes that safe.
|
||||
// Driven only by the android-only decode path; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
|
||||
pub fn note_displayed(
|
||||
&self,
|
||||
e2e_us: Option<u64>,
|
||||
display_us: Option<u64>,
|
||||
latch_us: Option<u64>,
|
||||
) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
|
||||
}
|
||||
@@ -313,12 +420,32 @@ impl VideoStats {
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.presents += 1;
|
||||
if let Some(l) = e2e_us {
|
||||
g.e2e_disp_us.push(l);
|
||||
}
|
||||
if let Some(l) = display_us {
|
||||
g.display_us.push(l);
|
||||
}
|
||||
if let Some(l) = latch_us {
|
||||
g.latch_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one released frame's pace-wait (decoded→release: the presenter's store + glass
|
||||
/// budget), µs — the `display` stage's other half. Decode-thread only.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_release(&self, pace_us: u64) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.pace_us.push(pace_us);
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
@@ -341,6 +468,10 @@ impl VideoStats {
|
||||
g.decode_us.sort_unstable();
|
||||
g.display_us.sort_unstable();
|
||||
g.e2e_disp_us.sort_unstable();
|
||||
g.pace_us.sort_unstable();
|
||||
g.latch_us.sort_unstable();
|
||||
g.feed_us.sort_unstable();
|
||||
g.codec_us.sort_unstable();
|
||||
let snap = Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
@@ -352,6 +483,11 @@ impl VideoStats {
|
||||
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||
display_p50_ms: pctl_ms(&g.display_us, 0.50),
|
||||
disp_valid: !g.e2e_disp_us.is_empty(),
|
||||
pace_p50_ms: pctl_ms(&g.pace_us, 0.50),
|
||||
latch_p50_ms: pctl_ms(&g.latch_us, 0.50),
|
||||
feed_p50_ms: pctl_ms(&g.feed_us, 0.50),
|
||||
codec_p50_ms: pctl_ms(&g.codec_us, 0.50),
|
||||
presents: g.presents,
|
||||
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||
lat_valid: !g.e2e_us.is_empty(),
|
||||
@@ -359,6 +495,7 @@ impl VideoStats {
|
||||
frames: g.frames,
|
||||
lost: dropped_total.saturating_sub(g.last_dropped_total),
|
||||
skipped: g.skipped,
|
||||
skipped_overflow: g.skipped_overflow,
|
||||
fec: fec_total.saturating_sub(g.last_fec_total),
|
||||
};
|
||||
g.window_start = Instant::now();
|
||||
@@ -371,7 +508,13 @@ impl VideoStats {
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.pace_us.clear();
|
||||
g.latch_us.clear();
|
||||
g.feed_us.clear();
|
||||
g.codec_us.clear();
|
||||
g.presents = 0;
|
||||
g.skipped = 0;
|
||||
g.skipped_overflow = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
snap
|
||||
|
||||
@@ -34,9 +34,10 @@ let package = Package(
|
||||
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
||||
// Registered with Core Text at first use; see BrandFont.swift.
|
||||
.copy("Resources/Fonts"),
|
||||
// The host cards' OS marks (template vector imagesets derived from the
|
||||
// assets/os-icons masters — FA brands CC BY 4.0 + Simple Icons CC0, see that
|
||||
// README). `.process` compiles the catalog; loaded via OsIcon.swift.
|
||||
// The host cards' OS marks (template vector imagesets generated from the
|
||||
// assets/os-icons masters by scripts/gen-os-icons.sh — per-mark provenance and
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
|
||||
@@ -315,7 +315,15 @@ struct ContentView: View {
|
||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||||
clipboardOn: model.clipboardEnabled,
|
||||
toggleClipboard: { model.toggleClipboardSync() },
|
||||
micAvailable: model.micAvailable,
|
||||
micMuted: model.micMuted,
|
||||
toggleMicMute: { model.toggleMicMute() },
|
||||
disconnect: { model.disconnect() }))
|
||||
// ⌃⌥⇧A fired while input was CAPTURED (InputCapture's chord path posts it — the menu's
|
||||
// identical equivalent can't reach a captured stream). Same toggle either way.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkToggleMicMute)) { _ in
|
||||
model.toggleMicMute()
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
|
||||
@@ -724,31 +732,48 @@ struct ContentView: View {
|
||||
}
|
||||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||||
}
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the platform's
|
||||
// reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are discoverable
|
||||
// even with statistics off. The banner's own task drops it (cancelled cleanly
|
||||
// if the session view goes away first). On tvOS it carries the ONLY exits —
|
||||
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
|
||||
// session branch), so the hold gestures must be told to the user.
|
||||
// The bottom-centre stack: the muted-microphone badge over the start-of-stream
|
||||
// shortcut banner. ONE overlay for both, so the two can never land on top of each
|
||||
// other in the seconds where they overlap.
|
||||
.overlay(alignment: .bottom) {
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(Self.shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.padding(.bottom, 24)
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false }
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
#if !os(tvOS)
|
||||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||||
if captureEnabled && model.micMuted {
|
||||
MicMutedBadge { model.setMicMuted(false) }
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the
|
||||
// platform's reserved controls on a glass pill for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are
|
||||
// discoverable even with statistics off. The banner's own task drops it
|
||||
// (cancelled cleanly if the session view goes away first). On tvOS it
|
||||
// carries the ONLY exits — Menu/B is swallowed during a session (the
|
||||
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
|
||||
// must be told to the user.
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) {
|
||||
showShortcutHint = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||
@@ -766,21 +791,24 @@ struct ContentView: View {
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled,
|
||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// Floating glass disc over the frame (26+, material fallback).
|
||||
// interactive: the disc IS the tap target, so the glass reacts
|
||||
// to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
// Match the hit region to the visible disc so every tap also
|
||||
// triggers the interactive-glass press highlight.
|
||||
.contentShape(Circle())
|
||||
HStack(spacing: 10) {
|
||||
Button { model.disconnect() } label: { touchDisc("xmark") }
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Disconnect")
|
||||
// The mic toggle rides the same discs, for the same reason: in these
|
||||
// tiers the HUD carries no buttons (compact is a stat pill, off is
|
||||
// nothing), so this is a touch-only user's ONLY way to mute. Absent —
|
||||
// not greyed — when the session sends no microphone at all.
|
||||
if model.micAvailable {
|
||||
Button { model.toggleMicMute() } label: {
|
||||
touchDisc(model.micMuted ? "mic.slash.fill" : "mic.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
model.micMuted ? "Unmute microphone" : "Mute microphone")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(12)
|
||||
.accessibilityLabel("Disconnect")
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
guard statsVerbosity == .off else { return }
|
||||
@@ -794,14 +822,34 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// One touch-control disc: an SF Symbol on a floating glass disc over the frame (26+,
|
||||
/// material fallback), sized as a comfortable tap target. `interactive`: the disc IS the tap
|
||||
/// target, so the glass reacts to press, and the hit region is matched to the visible disc so
|
||||
/// every tap triggers that press highlight.
|
||||
private func touchDisc(_ symbol: String) -> some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private static let shortcutHintText =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
/// The reserved combos, told once per session. The mute segment appears only when the session
|
||||
/// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie.
|
||||
private var shortcutHintText: String {
|
||||
let base =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 12
|
||||
#elseif os(tvOS)
|
||||
private static let shortcutHintText =
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Session state for the app shell: owns the connection, the input capture, the trust
|
||||
// handshake phase, and the pump-thread → main-actor stats relay.
|
||||
|
||||
// AVFoundation: AVCaptureDevice.authorizationStatus (the mic TCC grant behind `micAvailable`)
|
||||
// and, on tvOS, AVPlayer.eligibleForHDRPlayback (the TV-capability HDR gate).
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
import PunktfunkKit
|
||||
@@ -11,9 +14,6 @@ import SwiftUI
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate
|
||||
#endif
|
||||
|
||||
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
|
||||
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||||
@@ -137,6 +137,14 @@ final class SessionModel: ObservableObject {
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
/// The USER's in-stream mic mute (the HUD button, the Stream menu's ⌃⌥⇧A, the captured-state
|
||||
/// chord, the iOS mic disc) — session state, deliberately NOT persisted: a mute is for the
|
||||
/// people in the room right now, so every new session starts live if the mic is on at all.
|
||||
/// One of the two inputs to the effective mute; `isBackgrounded` is the other, and
|
||||
/// `applyMicMute` composes them — a user mute survives a trip through the background, and the
|
||||
/// background's privacy mute never clears the user's choice. Local and instant: it gates
|
||||
/// capture on this device, nothing is sent to the host.
|
||||
@Published private(set) var micMuted = false
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
@@ -338,7 +346,10 @@ final class SessionModel: ObservableObject {
|
||||
let clientCaps: UInt8 =
|
||||
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
|
||||
#else
|
||||
let clientCaps: UInt8 = 0
|
||||
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
|
||||
// reportPhase — advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
|
||||
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
|
||||
let clientCaps: UInt8 = 0x02
|
||||
#endif
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
@@ -437,7 +448,7 @@ final class SessionModel: ObservableObject {
|
||||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
||||
isBackgrounded = true
|
||||
conn.setVideoDropped(true)
|
||||
audio?.setMicMuted(true)
|
||||
applyMicMute() // now muted for privacy — on top of the user's own mute, not instead of it
|
||||
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
|
||||
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
|
||||
let minutes = max(1, timeoutMinutes)
|
||||
@@ -462,13 +473,57 @@ final class SessionModel: ObservableObject {
|
||||
backgroundDeadline = nil
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
audio?.setMicMuted(false)
|
||||
applyMicMute() // back to the user's own choice — which may well still be "muted"
|
||||
if let conn = connection {
|
||||
conn.setVideoDropped(false)
|
||||
conn.requestKeyframe()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Microphone mute (in-stream, per session)
|
||||
|
||||
/// Whether this session has a mic uplink there is any point in muting: the mic must be on in
|
||||
/// the session's RESOLVED settings (a profile can turn it on or off), the platform must have
|
||||
/// an app-accessible input at all, and the OS must not have refused us one. Drives whether the
|
||||
/// mute control is offered — a live-looking mute button over a session that sends no
|
||||
/// microphone would be a lie. Same three conditions `SessionAudio` starts an uplink on
|
||||
/// (`.notDetermined` counts: the prompt is pending and a grant starts the uplink mid-session).
|
||||
var micAvailable: Bool {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone — SessionAudio never opens an uplink either
|
||||
#else
|
||||
guard settings.micEnabled else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false // denied / restricted — there is no uplink to mute
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Flip the user's mute. The in-stream surfaces (HUD button, Stream menu, ⌃⌥⇧A while
|
||||
/// captured, the iOS mic disc) all land here.
|
||||
func toggleMicMute() {
|
||||
setMicMuted(!micMuted)
|
||||
}
|
||||
|
||||
/// Set the user's mute directly (the badge's tap-to-unmute). Ignored when the session has no
|
||||
/// microphone, so a stale surface can't leave a phantom "muted" badge over a session that was
|
||||
/// never sending anything.
|
||||
func setMicMuted(_ muted: Bool) {
|
||||
guard micAvailable, micMuted != muted else { return }
|
||||
micMuted = muted
|
||||
applyMicMute()
|
||||
}
|
||||
|
||||
/// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute —
|
||||
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
|
||||
/// changed, the other still holds, so returning from the background can't un-mute a user who
|
||||
/// muted mid-stream, and a user unmuting while backgrounded (Live Activity, another window)
|
||||
/// doesn't open the mic behind their back.
|
||||
private func applyMicMute() {
|
||||
audio?.setMicMuted(micMuted || isBackgrounded)
|
||||
}
|
||||
|
||||
/// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those
|
||||
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
|
||||
/// with it, so cycling still works in a session a profile put on a different tier.
|
||||
@@ -506,6 +561,9 @@ final class SessionModel: ObservableObject {
|
||||
backgroundTimer = nil
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
// The mic mute is per-session and never persisted: the next stream starts live (if the
|
||||
// mic is enabled), rather than silently carrying a mute nobody remembers making.
|
||||
micMuted = false
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
@@ -606,7 +664,8 @@ final class SessionModel: ObservableObject {
|
||||
speakerUID: settings.speakerUID,
|
||||
micUID: settings.micUID,
|
||||
micChannel: settings.micChannel,
|
||||
micEnabled: settings.micEnabled)
|
||||
micEnabled: settings.micEnabled,
|
||||
echoCancel: settings.echoCancel)
|
||||
self.audio = audio
|
||||
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||||
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
|
||||
// the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the
|
||||
// CROSS-CLIENT set every punktfunk client reserves — Ctrl+Alt+Shift+Q (release the captured
|
||||
// mouse) / +D (disconnect) / +S (stats) — and the menu is their discoverable surface on macOS
|
||||
// mouse) / +D (disconnect) / +S (stats), plus +A (mute the microphone), the Apple clients'
|
||||
// addition to it — and the menu is their discoverable surface on macOS
|
||||
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
||||
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
||||
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||
@@ -27,6 +28,12 @@ struct SessionFocus {
|
||||
/// Clipboard sync is live (host-acked) — drives the item's Stop/Share title.
|
||||
var clipboardOn: Bool
|
||||
var toggleClipboard: () -> Void
|
||||
/// The session has a mic uplink at all (its resolved `micEnabled`) — gates the mute item, so
|
||||
/// it is never an enabled control over a session that sends no microphone.
|
||||
var micAvailable: Bool
|
||||
/// The user's mic mute is engaged — drives the item's Mute/Unmute title.
|
||||
var micMuted: Bool
|
||||
var toggleMicMute: () -> Void
|
||||
var disconnect: () -> Void
|
||||
}
|
||||
|
||||
@@ -60,6 +67,17 @@ struct StreamCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true)
|
||||
// Mic mute, local and instant (it gates capture on this device — the host is never
|
||||
// asked). Per SESSION: it starts off every time, so this item is a live toggle, not a
|
||||
// setting. Greyed when the session sends no microphone at all (Settings → mic off, or
|
||||
// a profile that turns it off) rather than pretending there is something to mute.
|
||||
// Captured, the combo is handled by InputCapture's chord path before menus see it;
|
||||
// this item is the released-state path and the shortcut's documentation.
|
||||
Button(session?.micMuted == true ? "Unmute Microphone" : "Mute Microphone") {
|
||||
session?.toggleMicMute()
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true || session?.micAvailable != true)
|
||||
#if os(macOS)
|
||||
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
|
||||
// when the host doesn't advertise the cap (older host / operator policy off).
|
||||
|
||||
@@ -179,6 +179,18 @@ struct StreamHUDView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
// Mic mute — the in-stream toggle, on the same card as the other in-overlay action.
|
||||
// Absent (not greyed) when the session sends no microphone: the HUD is a status card,
|
||||
// and a dead control on it would read as "there is a mic, and it is on". The muted
|
||||
// STATE is not this button's job — the badge over the stream says that at every tier
|
||||
// and with the overlay off entirely. tvOS gets no control: no microphone, and a
|
||||
// focusable one would steal the controller's A press from the host.
|
||||
#if !os(tvOS)
|
||||
if model.micAvailable {
|
||||
Button(micButtonTitle) { model.toggleMicMute() }
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
}
|
||||
#endif
|
||||
// ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden)
|
||||
// and in InputCapture's monitor while captured; this button is the in-overlay,
|
||||
// click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
|
||||
@@ -195,6 +207,19 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The mute button's wording. macOS names the chord, exactly as its Disconnect button does;
|
||||
/// iOS/iPadOS spells the action out (the HUD's buttons there carry no shortcuts, even where a
|
||||
/// hardware keyboard could fire one — the Stream menu is that keyboard's surface).
|
||||
private var micButtonTitle: String {
|
||||
#if os(macOS)
|
||||
return model.micMuted ? "Unmute Mic (⌃⌥⇧A)" : "Mute Mic (⌃⌥⇧A)"
|
||||
#else
|
||||
return model.micMuted ? "Unmute Microphone" : "Mute Microphone"
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Card metrics
|
||||
|
||||
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||
@@ -242,6 +267,42 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides
|
||||
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
|
||||
/// may have cycled off, and which the compact tier reduces to a stat line): "am I muted?" is not a
|
||||
/// statistic, and a mute you can't see is how people talk to nobody for a minute. Same glass
|
||||
/// language as the HUD, sized like the start-of-stream banner it shares the bottom edge with.
|
||||
///
|
||||
/// It is also a control: tapping it unmutes. That is the guaranteed way back for a touch user who
|
||||
/// muted with the overlay off, and it costs the badge nothing (it is on screen either way).
|
||||
struct MicMutedBadge: View {
|
||||
let onUnmute: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onUnmute) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "mic.slash.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.red)
|
||||
Text("Microphone muted")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
// interactive: the badge IS the tap target, so the glass reacts to press.
|
||||
.glassBackground(Capsule(), interactive: true)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityLabel("Microphone muted")
|
||||
.accessibilityHint("Unmutes the microphone")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
|
||||
enum DeviceMetrics {
|
||||
|
||||
@@ -32,6 +32,7 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.enable444) private var enable444 = false
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@AppStorage(DefaultsKey.echoCancel) private var echoCancel = true
|
||||
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
@@ -316,6 +317,11 @@ struct GamepadSettingsView: View {
|
||||
id: "mic", icon: "mic", label: "Microphone",
|
||||
detail: "Send this device's microphone to the host's virtual mic.",
|
||||
value: $micEnabled),
|
||||
toggleRow(
|
||||
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
|
||||
detail: "Cancel the audio this device plays out of the mic signal — stops "
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
choiceRow(
|
||||
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||||
|
||||
@@ -98,6 +98,10 @@ enum SettingsFields {
|
||||
.init(name: "mic_enabled", key: DefaultsKey.micEnabled,
|
||||
overlay: \.micEnabled, effective: \.micEnabled)
|
||||
}
|
||||
static var echoCancel: SettingsField<Bool> {
|
||||
.init(name: "echo_cancel", key: DefaultsKey.echoCancel,
|
||||
overlay: \.echoCancel, effective: \.echoCancel)
|
||||
}
|
||||
static var touchMode: SettingsField<String> {
|
||||
.init(name: "touch_mode", key: DefaultsKey.touchMode,
|
||||
overlay: \.touchMode, effective: \.touchMode)
|
||||
@@ -175,6 +179,7 @@ extension SettingsView {
|
||||
base.compositor = compositor
|
||||
base.audioChannels = audioChannels
|
||||
base.micEnabled = micEnabled
|
||||
base.echoCancel = echoCancel
|
||||
base.gamepadType = gamepadType
|
||||
base.statsVerbosity = statsVerbosityRaw
|
||||
base.fullscreenWhileStreaming = fullscreenWhileStreaming
|
||||
|
||||
@@ -581,6 +581,10 @@ extension SettingsView {
|
||||
field: "mic_enabled") {
|
||||
Toggle("Send microphone to the host", isOn: scoped(SettingsFields.micEnabled))
|
||||
}
|
||||
described(echoCancelCaption, field: "echo_cancel") {
|
||||
Toggle("Echo cancellation", isOn: scoped(SettingsFields.echoCancel))
|
||||
.disabled(!effective.micEnabled)
|
||||
}
|
||||
#if os(macOS)
|
||||
if !inProfileScope {
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
@@ -619,6 +623,20 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Honest about the macOS escape hatch: the voice processor only follows the system
|
||||
/// default devices, so hand-picked endpoints silently keep the raw path (see
|
||||
/// SessionAudio's topology note) — better said here than discovered mid-call.
|
||||
private var echoCancelCaption: String {
|
||||
let base = "Voice processing cancels the audio this device plays out of the mic "
|
||||
+ "signal, so a speaker setup doesn't feed the game back to the host."
|
||||
#if os(macOS)
|
||||
return base + " Follows the system default devices — a hand-picked speaker, "
|
||||
+ "microphone or input channel streams the raw mic instead."
|
||||
#else
|
||||
return base
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Controllers
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
|
||||
@@ -65,6 +65,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||
@AppStorage(DefaultsKey.echoCancel) var echoCancel = true
|
||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||||
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Opus ⇄ PCM through CoreAudio's built-in codec (kAudioFormatOpus, macOS 10.13+ / iOS
|
||||
// 11+) — no bundled libopus. The host's audio plane is raw Opus packets (48 kHz stereo,
|
||||
// one frame per packet); AVAudioConverter handles them as single-packet
|
||||
// AVAudioCompressedBuffers with explicit packet descriptions.
|
||||
// one frame per packet); the mic uplink is 48 kHz MONO packets (one microphone bus —
|
||||
// the host's decoder upmixes, so duplicating it into a second channel only cost bits).
|
||||
// AVAudioConverter handles both as single-packet AVAudioCompressedBuffers with explicit
|
||||
// packet descriptions.
|
||||
//
|
||||
// Both classes are single-threaded by contract (one per direction, owned by their
|
||||
// drain/capture pipelines).
|
||||
@@ -14,16 +16,16 @@ enum OpusCodecError: Error {
|
||||
case convertFailed(String)
|
||||
}
|
||||
|
||||
/// 48 kHz stereo float32 interleaved — the PCM side of both converters and the layout
|
||||
/// of the playback ring buffer.
|
||||
/// 48 kHz stereo float32 interleaved — the decoder's PCM side (the host plane's shape).
|
||||
func opusPCMFormat() -> AVAudioFormat? {
|
||||
AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: true)
|
||||
}
|
||||
|
||||
/// The compressed side: raw Opus, `framesPerPacket` nominal samples per packet at 48 kHz
|
||||
/// (240 = the host's 5 ms audio plane; 960 = the 20 ms packets the encoder emits).
|
||||
private func opusFormat(framesPerPacket: UInt32) -> AVAudioFormat? {
|
||||
/// (240 = the host's 5 ms audio plane; 480 = the 10 ms packets the encoder emits) and
|
||||
/// `channels` (2 = the host plane, 1 = the mic uplink).
|
||||
private func opusFormat(framesPerPacket: UInt32, channels: UInt32) -> AVAudioFormat? {
|
||||
var desc = AudioStreamBasicDescription(
|
||||
mSampleRate: 48_000,
|
||||
mFormatID: kAudioFormatOpus,
|
||||
@@ -31,7 +33,7 @@ private func opusFormat(framesPerPacket: UInt32) -> AVAudioFormat? {
|
||||
mBytesPerPacket: 0,
|
||||
mFramesPerPacket: framesPerPacket,
|
||||
mBytesPerFrame: 0,
|
||||
mChannelsPerFrame: 2,
|
||||
mChannelsPerFrame: channels,
|
||||
mBitsPerChannel: 0,
|
||||
mReserved: 0)
|
||||
return AVAudioFormat(streamDescription: &desc)
|
||||
@@ -45,7 +47,8 @@ final class OpusDecoder {
|
||||
|
||||
/// `framesPerPacket`: the sender's packet duration in samples (host audio = 240).
|
||||
init(framesPerPacket: UInt32) throws {
|
||||
guard let pcm = opusPCMFormat(), let opus = opusFormat(framesPerPacket: framesPerPacket),
|
||||
guard let pcm = opusPCMFormat(),
|
||||
let opus = opusFormat(framesPerPacket: framesPerPacket, channels: 2),
|
||||
let converter = AVAudioConverter(from: opus, to: pcm)
|
||||
else { throw OpusCodecError.unavailable }
|
||||
self.converter = converter
|
||||
@@ -90,24 +93,43 @@ final class OpusDecoder {
|
||||
}
|
||||
|
||||
final class OpusEncoder {
|
||||
/// The encoder's packet duration: 960 samples = 20 ms, CoreAudio's default Opus
|
||||
/// framing. The host's mic service decodes any Opus frame size up to 120 ms.
|
||||
static let framesPerPacket: AVAudioFrameCount = 960
|
||||
/// The encoder's packet duration in samples: 480 = 10 ms, halving the packetization
|
||||
/// latency of the old 20 ms framing. CoreAudio honors it — mFramesPerPacket 480/mono
|
||||
/// creates a converter that truly emits 10 ms CELT packets (TOC config 30), one per
|
||||
/// 480-frame chunk, verified by inspection of the emitted TOC bytes and per-packet
|
||||
/// frame accounting. 960 stays as the fallback should an older codec refuse 480.
|
||||
/// The host's mic service decodes any Opus frame size up to 120 ms, so either is
|
||||
/// wire-compatible.
|
||||
let framesPerPacket: AVAudioFrameCount
|
||||
|
||||
/// 48 kHz MONO float32 interleaved — the uplink carries one microphone bus.
|
||||
let pcmFormat: AVAudioFormat
|
||||
|
||||
private let converter: AVAudioConverter
|
||||
private let outBuf: AVAudioCompressedBuffer
|
||||
let pcmFormat: AVAudioFormat
|
||||
|
||||
init() throws {
|
||||
guard let pcm = opusPCMFormat(),
|
||||
let opus = opusFormat(framesPerPacket: UInt32(Self.framesPerPacket)),
|
||||
let converter = AVAudioConverter(from: pcm, to: opus)
|
||||
guard let pcm = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1,
|
||||
interleaved: true)
|
||||
else { throw OpusCodecError.unavailable }
|
||||
converter.bitRate = 96_000
|
||||
self.converter = converter
|
||||
self.pcmFormat = pcm
|
||||
var made: (converter: AVAudioConverter, fpp: AVAudioFrameCount)?
|
||||
for fpp: AVAudioFrameCount in [480, 960] {
|
||||
if let opus = opusFormat(framesPerPacket: UInt32(fpp), channels: 1),
|
||||
let converter = AVAudioConverter(from: pcm, to: opus) {
|
||||
made = (converter, fpp)
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let made else { throw OpusCodecError.unavailable }
|
||||
// 48 kbps: transparent for mono voice — the old 96 kbps budget was sized for
|
||||
// the duplicated-stereo framing this encoder no longer emits.
|
||||
made.converter.bitRate = 48_000
|
||||
converter = made.converter
|
||||
framesPerPacket = made.fpp
|
||||
pcmFormat = pcm
|
||||
outBuf = AVAudioCompressedBuffer(
|
||||
format: opus, packetCapacity: 4, maximumPacketSize: 1500)
|
||||
format: made.converter.outputFormat, packetCapacity: 4, maximumPacketSize: 1500)
|
||||
}
|
||||
|
||||
/// Encode exactly `framesPerPacket` frames of `pcmFormat` audio; returns the encoded
|
||||
|
||||
@@ -5,15 +5,22 @@
|
||||
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
|
||||
// network gap costs one dip, not permanent crackle).
|
||||
//
|
||||
// mic → host: a second AVAudioEngine taps the input device, folds it to one mono bus (the
|
||||
// chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz
|
||||
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them
|
||||
// into a virtual PipeWire source.
|
||||
// mic → host: a tap on the input node folds the capture to one mono bus (the chosen channel
|
||||
// of a multi-channel interface, or a sum of all channels), resamples to 48 kHz mono, slices
|
||||
// 10 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them into a
|
||||
// virtual PipeWire source.
|
||||
//
|
||||
// Engine topology. With the mic enabled and echo cancellation on (both defaults), BOTH
|
||||
// directions run on ONE AVAudioEngine with the system voice processor engaged
|
||||
// (`setVoiceProcessingEnabled`) — AEC needs render and capture on the same unit so it can
|
||||
// subtract what the speaker is playing from what the mic hears; without it, a loudspeaker
|
||||
// client feeds the host's own game audio straight back to it (the primary reported echo
|
||||
// source). The voice processor can only follow the system DEFAULT devices, so explicit
|
||||
// endpoint choices fall back to the old two-engine topology — see `wantsCombined` for the
|
||||
// exact decision, and `startCapture` for why two engines handle arbitrary device pairs.
|
||||
//
|
||||
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
||||
// concrete device and follows default-device changes). Two engines, not one — a single
|
||||
// AVAudioEngine ties input+output to one aggregate clock, separate engines keep
|
||||
// arbitrary mic/speaker combinations trivial.
|
||||
// concrete device and follows default-device changes).
|
||||
|
||||
import AVFoundation
|
||||
import os
|
||||
@@ -40,7 +47,21 @@ public final class SessionAudio {
|
||||
private let stateLock = NSLock()
|
||||
private var playbackEngine: AVAudioEngine?
|
||||
private var captureEngine: AVAudioEngine?
|
||||
/// The one engine running BOTH directions when the voice processor is engaged;
|
||||
/// `playbackEngine`/`captureEngine` stay nil while this is set.
|
||||
private var combinedEngine: AVAudioEngine?
|
||||
private var drainStarted = false
|
||||
/// The mute LATCH: the effective mute the owner last asked for (see `setMicMuted`). Held
|
||||
/// because the uplink engine can appear LATER than the request — the mic permission prompt
|
||||
/// is answered at the user's leisure, and the engine is built on the grant — so a mute set
|
||||
/// in the meantime must be waiting for it. Applied by whichever start path wins the race.
|
||||
/// Guarded by `stateLock`, like the engines it applies to.
|
||||
private var micMuted = false
|
||||
/// The playback jitter ring — created by whichever engine starts playback first and KEPT
|
||||
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
|
||||
/// like every start path.
|
||||
private var ring: AudioRing?
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
/// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE
|
||||
@@ -69,11 +90,15 @@ public final class SessionAudio {
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) {
|
||||
/// `echoCancel` picks the engine topology — see the header note and `wantsCombined`.
|
||||
public func start(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
) {
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled)
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
@@ -85,7 +110,7 @@ public final class SessionAudio {
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled)
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -104,6 +129,12 @@ public final class SessionAudio {
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||
// is actually granted, the capture tap handles the buffers it gets.
|
||||
try? session.setPreferredIOBufferDuration(0.005)
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
}
|
||||
@@ -117,32 +148,80 @@ public final class SessionAudio {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||
/// iOS/tvOS the session is already active by the time this runs.
|
||||
private func startEngines(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
) {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#else
|
||||
guard micEnabled else { return }
|
||||
guard micEnabled else {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
return
|
||||
}
|
||||
let combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
if combined {
|
||||
startCombined(speakerUID: speakerUID, micUID: micUID, micChannel: micChannel)
|
||||
} else {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
}
|
||||
case .notDetermined:
|
||||
// Playback must not wait out the permission prompt (the user answers at their
|
||||
// leisure) — start it now, and on a grant either bolt the capture engine on
|
||||
// (split) or swap the playback engine for the combined one (the ring and its
|
||||
// drain thread carry over — see `makePlaybackChain`).
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, granted, !self.flag.isStopped else { return }
|
||||
self.startCapture(micUID: micUID, micChannel: micChannel)
|
||||
if combined {
|
||||
self.stateLock.lock()
|
||||
let playback = self.playbackEngine
|
||||
self.playbackEngine = nil
|
||||
self.stateLock.unlock()
|
||||
playback?.stop()
|
||||
self.startCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel)
|
||||
} else {
|
||||
self.startCapture(micUID: micUID, micChannel: micChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
log.warning("microphone access denied — mic uplink disabled (System Settings → Privacy)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One engine or two: the voice processor requires render + capture on one unit, and that
|
||||
/// unit can only follow the system DEFAULT devices — so echo cancellation gets the combined
|
||||
/// engine only while nothing is explicitly pinned. On macOS a chosen speaker/mic UID or a
|
||||
/// picked input channel (the voice processor's capture side is its own mono mix — a
|
||||
/// per-channel pick can't survive it) keeps today's two-engine path, AEC-less but honoring
|
||||
/// the exact endpoints the user named. On iOS routes are session-managed and the UIDs are
|
||||
/// ignored, so the toggle alone decides.
|
||||
private func wantsCombined(
|
||||
speakerUID: String, micUID: String, micChannel: Int, echoCancel: Bool
|
||||
) -> Bool {
|
||||
guard echoCancel else { return false }
|
||||
#if os(macOS)
|
||||
return speakerUID.isEmpty && micUID.isEmpty && micChannel == 0
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Stop both directions. Safe from any thread; waits the drain thread out (≤ its
|
||||
/// poll timeout) so the caller can close the connection right after.
|
||||
public func stop() {
|
||||
@@ -152,6 +231,8 @@ public final class SessionAudio {
|
||||
captureEngine = nil
|
||||
let playback = playbackEngine
|
||||
playbackEngine = nil
|
||||
let combined = combinedEngine
|
||||
combinedEngine = nil
|
||||
let wasDraining = drainStarted
|
||||
drainStarted = false
|
||||
stateLock.unlock()
|
||||
@@ -160,6 +241,10 @@ public final class SessionAudio {
|
||||
capture.stop()
|
||||
}
|
||||
playback?.stop()
|
||||
if let combined {
|
||||
combined.inputNode.removeTap(onBus: 0)
|
||||
combined.stop()
|
||||
}
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
||||
@@ -180,15 +265,38 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio
|
||||
/// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when
|
||||
/// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for
|
||||
/// background playback, so iOS may keep showing the recording indicator until a full reconfigure
|
||||
/// — this stops the actual capture, which is the privacy-relevant part. Main thread.
|
||||
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
|
||||
/// mechanism: the owner composes its reasons — the user's in-stream mute and the background
|
||||
/// keep-alive's privacy mute — into one effective state and passes that here, so neither can
|
||||
/// clear the other (see `SessionModel.applyMicMute`).
|
||||
///
|
||||
/// Two-engine sessions pause/resume the capture engine; a combined session instead mutes the
|
||||
/// voice processor's input (playback shares that engine and must keep running, so the engine
|
||||
/// itself never pauses — the mute zeroes the mic at the IO unit, and the tap encodes silence).
|
||||
/// Local and instant either way: nothing is negotiated with the host, and the packets that do
|
||||
/// leave carry silence. A no-op when there's no uplink (playback-only / tvOS / mic disabled),
|
||||
/// except that the state is LATCHED for an uplink that starts later. The audio SESSION stays
|
||||
/// active for background playback, so iOS may keep showing the recording indicator until a
|
||||
/// full reconfigure — either path stops room audio leaving the device, which is the
|
||||
/// privacy-relevant part. Main thread.
|
||||
public func setMicMuted(_ muted: Bool) {
|
||||
stateLock.lock()
|
||||
micMuted = muted
|
||||
let capture = captureEngine
|
||||
let combined = combinedEngine
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: capture, combined: combined)
|
||||
}
|
||||
|
||||
/// Push the latched mute onto whichever engine carries the uplink. Split out from
|
||||
/// `setMicMuted` because the start paths call it too, with the engine they just started —
|
||||
/// that's how a mute requested before the permission grant lands on the engine the grant
|
||||
/// creates. Never resumes a stopped session's engine.
|
||||
private func apply(micMuted muted: Bool, capture: AVAudioEngine?, combined: AVAudioEngine?) {
|
||||
if let combined {
|
||||
combined.inputNode.isVoiceProcessingInputMuted = muted
|
||||
return
|
||||
}
|
||||
guard let capture else { return }
|
||||
if muted {
|
||||
capture.pause()
|
||||
@@ -199,28 +307,21 @@ public final class SessionAudio {
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
||||
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
|
||||
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
|
||||
/// when the host's channel layout can't be expressed (already logged). Main thread.
|
||||
private func makePlaybackChain()
|
||||
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
|
||||
{
|
||||
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||
// before the first sample plays), both scaled by the channel count.
|
||||
let ring = AudioRing(
|
||||
let ring = self.ring ?? AudioRing(
|
||||
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
#if os(macOS)
|
||||
if !speakerUID.isEmpty {
|
||||
if let dev = AudioDevices.deviceID(forUID: speakerUID),
|
||||
let unit = engine.outputNode.audioUnit {
|
||||
if !Self.setDevice(dev, on: unit) {
|
||||
log.error("could not select speaker \(speakerUID) — using default")
|
||||
}
|
||||
} else {
|
||||
log.warning("speaker \(speakerUID) not present — using default")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
self.ring = ring
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
|
||||
@@ -234,7 +335,7 @@ public final class SessionAudio {
|
||||
}
|
||||
guard let format else {
|
||||
log.error("could not build \(channels)-channel audio format — audio disabled")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
let scratch = ScratchBuffer() // block-owned; freed with the closure
|
||||
let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in
|
||||
@@ -252,6 +353,24 @@ public final class SessionAudio {
|
||||
}
|
||||
return noErr
|
||||
}
|
||||
return (ring, source, format)
|
||||
}
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
guard let (ring, source, format) = makePlaybackChain() else { return }
|
||||
let engine = AVAudioEngine()
|
||||
#if os(macOS)
|
||||
if !speakerUID.isEmpty {
|
||||
if let dev = AudioDevices.deviceID(forUID: speakerUID),
|
||||
let unit = engine.outputNode.audioUnit {
|
||||
if !Self.setDevice(dev, on: unit) {
|
||||
log.error("could not select speaker \(speakerUID) — using default")
|
||||
}
|
||||
} else {
|
||||
log.warning("speaker \(speakerUID) not present — using default")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: format)
|
||||
engine.prepare()
|
||||
@@ -272,8 +391,14 @@ public final class SessionAudio {
|
||||
startDrain(into: ring)
|
||||
}
|
||||
|
||||
/// Idempotent — the permission-grant engine swap reaches here a second time with the
|
||||
/// drain thread already feeding the (carried-over) ring.
|
||||
private func startDrain(into ring: AudioRing) {
|
||||
stateLock.lock()
|
||||
if drainStarted {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
drainStarted = true
|
||||
stateLock.unlock()
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
@@ -308,6 +433,80 @@ public final class SessionAudio {
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One engine, both directions: engage the system voice processor on the shared IO unit
|
||||
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
|
||||
/// mic tap off its capture side. Every failure falls back to a WORKING configuration —
|
||||
/// the split path (no AEC) when the voice processor won't engage, plain playback when the
|
||||
/// mic chain can't be built — a session never loses audio to the echo-cancel feature.
|
||||
private func startCombined(speakerUID: String, micUID: String, micChannel: Int) {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
do {
|
||||
// Before anything reads the input's format: the voice processor changes it (often
|
||||
// to its own mono mix, sometimes at a lower rate) — installMicTap reads the format
|
||||
// AFTER this, so the converter chain adapts to whatever the processor emits.
|
||||
try input.setVoiceProcessingEnabled(true)
|
||||
} catch {
|
||||
log.warning("""
|
||||
voice processing unavailable (\(error.localizedDescription)) — separate \
|
||||
engines, no echo cancellation
|
||||
""")
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
// Symmetric enable for the render side; with both directions on one engine the
|
||||
// input-node enable already covers it, so a refusal here is not a failure.
|
||||
try? engine.outputNode.setVoiceProcessingEnabled(true)
|
||||
// This is a game stream, not a call: never duck the host's audio under the outgoing
|
||||
// voice. .min is the closest to "off" the API offers, and advanced (selective)
|
||||
// ducking stays off with it.
|
||||
input.voiceProcessingOtherAudioDuckingConfiguration = .init(
|
||||
enableAdvancedDucking: false, duckingLevel: .min)
|
||||
|
||||
guard let (ring, source, format) = makePlaybackChain() else {
|
||||
// Playback impossible (logged) — keep the uplink alive, as the split path would.
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: format)
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable (logged) — keep the session audible on the plain playback
|
||||
// engine rather than playing through an idle voice processor.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
return
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
if flag.isStopped {
|
||||
stateLock.unlock()
|
||||
input.removeTap(onBus: 0)
|
||||
engine.stop() // stop() already ran — don't strand a started engine (or a hot mic)
|
||||
return
|
||||
}
|
||||
combinedEngine = engine
|
||||
let muted = micMuted // latched before this engine existed (a mute during the prompt)
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: nil, combined: engine)
|
||||
startDrain(into: ring)
|
||||
log.info("audio engines joined — voice processing (echo cancellation) active")
|
||||
}
|
||||
|
||||
/// The split path: capture on its OWN engine, playback on another — the pre-echo-cancel
|
||||
/// topology, kept verbatim. Two engines, not one — a single AVAudioEngine ties
|
||||
/// input+output to one aggregate clock, separate engines keep arbitrary mic/speaker
|
||||
/// combinations trivial. That freedom is exactly why the voice processor can't ride this
|
||||
/// path (AEC needs both directions on one unit) and why explicitly pinned endpoints land
|
||||
/// here — see `wantsCombined`.
|
||||
private func startCapture(micUID: String, micChannel: Int) {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
@@ -322,11 +521,44 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("capture engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
if flag.isStopped {
|
||||
// stop() ran while we were starting (the permission prompt resolves at the
|
||||
// user's leisure) — tear the engine down ourselves, nobody else owns it now.
|
||||
stateLock.unlock()
|
||||
input.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
return
|
||||
}
|
||||
captureEngine = engine
|
||||
let muted = micMuted // latched before this engine existed (a mute during the prompt)
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: engine, combined: nil)
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
}
|
||||
|
||||
/// Resolve the input's live format + fold plan, build the mono→Opus chain, and install the
|
||||
/// capture tap on `input` — everything mic except engine ownership, shared verbatim by the
|
||||
/// combined and split topologies. Reads `input.outputFormat(forBus:)` at call time, so the
|
||||
/// chain follows whatever the node emits: the raw device format, or the voice processor's
|
||||
/// own mix when that's enabled. False (logged) when no input is usable or the encoder
|
||||
/// can't be built; the tap is installed on true.
|
||||
private func installMicTap(
|
||||
on input: AVAudioInputNode, micUID: String, micChannel: Int
|
||||
) -> Bool {
|
||||
let inFormat = input.outputFormat(forBus: 0)
|
||||
guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {
|
||||
log.error("no usable input device — mic uplink disabled")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic
|
||||
@@ -378,22 +610,38 @@ public final class SessionAudio {
|
||||
#endif
|
||||
|
||||
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||
// mono@inputSR → the encoder's 48 kHz stereo, so it handles both the rate change and the
|
||||
// mono→stereo duplication, and the wrong-channel downmix never happens.
|
||||
// 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.
|
||||
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),
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
|
||||
let monoScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: scratchFrames),
|
||||
let stagingScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
|
||||
else {
|
||||
log.error("Opus encoder unavailable — mic uplink disabled")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Tap-thread-confined state: resample into `staging`, accumulate in `fifo`,
|
||||
// slice 960-frame chunks for the encoder.
|
||||
// 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
|
||||
var fifo: [Float] = []
|
||||
fifo.reserveCapacity(48_000)
|
||||
var seq: UInt32 = 0
|
||||
@@ -412,14 +660,26 @@ public final class SessionAudio {
|
||||
var inputPeak: Float = 0
|
||||
var levelReported = false
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
|
||||
// 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
|
||||
if flag.isStopped { return }
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let src = buffer.floatChannelData,
|
||||
let mono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let dst = mono.floatChannelData?[0]
|
||||
else { return }
|
||||
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))
|
||||
else { return }
|
||||
mono = biggerMono
|
||||
staging = biggerStaging
|
||||
}
|
||||
guard let dst = mono.floatChannelData?[0] else { return }
|
||||
mono.frameLength = buffer.frameLength
|
||||
|
||||
// Fold the multi-channel input down to the one mono bus we encode.
|
||||
@@ -451,11 +711,6 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
|
||||
let ratio = 48_000 / inFormat.sampleRate
|
||||
let outCapacity = AVAudioFrameCount((Double(frames) * ratio).rounded(.up) + 64)
|
||||
guard let staging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
|
||||
else { return }
|
||||
var fed = false
|
||||
var convError: NSError?
|
||||
let status = resampler.convert(to: staging, error: &convError) { _, outStatus in
|
||||
@@ -469,16 +724,20 @@ public final class SessionAudio {
|
||||
}
|
||||
guard status != .error, let p = staging.floatChannelData?[0] else { return }
|
||||
fifo.append(contentsOf: UnsafeBufferPointer(
|
||||
start: p, count: Int(staging.frameLength) * 2))
|
||||
start: p, count: Int(staging.frameLength)))
|
||||
|
||||
let samplesPerChunk = Int(OpusEncoder.framesPerPacket) * 2
|
||||
while fifo.count >= samplesPerChunk {
|
||||
chunk.frameLength = OpusEncoder.framesPerPacket
|
||||
// Consume whole chunks through a head index, then drop the eaten prefix in ONE
|
||||
// move of the sub-chunk remainder. The old per-chunk removeFirst memmoved the
|
||||
// entire backlog for every packet — O(n) on the render-adjacent tap thread.
|
||||
let samplesPerChunk = Int(encoder.framesPerPacket)
|
||||
var head = 0
|
||||
while fifo.count - head >= samplesPerChunk {
|
||||
chunk.frameLength = encoder.framesPerPacket
|
||||
fifo.withUnsafeBufferPointer { src in
|
||||
chunk.floatChannelData![0].update(
|
||||
from: src.baseAddress!, count: samplesPerChunk)
|
||||
from: src.baseAddress! + head, count: samplesPerChunk)
|
||||
}
|
||||
fifo.removeFirst(samplesPerChunk)
|
||||
head += samplesPerChunk
|
||||
guard let packets = try? encoder.encode(chunk) else { continue }
|
||||
for packet in packets {
|
||||
connection.sendMic(
|
||||
@@ -486,28 +745,9 @@ public final class SessionAudio {
|
||||
seq &+= 1
|
||||
}
|
||||
}
|
||||
if head > 0 { fifo.removeFirst(head) } // keeps capacity — no realloc
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("capture engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
if flag.isStopped {
|
||||
// stop() ran while we were starting (the permission prompt resolves at the
|
||||
// user's leisure) — tear the engine down ourselves, nobody else owns it now.
|
||||
stateLock.unlock()
|
||||
input.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
return
|
||||
}
|
||||
captureEngine = engine
|
||||
stateLock.unlock()
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||
|
||||
@@ -761,6 +761,21 @@ public final class PunktfunkConnection {
|
||||
return out
|
||||
}
|
||||
|
||||
/// Report the display-latch grid + circular arrival-phase statistic so the host can
|
||||
/// phase-lock its capture tick (design/phase-locked-capture.md). Fire-and-forget; call
|
||||
/// ~1 Hz from a vsync-aware presenter. `nextLatchHostNs` must already be HOST clock —
|
||||
/// convert with `clockOffsetNs` (host − client). No-op toward a host that never armed.
|
||||
public func reportPhase(
|
||||
nextLatchHostNs: UInt64, latchPeriodNs: UInt32, uncertaintyNs: UInt32,
|
||||
arrivalLeadNs: UInt32, coherenceMilli: UInt16
|
||||
) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_report_phase(
|
||||
h, nextLatchHostNs, latchPeriodNs, uncertaintyNs, arrivalLeadNs, coherenceMilli)
|
||||
}
|
||||
|
||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
|
||||
@@ -128,11 +128,19 @@ public final class InputCapture {
|
||||
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
||||
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats
|
||||
/// overlay tier (off → compact → normal → detailed). Main queue.
|
||||
/// overlay tier (off → compact → normal → detailed). ⌃⌥⇧A (`onToggleMicMute`, below) rides
|
||||
/// the same path. Main queue.
|
||||
public var onReleaseCapture: (() -> Void)?
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌥⇧A — mute/unmute the microphone uplink, the one in-stream control a captured
|
||||
/// session can't otherwise reach (the HUD's button is behind a grabbed cursor). Same delivery
|
||||
/// rule as the combos above: only WHILE FORWARDING, because that's when the menu's identical
|
||||
/// key equivalent can't fire. ⌃⌥⇧M — the obvious letter — is long since the mouse-model flip
|
||||
/// (cross-client), so A ("audio in") is the mic's. Main queue.
|
||||
public var onToggleMicMute: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the
|
||||
/// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view
|
||||
/// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the
|
||||
@@ -140,13 +148,13 @@ public final class InputCapture {
|
||||
public var onToggleFullscreen: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧ chords, both L/R sides:
|
||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
|
||||
|
||||
/// Whether Control AND Option AND Shift are all currently held (either side of each counts) —
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧Q release chord.
|
||||
private var hasReleaseChordModifiers: Bool {
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧ chords (Q releases capture, A mutes the mic).
|
||||
private var hasChordModifiers: Bool {
|
||||
let m = chordModifiersDown
|
||||
return (m.contains(0xA2) || m.contains(0xA3)) // control
|
||||
&& (m.contains(0xA4) || m.contains(0xA5)) // option
|
||||
@@ -284,6 +292,10 @@ public final class InputCapture {
|
||||
self.suppressedVK = 0x53
|
||||
self.onCycleStats?()
|
||||
return nil
|
||||
case 0 /* A */:
|
||||
self.suppressedVK = 0x41
|
||||
self.onToggleMicMute?()
|
||||
return nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -704,7 +716,7 @@ public final class InputCapture {
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧ chords below — in both forwarding
|
||||
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
|
||||
if Self.chordModifierVKs.contains(vk) {
|
||||
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
|
||||
@@ -732,11 +744,19 @@ public final class InputCapture {
|
||||
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
|
||||
// the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release
|
||||
// path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q).
|
||||
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
|
||||
if pressed, vk == 0x51, self.hasChordModifiers {
|
||||
self.suppressedVK = 0x51
|
||||
self.onReleaseCapture?()
|
||||
return
|
||||
}
|
||||
// ⌃⌥⇧A mutes/unmutes the mic uplink — same detection, same latching, and needed here
|
||||
// for the same reason as on macOS: a captured iPad swallows the Stream menu's
|
||||
// identical key equivalent. VK 0x41 is layout-independent (physical A).
|
||||
if pressed, vk == 0x41, self.hasChordModifiers {
|
||||
self.suppressedVK = 0x41
|
||||
self.onToggleMicMute?()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// The host cards' OS marks: template vector imagesets in Resources/OsIcons.xcassets
|
||||
// (derived from the repo's assets/os-icons masters — Font Awesome Free brands CC BY 4.0 +
|
||||
// Simple Icons CC0; provenance in that directory's README), resolved from the host's
|
||||
// (generated from the repo's assets/os-icons masters by scripts/gen-os-icons.sh —
|
||||
// per-mark provenance and licensing in that directory's README), resolved from the host's
|
||||
// OS-identity chain via PunktfunkShared's `osIconTokens` walk. Template rendering means
|
||||
// they tint with `foregroundStyle` like an SF Symbol.
|
||||
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
|
||||
/// The icon tokens this client ships art for. A distro without its own mark (Bazzite,
|
||||
/// CachyOS, ...) degrades to its family's and finally to Tux via the chain walk.
|
||||
/// The icon tokens this client ships art for: the families a chain can land on, plus the
|
||||
/// gaming distros that earn their own mark because "a Bazzite box" and "a Fedora box" are
|
||||
/// different machines to the person reading the card. A distro with no mark of its own
|
||||
/// still degrades to its family's and finally to Tux via the chain walk.
|
||||
private let osIconTokensShipped: Set<String> = [
|
||||
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos",
|
||||
"opensuse",
|
||||
"opensuse", "bazzite", "cachyos", "nobara",
|
||||
]
|
||||
|
||||
/// The mark for an OS-identity chain (`linux/fedora/bazzite`, ...), or nil — no view at
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "bazzite.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "cachyos.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "nobara.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -355,6 +355,13 @@ final class SessionPresenter {
|
||||
/// behavior — iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
|
||||
/// rate (it already tracks the display and must NOT be capped to the stream rate).
|
||||
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
|
||||
/// Pen-proximity panel-rate boost pass-through (Stage2Pipeline.setInteractionBoost):
|
||||
/// deadline pacing only — under arrival/glass the staged hint feeds no link, so this
|
||||
/// is a no-op there. MAIN thread.
|
||||
func setInteractionBoost(_ on: Bool) {
|
||||
stage2?.setInteractionBoost(on)
|
||||
}
|
||||
|
||||
private func syncFrameRate(hz: UInt32) {
|
||||
guard hz > 0 else { return }
|
||||
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
|
||||
|
||||
@@ -215,7 +215,8 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||
/// "fixed-interval" jitter reports).
|
||||
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
|
||||
/// - `glass` (stage-3; tvOS's default until the 2026-07 rebuild moved it to stage-4, now an
|
||||
/// on-device A/B rung): at most a small BOUNDED number of presented-but-
|
||||
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
||||
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
||||
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
||||
@@ -278,10 +279,27 @@ final class LatestBox<T>: @unchecked Sendable {
|
||||
private final class FrameRateHint: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var pending: CAFrameRateRange?
|
||||
private var streamHz: Float = 0
|
||||
private var boosted = false
|
||||
func stage(hz: Float) {
|
||||
guard hz > 0 else { return }
|
||||
lock.lock()
|
||||
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
|
||||
streamHz = hz
|
||||
pending = Self.range(hz: hz, boosted: boosted)
|
||||
lock.unlock()
|
||||
}
|
||||
/// Pen-proximity boost: pin `minimum = preferred` at the range's CEILING instead of the
|
||||
/// stream rate. UIKit delivers touch/Pencil events at the PANEL's cadence, and the panel
|
||||
/// follows this link's vote — so a 60 fps stream on a 120 Hz iPad halves pencil sampling
|
||||
/// unless a boost lifts the panel while the Pencil is in range. Presents still pace at
|
||||
/// stream rate (extra link updates just vend into the newest-wins stash), so the cost is
|
||||
/// empty wakes, scoped to pen proximity.
|
||||
func setBoost(_ on: Bool) {
|
||||
lock.lock()
|
||||
if boosted != on {
|
||||
boosted = on
|
||||
if streamHz > 0 { pending = Self.range(hz: streamHz, boosted: on) }
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
func drain() -> CAFrameRateRange? {
|
||||
@@ -291,6 +309,113 @@ private final class FrameRateHint: @unchecked Sendable {
|
||||
pending = nil
|
||||
return p
|
||||
}
|
||||
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
|
||||
let cap = max(hz, 120)
|
||||
let preferred = boosted ? cap : hz
|
||||
return CAFrameRateRange(minimum: preferred, maximum: cap, preferred: preferred)
|
||||
}
|
||||
}
|
||||
|
||||
/// The client half of phase-locked capture (design/phase-locked-capture.md): the decode
|
||||
/// callback deposits per-AU arrival stamps (client CLOCK_REALTIME — the core's reassembly-
|
||||
/// completion time), the deadline link's thread deposits the latch grid, and ~1 Hz that same
|
||||
/// thread flushes the circular arrival-phase statistic to the host. The statistic is a
|
||||
/// verbatim port of `punktfunk_core::phase::circular_latch` — the host's v3 controller
|
||||
/// (grid-locked submits, coherence-gated engage) was tuned against exactly it, and a
|
||||
/// period-smeared Wi-Fi link correctly reads coherence ≈ 0 there, so the controller never
|
||||
/// engages where alignment is physically pointless. Shared box (never captures the pipeline);
|
||||
/// a session's connection binds/unbinds like DecodeReport/KeyframeRecovery.
|
||||
final class PhaseReporter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var connection: PunktfunkConnection?
|
||||
/// Arrival stamps since the last flush, client CLOCK_REALTIME. Bounded: ~1 s at 240 fps.
|
||||
private var arrivalsNs: [Int64] = []
|
||||
/// Smallest update-to-update spacing this window: successive `nextLatch` values sit one
|
||||
/// panel period apart except across skipped link updates (2×, 3×, …), so the window
|
||||
/// minimum IS the period. Re-learned every flush so VRR/mode switches track both ways.
|
||||
private var periodNs: Int64 = 0
|
||||
private var prevLatchRealNs: Int64 = 0
|
||||
private var lastFlushRealNs: Int64 = 0
|
||||
|
||||
func bind(_ c: PunktfunkConnection?) {
|
||||
lock.lock()
|
||||
connection = c
|
||||
arrivalsNs.removeAll()
|
||||
periodNs = 0
|
||||
prevLatchRealNs = 0
|
||||
lastFlushRealNs = 0
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Decode-callback side: one AU's arrival (reassembly-completion) stamp.
|
||||
func noteArrival(receivedNs: Int64) {
|
||||
lock.lock()
|
||||
if connection != nil, arrivalsNs.count < 256 { arrivalsNs.append(receivedNs) }
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Link-thread side, once per update: where the NEXT latch sits on the client's realtime
|
||||
/// clock (the arrival stamps' domain). Learns the period from update spacing and ~1 Hz
|
||||
/// converts the window's arrivals into leads against this grid, then reports — a
|
||||
/// fire-and-forget datagram push on the control plane.
|
||||
func noteGrid(nextLatchRealNs: Int64) {
|
||||
lock.lock()
|
||||
if prevLatchRealNs > 0 {
|
||||
let delta = nextLatchRealNs - prevLatchRealNs
|
||||
// 2–100 ms accepts 10–500 Hz panels, rejects wakeup hiccups and clock jumps.
|
||||
if delta > 2_000_000, delta < 100_000_000, periodNs == 0 || delta < periodNs {
|
||||
periodNs = delta
|
||||
}
|
||||
}
|
||||
prevLatchRealNs = nextLatchRealNs
|
||||
guard let c = connection, periodNs > 0, arrivalsNs.count >= 8,
|
||||
nextLatchRealNs - lastFlushRealNs >= 1_000_000_000
|
||||
else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lastFlushRealNs = nextLatchRealNs
|
||||
let period = periodNs
|
||||
let leadsUs = arrivalsNs.map { a -> UInt64 in
|
||||
let m = (nextLatchRealNs - a) % period
|
||||
return UInt64(m < 0 ? m + period : m) / 1000
|
||||
}
|
||||
arrivalsNs.removeAll(keepingCapacity: true)
|
||||
periodNs = 0
|
||||
let offsetNs = c.clockOffsetNs
|
||||
lock.unlock()
|
||||
guard
|
||||
let (leadMeanNs, coherence) = Self.circularLatch(
|
||||
samplesUs: leadsUs, periodNs: period)
|
||||
else { return }
|
||||
c.reportPhase(
|
||||
nextLatchHostNs: UInt64(max(0, nextLatchRealNs + offsetNs)),
|
||||
latchPeriodNs: UInt32(clamping: period),
|
||||
uncertaintyNs: 1_000_000, // skew residual — same conservative 1 ms as Android
|
||||
arrivalLeadNs: UInt32(clamping: leadMeanNs),
|
||||
coherenceMilli: coherence)
|
||||
}
|
||||
|
||||
/// Verbatim port of `punktfunk_core::phase::circular_latch` (µs samples against an ns
|
||||
/// period; nil under 8 samples). The MEAN is what a phase controller can steer under
|
||||
/// jitter — a period-spanning distribution's median is immovable — and the coherence
|
||||
/// (resultant length, ‰) says whether any phase exists to steer at all.
|
||||
static func circularLatch(samplesUs: [UInt64], periodNs: Int64) -> (UInt64, UInt16)? {
|
||||
guard samplesUs.count >= 8, periodNs > 0 else { return nil }
|
||||
let periodUs = Double(periodNs) / 1000.0
|
||||
var x = 0.0
|
||||
var y = 0.0
|
||||
for s in samplesUs {
|
||||
let theta = Double(s).truncatingRemainder(dividingBy: periodUs) / periodUs * 2 * .pi
|
||||
x += cos(theta)
|
||||
y += sin(theta)
|
||||
}
|
||||
let n = Double(samplesUs.count)
|
||||
let r = (x * x + y * y).squareRoot() / n
|
||||
var meanTheta = atan2(y, x)
|
||||
if meanTheta < 0 { meanTheta += 2 * .pi }
|
||||
return (UInt64(meanTheta / (2 * .pi) * Double(periodNs)), UInt16(r * 1000.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
|
||||
@@ -304,6 +429,8 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
private let renderSignal: DispatchSemaphore
|
||||
private let hint: FrameRateHint
|
||||
private let stats: PresentDebugStats?
|
||||
/// Phase-locked capture's grid feed — this link IS the latch grid presents pace against.
|
||||
private let phase: PhaseReporter?
|
||||
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vend→glass
|
||||
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
|
||||
/// shown display/e2e numbers. Self-adapting — reads ~2 refresh periods composited today,
|
||||
@@ -316,13 +443,15 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
|
||||
init(
|
||||
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
||||
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
|
||||
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?,
|
||||
phase: PhaseReporter?
|
||||
) {
|
||||
self.stash = stash
|
||||
self.renderSignal = renderSignal
|
||||
self.hint = hint
|
||||
self.stats = stats
|
||||
self.floorMeter = floorMeter
|
||||
self.phase = phase
|
||||
}
|
||||
|
||||
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
||||
@@ -354,6 +483,15 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
floorMeter.record(
|
||||
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
|
||||
}
|
||||
// Phase-locked capture: this update's target present, converted into the arrival
|
||||
// stamps' CLOCK_REALTIME domain. Per-update cost is one clock read; the reporter
|
||||
// itself flushes ~1 Hz.
|
||||
if let phase {
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
phase.noteGrid(nextLatchRealNs: nowNs + Int64(leadS * 1_000_000_000))
|
||||
}
|
||||
stash.put(update.drawable)
|
||||
renderSignal.signal()
|
||||
}
|
||||
@@ -583,6 +721,7 @@ public final class Stage2Pipeline {
|
||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||
/// binds the live connection + arming flag (see DecodeReport).
|
||||
private let decodeReport = DecodeReport()
|
||||
private let phaseReporter = PhaseReporter()
|
||||
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
|
||||
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
|
||||
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||
@@ -649,6 +788,7 @@ public final class Stage2Pipeline {
|
||||
let renderSignal = renderSignal
|
||||
let gate = gate
|
||||
let decodeReport = decodeReport
|
||||
let phaseReporter = phaseReporter
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
@@ -660,6 +800,9 @@ public final class Stage2Pipeline {
|
||||
// device's real decode limit instead of the network link ceiling. Every decoded
|
||||
// frame (not just presented ones), so a newest-wins drop can't hide the backlog.
|
||||
decodeReport.record(receivedNs: frame.receivedNs, decodedNs: frame.decodedNs)
|
||||
// Phase-locked capture's arrival half: the stamp VALUE is reassembly
|
||||
// completion, so recording it at decode adds no bias to the 1 Hz aggregate.
|
||||
phaseReporter.noteArrival(receivedNs: frame.receivedNs)
|
||||
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
|
||||
// garbage VideoToolbox returns Ok for a reference-missing delta) — don't submit it,
|
||||
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
|
||||
@@ -689,6 +832,7 @@ public final class Stage2Pipeline {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
|
||||
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
|
||||
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||
|
||||
@@ -975,6 +1119,7 @@ public final class Stage2Pipeline {
|
||||
let stash = LatestBox<CAMetalDrawable>()
|
||||
|
||||
let floorMeter = presentFloorMeter
|
||||
let phaseReporter = phaseReporter
|
||||
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
||||
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
|
||||
// drawableSize for the whole connect window: every vend fails allocation and the system
|
||||
@@ -985,7 +1130,7 @@ public final class Stage2Pipeline {
|
||||
let linkThread = Thread {
|
||||
let delegate = DeadlineLinkDelegate(
|
||||
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
||||
floorMeter: floorMeter)
|
||||
floorMeter: floorMeter, phase: phaseReporter)
|
||||
let link = CAMetalDisplayLink(metalLayer: layer)
|
||||
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
||||
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
||||
@@ -1107,6 +1252,15 @@ public final class Stage2Pipeline {
|
||||
frameRateHint.stage(hz: hz)
|
||||
}
|
||||
|
||||
/// Pen-proximity rate boost (drawing workloads): drive the deadline link — and with it the
|
||||
/// panel, whose cadence paces UIKit's touch/Pencil event delivery — at the range ceiling
|
||||
/// while a Pencil is in range, so a sub-panel-rate stream stops halving pencil sampling.
|
||||
/// Staged like the rate hint; no-op under arrival/glass pacing. MAIN thread.
|
||||
public func setInteractionBoost(_ on: Bool) {
|
||||
frameRateHint.setBoost(on)
|
||||
presentLog.info("pen boost \(on ? "engaged" : "released", privacy: .public)")
|
||||
}
|
||||
|
||||
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||
public func setDrawableTarget(_ size: CGSize) {
|
||||
@@ -1154,6 +1308,7 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
decoder.reset()
|
||||
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
||||
phaseReporter.bind(nil) // and stop phase reports toward the dead connection
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -23,6 +23,10 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
||||
|
||||
/// One assembled batch (≤ `PUNKTFUNK_PEN_BATCH_MAX` samples) ready for the connection.
|
||||
var send: (([PunktfunkPenSample]) -> Void)?
|
||||
/// Proximity transitions (in-range ∪ touching) — drives the panel-rate boost while the
|
||||
/// Pencil is near the glass. Fired from emit(), the choke point every state change exits
|
||||
/// through.
|
||||
var onProximity: ((Bool) -> Void)?
|
||||
/// View-space point → normalized [0,1] video coordinates (the letterbox mapping the
|
||||
/// touch path already uses). nil until a mode is negotiated — samples are dropped then.
|
||||
var videoNorm: ((CGPoint) -> (Float, Float)?)?
|
||||
@@ -40,6 +44,10 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
||||
private(set) var hoverActive = false
|
||||
private var last = PencilStream.idleSample()
|
||||
private var heartbeat: Timer?
|
||||
/// When the last batch (event or keepalive) went out — the heartbeat tick's idle test.
|
||||
private var lastSendTs: TimeInterval = 0
|
||||
/// The last proximity state surfaced through `onProximity` (transition detection).
|
||||
private var lastProximity = false
|
||||
|
||||
// MARK: - Contact path (UITouch, `.pencil` only)
|
||||
|
||||
@@ -52,11 +60,12 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
||||
touching = true
|
||||
inRange = true
|
||||
// Coalesced samples restore the Pencil's full capture rate (UIKit delivers at
|
||||
// display cadence); oldest first, `dt_us` preserving their spacing.
|
||||
// display cadence); oldest first, `dt_us` preserving their spacing. The whole run
|
||||
// is kept — emit() splits anything over the wire's batch cap.
|
||||
let raw = event?.coalescedTouches(for: touch) ?? [touch]
|
||||
var batch: [PunktfunkPenSample] = []
|
||||
var prevTs: TimeInterval?
|
||||
for t in raw.suffix(Int(PUNKTFUNK_PEN_BATCH_MAX)) {
|
||||
for t in raw {
|
||||
guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue }
|
||||
prevTs = t.timestamp
|
||||
batch.append(s)
|
||||
@@ -202,27 +211,51 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
||||
guard !batch.isEmpty else { return }
|
||||
last = batch[batch.count - 1]
|
||||
last.dt_us = 0
|
||||
send?(batch)
|
||||
armHeartbeat()
|
||||
// A run longer than the wire's batch cap is SPLIT into consecutive sends, never
|
||||
// truncated (the send_pen contract): an over-cap run means the main thread hitched
|
||||
// past a frame of 240 Hz samples, and dropping its head would notch exactly the
|
||||
// stroke geometry a drawing app can least afford to lose.
|
||||
var start = 0
|
||||
while start < batch.count {
|
||||
let end = min(start + Int(PUNKTFUNK_PEN_BATCH_MAX), batch.count)
|
||||
send?(Array(batch[start..<end]))
|
||||
start = end
|
||||
}
|
||||
lastSendTs = CACurrentMediaTime()
|
||||
syncHeartbeat()
|
||||
let near = inRange || touching
|
||||
if near != lastProximity {
|
||||
lastProximity = near
|
||||
onProximity?(near)
|
||||
}
|
||||
}
|
||||
|
||||
/// The ≤100 ms keepalive while in range (see the file header). 80 ms leaves headroom
|
||||
/// under the host's 200 ms failsafe even with one lost datagram.
|
||||
private func armHeartbeat() {
|
||||
heartbeat?.invalidate()
|
||||
/// The ≤100 ms keepalive while in range (see the file header): ONE long-lived 50 ms timer
|
||||
/// in `.common` run-loop mode, armed on range entry and torn down on exit. `.default`-mode
|
||||
/// scheduling pauses during run-loop tracking, where a stationary pen would silently cross
|
||||
/// the host's 200 ms force-release mid-stroke; the old per-emit re-arm also churned the run
|
||||
/// loop once per event during a stroke. The tick resends only after ≥50 ms of send silence,
|
||||
/// so an active stroke costs nothing and the worst-case gap stays ≈100 ms — the wire bound.
|
||||
private func syncHeartbeat() {
|
||||
guard inRange || touching else {
|
||||
heartbeat?.invalidate()
|
||||
heartbeat = nil
|
||||
return
|
||||
}
|
||||
heartbeat = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) {
|
||||
[weak self] _ in
|
||||
guard heartbeat == nil else { return }
|
||||
let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] t in
|
||||
guard let self, self.inRange || self.touching else {
|
||||
self?.heartbeat?.invalidate()
|
||||
t.invalidate()
|
||||
self?.heartbeat = nil
|
||||
return
|
||||
}
|
||||
self.send?([self.last])
|
||||
if CACurrentMediaTime() - self.lastSendTs >= 0.05 {
|
||||
self.send?([self.last])
|
||||
self.lastSendTs = CACurrentMediaTime()
|
||||
}
|
||||
}
|
||||
heartbeat = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
// MARK: - Angle conversions
|
||||
|
||||
@@ -881,6 +881,12 @@ public final class StreamLayerView: NSView {
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||
}
|
||||
capture.onToggleMicMute = { [weak self] in
|
||||
// Session-level state the view doesn't own — post to the app (same routing as the
|
||||
// fullscreen chord), so the captured and released paths end at one toggle.
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil)
|
||||
}
|
||||
capture.onCycleStats = { [weak self] in
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||
|
||||
@@ -154,6 +154,9 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
/// Pending pen-boost release — 2 s of hysteresis so hover flicker at the glass edge
|
||||
/// doesn't thrash the deadline link's rate range (see `setInteractionBoost`).
|
||||
private var penBoostRelease: DispatchWorkItem?
|
||||
#if os(tvOS)
|
||||
/// The window's display manager the session's mode request was set on — held weakly so
|
||||
/// stop() can clear the request even after the view has left the window.
|
||||
@@ -320,12 +323,22 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// prior session (stop() doesn't clear it). Otherwise a stale `true` could later
|
||||
// re-engage capture on a foreground that the new session never asked for.
|
||||
wasCapturedOnResign = false
|
||||
// Read the LIVE mode per touch batch — an accepted requestMode() mid-stream
|
||||
// changes the letterbox, and touches must follow it.
|
||||
// The letterbox must follow an accepted requestMode() mid-stream, so this stays a live
|
||||
// read — but behind a short TTL: the pencil path maps every coalesced sample through
|
||||
// here (≤8 per event at panel rate, main thread), and a per-sample FFI read re-takes
|
||||
// the ABI lock the batch send itself needs next. 250 ms staleness is invisible next to
|
||||
// the video reconfigure a mode switch performs anyway. Main-thread-only closure.
|
||||
var cachedMode = CGSize.zero
|
||||
var cachedAt = CACurrentMediaTime() - 1
|
||||
streamView.currentHostMode = { [weak connection] in
|
||||
guard let connection else { return .zero }
|
||||
let mode = connection.currentMode()
|
||||
return CGSize(width: Double(mode.width), height: Double(mode.height))
|
||||
let now = CACurrentMediaTime()
|
||||
if now - cachedAt > 0.25 {
|
||||
guard let connection else { return .zero }
|
||||
let mode = connection.currentMode()
|
||||
cachedMode = CGSize(width: Double(mode.width), height: Double(mode.height))
|
||||
cachedAt = now
|
||||
}
|
||||
return cachedMode
|
||||
}
|
||||
streamView.onTouchEvent = { [weak self, weak connection] event in
|
||||
// Touch IS the intent during a trusted session, but must not leak to the host
|
||||
@@ -341,6 +354,26 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard self?.captureEnabled == true else { return }
|
||||
connection?.sendPen(batch)
|
||||
}
|
||||
// Pencil near the glass ⇒ pin the panel (and with it UIKit's event cadence) at the
|
||||
// link range's ceiling, so a sub-panel-rate stream stops halving pencil sampling.
|
||||
// Engage is immediate; release waits 2 s so edge-of-canvas hover flicker can't
|
||||
// thrash the link. MAIN thread (UIKit events + main-queue work item).
|
||||
streamView.onPenProximity = { [weak self] near in
|
||||
guard let self else { return }
|
||||
self.penBoostRelease?.cancel()
|
||||
self.penBoostRelease = nil
|
||||
if near {
|
||||
self.presenter.setInteractionBoost(true)
|
||||
} else {
|
||||
let release = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.penBoostRelease = nil
|
||||
self.presenter.setInteractionBoost(false)
|
||||
}
|
||||
self.penBoostRelease = release
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: release)
|
||||
}
|
||||
}
|
||||
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
||||
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
|
||||
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
|
||||
@@ -391,6 +424,12 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
capture.onReleaseCapture = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
// ⌃⌥⇧A mutes/unmutes the mic uplink. Session state this controller doesn't own, so it
|
||||
// posts to the app exactly as the macOS chord does — the Stream menu's identical
|
||||
// equivalent (which a captured scene swallows) ends at the same toggle.
|
||||
capture.onToggleMicMute = {
|
||||
NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil)
|
||||
}
|
||||
capture.onPreempted = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
@@ -513,6 +552,9 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
streamView.resetTouchInput()
|
||||
streamView.onTouchEvent = nil
|
||||
streamView.onPenBatch = nil // after reset — the pen's leave-range sample rides it
|
||||
streamView.onPenProximity = nil // after reset — its leave-range transition fired above
|
||||
penBoostRelease?.cancel()
|
||||
penBoostRelease = nil
|
||||
streamView.penEnabled = false
|
||||
streamView.onPointerMoveAbs = nil
|
||||
streamView.onPointerButton = nil
|
||||
@@ -713,6 +755,8 @@ final class StreamLayerUIView: UIView {
|
||||
/// The host advertised `HOST_CAP_PEN`, so Pencil input splits out of the finger path onto
|
||||
/// the pen plane — independent of the touch-input mode (drawing must not depend on it).
|
||||
var penEnabled = false
|
||||
/// Pencil proximity transitions (hover or contact) — the presenter's panel-rate boost.
|
||||
var onPenProximity: ((Bool) -> Void)?
|
||||
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
||||
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
||||
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
|
||||
@@ -740,6 +784,7 @@ final class StreamLayerUIView: UIView {
|
||||
private lazy var pencil: PencilStream = {
|
||||
let stream = PencilStream()
|
||||
stream.send = { [weak self] batch in self?.onPenBatch?(batch) }
|
||||
stream.onProximity = { [weak self] near in self?.onPenProximity?(near) }
|
||||
stream.videoNorm = { [weak self] point in
|
||||
guard let h = self?.hostPoint(from: point) else { return nil }
|
||||
return (Float(h.x) / Float(max(h.w - 1, 1)), Float(h.y) / Float(max(h.h - 1, 1)))
|
||||
|
||||
@@ -42,6 +42,13 @@ public enum DefaultsKey {
|
||||
/// falls back. Drives the decoder via `Welcome.codec`.
|
||||
public static let codec = "punktfunk.codec"
|
||||
public static let micEnabled = "punktfunk.micEnabled"
|
||||
/// Echo cancellation for the mic uplink (on by default): playback + capture share ONE
|
||||
/// audio engine so the system voice processor can subtract what this device is playing
|
||||
/// from what its mic hears — without it a loudspeaker client feeds the game audio straight
|
||||
/// back to the host. Off = the raw two-engine capture path. macOS: an explicitly pinned
|
||||
/// speaker/mic or mic channel also bypasses it (the voice processor only follows the
|
||||
/// system default devices) — see SessionAudio's topology note.
|
||||
public static let echoCancel = "punktfunk.echoCancel"
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
public static let micUID = "punktfunk.micUID"
|
||||
/// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every
|
||||
@@ -193,6 +200,12 @@ extension Notification.Name {
|
||||
/// state. macOS only.
|
||||
public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen")
|
||||
|
||||
/// Posted by InputCapture's chord path (⌃⌥⇧A) when the combo fires while input is CAPTURED —
|
||||
/// the state in which the Stream menu's identical key equivalent never reaches the app. The
|
||||
/// live session's owner (ContentView) flips the session's mic mute. Released, the menu item
|
||||
/// handles the same combo directly; both end at `SessionModel.toggleMicMute`.
|
||||
public static let punktfunkToggleMicMute = Notification.Name("io.unom.punktfunk.toggle-mic-mute")
|
||||
|
||||
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
||||
/// which runs in the app's process): the app tears the active session down deliberately
|
||||
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
||||
|
||||
@@ -29,6 +29,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var compositor = 0
|
||||
public var audioChannels = 2
|
||||
public var micEnabled = true
|
||||
public var echoCancel = true
|
||||
public var touchMode = "trackpad"
|
||||
public var mouseMode = "capture"
|
||||
public var invertScroll = false
|
||||
@@ -87,6 +88,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
compositor = int(DefaultsKey.compositor, compositor)
|
||||
audioChannels = int(DefaultsKey.audioChannels, audioChannels)
|
||||
micEnabled = bool(DefaultsKey.micEnabled, micEnabled)
|
||||
echoCancel = bool(DefaultsKey.echoCancel, echoCancel)
|
||||
touchMode = str(DefaultsKey.touchMode, touchMode)
|
||||
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
@@ -133,6 +135,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.compositor { s.compositor = v }
|
||||
if let v = overlay.audioChannels { s.audioChannels = v }
|
||||
if let v = overlay.micEnabled { s.micEnabled = v }
|
||||
if let v = overlay.echoCancel { s.echoCancel = v }
|
||||
if let v = overlay.touchMode { s.touchMode = v }
|
||||
if let v = overlay.mouseMode { s.mouseMode = v }
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
|
||||
@@ -105,6 +105,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var compositor: Int?
|
||||
public var audioChannels: Int?
|
||||
public var micEnabled: Bool?
|
||||
public var echoCancel: Bool?
|
||||
public var touchMode: String?
|
||||
public var mouseMode: String?
|
||||
public var invertScroll: Bool?
|
||||
@@ -145,6 +146,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case compositor
|
||||
case audioChannels = "audio_channels"
|
||||
case micEnabled = "mic_enabled"
|
||||
case echoCancel = "echo_cancel"
|
||||
case touchMode = "touch_mode"
|
||||
case mouseMode = "mouse_mode"
|
||||
case invertScroll = "invert_scroll"
|
||||
@@ -177,6 +179,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
compositor = int(.compositor)
|
||||
audioChannels = int(.audioChannels)
|
||||
micEnabled = bool(.micEnabled)
|
||||
echoCancel = bool(.echoCancel)
|
||||
touchMode = str(.touchMode)
|
||||
mouseMode = str(.mouseMode)
|
||||
invertScroll = bool(.invertScroll)
|
||||
@@ -211,6 +214,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(compositor, forKey: AnyKey(Key.compositor.rawValue))
|
||||
try c.encodeIfPresent(audioChannels, forKey: AnyKey(Key.audioChannels.rawValue))
|
||||
try c.encodeIfPresent(micEnabled, forKey: AnyKey(Key.micEnabled.rawValue))
|
||||
try c.encodeIfPresent(echoCancel, forKey: AnyKey(Key.echoCancel.rawValue))
|
||||
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
|
||||
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
|
||||
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
|
||||
@@ -262,6 +266,7 @@ public enum OverlayField {
|
||||
case "compositor": overlay.compositor = nil
|
||||
case "audio_channels": overlay.audioChannels = nil
|
||||
case "mic_enabled": overlay.micEnabled = nil
|
||||
case "echo_cancel": overlay.echoCancel = nil
|
||||
case "touch_mode": overlay.touchMode = nil
|
||||
case "mouse_mode": overlay.mouseMode = nil
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
@@ -296,6 +301,7 @@ public enum OverlayField {
|
||||
case "compositor": return o.compositor != nil
|
||||
case "audio_channels": return o.audioChannels != nil
|
||||
case "mic_enabled": return o.micEnabled != nil
|
||||
case "echo_cancel": return o.echoCancel != nil
|
||||
case "touch_mode": return o.touchMode != nil
|
||||
case "mouse_mode": return o.mouseMode != nil
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
|
||||
@@ -9,32 +9,34 @@ import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class OpusCodecTests: XCTestCase {
|
||||
/// Encode a 440 Hz stereo tone, decode it back, and require the result to be
|
||||
/// recognizably the same signal (Opus is lossy — check correlation, not bytes).
|
||||
/// Encode a 440 Hz mono tone (the uplink's shape), decode it back through the
|
||||
/// STEREO-configured decoder (the host-plane shape — Opus upmixes mono packets), and
|
||||
/// require the result to be recognizably the same signal (Opus is lossy — check
|
||||
/// correlation, not bytes).
|
||||
func testEncodeDecodeRoundTripPreservesTone() throws {
|
||||
let encoder = try OpusEncoder()
|
||||
let decoder = try OpusDecoder(framesPerPacket: UInt32(OpusEncoder.framesPerPacket))
|
||||
let decoder = try OpusDecoder(framesPerPacket: UInt32(encoder.framesPerPacket))
|
||||
let pcmFormat = encoder.pcmFormat
|
||||
|
||||
let frames = OpusEncoder.framesPerPacket
|
||||
let frames = encoder.framesPerPacket
|
||||
var packets: [Data] = []
|
||||
var phase: Float = 0
|
||||
let step = 2 * Float.pi * 440 / 48_000
|
||||
|
||||
// 50 packets = 1 s of tone.
|
||||
for _ in 0..<50 {
|
||||
// 1 s of tone, whatever packet duration the encoder chose (10 ms → 100 chunks).
|
||||
let chunks = Int(48_000 / frames)
|
||||
for _ in 0..<chunks {
|
||||
let buf = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: frames)!
|
||||
buf.frameLength = frames
|
||||
let p = buf.floatChannelData![0] // interleaved: one plane, L R L R …
|
||||
let p = buf.floatChannelData![0] // mono: one plane
|
||||
for f in 0..<Int(frames) {
|
||||
let s = sin(phase) * 0.5
|
||||
p[f] = sin(phase) * 0.5
|
||||
phase += step
|
||||
p[f * 2] = s
|
||||
p[f * 2 + 1] = s
|
||||
}
|
||||
packets.append(contentsOf: try encoder.encode(buf))
|
||||
}
|
||||
XCTAssertGreaterThanOrEqual(packets.count, 45, "encoder must emit ~one packet per buffer")
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
packets.count, chunks - 5, "encoder must emit ~one packet per buffer")
|
||||
XCTAssertTrue(packets.allSatisfy { !$0.isEmpty })
|
||||
|
||||
var decoded: [Float] = []
|
||||
|
||||
@@ -62,29 +62,30 @@ final class RemoteFirstLightTests: XCTestCase {
|
||||
host: host, port: port, width: 1280, height: 720, refreshHz: 60)
|
||||
defer { conn.close() }
|
||||
|
||||
// Mic uplink: 2 s of 440 Hz tone (the host's mic service opens its virtual
|
||||
// Mic uplink: 2 s of 440 Hz mono tone (the host's mic service opens its virtual
|
||||
// source on the first frame — check its log).
|
||||
let encoder = try OpusEncoder()
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)!
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket)!
|
||||
var phase: Float = 0
|
||||
let step = 2 * Float.pi * 440 / 48_000
|
||||
var seq: UInt32 = 0
|
||||
for _ in 0..<100 {
|
||||
chunk.frameLength = OpusEncoder.framesPerPacket
|
||||
let p = chunk.floatChannelData![0]
|
||||
for f in 0..<Int(OpusEncoder.framesPerPacket) {
|
||||
let s = sin(phase) * 0.25
|
||||
let chunks = 2 * 48_000 / Int(encoder.framesPerPacket)
|
||||
let packetNs = UInt64(encoder.framesPerPacket) * 1_000_000_000 / 48_000
|
||||
for _ in 0..<chunks {
|
||||
chunk.frameLength = encoder.framesPerPacket
|
||||
let p = chunk.floatChannelData![0] // mono: one plane
|
||||
for f in 0..<Int(encoder.framesPerPacket) {
|
||||
p[f] = sin(phase) * 0.25
|
||||
phase += step
|
||||
p[f * 2] = s
|
||||
p[f * 2 + 1] = s
|
||||
}
|
||||
for packet in try encoder.encode(chunk) {
|
||||
conn.sendMic(packet, seq: seq, ptsNs: UInt64(seq) * 20_000_000)
|
||||
conn.sendMic(packet, seq: seq, ptsNs: UInt64(seq) * packetNs)
|
||||
seq &+= 1
|
||||
}
|
||||
}
|
||||
XCTAssertGreaterThanOrEqual(seq, 95, "mic encoder must emit ~one packet per chunk")
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
seq, UInt32(chunks - 5), "mic encoder must emit ~one packet per chunk")
|
||||
|
||||
// Downlink: pull host audio packets and decode them (the host streams its sink
|
||||
// monitor — silence still produces packets).
|
||||
|
||||
+194
-26
@@ -59,7 +59,131 @@ punktfunk — the Punktfunk client, headless
|
||||
|
||||
A <host-ref> is a saved host's id, its name, or an address — the same reference a
|
||||
punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found,
|
||||
6 needs a person.";
|
||||
6 needs a person.
|
||||
|
||||
\"punktfunk help <command>\" (or any command with --help) explains that command.";
|
||||
|
||||
/// The long help for one verb — `punktfunk help <verb>`, or `--help` after the verb.
|
||||
/// Each entry documents its flags and the behaviour a script would need to know
|
||||
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||
Some(match verb {
|
||||
"pair" => {
|
||||
"\
|
||||
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
|
||||
--name LABEL the label the host files this device under
|
||||
(default: this machine's name)
|
||||
|
||||
Pairing verifies the host end-to-end and pins its fingerprint in the saved-hosts
|
||||
store, so every later connect — here, in the desktop client or the console — is
|
||||
silent. The port defaults to 9777. Prints `paired <addr>:<port> fp=<hex>` on
|
||||
success; exit 3 if the host refuses or the PIN is wrong."
|
||||
}
|
||||
"hosts" => {
|
||||
"\
|
||||
punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
Every saved host, name TAB addr:port TAB paired/trusted TAB state.
|
||||
--probe asks each host directly (no mDNS, so routed/VPN hosts answer
|
||||
too); --json emits one object with per-host detail, profiles included.
|
||||
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
Save a host by address — the door for a box mDNS never sees (Tailscale,
|
||||
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||
|
||||
punktfunk hosts forget <host-ref>
|
||||
Remove a saved host, its pinned fingerprint included. A later connect
|
||||
must pair or trust it again."
|
||||
}
|
||||
"wake" => {
|
||||
"\
|
||||
punktfunk wake <host-ref> [--wait] — Wake-on-LAN
|
||||
|
||||
Sends a magic packet to a saved host's MAC (learned from its advert while it
|
||||
was awake; exit 5 if none is known yet). With --wait, keeps sending every 6 s
|
||||
and polls presence every second for up to 90 s, exiting 0 the moment the host
|
||||
answers — the same cadence every graphical shell uses."
|
||||
}
|
||||
"library" => {
|
||||
"\
|
||||
punktfunk library <host-ref> [--json] — the host's game library
|
||||
|
||||
TSV on stdout by default (id TAB store TAB title), one game per line; --json
|
||||
emits {\"games\":[…]} for tools — the Playnite importer shells to exactly
|
||||
this. Needs a paired host (exit 6 otherwise)."
|
||||
}
|
||||
"launch" => {
|
||||
"\
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
|
||||
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||
and relays its lifecycle to stderr.
|
||||
|
||||
--game ID ask the host to launch this library title into the stream
|
||||
--profile REF use a settings profile (id or name) for this connect only;
|
||||
without it the host's own binding applies
|
||||
--fullscreen start the stream window fullscreen
|
||||
--exec become the session process instead of supervising it — the
|
||||
gamescope-wrapper mode, where the launched process must BE
|
||||
the streaming one for focus and lifecycle to work
|
||||
|
||||
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||
trusts this device (re-pair), 4 the renderer could not start."
|
||||
}
|
||||
"open" => {
|
||||
"\
|
||||
punktfunk open <punktfunk://…> — follow a punktfunk:// link, headless
|
||||
|
||||
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."
|
||||
}
|
||||
"reachable" => {
|
||||
"\
|
||||
punktfunk reachable <host-ref> — one bounded reachability probe
|
||||
|
||||
Asks the host directly (no mDNS), so routed/VPN hosts answer too. The
|
||||
reference may be an unsaved address — this verb answers \"can I reach it\",
|
||||
not \"do I know it\". Exit 0 reachable, 2 not; one line either way."
|
||||
}
|
||||
"speed-test" => {
|
||||
"\
|
||||
punktfunk speed-test <host-ref> [--json] — measure the real data plane
|
||||
|
||||
Runs the host's bandwidth probe over an actual session connect and prints the
|
||||
measured throughput, loss, and the bitrate it recommends. Deliberately does
|
||||
NOT apply the result: which layer a bitrate belongs in (a bound profile, the
|
||||
global default) is a decision the GUI makes with the user, and a CLI silently
|
||||
rewriting settings would be exactly the surprise that rule exists to prevent."
|
||||
}
|
||||
"profiles" => {
|
||||
"\
|
||||
punktfunk profiles list [--json] — the settings profiles on this device
|
||||
|
||||
One line per profile: id TAB name TAB how many settings it overrides.
|
||||
Profiles are created and edited in the desktop client; a connect uses one via
|
||||
`punktfunk launch --profile` or a punktfunk:// link that names it."
|
||||
}
|
||||
"reset" => {
|
||||
"\
|
||||
punktfunk reset — forget every saved host and reset stream settings
|
||||
|
||||
Asks for confirmation, and refuses (exit 6) when there is no terminal to ask
|
||||
on. This device's identity keypair is deliberately kept, so hosts that knew
|
||||
this machine still recognise it after re-pairing; delete the identity files
|
||||
from the config directory for a true factory reset."
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The value after `--flag`, if any.
|
||||
fn value(args: &[String], flag: &str) -> Option<String> {
|
||||
@@ -138,6 +262,12 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
return OK;
|
||||
};
|
||||
let rest: Vec<String> = args[1..].to_vec();
|
||||
// `--help`/`-h` after any verb prints that verb's help — before dispatch, so a verb
|
||||
// never mistakes the flag for its subject (`punktfunk pair -h` must not dial "-h").
|
||||
if rest.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!("{}", verb_help(&verb).unwrap_or(USAGE));
|
||||
return OK;
|
||||
}
|
||||
match verb.as_str() {
|
||||
"pair" => pair(&rest),
|
||||
"hosts" => hosts(&rest),
|
||||
@@ -149,10 +279,22 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
"speed-test" => speed_test(&rest),
|
||||
"profiles" => profiles(&rest),
|
||||
"reset" => reset(),
|
||||
"-h" | "--help" | "help" => {
|
||||
println!("{USAGE}");
|
||||
OK
|
||||
}
|
||||
"-h" | "--help" | "help" => match positional(&rest, 0) {
|
||||
None => {
|
||||
println!("{USAGE}");
|
||||
OK
|
||||
}
|
||||
Some(topic) => match verb_help(&topic) {
|
||||
Some(h) => {
|
||||
println!("{h}");
|
||||
OK
|
||||
}
|
||||
None => {
|
||||
eprintln!("no command called \"{topic}\"\n\n{USAGE}");
|
||||
UNRESOLVED
|
||||
}
|
||||
},
|
||||
},
|
||||
"--version" | "version" => {
|
||||
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
|
||||
OK
|
||||
@@ -600,10 +742,17 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
return UNRESOLVED;
|
||||
};
|
||||
// An address that isn't saved is still a legitimate thing to probe — this verb answers
|
||||
// "can I reach this?", not "do I know this?".
|
||||
let (addr, port) = match resolve(&reference) {
|
||||
Ok((known, i)) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
Err(_) => split_host_port(&reference),
|
||||
// "can I reach this?", not "do I know this?". Resolved QUIETLY for the same reason:
|
||||
// `resolve`'s "pair it first" advice is for verbs that need a saved host, and printing
|
||||
// it here would scold the exact usage this verb documents.
|
||||
let known = KnownHosts::load();
|
||||
let link = DeepLink {
|
||||
host_ref: reference.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let (addr, port) = match deeplink::resolve_host(&link, &known) {
|
||||
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
_ => split_host_port(&reference),
|
||||
};
|
||||
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||
println!("reachable {addr}:{port}");
|
||||
@@ -649,14 +798,15 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
},
|
||||
punktfunk_core::config::CompositorPref::Auto,
|
||||
punktfunk_core::config::GamepadPref::Auto,
|
||||
0, // bitrate_kbps: the host's default; this connect never presents
|
||||
0, // video_caps: nothing decodes here
|
||||
2, // audio_channels
|
||||
0, // video_codecs: the probe carries no video
|
||||
0, // preferred_codec
|
||||
None, // display_hdr
|
||||
0, // client_caps: nothing renders a cursor
|
||||
None, // launch
|
||||
0, // bitrate_kbps: the host's default; this connect never presents
|
||||
0, // video_caps: nothing decodes here
|
||||
2, // audio_channels
|
||||
0, // video_codecs: the probe carries no video
|
||||
0, // preferred_codec
|
||||
None, // display_hdr
|
||||
0, // client_caps: nothing renders a cursor
|
||||
false, // frame_parts: probe/whole-AU consumer
|
||||
None, // launch
|
||||
Some(punktfunk_core::client::device_name()),
|
||||
Some(pin),
|
||||
Some(identity),
|
||||
@@ -769,15 +919,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
/// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with
|
||||
/// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX.
|
||||
fn is_tty() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY-free check: `isatty` via the std file descriptor, without libc.
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -820,6 +962,32 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777));
|
||||
}
|
||||
|
||||
/// Every advertised verb documents itself — a USAGE line without a help entry is a
|
||||
/// promise `help <verb>` breaks. The overview and each entry must also name the verb.
|
||||
#[test]
|
||||
fn every_usage_verb_has_help() {
|
||||
for verb in [
|
||||
"pair",
|
||||
"hosts",
|
||||
"wake",
|
||||
"library",
|
||||
"launch",
|
||||
"open",
|
||||
"reachable",
|
||||
"speed-test",
|
||||
"profiles",
|
||||
"reset",
|
||||
] {
|
||||
let h = verb_help(verb).unwrap_or_else(|| panic!("no help for {verb}"));
|
||||
assert!(
|
||||
h.starts_with(&format!("punktfunk {verb}")),
|
||||
"help for {verb} must lead with its own invocation"
|
||||
);
|
||||
assert!(USAGE.contains(verb), "USAGE must advertise {verb}");
|
||||
}
|
||||
assert!(verb_help("bogus").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_the_argument_after_its_flag() {
|
||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Runs the REAL `punktfunk` binary and pins its self-documentation contract: help goes
|
||||
//! to stdout and exits 0, an unknown verb refuses on stderr with the not-found code.
|
||||
//!
|
||||
//! This exists so CI *executes* the shipped binary at least once per platform. A binary
|
||||
//! that compiles but is the wrong program passes every build/clippy/fmt gate — that is
|
||||
//! exactly how 0.22.0 shipped a stub as `punktfunk-session` — and only a gate that runs
|
||||
//! the thing catches the class. The help paths are the right probe: they touch no config
|
||||
//! stores and no network, so they are safe on any runner.
|
||||
#![cfg(any(target_os = "linux", windows))]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn punktfunk(args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_punktfunk"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run punktfunk")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_and_help_print_the_overview_on_stdout() {
|
||||
for args in [&[][..], &["help"][..], &["--help"][..], &["-h"][..]] {
|
||||
let out = punktfunk(args);
|
||||
assert!(out.status.success(), "{args:?} must exit 0");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("punktfunk pair"),
|
||||
"{args:?} overview lists the verbs"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("help <command>"),
|
||||
"{args:?} overview points at per-verb help"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_verb_help_answers_both_spellings() {
|
||||
for args in [
|
||||
&["help", "launch"][..],
|
||||
&["launch", "--help"][..],
|
||||
&["launch", "-h"][..],
|
||||
] {
|
||||
let out = punktfunk(args);
|
||||
assert!(out.status.success(), "{args:?} must exit 0");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("--exec"),
|
||||
"{args:?} documents launch's flags"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_prints_the_crate_version() {
|
||||
let out = punktfunk(&["--version"]);
|
||||
assert!(out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains(env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||
let out = punktfunk(&["frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown verb exits 5");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command"));
|
||||
|
||||
let out = punktfunk(&["help", "frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||
}
|
||||
@@ -54,6 +54,25 @@ Loader's own (SHA-256-verified) install. Installs and updates can take a couple
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
before the actual download proceeds.
|
||||
|
||||
### Updating the client
|
||||
|
||||
The plugin also reports — and where it can, installs — updates for the **client** it launches.
|
||||
What is possible depends on how that client was installed, and the About tab names the install
|
||||
kind so the answer is never a mystery:
|
||||
|
||||
| Install | Update |
|
||||
| --- | --- |
|
||||
| **Flatpak** (the usual Deck client) | One tap. `flatpak update --user io.unom.Punktfunk` — a per-user install, which is why `sudo flatpak update` never touches it. |
|
||||
| **.deb / .rpm** (and rpm-ostree, which stages for the next reboot) | One tap, *after* an explicit opt-in: `sudo usermod -aG punktfunk-update $USER`. The tap starts a fixed, parameterless root oneshot (`punktfunk-client-update.service`) through polkit — nothing about the request is attacker-influenceable, and the payload comes from your distro's own signed repositories. |
|
||||
| **pacman** | Same, plus the root-owned `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf` — a partial upgrade is against Arch doctrine, so the only thing the helper will run is a full `pacman -Syu`. |
|
||||
| **sysext, nix, a source build** | The plugin shows the command and stops. There is no feed behind those installs, and a button that can only fail is worse than one honest line. |
|
||||
|
||||
Whether a *newer* client exists is the client's own answer (`punktfunk-client --check-update`),
|
||||
read from the Ed25519-signed per-channel manifest the host's update check already trusts —
|
||||
`PUNKTFUNK_UPDATE_CHECK=0` disables the check, `PUNKTFUNK_UPDATE_APPLY=0` keeps the check but
|
||||
never offers to install. A client too old to have that mode is reported as such rather than as
|
||||
up to date.
|
||||
|
||||
## Build & sideload (development)
|
||||
|
||||
```sh
|
||||
|
||||
+165
-15
@@ -22,8 +22,14 @@ The backend's jobs are the things Steam can't do:
|
||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||
* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a
|
||||
newer build is available (the frontend then drives Decky's own install RPC to apply it).
|
||||
* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's
|
||||
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
|
||||
install RPC to apply it); the client's depends on how it was installed — a flatpak is compared
|
||||
by OSTree commit here, anything else is asked of the client itself
|
||||
(``punktfunk-client --check-update``, which verifies a signed manifest).
|
||||
* **update_client()** — apply the client update by whichever route that install supports:
|
||||
``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or
|
||||
a refusal carrying the command to run by hand.
|
||||
|
||||
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
|
||||
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
|
||||
@@ -392,6 +398,18 @@ def _client_argv() -> list[str] | None:
|
||||
return [native] if native else None
|
||||
|
||||
|
||||
def _client_is_flatpak() -> bool:
|
||||
"""Is the client this plugin actually drives the FLATPAK one?
|
||||
|
||||
Not the same question as "is the flatpak installed": `PF_DECKY_CLIENT=native` forces the
|
||||
native binary on a box that has both, and the update check has to describe the client the
|
||||
launcher will really run — otherwise a Deck with both would be offered a flatpak update for
|
||||
a client it never starts.
|
||||
"""
|
||||
prefix = _client_argv()
|
||||
return bool(prefix) and prefix[0] == _flatpak()
|
||||
|
||||
|
||||
def _flatpak_env() -> dict:
|
||||
"""Environment for a headless client run from the backend (no display needed for pairing).
|
||||
Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless
|
||||
@@ -562,7 +580,12 @@ async def _client_update_state() -> dict:
|
||||
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
|
||||
it versions independently of this plugin — so we compare the installed commit against the
|
||||
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
|
||||
(not installed, no flatpak, offline)."""
|
||||
(not installed, no flatpak, offline).
|
||||
|
||||
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
|
||||
flatpak built from main between releases carries the release's crate version, so the
|
||||
signed-manifest comparison the native path uses would call it up to date when it isn't.
|
||||
Native installs have no commit to compare and go through :func:`_native_update_state`."""
|
||||
state = {"available": False, "installed": "", "remote": ""}
|
||||
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
if rc != 0:
|
||||
@@ -581,6 +604,50 @@ async def _client_update_state() -> dict:
|
||||
return state
|
||||
|
||||
|
||||
# --- native (non-flatpak) client updates ----------------------------------------------------
|
||||
#
|
||||
# A .deb/.rpm/pacman/sysext/nix client is not something this plugin can reason about on its own:
|
||||
# working out whether a newer build exists means fetching a per-channel manifest and verifying
|
||||
# its Ed25519 signature, and Decky's embedded Python has no crypto library to do that with (nor
|
||||
# should the trust rule live in two languages). So the CLIENT answers both questions —
|
||||
# `--check-update --json` says what is available and who could install it, `--apply-update`
|
||||
# drives the packaged root helper — and this backend is a UI over those, exactly as it already
|
||||
# is for `--pair` / `--library` / `--list-hosts`.
|
||||
#
|
||||
# Shape of `--check-update --json` (pf_client_core::update::Status):
|
||||
# {kind, channel, current, latest, update_available, apply, applier, command,
|
||||
# opt_in_hint?, notes_url, error?}
|
||||
# `applier` is what this file routes on: "flatpak" (we run flatpak), "helper" (the client runs
|
||||
# the root helper), "none" (show `command` — nothing here can install it).
|
||||
|
||||
|
||||
async def _native_update_state() -> dict:
|
||||
"""Ask a NATIVE client whether a newer build exists for its channel. Returns the client's
|
||||
own status dict, or ``{}`` when it couldn't be asked (no native client, a client too old to
|
||||
have the mode, offline). Best-effort by design: an unanswerable check must read as "can't
|
||||
tell", never as "up to date"."""
|
||||
rc, out, err = await _run_client(["--check-update", "--json"], timeout=30.0)
|
||||
# The JSON is authoritative whenever there IS JSON, whatever the exit code: the client
|
||||
# exits 0 up-to-date, 10 update-available, and 1 when the check failed — but in that last
|
||||
# case it STILL prints a status carrying `error` plus the install kind and the command for
|
||||
# this box, which is exactly what the UI needs to explain itself. Reading only the exit
|
||||
# code would throw all of that away and report a bare "couldn't check".
|
||||
if out.strip():
|
||||
try:
|
||||
data = json.loads(out)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
decky.logger.warning("check-update: unparseable output: %s", out[:200])
|
||||
if rc == -1:
|
||||
return {}
|
||||
# A client predating `--check-update` ignores the flag and falls through to GTK init, which
|
||||
# fails headless — the same signature the other headless modes classify.
|
||||
code = _classify_library_error(err)
|
||||
decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code)
|
||||
return {"error": code} if code == "client-outdated" else {}
|
||||
|
||||
|
||||
def _split_txt(txt: str) -> list[str]:
|
||||
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
|
||||
tokens: list[str] = []
|
||||
@@ -1138,14 +1205,71 @@ class Plugin:
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
async def _update_native_client(self) -> dict:
|
||||
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
|
||||
``--apply-update``, which starts the packaged root helper.
|
||||
|
||||
The timeout is generous because a package-manager run on a stale box is slow; the
|
||||
client caps its own wait at 30 min, so this one sits below that and reports a timeout
|
||||
rather than hanging the QAM forever.
|
||||
"""
|
||||
state = await _native_update_state()
|
||||
if not state:
|
||||
# No client answered at all (none installed, or one too old for the mode) — say
|
||||
# that, rather than "this install updates by hand", which would be a guess.
|
||||
return {"ok": False, "updated": False, "error": "client-unavailable"}
|
||||
applier = state.get("applier")
|
||||
if applier != "helper":
|
||||
# Nothing here can install it — hand back the command the client computed, so the
|
||||
# UI shows one true line instead of guessing per install kind.
|
||||
return {
|
||||
"ok": False,
|
||||
"updated": False,
|
||||
"error": "manual",
|
||||
"command": state.get("opt_in_hint") or state.get("command", ""),
|
||||
}
|
||||
rc, out, err = await _run_client(["--apply-update", "--json"], timeout=900.0)
|
||||
if rc == -1:
|
||||
return {"ok": False, "updated": False, "error": "timeout"}
|
||||
outcome: dict = {}
|
||||
if out.strip():
|
||||
try:
|
||||
outcome = json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if not outcome.get("ok"):
|
||||
detail = outcome.get("error") or (err.strip().splitlines() or ["update failed"])[-1]
|
||||
decky.logger.warning("native client update failed (rc=%s): %s", rc, detail)
|
||||
return {"ok": False, "updated": False, "error": "update-failed", "detail": detail}
|
||||
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
||||
decky.logger.info(
|
||||
"native client update: %s -> %s (changed=%s, staged=%s)",
|
||||
outcome.get("before", ""), outcome.get("after", ""),
|
||||
outcome.get("changed"), outcome.get("staged"),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"updated": bool(outcome.get("changed")),
|
||||
"staged": bool(outcome.get("staged")),
|
||||
}
|
||||
|
||||
async def update_client(self) -> dict:
|
||||
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
|
||||
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
|
||||
Returns whether a new commit was actually pulled. Best-effort; non-fatal. A NATIVE client
|
||||
is updated by whatever installed it (distro package manager, sysext, nix), never here —
|
||||
`check_update` reports no client update for one, so the UI never offers this."""
|
||||
if not _flatpak_installed():
|
||||
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
|
||||
"""Update the **client**, by whichever route this box's install actually supports.
|
||||
|
||||
* **flatpak** — ``flatpak update --user`` in the USER installation, the scope a Steam
|
||||
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
|
||||
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
|
||||
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
|
||||
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
|
||||
passes nothing to it; the helper derives everything from root-owned state.
|
||||
* **anything else** (sysext, nix, a source build, no opt-in) — refused with the exact
|
||||
command to run, which the UI shows. `check_update` reports the same, so the UI knows
|
||||
not to offer a button in the first place.
|
||||
|
||||
Returns ``{ok, updated, error?, detail?, command?, staged?}``. Best-effort; non-fatal.
|
||||
"""
|
||||
if not _client_is_flatpak():
|
||||
return await self._update_native_client()
|
||||
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
before_commit = _field_from(before, "Commit")
|
||||
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
|
||||
@@ -1183,6 +1307,13 @@ class Plugin:
|
||||
"client_update_available": False,
|
||||
"client_current": "",
|
||||
"client_latest": "",
|
||||
# How the client got here (`flatpak`, `apt`, `dnf`, `sysext`, `nix`, `source`, …),
|
||||
# who could install an update (`flatpak` | `helper` | `none`), and the one line that
|
||||
# does it by hand. Empty on a flatpak-only box that never reaches the native path.
|
||||
"client_install": "",
|
||||
"client_applier": "",
|
||||
"client_command": "",
|
||||
"client_opt_in": "",
|
||||
}
|
||||
|
||||
now = time.monotonic()
|
||||
@@ -1190,12 +1321,31 @@ class Plugin:
|
||||
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
|
||||
return cached
|
||||
|
||||
# Client (flatpak) update — checked ALWAYS, even on a dev/sideloaded plugin build.
|
||||
# Client update — checked ALWAYS, even on a dev/sideloaded plugin build. Which check
|
||||
# runs depends on how the client was installed: the flatpak compares OSTree commits
|
||||
# (exact for a per-user flatpak), everything else asks the client itself, which
|
||||
# verifies the signed per-channel manifest. See _client_update_state / _native_update_state.
|
||||
try:
|
||||
cu = await _client_update_state()
|
||||
result["client_update_available"] = bool(cu["available"])
|
||||
result["client_current"] = (cu["installed"] or "")[:10]
|
||||
result["client_latest"] = (cu["remote"] or "")[:10]
|
||||
if _client_is_flatpak():
|
||||
cu = await _client_update_state()
|
||||
result["client_update_available"] = bool(cu["available"])
|
||||
result["client_current"] = (cu["installed"] or "")[:10]
|
||||
result["client_latest"] = (cu["remote"] or "")[:10]
|
||||
result["client_install"] = "flatpak"
|
||||
result["client_applier"] = "flatpak"
|
||||
result["client_command"] = f"flatpak update --user {APP_ID}"
|
||||
else:
|
||||
nu = await _native_update_state()
|
||||
result["client_update_available"] = bool(nu.get("update_available"))
|
||||
result["client_current"] = str(nu.get("current", ""))
|
||||
result["client_latest"] = str(nu.get("latest", ""))
|
||||
result["client_install"] = str(nu.get("kind", ""))
|
||||
result["client_applier"] = str(nu.get("applier", ""))
|
||||
result["client_command"] = str(nu.get("command", ""))
|
||||
result["client_opt_in"] = str(nu.get("opt_in_hint", "") or "")
|
||||
if nu.get("error"):
|
||||
# "Couldn't tell" — never rendered as up to date; the UI shows the reason.
|
||||
result["client_error"] = str(nu["error"])
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.warning("client update check failed", exc_info=True)
|
||||
|
||||
|
||||
@@ -113,6 +113,10 @@ export interface StreamSettings {
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
|
||||
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
|
||||
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
|
||||
// here would be a dead one. The desktop client's row still edits this same file.
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
}
|
||||
@@ -124,11 +128,20 @@ export interface UpdateInfo {
|
||||
hash: string; // sha256 of that zip (Decky verifies it)
|
||||
channel: string; // "latest" (stable) | "canary"
|
||||
update_available: boolean; // a newer PLUGIN build is available
|
||||
// The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so
|
||||
// `sudo flatpak update` never touches it — the plugin offers a user-scope update instead.
|
||||
// The CLIENT versions independently of this plugin, and how it updates depends on how it was
|
||||
// installed. A flatpak is a per-user install `sudo flatpak update` never touches, compared by
|
||||
// OSTree commit; every other install (.deb/.rpm/pacman/sysext/nix/source) is compared by the
|
||||
// client itself against the signed per-channel manifest (`punktfunk-client --check-update`).
|
||||
client_update_available: boolean;
|
||||
client_current: string; // installed client commit (short) — informational
|
||||
client_latest: string; // remote client commit (short) — informational
|
||||
client_current: string; // installed client commit (flatpak) or version (native)
|
||||
client_latest: string; // newest client commit (flatpak) or version (native)
|
||||
client_install: string; // "flatpak" | "apt" | "dnf" | "pacman" | "sysext" | "nix" | "source" | ""
|
||||
// Who can perform the update: "flatpak" (this plugin runs it), "helper" (the client drives the
|
||||
// packaged root helper), "none" (nothing here can — show `client_command`).
|
||||
client_applier: string;
|
||||
client_command: string; // one copy-pastable line that updates this install by hand
|
||||
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
|
||||
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
|
||||
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
||||
}
|
||||
|
||||
@@ -206,8 +219,18 @@ export const probeHost = callable<
|
||||
{ ok: boolean; online?: boolean; error?: string }
|
||||
>("probe_host");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||
// Update the client by whichever route its install supports: `flatpak update --user` for the
|
||||
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
|
||||
// native install. Everything else comes back `ok: false, error: "manual"` with `command` — the
|
||||
// line to run by hand. A package-manager run can take minutes; the backend allows 15.
|
||||
export const updateClient = callable<
|
||||
[],
|
||||
{ ok: boolean; updated: boolean; error?: string }
|
||||
{
|
||||
ok: boolean;
|
||||
updated: boolean;
|
||||
staged?: boolean; // installed, but a reboot activates it (rpm-ostree)
|
||||
error?: string;
|
||||
detail?: string;
|
||||
command?: string; // set with error "manual"
|
||||
}
|
||||
>("update_client");
|
||||
|
||||
+114
-17
@@ -240,11 +240,61 @@ export function useUpdate() {
|
||||
return { info, checking, check };
|
||||
}
|
||||
|
||||
/** True when EITHER the plugin or the flatpak client has a pending update. */
|
||||
/** True when EITHER the plugin or the client has a pending update. */
|
||||
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && (info.update_available || info.client_update_available);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this Deck actually INSTALL the pending client update, or only tell you how?
|
||||
*
|
||||
* A flatpak and a one-tap-capable native install (the packaged root helper + the operator's
|
||||
* group opt-in) get a button; a sysext, a nix profile, a source build or a box that hasn't
|
||||
* opted in gets the command. Offering a button that can only fail is worse than saying so.
|
||||
*/
|
||||
export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boolean {
|
||||
return (
|
||||
!!info &&
|
||||
info.client_update_available &&
|
||||
(info.client_applier === "flatpak" || info.client_applier === "helper")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
|
||||
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
|
||||
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
|
||||
*/
|
||||
export function clientInstallLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "flatpak":
|
||||
return "Flatpak (per-user)";
|
||||
case "apt":
|
||||
return "System package (apt)";
|
||||
case "dnf":
|
||||
return "System package (dnf)";
|
||||
case "rpm-ostree":
|
||||
return "Layered package (rpm-ostree)";
|
||||
case "pacman":
|
||||
return "System package (pacman)";
|
||||
case "sysext":
|
||||
return "System extension (sysext)";
|
||||
case "nix":
|
||||
return "Nix profile";
|
||||
case "steamos-source":
|
||||
return "On-device build";
|
||||
case "source":
|
||||
return "Built from source";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the only pending update is one this Deck can't apply itself. */
|
||||
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
|
||||
}
|
||||
|
||||
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||||
export async function checkForUpdatesNow(
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
@@ -256,8 +306,20 @@ export async function checkForUpdatesNow(
|
||||
} else if (hasUpdate(res)) {
|
||||
const parts: string[] = [];
|
||||
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
|
||||
if (res.client_update_available) parts.push("client");
|
||||
if (res.client_update_available) {
|
||||
parts.push(res.client_latest ? `client ${res.client_latest}` : "client");
|
||||
}
|
||||
body = `Update available: ${parts.join(" + ")}.`;
|
||||
if (clientUpdateIsManualOnly(res)) {
|
||||
// Say the honest thing up front rather than letting the user find out at the button.
|
||||
body += " The client updates outside Punktfunk on this install.";
|
||||
}
|
||||
} else if (res.client_error) {
|
||||
// A failed CLIENT check must never read as "up to date" — that is the one wrong answer.
|
||||
body =
|
||||
res.client_error === "client-outdated"
|
||||
? "Couldn’t check the client — it predates update checks. Update it once by hand."
|
||||
: "Couldn’t check the client for updates.";
|
||||
} else if (res.error === "update-channel-unknown") {
|
||||
body = "Development build — plugin updates are disabled; the client is up to date.";
|
||||
} else {
|
||||
@@ -266,32 +328,67 @@ export async function checkForUpdatesNow(
|
||||
toaster.toast({ title: "Punktfunk", body });
|
||||
}
|
||||
|
||||
/** One line of user-facing copy for whatever `updateClient()` came back with. */
|
||||
function clientUpdateResultBody(r: Awaited<ReturnType<typeof updateClient>>): string {
|
||||
if (r.ok) {
|
||||
if (r.staged) return "Client updated — reboot to finish.";
|
||||
return r.updated ? "Client updated to the latest version." : "Client is already up to date.";
|
||||
}
|
||||
// "manual" is not a failure: the box simply can't install it, and `command` says how.
|
||||
if (r.error === "manual") {
|
||||
return r.command
|
||||
? `This client updates outside Punktfunk. Run: ${r.command}`
|
||||
: "This client updates outside Punktfunk — use the way you installed it.";
|
||||
}
|
||||
if (r.error === "timeout") return "Client update timed out — check the box and try again.";
|
||||
if (r.error === "client-unavailable")
|
||||
return "Couldn’t reach the client to update it — is it still installed?";
|
||||
return `Client update failed${r.detail ? `: ${r.detail}` : r.error ? ` (${r.error})` : ""}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
|
||||
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
|
||||
* reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is
|
||||
* fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so
|
||||
* the "Update available" button clears.
|
||||
* Apply whichever updates are pending.
|
||||
*
|
||||
* The CLIENT goes first and is awaited, by whichever route its install supports — a user-scope
|
||||
* `flatpak update`, or the packaged root helper via `punktfunk-client --apply-update`. An
|
||||
* install neither can serve is not attempted at all: the user gets the command in a toast,
|
||||
* because a button that can only fail teaches nothing.
|
||||
*
|
||||
* The PLUGIN goes last and is fire-and-forget: Decky's install RPC reinstalls and reloads the
|
||||
* plugin, tearing this panel down before any result could arrive. `check` (when passed)
|
||||
* refreshes the panel state after a client-only update so the "Update available" button clears.
|
||||
*/
|
||||
export async function applyUpdate(
|
||||
info: UpdateInfo,
|
||||
check?: (force: boolean) => Promise<UpdateInfo | null>,
|
||||
): Promise<void> {
|
||||
if (info.client_update_available) {
|
||||
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
|
||||
if (info.client_update_available && clientUpdateIsOneTap(info)) {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
// A package-manager run is not instant; say so before the wait, not after.
|
||||
body:
|
||||
info.client_applier === "helper"
|
||||
? "Updating the client — this can take a few minutes…"
|
||||
: "Updating the client…",
|
||||
});
|
||||
try {
|
||||
const r = await updateClient();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: !r.ok
|
||||
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
|
||||
: r.updated
|
||||
? "Client updated to the latest version."
|
||||
: "Client is already up to date.",
|
||||
});
|
||||
toaster.toast({ title: "Punktfunk", body: clientUpdateResultBody(r) });
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
||||
}
|
||||
} else if (info.client_update_available) {
|
||||
// Nothing here can install it — hand over the one line that does, rather than a button
|
||||
// that would fail. `client_opt_in` wins when joining the group is what's missing, since
|
||||
// that is the step that turns this into a one-tap update from then on.
|
||||
const line = info.client_opt_in || info.client_command;
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: line
|
||||
? `Client update available (${info.client_latest}). Run: ${line}`
|
||||
: `A newer client (${info.client_latest}) is available — update it the way you installed it.`,
|
||||
duration: 12_000,
|
||||
});
|
||||
}
|
||||
|
||||
if (info.update_available) {
|
||||
|
||||
+37
-21
@@ -25,6 +25,7 @@ import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
@@ -72,27 +73,42 @@ const QamPanel: FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasUpdate(update) && (
|
||||
<PanelSection title="Update available">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin v${update!.current} → v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: "New client version"
|
||||
}
|
||||
description="Installing can take a couple of minutes"
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||
Update Punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
{hasUpdate(update) &&
|
||||
// A client this Deck can't install (a sysext, a nix profile, a source build, or a box
|
||||
// that hasn't opted into one-tap updates) gets the command, not a button — tapping
|
||||
// something that can only fail is worse than reading one line. A pending PLUGIN update
|
||||
// still wins the button, since that half always works.
|
||||
(clientUpdateIsManualOnly(update) && !update!.update_available ? (
|
||||
<PanelSection title="Client update available">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable
|
||||
label={update!.client_latest || "Newer version"}
|
||||
description={update!.client_opt_in || update!.client_command}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
) : (
|
||||
<PanelSection title="Update available">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin v${update!.current} → v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: "New client version"
|
||||
}
|
||||
description="Installing can take a couple of minutes"
|
||||
>
|
||||
<FaDownload style={{ marginRight: "0.5em" }} />
|
||||
Update Punktfunk
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
))}
|
||||
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
|
||||
@@ -1,40 +1,82 @@
|
||||
// The host row's OS mark, resolved from the host's OS-identity chain (mDNS `os` TXT /
|
||||
// `--list-hosts` `os`, e.g. "linux/fedora/bazzite"): walk the chain most-specific-first and
|
||||
// take the first token react-icons has a brand mark for, so an unknown distro degrades to its
|
||||
// family's mark and finally to Tux. Mirrors pf-client-core's `os_icon_tokens` (aliases
|
||||
// macos→apple, steamos→steam); null when the chain is absent or entirely unknown — the row
|
||||
// then renders exactly as it did before the field existed.
|
||||
// take the first token we ship a mark for, so an unknown distro degrades to its family's
|
||||
// mark and finally to Tux. Mirrors pf-client-core's `os_icon_tokens` (aliases macos→apple,
|
||||
// steamos→steam); null when the chain is absent or entirely unknown — the row then renders
|
||||
// exactly as it did before the field existed.
|
||||
//
|
||||
// Path data is transcribed from the assets/os-icons masters rather than pulled from
|
||||
// react-icons: that package's Font Awesome 5 brand set still carries the perspective-skewed
|
||||
// Windows flag, and it has no Bazzite or CachyOS mark at all. `bash scripts/gen-os-icons.sh
|
||||
// <token>` prints a master's viewBox + path ready to paste here.
|
||||
import { FC } from "react";
|
||||
import {
|
||||
FaApple,
|
||||
FaFedora,
|
||||
FaLinux,
|
||||
FaSteam,
|
||||
FaSuse,
|
||||
FaUbuntu,
|
||||
FaWindows,
|
||||
} from "react-icons/fa";
|
||||
import { SiArchlinux, SiDebian, SiNixos } from "react-icons/si";
|
||||
import { IconType } from "react-icons";
|
||||
|
||||
const OS_ICONS: Record<string, IconType> = {
|
||||
windows: FaWindows,
|
||||
apple: FaApple,
|
||||
macos: FaApple,
|
||||
linux: FaLinux,
|
||||
steam: FaSteam,
|
||||
steamos: FaSteam,
|
||||
ubuntu: FaUbuntu,
|
||||
fedora: FaFedora,
|
||||
opensuse: FaSuse,
|
||||
arch: SiArchlinux,
|
||||
debian: SiDebian,
|
||||
nixos: SiNixos,
|
||||
/** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */
|
||||
const OS_ICONS: Record<string, { viewBox: string; d: string }> = {
|
||||
windows: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
|
||||
},
|
||||
apple: {
|
||||
viewBox: "0 0 384 512",
|
||||
d: "M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z",
|
||||
},
|
||||
linux: {
|
||||
viewBox: "0 0 448 512",
|
||||
d: "M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z",
|
||||
},
|
||||
steam: {
|
||||
viewBox: "0 0 496 512",
|
||||
d: "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
|
||||
},
|
||||
ubuntu: {
|
||||
viewBox: "0 0 496 512",
|
||||
d: "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z",
|
||||
},
|
||||
fedora: {
|
||||
viewBox: "0 0 448 512",
|
||||
d: "M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z",
|
||||
},
|
||||
arch: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091",
|
||||
},
|
||||
debian: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83",
|
||||
},
|
||||
nixos: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z",
|
||||
},
|
||||
opensuse: {
|
||||
viewBox: "0 0 640 512",
|
||||
d: "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
|
||||
},
|
||||
// The gaming distros get their own mark rather than their family's: on a Deck, "a Bazzite
|
||||
// box" and "a Fedora box" are different machines to the person reading the row.
|
||||
bazzite: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
|
||||
},
|
||||
cachyos: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
|
||||
},
|
||||
nobara: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveOsIcon(os: string | undefined): IconType | null {
|
||||
for (const token of (os ?? "").toLowerCase().split("/").reverse()) {
|
||||
const icon = OS_ICONS[token];
|
||||
/** `macos` renders the apple mark, `steamos` the steam mark — same aliases as every client. */
|
||||
const ALIASES: Record<string, string> = { macos: "apple", steamos: "steam" };
|
||||
|
||||
export function resolveOsIcon(
|
||||
os: string | undefined,
|
||||
): { viewBox: string; d: string } | null {
|
||||
for (const raw of (os ?? "").toLowerCase().split("/").reverse()) {
|
||||
const icon = OS_ICONS[ALIASES[raw] ?? raw];
|
||||
if (icon) {
|
||||
return icon;
|
||||
}
|
||||
@@ -44,6 +86,20 @@ export function resolveOsIcon(os: string | undefined): IconType | null {
|
||||
|
||||
/** The mark itself, or nothing — sized/colored by the surrounding text like the lock glyph. */
|
||||
export const OsMark: FC<{ os?: string }> = ({ os }) => {
|
||||
const Icon = resolveOsIcon(os);
|
||||
return Icon ? <Icon /> : null;
|
||||
const icon = resolveOsIcon(os);
|
||||
if (!icon) return null;
|
||||
// 1em square with the viewBox letterboxed inside it: the same box react-icons drew, so a
|
||||
// non-square mark keeps its aspect ratio instead of being stretched to fill.
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={icon.viewBox}
|
||||
height="1em"
|
||||
width="1em"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={icon.d} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
PinsApi,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientInstallLabel,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
@@ -391,6 +393,16 @@ const AboutTab: FC<{
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
|
||||
install kind decides everything below it. */}
|
||||
{!!update?.client_install && (
|
||||
<Field
|
||||
label="Client"
|
||||
description={`${clientInstallLabel(update.client_install)}${
|
||||
update.client_current ? ` · ${update.client_current}` : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
label={
|
||||
@@ -398,19 +410,37 @@ const AboutTab: FC<{
|
||||
? `Plugin update — v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: "Client update available"
|
||||
: `Client update — ${update!.client_latest || "available"}`
|
||||
}
|
||||
description={
|
||||
// Only promise a one-tap install when there is one. On a notify-only install the
|
||||
// row becomes the command itself, which is the whole answer for that box.
|
||||
clientUpdateIsManualOnly(update) && !update!.update_available
|
||||
? update!.client_opt_in || update!.client_command
|
||||
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
}
|
||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{!!update?.client_error && (
|
||||
<Field
|
||||
label="Client update check"
|
||||
description={
|
||||
update.client_error === "client-outdated"
|
||||
? "This client predates update checks — update it once by hand and the check starts working."
|
||||
: "Couldn’t check the client for updates."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
|
||||
+15
-1
@@ -1,7 +1,21 @@
|
||||
//! Compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic icons)
|
||||
//! into a gresource bundle, registered at startup via `gio::resources_register_include!`.
|
||||
//! into a gresource bundle, registered at startup via `gio::resources_register_include!`,
|
||||
//! and stamp the build version the update check compares against.
|
||||
|
||||
fn main() {
|
||||
// Build provenance, identical to the host's (crates/punktfunk-host/build.rs): the packaging
|
||||
// jobs set PUNKTFUNK_BUILD_VERSION to the full package version (`0.23.0~ci10250.gab12cd34`
|
||||
// on deb, `0.23.0-0.ci10250.g…` on rpm, …), a plain `cargo build` falls back to the crate
|
||||
// version. This is what `--version` prints and what `--check-update` compares against the
|
||||
// signed manifest — and the canary suffix is load-bearing there, because canary channels
|
||||
// compare CI run numbers rather than patch fields (pf_update_check::version).
|
||||
let version = std::env::var("PUNKTFUNK_BUILD_VERSION")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
|
||||
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
|
||||
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
|
||||
|
||||
// Host cfg gate mirrors this crate's `#[cfg(target_os = "linux")]` modules: on any other
|
||||
// host the crate compiles to an empty stub and `glib-compile-resources` may not exist.
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user