Compare commits
@@ -4,6 +4,14 @@
|
||||
# `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here
|
||||
# reds nothing else. PNGs land as a 30-day artifact; not committed or published.
|
||||
name: android-screenshots
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -18,7 +26,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||
- name: JDK 21 (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -39,7 +47,10 @@ jobs:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }}
|
||||
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
|
||||
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
|
||||
# restore a key that can never hold the new one.
|
||||
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: android-screenshots-
|
||||
|
||||
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
|
||||
|
||||
@@ -8,15 +8,42 @@
|
||||
# ci/rust-ci.Dockerfile. Emulator instrumentation tests are deferred until a KVM-capable runner
|
||||
# exists (they self-skip otherwise, like apple.yml's RemoteFirstLightTests).
|
||||
name: android
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Scope canary builds to what this artifact is built FROM — a docs-only or
|
||||
# web-only push should not light up the whole fleet. Applies to branch pushes;
|
||||
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/android/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/android/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -26,7 +53,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.2 runs on JDK 17–21, not the host default)
|
||||
- name: JDK 21 (AGP 9.3 runs on JDK 17–21, not the host default)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -73,7 +100,8 @@ jobs:
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
target
|
||||
key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts') }}
|
||||
# gradle-wrapper.properties is in the key on purpose — see android-screenshots.yml.
|
||||
key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: android-
|
||||
|
||||
- name: cargo-ndk
|
||||
|
||||
@@ -8,11 +8,40 @@
|
||||
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
|
||||
# from the build/test job and best-effort, so a capture gap never reds the core signal.
|
||||
name: apple
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Scope canary builds to what this artifact is built FROM — a docs-only or
|
||||
# web-only push should not light up the whole fleet. Applies to branch pushes;
|
||||
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/apple/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/apple.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/apple/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/apple.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -14,10 +14,36 @@
|
||||
# NOTE: this token + the registry-held private key are the trust root — a token holder can
|
||||
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
|
||||
name: arch
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Scope canary builds to what this artifact is built FROM — a docs-only or
|
||||
# web-only push should not light up the whole fleet. Applies to branch pushes;
|
||||
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/linux/**'
|
||||
- 'clients/session/**'
|
||||
- 'clients/shared/**'
|
||||
- 'clients/cli/**'
|
||||
- 'web/**'
|
||||
- 'sdk/**'
|
||||
- 'packaging/arch/**'
|
||||
- 'packaging/gamescope/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/arch.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
|
||||
@@ -1,32 +1,64 @@
|
||||
# Supply-chain advisory scan for BOTH dependency trees the project ships to users:
|
||||
# Supply-chain advisory scan for EVERY dependency tree the project ships or publishes, plus the
|
||||
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
|
||||
# it lands).
|
||||
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
|
||||
# * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login
|
||||
# gate, session sealing, and the mgmt bearer token, so its deps matter too.
|
||||
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad
|
||||
# dep the moment it lands), and on demand.
|
||||
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
|
||||
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
|
||||
# plugin-kit (@punktfunk/plugin-kit).
|
||||
# * pnpm audit → clients/decky (the Steam Deck plugin).
|
||||
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
|
||||
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
|
||||
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
|
||||
# verified against the LIVE site (the docs don't build standalone) — tracked in
|
||||
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
|
||||
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
|
||||
# fails if any crate carries a license outside the allowlist — the regression
|
||||
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
|
||||
# nothing scans it — see the CRA roadmap.)
|
||||
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
|
||||
# change, and on demand.
|
||||
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
|
||||
name: audit
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
|
||||
paths:
|
||||
- 'Cargo.lock'
|
||||
- 'packaging/windows/drivers/Cargo.lock'
|
||||
- 'web/bun.lock'
|
||||
- 'docs-site/bun.lock'
|
||||
- 'sdk/bun.lock'
|
||||
- 'plugin-kit/bun.lock'
|
||||
- 'clients/decky/pnpm-lock.yaml'
|
||||
- 'about.toml'
|
||||
- '.gitea/workflows/audit.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cargo-audit:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
/usr/local/cargo/registry
|
||||
key: cargo-audit-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-audit-
|
||||
- name: cargo audit
|
||||
@@ -36,13 +68,17 @@ jobs:
|
||||
cargo audit
|
||||
|
||||
bun-audit:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tree: [web, sdk, plugin-kit]
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
working-directory: ${{ matrix.tree }}
|
||||
steps:
|
||||
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
|
||||
# (same preamble as web-screenshots.yml / ci.yml's web job).
|
||||
@@ -50,9 +86,75 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No
|
||||
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the
|
||||
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep
|
||||
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here).
|
||||
# `bun audit` queries the registry advisory DB for the versions pinned in the tree's
|
||||
# bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
|
||||
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
|
||||
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
|
||||
# noting why here).
|
||||
- name: bun audit
|
||||
run: bun audit
|
||||
|
||||
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
|
||||
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
|
||||
# act_runner does not reliably honor — a red job here would take the whole run red). The full
|
||||
# advisory list still lands in the log; the warning marks it wasn't clean.
|
||||
docs-site-audit:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs-site
|
||||
steps:
|
||||
- name: Install git + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
- name: bun audit (non-blocking)
|
||||
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
|
||||
|
||||
pnpm-audit:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: clients/decky
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
|
||||
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
|
||||
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
|
||||
# build tooling that never leaves CI (auditing them fails on toolchain advisories that
|
||||
# can't reach a user — the docs-site problem in miniature).
|
||||
- name: pnpm audit
|
||||
run: |
|
||||
npm install -g pnpm@10
|
||||
pnpm audit --prod
|
||||
|
||||
# The regression guard about.toml documents: fail if any crate in either Rust workspace carries
|
||||
# a license outside the `accepted` allowlist (e.g. a copyleft dep silently entering the linked
|
||||
# set). cargo-about is version-pinned: the config uses the per-crate `accepted` syntax
|
||||
# validated against exactly this version.
|
||||
license-gate:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
/usr/local/cargo/registry
|
||||
key: cargo-about-0.9.1
|
||||
restore-keys: cargo-about-
|
||||
- name: cargo about license gate (host + driver workspaces)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
|
||||
cargo about generate about.hbs --fail -o /dev/null
|
||||
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Report-only CPU benchmarks, moved out of ci.yml: they never fail the build (shared CI
|
||||
# hardware is too noisy to gate on), so running them per-push only occupied a fleet slot
|
||||
# during fan-out storms. Nightly + on demand is exactly as much signal at none of the
|
||||
# queue cost. The tight regression gate + the real encode/stream path live on the
|
||||
# self-hosted GPU runner (Tier 3, bench-gpu.yml).
|
||||
name: bench
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
|
||||
# never collide; every Rust job on every host feeds and reads one warm cache.
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
bench:
|
||||
# Tier-1 (criterion microbenchmarks) + Tier-2 (FEC loss recovery) — GPU-free, so they run here.
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
- name: Prep
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3; }
|
||||
- name: Tier-1 microbenchmarks (criterion)
|
||||
run: cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
|
||||
- name: Tier-2 FEC loss recovery (loss-harness)
|
||||
run: cargo run -q -p loss-harness
|
||||
- name: Compare vs baseline (report-only)
|
||||
run: python3 scripts/bench/compare.py --threshold 0.5
|
||||
@@ -1,23 +1,58 @@
|
||||
# CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-latest` runner; the Rust
|
||||
# job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8,
|
||||
# CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-24.04` fleet label; the
|
||||
# Rust job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8,
|
||||
# PipeWire, GL/GBM, libcuda link stub, pinned-channel rustup) so the workspace links the
|
||||
# same libs as the dev boxes. Apple client CI lives in apple.yml (macOS runner).
|
||||
# same libs as the dev boxes. Builder images come from the LAN registry on home-ci-core
|
||||
# (content-keyed, docker.yml) — never the WAN. Apple client CI lives in apple.yml (macOS
|
||||
# runner). The report-only benchmarks moved to bench.yml (nightly + dispatch) so they stop
|
||||
# occupying a fleet slot on every push.
|
||||
name: ci
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
|
||||
# never collide; every Rust job on every host feeds and reads one warm cache.
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
|
||||
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
|
||||
# keeps the job green across image-content changes; a no-op once the image has them.
|
||||
@@ -125,6 +160,9 @@ jobs:
|
||||
- name: C ABI harness (standalone link proof)
|
||||
run: bash crates/punktfunk-core/tests/c/run.sh
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Verify generated header is committed & up to date
|
||||
run: |
|
||||
cargo build -p punktfunk-core --locked
|
||||
@@ -144,11 +182,21 @@ jobs:
|
||||
rust-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -227,25 +275,3 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
|
||||
bench:
|
||||
# Tier-1 (criterion microbenchmarks) + Tier-2 (FEC loss recovery) — GPU-free, so they run here.
|
||||
# Report-only: prints the numbers + a diff vs the committed baseline to the job summary and never
|
||||
# fails the build (shared CI hardware is too noisy to gate on). The tight regression gate + the
|
||||
# real encode/stream path live on the self-hosted GPU runner (Tier 3, bench-gpu.yml).
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prep
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3; }
|
||||
- name: Tier-1 microbenchmarks (criterion)
|
||||
run: cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
|
||||
- name: Tier-2 FEC loss recovery (loss-harness)
|
||||
run: cargo run -q -p loss-harness
|
||||
- name: Compare vs baseline (report-only)
|
||||
run: python3 scripts/bench/compare.py --threshold 0.5
|
||||
|
||||
@@ -23,10 +23,36 @@
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||
name: deb
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Scope canary builds to what this artifact is built FROM — a docs-only or
|
||||
# web-only push should not light up the whole fleet. Applies to branch pushes;
|
||||
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/linux/**'
|
||||
- 'clients/session/**'
|
||||
- 'clients/shared/**'
|
||||
- 'clients/cli/**'
|
||||
- 'web/**'
|
||||
- 'sdk/**'
|
||||
- 'packaging/debian/**'
|
||||
- 'packaging/linux/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/deb.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release for every platform (see
|
||||
# docs-site channels.md). The old version-shadow (a client tag shipping a host package
|
||||
# that outranked rolling builds) is now structurally impossible — main publishes to the
|
||||
@@ -38,16 +64,35 @@ env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
COMPONENT: main
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
# A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
@@ -102,10 +147,15 @@ jobs:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both). 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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
- 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
|
||||
@@ -184,11 +234,21 @@ jobs:
|
||||
build-publish-host:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
- name: Version + channel
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
@@ -291,11 +351,21 @@ jobs:
|
||||
build-publish-client-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
|
||||
# commit), so the arm64 package always shares the amd64 version line.
|
||||
- name: Version + channel
|
||||
|
||||
@@ -20,10 +20,25 @@
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
|
||||
name: decky
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Scope canary builds to what this artifact is built FROM — a docs-only or
|
||||
# web-only push should not light up the whole fleet. Applies to branch pushes;
|
||||
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
|
||||
paths:
|
||||
- 'clients/decky/**'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/decky.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
# Build + push the dockerized pieces to the Gitea container registry:
|
||||
# punktfunk-web — management console (web/Dockerfile, repo-root context)
|
||||
# punktfunk-docs — documentation site (docs-site/Dockerfile)
|
||||
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
|
||||
# punktfunk-rust-ci-arm64cross — the above + an arm64 sysroot, for the aarch64 client legs
|
||||
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
|
||||
# Build + push the dockerized pieces.
|
||||
#
|
||||
# Two very different image families now:
|
||||
#
|
||||
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
|
||||
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
|
||||
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
|
||||
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
|
||||
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
|
||||
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
|
||||
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
|
||||
# is re-pushed alongside every new key and is what the consuming workflows pin.
|
||||
#
|
||||
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
|
||||
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
|
||||
# unom-1 deploys pull from there and releases pin them.
|
||||
#
|
||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope.
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
|
||||
# the LAN registry is unauthenticated inside the LAN).
|
||||
#
|
||||
# Bootstrap note: ci.yml's rust job pulls punktfunk-rust-ci:latest from the registry, so
|
||||
# this workflow (or a manual push) must have succeeded once before that job can run; on
|
||||
# the same push, ci.yml builds against the PREVIOUS image. All three were seeded manually
|
||||
# on 2026-06-12.
|
||||
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
|
||||
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
|
||||
# images); after that, this workflow keeps :latest current whenever ci/ changes.
|
||||
name: docker
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -23,9 +42,142 @@ on:
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
CI_REGISTRY: 192.168.1.58:5010
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
builders:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- image: punktfunk-rust-ci
|
||||
dockerfile: ci/rust-ci.Dockerfile
|
||||
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
|
||||
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
|
||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||
- image: punktfunk-rust-ci-noble
|
||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
|
||||
- image: punktfunk-fedora44-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
buildargs: --build-arg FEDORA_VERSION=44
|
||||
keysuffix: -f44
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The key is the git TREE HASH of ci/ — every byte any of these Dockerfiles can see
|
||||
# (they all use ci/ as build context). One key for the whole family on purpose: a
|
||||
# change to any of them re-keys all four, and a spurious rebuild of a sibling is
|
||||
# cheap, rare, and infinitely better than a stale one.
|
||||
- name: Content key
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
echo "KEY=ck-$(git rev-parse HEAD:ci | cut -c1-12)${{ matrix.keysuffix }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check whether this key already exists
|
||||
id: exists
|
||||
run: |
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
if curl -sf -o /dev/null -H "$ACCEPT" \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"; then
|
||||
echo "hit=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::${{ matrix.image }}:$KEY already in the LAN registry — nothing to build"
|
||||
else
|
||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
# --pull is cheap now: base images come through the ci-core pull-through mirror.
|
||||
run: |
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
|
||||
ci
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
|
||||
|
||||
# A release pins reproducible builder images without any rebuild: copy the key's
|
||||
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
|
||||
- name: Tag for release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
MT=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY" \
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
|
||||
# by the arm64 client legs in ci.yml/deb.yml. Its key also folds in rust-toolchain.toml:
|
||||
# the Dockerfile installs the aarch64 target against the toolchain the workspace pins.
|
||||
builders-arm64cross:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: builders
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
IMAGE: punktfunk-rust-ci-arm64cross
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Content key
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
echo "KEY=ck-$(printf '%s%s' "$(git rev-parse HEAD:ci)" "$(git rev-parse HEAD:rust-toolchain.toml)" | sha256sum | cut -c1-12)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check whether this key already exists
|
||||
id: exists
|
||||
run: |
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
if curl -sf -o /dev/null -H "$ACCEPT" \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"; then
|
||||
echo "hit=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$IMAGE:$KEY already in the LAN registry — nothing to build"
|
||||
else
|
||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
# Root context: it needs rust-toolchain.toml to install the target against the
|
||||
# toolchain the workspace actually pins.
|
||||
run: |
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$CI_REGISTRY/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY/$IMAGE:latest" \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY/$IMAGE:latest"
|
||||
|
||||
- name: Tag for release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
MT=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
|
||||
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
|
||||
apps:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
@@ -37,23 +189,6 @@ jobs:
|
||||
- image: punktfunk-docs
|
||||
dockerfile: docs-site/Dockerfile
|
||||
context: docs-site
|
||||
- image: punktfunk-rust-ci
|
||||
dockerfile: ci/rust-ci.Dockerfile
|
||||
context: ci
|
||||
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
|
||||
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
|
||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||
- image: punktfunk-rust-ci-noble
|
||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||
context: ci
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
context: ci
|
||||
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
|
||||
- image: punktfunk-fedora44-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
context: ci
|
||||
buildargs: --build-arg FEDORA_VERSION=44
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -68,7 +203,7 @@ jobs:
|
||||
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
|
||||
EXTRA=""
|
||||
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
docker build --pull \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
|
||||
@@ -81,48 +216,13 @@ jobs:
|
||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# and so must not race the matrix entry that publishes that base. Consumed by the arm64
|
||||
# client legs in deb.yml/rpm.yml/arch.yml. Root context: it needs rust-toolchain.toml to
|
||||
# install the target against the toolchain the workspace actually pins.
|
||||
build-push-arm64cross:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
IMAGE: punktfunk-rust-ci-arm64cross
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login "$REGISTRY" -u enricobuehler --password-stdin
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
EXTRA=""
|
||||
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$REGISTRY/$OWNER/$IMAGE:latest" \
|
||||
-t "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}" \
|
||||
$EXTRA \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
docker push "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}"
|
||||
docker push "$REGISTRY/$OWNER/$IMAGE:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
||||
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
||||
# as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the
|
||||
# unom-ci-deploy key).
|
||||
deploy-docs:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
needs: apps
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
|
||||
name: flatpak
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -127,6 +135,25 @@ jobs:
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
# This job was the fleet's single heaviest network consumer: every run re-downloaded
|
||||
# the GNOME runtime + SDK + llvm/rust/ffmpeg extensions (multi-GB from Flathub) and
|
||||
# every crate source. Both live in well-defined directories, both are idempotently
|
||||
# verified/extended by the steps below, and the central cache server restores them
|
||||
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
|
||||
# (runtimes/extensions) and manifest+Cargo.lock (crate sources + builder state).
|
||||
- name: Cache Flathub runtimes
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/flatpak
|
||||
key: flatpak-runtimes-${{ hashFiles('packaging/flatpak/**') }}
|
||||
restore-keys: flatpak-runtimes-
|
||||
- name: Cache flatpak-builder state (crate sources, ccache)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .flatpak-builder
|
||||
key: flatpak-builder-state-${{ hashFiles('Cargo.lock', 'packaging/flatpak/**') }}
|
||||
restore-keys: flatpak-builder-state-
|
||||
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||
# <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
|
||||
|
||||
@@ -5,23 +5,55 @@
|
||||
# Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day
|
||||
# artifact; they are not committed or published.
|
||||
name: linux-client-screenshots
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
|
||||
# never collide; every Rust job on every host feeds and reads one warm cache.
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps.
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
# Client link deps (baked into the image; kept here so the job is green across image
|
||||
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
|
||||
# software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
#
|
||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
|
||||
name: plugin-kit-publish
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
|
||||
# loud warning.
|
||||
name: release
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -9,10 +9,33 @@
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||
name: rpm
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Scope canary builds to what this artifact is built FROM — a docs-only or
|
||||
# web-only push should not light up the whole fleet. Applies to branch pushes;
|
||||
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'web/**'
|
||||
- 'sdk/**'
|
||||
- 'packaging/rpm/**'
|
||||
- 'packaging/gamescope/**'
|
||||
- 'packaging/bazzite/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/rpm.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
|
||||
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
|
||||
# version-shadow (a release outranking rolling builds in one group) is structurally gone.
|
||||
@@ -22,6 +45,15 @@ on:
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
@@ -40,13 +72,23 @@ jobs:
|
||||
group: fedora-44
|
||||
fedver: 44
|
||||
container:
|
||||
image: git.unom.io/unom/${{ matrix.image }}:latest
|
||||
image: 192.168.1.58:5010/${{ matrix.image }}:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_HOME: /usr/local/cargo
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
|
||||
# rpmbuild + git archive need the checkout trusted; cache the crates download.
|
||||
# The client link deps are also baked into the fedora-rpm image, but this job runs
|
||||
# against the image from the PREVIOUS push (docker.yml bootstrap note) — keep it
|
||||
@@ -75,8 +117,8 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
key: cargo-home-fedora-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-fedora-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
|
||||
@@ -102,7 +144,9 @@ jobs:
|
||||
# Recommends both). Both need bun (ensured in Prep).
|
||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
|
||||
|
||||
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
||||
# Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a
|
||||
# missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo.
|
||||
- name: Sign RPMs
|
||||
env:
|
||||
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
|
||||
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
||||
@@ -189,16 +233,27 @@ jobs:
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
|
||||
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
|
||||
# anything, sitting on the same registry as the images they describe.
|
||||
- name: Publish the sysext feed
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
case "$GROUP" in
|
||||
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
|
||||
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6; OTHER="f${{ matrix.fedver }}" ;;
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0; OTHER="f${{ matrix.fedver }}-canary" ;;
|
||||
esac
|
||||
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
# Re-seal this Fedora major's OTHER channel too. Stable feeds only publish on a tag, so
|
||||
# without this a stable box would sit in front of an unsigned (hence refused) feed until
|
||||
# the next release; canary pushes are frequent, so every live feed gets sealed within a
|
||||
# day of this landing, and a key rotation propagates without rebuilding any image.
|
||||
# Best-effort: a channel that has never published yet has no manifest to seal.
|
||||
bash packaging/bazzite/publish-sysext-feed.sh --seal "$OTHER" \
|
||||
|| echo "::warning::could not seal the $OTHER feed (no manifest yet?)"
|
||||
|
||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Per-release SBOM (CRA Annex I Part II §1: identify and document the components in the product,
|
||||
# in a commonly used machine-readable format — we emit CycloneDX JSON).
|
||||
#
|
||||
# Tag push → the SBOM is attached to the Gitea release, next to the artifacts it describes.
|
||||
# Release assets are never pruned (security updates must stay available ≥10 years, CRA Art. 13),
|
||||
# so the SBOM's retention rides on the release's.
|
||||
# workflow_dispatch on a non-tag ref → generated and uploaded as a workflow artifact only
|
||||
# (pipeline validation / an on-demand snapshot); no release is touched.
|
||||
#
|
||||
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
|
||||
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
|
||||
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, gamescope).
|
||||
name: sbom
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sbom:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# fetch-depth 0: the dispatch path derives the canary base from the tag history
|
||||
# (scripts/ci/pf-version.sh), which a shallow clone cannot see.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
|
||||
- name: Install syft
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v1.49.0
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VERSION="${GITHUB_REF_NAME#v}" ;;
|
||||
*) eval "$(bash scripts/ci/pf-version.sh)"; VERSION="${PF_BASE}-snapshot" ;;
|
||||
esac
|
||||
sh scripts/ci/gen-sbom.sh "$VERSION" "punktfunk-${VERSION}.cdx.json"
|
||||
echo "SBOM_FILE=punktfunk-${VERSION}.cdx.json" >> "$GITHUB_ENV"
|
||||
- name: Attach to release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$SBOM_FILE"
|
||||
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml).
|
||||
- name: Upload artifact (non-tag runs)
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: sbom
|
||||
path: punktfunk-*.cdx.json
|
||||
@@ -7,6 +7,14 @@
|
||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with
|
||||
# write:package scope). No new secret needed.
|
||||
name: sdk-publish
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
# host packaging). Best-effort: a standalone workflow, so a failure here reds
|
||||
# nothing else. PNGs land as a 30-day artifact; they are not committed or published.
|
||||
name: web-screenshots
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
|
||||
# first GITHUB_ENV line — see windows.yml).
|
||||
name: windows-drivers
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
|
||||
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
|
||||
# See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||
@@ -38,6 +40,14 @@
|
||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||
name: windows-host
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -81,6 +91,15 @@ env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PKG: punktfunk-host-windows
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
package:
|
||||
@@ -113,10 +132,9 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
# CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc,
|
||||
# opus) the host pulls; CARGO_WORKSPACE_DIR mirrors the client workflows. Both via GITHUB_ENV
|
||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||
# opus) the host pulls; via GITHUB_ENV (pwsh Out-File utf8 = no BOM, unlike Windows
|
||||
# PowerShell 5.1 — keeps the first line clean).
|
||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# audiopus_sys' vendored opus declares cmake_minimum_required < 3.5, which CMake 4.x
|
||||
# refuses outright. Green runs today only survive on the cached configure output — a
|
||||
# target-dir purge (the runner's disk-cleanup task) would fail the fresh configure, as
|
||||
@@ -342,6 +360,12 @@ jobs:
|
||||
env:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
|
||||
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
|
||||
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
|
||||
# which the installer then trusts as a machine root — see packaging/windows/README.md.
|
||||
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
|
||||
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
|
||||
run: |
|
||||
& packaging/windows/pack-host-installer.ps1 `
|
||||
-Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out
|
||||
|
||||
@@ -21,12 +21,23 @@
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
|
||||
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed
|
||||
# with them. Otherwise an ephemeral self-signed cert is generated and its public .cer is published
|
||||
# next to the .msix (users import it to Trusted People before install). Drop in a real cert later
|
||||
# with no workflow change — just add the secrets (+ pass -Publisher if its subject differs).
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
|
||||
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
|
||||
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
|
||||
# .cer is published next to the .msix (users import it to Trusted People before install).
|
||||
#
|
||||
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
|
||||
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
|
||||
name: windows-msix
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -49,6 +60,15 @@ env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PKG: punktfunk-client-windows
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
package:
|
||||
@@ -81,11 +101,9 @@ jobs:
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR (per-arch, short)
|
||||
# dodges the MAX_PATH wall in the CMake-from-source crates (see windows.yml). FFMPEG_DIR
|
||||
# selects the arch's import libs + is read by pack-msix.ps1 for the runtime DLLs. All via
|
||||
# GITHUB_ENV.
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
|
||||
# crates (see windows.yml). FFMPEG_DIR selects the arch's import libs + is read by
|
||||
# pack-msix.ps1 for the runtime DLLs. All via GITHUB_ENV.
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
|
||||
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
|
||||
# / per-arch vars are set in a step:
|
||||
# - CARGO_WORKSPACE_DIR windows-reactor's build.rs unwraps it + stages the Win App SDK
|
||||
# NuGets/winmd under it (from GITHUB_WORKSPACE).
|
||||
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
|
||||
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
|
||||
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
|
||||
@@ -34,10 +32,18 @@
|
||||
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
|
||||
#
|
||||
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
|
||||
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (the
|
||||
# CARGO_WORKSPACE_DIR var silently never gets set -> reactor build.rs panics). pwsh writes no BOM.
|
||||
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
|
||||
# var silently never gets set). pwsh writes no BOM.
|
||||
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
|
||||
name: windows
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -67,6 +73,20 @@ on:
|
||||
- '.gitea/workflows/windows.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
|
||||
# never collide; every Rust job on every host feeds and reads one warm cache.
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
|
||||
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
|
||||
@@ -97,7 +117,6 @@ jobs:
|
||||
- name: Configure + toolchain versions
|
||||
shell: pwsh
|
||||
run: |
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
|
||||
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
|
||||
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
@@ -945,6 +945,19 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
"pf-inject",
|
||||
"pf-vdisplay",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
@@ -1585,6 +1598,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-build-tools"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9871f38b67853c358b8190f77b9f878eb27d933a950f1045b244c4559a9f5f0"
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.22.6"
|
||||
@@ -2871,7 +2890,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3280,6 +3299,17 @@ dependencies = [
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.21.0"
|
||||
@@ -3302,6 +3332,7 @@ version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"glib-build-tools",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"pf-client-core",
|
||||
@@ -3317,10 +3348,15 @@ name = "punktfunk-client-session"
|
||||
version = "0.21.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",
|
||||
@@ -3338,9 +3374,10 @@ dependencies = [
|
||||
"punktfunk-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"test_reactor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-reactor",
|
||||
"windows-reactor-setup",
|
||||
"winresource",
|
||||
@@ -4540,6 +4577,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "test_reactor"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"rustc-hash",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-reactor",
|
||||
"windows-reactor-setup",
|
||||
"windows-reference",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -5384,16 +5433,26 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-reference",
|
||||
"windows-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-canvas"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-window",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
@@ -5406,9 +5465,20 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-composition"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5427,13 +5497,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-interface 0.59.3 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-result 0.4.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-strings 0.5.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-implement 0.60.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-interface 0.59.3 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-result 0.4.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-strings 0.5.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5450,11 +5520,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5471,7 +5541,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5492,7 +5562,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5508,7 +5578,7 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
@@ -5523,38 +5593,40 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-reactor"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"rustc-hash",
|
||||
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-canvas",
|
||||
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-composition",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-reference",
|
||||
"windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-reactor-setup"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
|
||||
[[package]]
|
||||
name = "windows-reference"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-time",
|
||||
]
|
||||
|
||||
@@ -5570,9 +5642,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5598,9 +5670,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5708,18 +5780,26 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-time"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-window"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -24,10 +24,12 @@ members = [
|
||||
"crates/pyrowave-sys",
|
||||
"crates/libvpl-sys",
|
||||
"clients/probe",
|
||||
"clients/cli",
|
||||
"clients/linux",
|
||||
"clients/session",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/cursor-probe",
|
||||
"tools/display-disturb",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
|
||||
@@ -56,6 +56,38 @@ https://docs.punktfunk.unom.io/docs/security):
|
||||
|
||||
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
|
||||
|
||||
## Verifying what you downloaded
|
||||
|
||||
Every distribution path is authenticated. Nothing below needs an account or a network round trip to
|
||||
us beyond the download itself.
|
||||
|
||||
- **Release-page downloads** (DMG, MSIX, setup.exe, APK, decky zip, .deb/.rpm) each ship a
|
||||
`<file>.sha256` next to them. In your download directory:
|
||||
`sha256sum -c punktfunk-1.2.3.dmg.sha256` (macOS: `shasum -a 256 -c …`).
|
||||
- **RPMs** from the dnf repo are OpenPGP-signed with `packages@unom.io` (`AF245C506F4E4763`); the
|
||||
repo file in [`packaging/rpm/README.md`](packaging/rpm/README.md) sets `gpgcheck=1`, so dnf
|
||||
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
|
||||
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
|
||||
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
|
||||
public key is baked into the script rather than fetched from the feed.
|
||||
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
|
||||
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
|
||||
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
|
||||
- **The Windows drivers** (virtual display, virtual gamepads) are signed with a stable self-signed
|
||||
certificate, `CN=punktfunk-driver`
|
||||
(SHA-1 `4B8493E7CD565758D335F8F4F05C5A7261A13E02`), also published in
|
||||
[`packaging/windows/README.md`](packaging/windows/README.md). The installer has to add it to the
|
||||
machine's trusted roots for a self-signed driver to install at all, so — unlike the cases above —
|
||||
this signature does **not** authenticate the download: it gives the drivers a stable publisher
|
||||
identity you can compare against the published fingerprint, and it is removed again on uninstall.
|
||||
Verify with `Get-AuthenticodeSignature` on the installed `pf_vdisplay.dll`, or list what is
|
||||
trusted with `Get-ChildItem Cert:\LocalMachine\Root | ? Subject -like '*punktfunk*'`.
|
||||
|
||||
A checksum on its own only tells you the download wasn't corrupted in transit — it says nothing
|
||||
about who produced the file, since anyone able to replace an artifact can replace its checksum.
|
||||
Where that distinction matters (the update feeds, the package repos), the checksums are covered by
|
||||
a signature. If a signature check fails, please don't work around it; report it.
|
||||
|
||||
## Safe harbor
|
||||
|
||||
We consider good-faith security research that follows this policy to be authorized, and we won't
|
||||
|
||||
@@ -37,13 +37,15 @@ accepted = [
|
||||
ignore-build-dependencies = true
|
||||
ignore-dev-dependencies = true
|
||||
|
||||
# r-efi offers an LGPL-2.1-or-later arm but is tri-licensed; take a permissive arm. (It is also
|
||||
# UEFI-target-gated out of every shipped build.)
|
||||
[r-efi.clarify]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[ring.clarify]
|
||||
license = "MIT AND ISC AND OpenSSL"
|
||||
|
||||
[aws-lc-sys.clarify]
|
||||
license = "ISC AND Apache-2.0 AND MIT AND BSD-3-Clause AND OpenSSL"
|
||||
# Per-crate license-acceptance additions (cargo-about ≥0.6 syntax; the old `[crate.clarify]`
|
||||
# license-only form fails to deserialize under cargo-about 0.9, which now wants checksummed file
|
||||
# clarifications — per-crate `accepted` extensions express the same intent without checksums).
|
||||
#
|
||||
# r-efi is tri-licensed with an LGPL-2.1-or-later arm; cargo-about resolves OR-expressions to an
|
||||
# accepted arm on its own (MIT/Apache-2.0 are globally accepted), so it needs no entry. (It is
|
||||
# also UEFI-target-gated out of every shipped build.)
|
||||
#
|
||||
# ring's license is an AND of permissive terms including the OpenSSL license; accept the
|
||||
# OpenSSL/ISC parts for this crate only, not globally.
|
||||
[ring]
|
||||
accepted = ["OpenSSL", "ISC"]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.20.0"
|
||||
"version": "0.21.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1558,7 +1558,7 @@
|
||||
"host"
|
||||
],
|
||||
"summary": "Local status summary for the tray icon",
|
||||
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
|
||||
"description": "Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN\nvalues, no fingerprints). Unauthenticated, but served to loopback peers only.",
|
||||
"operationId": "getLocalSummary",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -5335,6 +5335,8 @@
|
||||
"abi_version",
|
||||
"app_version",
|
||||
"gfe_version",
|
||||
"os",
|
||||
"os_name",
|
||||
"codecs",
|
||||
"gamestream",
|
||||
"ports"
|
||||
@@ -5372,6 +5374,16 @@
|
||||
"type": "string",
|
||||
"description": "Best-effort primary LAN IP."
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
"description": "OS identity chain, generic → most specific, slash-separated (`windows` | `macos` |\n`linux[/<family>][/<id>]`). A client walks it most-specific-first and shows the first\ntoken it has an icon for, so an unknown distro still degrades to its family's mark.",
|
||||
"example": "linux/fedora/bazzite"
|
||||
},
|
||||
"os_name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable OS name (os-release `PRETTY_NAME`; `\"Windows\"`/`\"macOS\"` elsewhere).",
|
||||
"example": "Bazzite 42 (Kinoite)"
|
||||
},
|
||||
"ports": {
|
||||
"$ref": "#/components/schemas/PortMap"
|
||||
},
|
||||
@@ -5674,7 +5686,7 @@
|
||||
},
|
||||
"LocalSummary": {
|
||||
"type": "object",
|
||||
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
|
||||
"description": "Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no\nfingerprints. The ONE name exposed is `client_name`, the streaming client's display label\n(deliberate loosening for the tray's \"client connected\" toast: it tells the local user who is\non their machine, which is disclosure in the user's favor — and any local process could\nalready infer a session exists from the booleans here). Served unauthenticated to LOOPBACK\npeers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on\nWindows, so the per-user tray process cannot authenticate — this narrow read-only route is\nits status source.",
|
||||
"required": [
|
||||
"version",
|
||||
"video_streaming",
|
||||
@@ -5690,6 +5702,13 @@
|
||||
"type": "boolean",
|
||||
"description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
|
||||
},
|
||||
"client_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Display name of the (first) streaming native client — the trust store's name for it, else\nthe name the device sent at connect. `null` when idle, for a nameless client, or for a\nGameStream session (that plane carries no device name)."
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
|
||||
opensuse in assets/os-icons/) are from Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
|
||||
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
|
||||
The icons are redistributed here as monochrome SVG path data with no
|
||||
modifications beyond color normalization (fill="currentColor").
|
||||
|
||||
Per the Font Awesome Free license (https://fontawesome.com/license/free):
|
||||
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses."
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement.
|
||||
@@ -0,0 +1,10 @@
|
||||
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).
|
||||
|
||||
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
|
||||
required; this notice is provided for provenance.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement. See
|
||||
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
|
||||
@@ -0,0 +1,30 @@
|
||||
# OS icon masters
|
||||
|
||||
The canonical OS/distro brand marks every client derives its host-card OS icon from
|
||||
(web console inline SVGs, GTK symbolic icons, Windows PNGs, Apple template imagesets,
|
||||
Android `ImageVector`s). One file per **icon token** of the host's OS-identity chain
|
||||
(see `crates/punktfunk-host/src/osinfo.rs` and `crates/pf-client-core/src/os.rs`):
|
||||
|
||||
| token | mark | source |
|
||||
|---|---|---|
|
||||
| `windows` | Windows | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `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) |
|
||||
| `ubuntu` | Ubuntu | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `fedora` | Fedora | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `opensuse` | SUSE | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
|
||||
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
|
||||
| `debian` | Debian | Simple Icons (CC0 1.0) |
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 640 B |
@@ -0,0 +1,2 @@
|
||||
<!-- arch — from Simple Icons (CC0 1.0), via react-icons SiArchlinux. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 841 B |
@@ -0,0 +1,2 @@
|
||||
<!-- debian — from Simple Icons (CC0 1.0), via react-icons SiDebian. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- fedora — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaFedora. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- linux — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaLinux. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- nixos — from Simple Icons (CC0 1.0), via react-icons SiNixos. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 880 B |
@@ -0,0 +1,2 @@
|
||||
<!-- opensuse — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSuse. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSteam. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 937 B |
@@ -0,0 +1,2 @@
|
||||
<!-- ubuntu — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaUbuntu. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path 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"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +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>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -66,3 +66,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& sccache --version
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# explicit `Architectures:` or apt tries to fetch arm64 from the amd64 mirror and 404s.
|
||||
#
|
||||
# Built from the REPO ROOT context (not ci/) — see the rust-toolchain.toml copy below.
|
||||
FROM git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
FROM 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
|
||||
@@ -83,3 +83,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
--component rustfmt,clippy \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo clippy --version && cargo fmt --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& sccache --version
|
||||
|
||||
@@ -50,3 +50,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
--component rustfmt,clippy \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo clippy --version && cargo fmt --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& sccache --version
|
||||
|
||||
@@ -90,6 +90,21 @@
|
||||
<!-- TV launcher entry. -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- punktfunk:// deep links (design/client-deep-links.md §2): an external tool, an OS
|
||||
shortcut or a wiki page opens a stream on a host this device already trusts. The
|
||||
URL carries only REFERENCES to things that exist here (a host record, a settings
|
||||
profile, a library id) — never resolution/bitrate/codec values, and never a
|
||||
pairing route; MainActivity's router enforces the rest. BROWSABLE is what lets a
|
||||
browser hand it over (behind its own "Open Punktfunk?" prompt).
|
||||
NOTE: launchMode deliberately stays `standard` and the configChanges set above is
|
||||
untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running
|
||||
stream, and neither has anything to gain from this filter. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="punktfunk" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -31,8 +31,9 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -42,14 +43,23 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.Tab
|
||||
|
||||
@Composable
|
||||
fun App(forceGamepadUi: Boolean = false) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||
// The active session (null = not streaming). It carries the settings the connect resolved,
|
||||
// so the stream screen never re-reads the store behind its own connect's back.
|
||||
var session by remember { mutableStateOf<ActiveSession?>(null) }
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
@@ -58,21 +68,53 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
|
||||
// destroyed mid-stream doesn't leave a ghost that blocks every future link.
|
||||
DisposableEffect(session) {
|
||||
MainActivity.liveStream = session?.let { MainActivity.LiveStream(it.hostId) }
|
||||
onDispose { MainActivity.liveStream = null }
|
||||
}
|
||||
|
||||
// The same rule for the rare in-instance case (a caller that set FLAG_ACTIVITY_SINGLE_TOP, so
|
||||
// the link reached `onNewIntent` on the streaming activity itself). Pointing at the host
|
||||
// already being streamed is the one exception, and its right answer is to do nothing — the
|
||||
// intent has already brought the app forward, which is exactly what "focus it" means here.
|
||||
val pendingLink = activity?.pendingDeepLink
|
||||
LaunchedEffect(pendingLink, session) {
|
||||
val url = pendingLink ?: return@LaunchedEffect
|
||||
val live = session ?: return@LaunchedEffect // not streaming: ConnectScreen routes it
|
||||
activity.pendingDeepLink = null
|
||||
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
|
||||
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
|
||||
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
|
||||
if (!sameHost) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Already streaming — end this session first.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = streamHandle != 0L,
|
||||
targetState = session,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "StreamTransition"
|
||||
) { isStreaming ->
|
||||
if (isStreaming) {
|
||||
) { active ->
|
||||
if (active != null) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||
StreamScreen(active, onDisconnect = { session = null })
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
onConnected = { streamHandle = it },
|
||||
onConnected = { session = it },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
@@ -103,7 +145,13 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
label = "TabTransition"
|
||||
) { targetTab ->
|
||||
when (targetTab) {
|
||||
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
||||
Tab.Connect -> ConnectScreen(
|
||||
settings = settings,
|
||||
onConnected = { session = it },
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
)
|
||||
Tab.Settings -> SettingsScreen(
|
||||
initial = settings,
|
||||
onChange = { settings = it; settingsStore.save(it) },
|
||||
@@ -167,7 +215,9 @@ private enum class GamepadScreen { Home, Settings, Library }
|
||||
fun GamepadShell(
|
||||
settings: Settings,
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onConnected: (Long) -> Unit,
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
@@ -194,6 +244,9 @@ fun GamepadShell(
|
||||
GamepadScreen.Home -> ConnectScreen(
|
||||
settings = settings,
|
||||
onConnected = onConnected,
|
||||
onSettingsChange = onSettingsChange,
|
||||
deepLink = deepLink,
|
||||
onDeepLinkHandled = onDeepLinkHandled,
|
||||
gamepadUi = true,
|
||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
@@ -353,16 +354,18 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
|
||||
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
|
||||
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
|
||||
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
|
||||
* Apple client's edit form.
|
||||
* Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
|
||||
* owns — shared clipboard (a trust decision about THIS machine, so it was never really a global).
|
||||
* The MAC is auto-learned from the host's mDNS advert while it's online, but this is where you can
|
||||
* enter or correct it (e.g. to wake a host you've only ever reached by address). [suggestedMacs]
|
||||
* prefills the field from the live advert when nothing's been learned yet. Keyed by the host so
|
||||
* reopening resets the fields. Mirrors the Apple client's edit form.
|
||||
*/
|
||||
@Composable
|
||||
internal fun EditHostDialog(
|
||||
target: KnownHost,
|
||||
suggestedMacs: List<String>,
|
||||
profiles: List<StreamProfile>,
|
||||
onSave: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -372,6 +375,13 @@ internal fun EditHostDialog(
|
||||
var mac by remember(target) {
|
||||
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
|
||||
}
|
||||
var clipboard by remember(target) { mutableStateOf(target.clipboardSync) }
|
||||
// A binding whose profile was deleted reads as "Default settings" (which is what it already
|
||||
// resolves to) and is cleaned off the record on the next save — never an error state.
|
||||
var boundId by remember(target, profiles) {
|
||||
mutableStateOf(target.profileId?.takeIf { id -> profiles.any { it.id == id } })
|
||||
}
|
||||
var pins by remember(target) { mutableStateOf(target.pinnedProfileIds) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit host") },
|
||||
@@ -407,6 +417,31 @@ internal fun EditHostDialog(
|
||||
placeholder = { Text("auto-filled when the host is seen") },
|
||||
singleLine = true,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Shared clipboard", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Text copied here pastes on this host and vice versa",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = clipboard, onCheckedChange = { clipboard = it })
|
||||
}
|
||||
if (profiles.isNotEmpty()) {
|
||||
HostProfileBinding(
|
||||
profiles = profiles,
|
||||
boundId = boundId,
|
||||
onBind = { boundId = it },
|
||||
pins = pins,
|
||||
onTogglePin = { id ->
|
||||
pins = if (id in pins) pins - id else pins + id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -419,6 +454,9 @@ internal fun EditHostDialog(
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: target.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
clipboardSync = clipboard,
|
||||
profileId = boundId,
|
||||
pinnedProfileIds = pins,
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -429,3 +467,103 @@ internal fun EditHostDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The network speed test, as a dialog: it narrates while it measures, then offers to apply the
|
||||
* recommendation to the layer the tested host actually reads bitrate from — see [SpeedTestTarget]
|
||||
* for why that is the interesting part. The apply buttons name their destination, so the write is
|
||||
* never a surprise.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeedTestDialog(
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
AlertDialog(
|
||||
// Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap
|
||||
// outside shouldn't look like it did something.
|
||||
onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() },
|
||||
title = { Text("Network speed test") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(hostName, style = MaterialTheme.typography.titleMedium)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
if (phase == SpeedTestPhase.Connecting) {
|
||||
"Connecting…"
|
||||
} else {
|
||||
"Measuring — the host is bursting test traffic for two seconds."
|
||||
},
|
||||
)
|
||||
}
|
||||
is SpeedTestPhase.Failed -> Text(
|
||||
phase.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(
|
||||
phase.measuredMbps,
|
||||
phase.lossPct,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
speedTestTargetNote(target),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (done != null) {
|
||||
TextButton(onClick = { onApply(true) }) {
|
||||
Text(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
// The both-are-defensible case: the user picks the layer, we don't guess.
|
||||
if (done != null && target is SpeedTestTarget.Ask) {
|
||||
TextButton(onClick = { onApply(false) }) { Text("Set as default") }
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** One line saying which layer an Apply will write to, and why that one. */
|
||||
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
|
||||
SpeedTestTarget.Global ->
|
||||
"This host uses the default settings, so the bitrate goes there."
|
||||
is SpeedTestTarget.Profile ->
|
||||
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
|
||||
"that override is what it actually reads."
|
||||
is SpeedTestTarget.Ask ->
|
||||
"This host streams with “${target.profile.name}”, which currently inherits the default " +
|
||||
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
|
||||
"the default affects everything that inherits it."
|
||||
}
|
||||
|
||||
@@ -53,16 +53,23 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.link.LinkError
|
||||
import io.unom.punktfunk.kit.link.LinkRoute
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -101,7 +108,10 @@ private class ConnectAttempt(val hostName: String) {
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
onConnected: (Long) -> Unit,
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
// Writes the global defaults back. Only the speed test uses it — that is the one action on this
|
||||
// screen that can land in the defaults layer (design/client-settings-profiles.md §5.3).
|
||||
onSettingsChange: (Settings) -> Unit = {},
|
||||
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
|
||||
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
|
||||
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||
@@ -109,6 +119,11 @@ fun ConnectScreen(
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
|
||||
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
|
||||
// link must go through all of them, not around them.
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
@@ -117,6 +132,10 @@ fun ConnectScreen(
|
||||
var port by remember { mutableStateOf("9777") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
// A confirmation, as opposed to [status]'s failures — "75 Mbit/s set in “Travel”". Separate
|
||||
// state because the two read completely differently: an error banner is red on purpose, and a
|
||||
// successful write dressed as one is a small lie every time it appears.
|
||||
var notice by remember { mutableStateOf<String?>(null) }
|
||||
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
|
||||
// when idle or when the request-access / wake flows own the screen instead.
|
||||
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
|
||||
@@ -195,6 +214,11 @@ fun ConnectScreen(
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The settings-profile catalog. Read here (not in the settings screen's copy) because this is
|
||||
// where profiles are USED: to resolve what a tap connects with, to offer the one-offs, and to
|
||||
// render the pinned cards. Re-read on entry, since Settings may have changed it in between.
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
var profiles by remember { mutableStateOf(profileStore.all()) }
|
||||
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
|
||||
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
|
||||
// take a minute-plus to advertise again.
|
||||
@@ -213,6 +237,13 @@ fun ConnectScreen(
|
||||
knownHostStore.learnMac(dh.host, dh.port, dh.mac)
|
||||
any = true
|
||||
}
|
||||
// Same for the OS-identity chain, so the card's icon survives the host sleeping.
|
||||
if (dh.os.isNotEmpty() &&
|
||||
knownHostStore.get(dh.host, dh.port)?.let { it.os != dh.os } == true
|
||||
) {
|
||||
knownHostStore.learnOs(dh.host, dh.port, dh.os)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
@@ -258,7 +289,7 @@ fun ConnectScreen(
|
||||
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
|
||||
// carousel (the console counterpart of the touch host card's overflow menu).
|
||||
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
var optionsTarget by remember { mutableStateOf<HostCardEntry?>(null) }
|
||||
|
||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||
@@ -267,15 +298,44 @@ fun ConnectScreen(
|
||||
|
||||
// Issue the native connect (shared by the normal connect and the request-access path). A plain
|
||||
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
|
||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
|
||||
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
|
||||
suspend fun connectNative(
|
||||
id: ClientIdentity,
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
pinHex: String,
|
||||
timeoutMs: Int,
|
||||
profile: StreamProfile?,
|
||||
launch: String?,
|
||||
): Long = connectToHost(
|
||||
context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex,
|
||||
launch = launch, timeoutMs = timeoutMs,
|
||||
)
|
||||
|
||||
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
|
||||
// clipboard decision (a property of the record, not a global). A host we never saved — a
|
||||
// connect that failed to pin — falls back to the on default the setting always had.
|
||||
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
|
||||
handle,
|
||||
settings.effectiveFor(profile),
|
||||
clipboardSync = record?.clipboardSync ?: true,
|
||||
profileName = profile?.name,
|
||||
hostId = record?.id,
|
||||
)
|
||||
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||
// fallback) instead of the error status line — discovery is already restarted when it runs, so
|
||||
// the wait can observe the host reappear.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
|
||||
fun doConnectDirect(
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
name: String,
|
||||
pinHex: String?,
|
||||
profile: StreamProfile?,
|
||||
launch: String? = null,
|
||||
onFailure: (() -> Unit)? = null,
|
||||
) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
@@ -284,9 +344,11 @@ fun ConnectScreen(
|
||||
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
|
||||
connecting = true
|
||||
status = null
|
||||
notice = null
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||
val handle =
|
||||
connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile, launch)
|
||||
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
|
||||
// cancelConnect — drop the just-opened session silently rather than navigating into it.
|
||||
if (thisAttempt.cancelled.get()) {
|
||||
@@ -296,13 +358,14 @@ fun ConnectScreen(
|
||||
attempt = null
|
||||
connecting = false
|
||||
if (handle != 0L) {
|
||||
var record = knownHostStore.get(targetHost, targetPort)
|
||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||
if (fp.isNotEmpty()) {
|
||||
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
|
||||
record = knownHostStore.trust(targetHost, targetPort, name, fp, paired = false)
|
||||
}
|
||||
}
|
||||
onConnected(handle)
|
||||
onConnected(session(handle, record, profile))
|
||||
} else {
|
||||
discovery.start()
|
||||
val token = NativeBridge.nativeTakeLastError()
|
||||
@@ -339,12 +402,22 @@ fun ConnectScreen(
|
||||
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
|
||||
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
|
||||
// seen live) dial straight through.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
fun doConnect(
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
name: String,
|
||||
pinHex: String?,
|
||||
oneOffProfile: String?,
|
||||
launch: String? = null,
|
||||
) {
|
||||
if (identity == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
val kh = knownHostStore.get(targetHost, targetPort)
|
||||
// Latched here, not per dial attempt: a wake-and-redial must stream with the same profile
|
||||
// the user asked for, and the "applies from the next session" footers stay truthful.
|
||||
val profile = profileStore.resolveFor(kh, oneOffProfile)
|
||||
val macs = kh?.mac ?: emptyList()
|
||||
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
|
||||
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
|
||||
@@ -355,7 +428,7 @@ fun ConnectScreen(
|
||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch, onFailure = {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
@@ -368,15 +441,18 @@ fun ConnectScreen(
|
||||
// connects) point at the live one, then dial there (no fallback on this
|
||||
// redial — a second failure surfaces as the plain error).
|
||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
knownHostStore.save(kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
doConnectDirect(
|
||||
live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex,
|
||||
profile, launch,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
} else {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +477,12 @@ fun ConnectScreen(
|
||||
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
|
||||
// we wait); a manually-typed host has none, so trust-on-first-use.
|
||||
val pinHex = target.advertisedFp ?: ""
|
||||
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS)
|
||||
// A host being trusted for the first time can't have a binding yet, so this is always
|
||||
// the plain defaults — a profile only ever enters via a later, deliberate choice.
|
||||
val handle = connectNative(
|
||||
id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS,
|
||||
profile = null, launch = target.launch,
|
||||
)
|
||||
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||
// don't touch UI a fresh action may now own.
|
||||
if (req.cancelled.get()) {
|
||||
@@ -414,11 +495,12 @@ fun ConnectScreen(
|
||||
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so
|
||||
// future connects are silent (exactly like after a PIN ceremony).
|
||||
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||
var record = knownHostStore.get(target.host, target.port)
|
||||
if (fp.isNotEmpty()) {
|
||||
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
|
||||
record = knownHostStore.trust(target.host, target.port, target.name, fp, paired = true)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
onConnected(handle)
|
||||
onConnected(session(handle, record, profile = null))
|
||||
} else {
|
||||
// Cause-specific: an operator denial, an approval timeout, and a request that
|
||||
// never reached the host are different problems with different fixes.
|
||||
@@ -441,6 +523,12 @@ fun ConnectScreen(
|
||||
targetPort: Int,
|
||||
dh: DiscoveredHost? = null,
|
||||
manualName: String? = null,
|
||||
// A one-off "Connect with ▸" pick. `null` = follow the host's binding (a plain tap);
|
||||
// `""` = force the global defaults, which is a real choice on a bound host and must
|
||||
// therefore survive as a value rather than collapsing into "unset". NEVER rebinds.
|
||||
oneOffProfile: String? = null,
|
||||
// A library id the host should boot straight into (`launch=` on a link).
|
||||
launch: String? = null,
|
||||
) {
|
||||
// Every dial/pair path funnels through here — with local network access denied the connect
|
||||
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
|
||||
@@ -456,18 +544,195 @@ fun ConnectScreen(
|
||||
when {
|
||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||
known != null && (adv == null || adv == known.fpHex) ->
|
||||
doConnect(targetHost, targetPort, known.name, known.fpHex)
|
||||
doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile, launch)
|
||||
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
|
||||
known != null -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
|
||||
known != null -> pendingTrust = PendingTrust(
|
||||
targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED,
|
||||
oneOffProfile, launch,
|
||||
)
|
||||
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
|
||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||
dh?.pairingRequired == false -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||
dh?.pairingRequired == false -> pendingTrust = PendingTrust(
|
||||
targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW,
|
||||
oneOffProfile, launch,
|
||||
)
|
||||
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
|
||||
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
|
||||
else -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
|
||||
else -> pendingTrust = PendingTrust(
|
||||
targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS,
|
||||
oneOffProfile, launch,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A speed test in flight: which host+profile it is measuring, and how far it has got. The
|
||||
// measurement is over a real connect, so it takes the same `connecting` gate every dial does.
|
||||
var speedTest by remember { mutableStateOf<HostCardEntry?>(null) }
|
||||
var speedTestPhase by remember { mutableStateOf<SpeedTestPhase>(SpeedTestPhase.Connecting) }
|
||||
|
||||
fun startSpeedTest(entry: HostCardEntry) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
// The magic packet isn't the only thing LNP blocks: without the grant this would EPERM its
|
||||
// way to a timeout and report a dead link on a perfectly good one.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
return
|
||||
}
|
||||
speedTest = entry
|
||||
speedTestPhase = SpeedTestPhase.Connecting
|
||||
notice = null
|
||||
connecting = true
|
||||
discovery.stop() // a browse running through the burst would measure itself
|
||||
scope.launch {
|
||||
runSpeedTest(context, id, entry.host.address, entry.host.port, entry.host.fpHex) { p ->
|
||||
// A dismissed dialog abandons the run; don't drag it back onto the screen.
|
||||
if (speedTest != null) speedTestPhase = p
|
||||
}
|
||||
connecting = false
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle a host+profile pin. Presentation only: it never touches the profile itself and never
|
||||
// changes the host's default binding.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
|
||||
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
// lives in the Edit sheet instead.
|
||||
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
|
||||
}
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
|
||||
}
|
||||
add(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {
|
||||
// The empty reference is "force the defaults", not "unset" — on a bound host that
|
||||
// is a real, different action from a plain tap.
|
||||
connect(kh.address, kh.port, oneOffProfile = "")
|
||||
},
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
|
||||
}
|
||||
if (pin == null) {
|
||||
profiles.forEachIndexed { i, p ->
|
||||
val pinned = p.id in kh.pinnedProfileIds
|
||||
add(
|
||||
HostMenuItem(
|
||||
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
|
||||
startsSection = i == 0,
|
||||
) { togglePin(kh, p) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
|
||||
// pinned combination is a plain one-click connect instead of a trip through a menu.
|
||||
val savedCards = savedHosts.flatMap { kh ->
|
||||
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
|
||||
}
|
||||
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
|
||||
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
|
||||
// profiles ever sees the gap.
|
||||
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
|
||||
|
||||
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
|
||||
//
|
||||
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
|
||||
// decisions. So it never pairs, never trusts on its own, and carries references rather than
|
||||
// values. Everything below is either "do exactly what the card does" or "refuse and say why" —
|
||||
// a shortcut that can't honour its reference must say so, because streaming with the wrong
|
||||
// settings is worse than an explanatory notice.
|
||||
LaunchedEffect(deepLink, identity, savedHosts) {
|
||||
val url = deepLink ?: return@LaunchedEffect
|
||||
// Wait for the identity rather than refusing: it arrives a beat after first composition and
|
||||
// the effect re-runs when it does.
|
||||
if (identity == null) return@LaunchedEffect
|
||||
onDeepLinkHandled()
|
||||
val parsed = DeepLinks.parse(url)
|
||||
if (parsed is DeepLinkResult.Refused) {
|
||||
// A link for someone else's scheme is not our business to complain about.
|
||||
if (parsed.error != LinkError.NOT_OUR_SCHEME) status = parsed.message()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val link = (parsed as DeepLinkResult.Parsed).link
|
||||
if (link.route != LinkRoute.CONNECT) {
|
||||
// `wake` and `browse` are reserved in the grammar and parse today; a front-end that
|
||||
// hasn't implemented them refuses with a notice rather than silently connecting.
|
||||
status = "Punktfunk on Android can't do “${link.route.word}” links yet."
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// A profile reference that can't be honoured refuses: a "Work" shortcut streaming with the
|
||||
// wrong settings is worse than an error naming what failed.
|
||||
val profileRef = link.profile
|
||||
if (profileRef != null) {
|
||||
val (_, resolution) = profileStore.resolve(profileRef)
|
||||
if (resolution != ProfileResolution.FOUND) {
|
||||
status = if (resolution == ProfileResolution.AMBIGUOUS) {
|
||||
"More than one profile is called “$profileRef” — rename one and try again."
|
||||
} else {
|
||||
"That link asks for a profile called “$profileRef”, which isn't on this device."
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
|
||||
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
|
||||
is HostResolution.Known -> {
|
||||
// A pin that contradicts the stored one is the link being stale or lying. Hard
|
||||
// refusal: this is the one case where doing what the card does would be wrong.
|
||||
if (link.pinConflict(resolved.host)) {
|
||||
status = "That link's fingerprint doesn't match the one pinned for " +
|
||||
"${resolved.host.name} — it's out of date, or it isn't that host."
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (resolved.host.fpHex.isEmpty()) {
|
||||
// Saved but never pinned (nothing writes such a record today, but the rule is
|
||||
// absolute): a link may not establish trust, so this is a confirmation.
|
||||
pendingTrust = PendingTrust(
|
||||
resolved.host.address, resolved.host.port, resolved.host.name,
|
||||
link.fp, PendingTrust.Kind.REQUEST_ACCESS, profileRef, link.launch,
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
connect(
|
||||
resolved.host.address, resolved.host.port,
|
||||
oneOffProfile = profileRef, launch = link.launch,
|
||||
)
|
||||
}
|
||||
// Unknown, or known only by address: the confirmation sheet, from which the normal
|
||||
// pairing flow proceeds under the user's eyes. Never a silent trust.
|
||||
is HostResolution.Unknown -> pendingTrust = PendingTrust(
|
||||
resolved.address,
|
||||
resolved.port,
|
||||
link.name ?: resolved.address,
|
||||
resolved.fp,
|
||||
PendingTrust.Kind.REQUEST_ACCESS,
|
||||
profileRef,
|
||||
link.launch,
|
||||
)
|
||||
HostResolution.Ambiguous ->
|
||||
status = "More than one saved host is called “${link.hostRef}” — " +
|
||||
"rename one, or use its address."
|
||||
HostResolution.Unresolvable ->
|
||||
status = "That link points at a host this device doesn't know."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,11 +743,15 @@ fun ConnectScreen(
|
||||
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||
val tiles = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.address}:${kh.port}",
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
// The binding is what a press will actually do, so the tile says so — the
|
||||
// console can't edit profiles, but it must never lie about which one it uses.
|
||||
subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" }
|
||||
?: "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
@@ -490,6 +759,23 @@ fun ConnectScreen(
|
||||
activate = { connect(kh.address, kh.port) },
|
||||
),
|
||||
)
|
||||
// Pinned host+profile combinations, right after their host: one focus-and-press
|
||||
// each, which is the affordance a controller surface does well (menus are not).
|
||||
profileStore.pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
subtitle = p.name,
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
pinnedProfileId = p.id,
|
||||
activate = { connect(kh.address, kh.port, oneOffProfile = p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
@@ -526,7 +812,11 @@ fun ConnectScreen(
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
|
||||
onOptions = { tile ->
|
||||
tile.knownHost?.let { kh ->
|
||||
optionsTarget = HostCardEntry(kh, tile.pinnedProfileId?.let(profileStore::byId))
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -548,6 +838,23 @@ fun ConnectScreen(
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
notice?.let {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
status?.let {
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
@@ -612,24 +919,45 @@ fun ConnectScreen(
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh ->
|
||||
items(savedCards, key = { it.key }) { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
// Live advert preferred (the store lags a discovery tick), else stored.
|
||||
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
|
||||
?: kh.os,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(kh.address, kh.port) },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
// A pinned card connects with ITS profile; the host's own card follows the
|
||||
// binding, which is exactly what its chip says it will do.
|
||||
onConnect = {
|
||||
if (pin != null) {
|
||||
connect(kh.address, kh.port, oneOffProfile = pin.id)
|
||||
} else {
|
||||
connect(kh.address, kh.port)
|
||||
}
|
||||
},
|
||||
onEdit = { editTarget = kh },
|
||||
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
|
||||
// shortcut, not a second host, and offering destructive host actions on it
|
||||
// would blur exactly that.
|
||||
onForget = if (pin != null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
},
|
||||
onEdit = if (pin != null) null else ({ editTarget = kh }),
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
{
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
@@ -648,6 +976,11 @@ fun ConnectScreen(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profileLabel = pin?.name ?: bound?.name,
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
reserveProfileSlot = anyProfileChip,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -663,6 +996,7 @@ fun ConnectScreen(
|
||||
address = "${dh.host}:${dh.port}",
|
||||
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
||||
online = true, // in the discovered list ⇒ live on mDNS right now
|
||||
os = dh.os,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(dh.host, dh.port, dh) },
|
||||
onForget = null,
|
||||
@@ -741,15 +1075,15 @@ fun ConnectScreen(
|
||||
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||
val onSavePaired = { fp: String ->
|
||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
|
||||
}
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW ->
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
@@ -774,7 +1108,9 @@ fun ConnectScreen(
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { kh ->
|
||||
optionsTarget?.let { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val offline = !kh.isOnline(discovered, reachable)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
@@ -794,27 +1130,56 @@ fun ConnectScreen(
|
||||
},
|
||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||
// so a TV remote reaches the library here instead of via the Y face button.
|
||||
onLibrary = if (settings.libraryEnabled) {
|
||||
onLibrary = if (settings.libraryEnabled && pin == null) {
|
||||
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSpeedTest = if (pin == null) {
|
||||
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onEdit = { optionsTarget = null; editTarget = kh },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
optionsTarget = null
|
||||
},
|
||||
onDismiss = { optionsTarget = null },
|
||||
// A pin's only action: unpinning touches neither the host nor the profile.
|
||||
onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } },
|
||||
profileName = pin?.name,
|
||||
)
|
||||
}
|
||||
|
||||
speedTest?.let { entry ->
|
||||
val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore)
|
||||
val dismiss = { speedTest = null }
|
||||
val apply: (Boolean) -> Unit = { toProfile ->
|
||||
val done = speedTestPhase as? SpeedTestPhase.Done
|
||||
if (done != null) {
|
||||
val where = applySpeedTestResult(
|
||||
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
|
||||
)
|
||||
profiles = profileStore.all()
|
||||
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
|
||||
}
|
||||
speedTest = null
|
||||
}
|
||||
if (gamepadUi) {
|
||||
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
|
||||
} else {
|
||||
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
editTarget?.let { kh ->
|
||||
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||
knownHostStore.update(kh.address, kh.port, updated)
|
||||
knownHostStore.save(updated)
|
||||
savedHosts = knownHostStore.all()
|
||||
editTarget = null
|
||||
}
|
||||
@@ -832,6 +1197,7 @@ fun ConnectScreen(
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = suggested,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
@@ -872,6 +1238,15 @@ fun ConnectScreen(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
|
||||
* host+profile cards. Pins are additive presentation state on the host record — never duplicated
|
||||
* host entries, which would fork pairing, trust and renames (design §5.2a).
|
||||
*/
|
||||
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||
|
||||
@@ -213,19 +213,92 @@ fun GamepadHostOptionsDialog(
|
||||
onEdit: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSpeedTest: (() -> Unit)? = null,
|
||||
/**
|
||||
* Non-null when this is a PINNED host+profile tile, whose only action is to unpin. A pin is a
|
||||
* shortcut, not a second host — offering the host's destructive actions on it would blur
|
||||
* exactly that, and the touch grid withholds them for the same reason.
|
||||
*/
|
||||
onUnpin: (() -> Unit)? = null,
|
||||
profileName: String? = null,
|
||||
) {
|
||||
GamepadDialog(
|
||||
title = hostName,
|
||||
title = if (profileName != null) "$hostName · $profileName" else hostName,
|
||||
onDismiss = onDismiss,
|
||||
actions = buildList {
|
||||
if (onUnpin != null) {
|
||||
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
return@buildList
|
||||
}
|
||||
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
|
||||
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||
add(DialogAction("Forget", onClick = onForget))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
DialogText("Manage this saved host.")
|
||||
DialogText(
|
||||
if (onUnpin != null) {
|
||||
"This card is a shortcut to this host with one profile. Unpinning it changes " +
|
||||
"nothing about the host or the profile."
|
||||
} else {
|
||||
"Manage this saved host."
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
* couch surface too, even though profile EDITING doesn't.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadSpeedTestDialog(
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
GamepadDialog(
|
||||
title = "Network speed test",
|
||||
onDismiss = onDismiss,
|
||||
actions = buildList {
|
||||
if (done != null) {
|
||||
add(
|
||||
DialogAction(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
primary = true,
|
||||
) { onApply(true) },
|
||||
)
|
||||
if (target is SpeedTestTarget.Ask) {
|
||||
add(DialogAction("Set as default") { onApply(false) })
|
||||
}
|
||||
}
|
||||
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
DialogText(hostName)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting -> DialogText("Connecting…")
|
||||
SpeedTestPhase.Measuring ->
|
||||
DialogText("Measuring — the host is bursting test traffic for two seconds.")
|
||||
is SpeedTestPhase.Failed -> DialogText(phase.message)
|
||||
is SpeedTestPhase.Done -> {
|
||||
DialogText(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
|
||||
)
|
||||
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,11 +73,17 @@ class HomeTile(
|
||||
val connecting: Boolean = false,
|
||||
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
|
||||
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
||||
/**
|
||||
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
|
||||
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
|
||||
* belong to the host's own tile, and this one offers only Unpin.
|
||||
*/
|
||||
val pinnedProfileId: String? = null,
|
||||
val activate: () -> Unit,
|
||||
) {
|
||||
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
||||
// first" message if the host hasn't authorized this device for its management API.
|
||||
val hasLibrary: Boolean get() = knownHost != null
|
||||
val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,7 +87,9 @@ fun GamepadSettingsScreen(
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
@@ -139,7 +141,10 @@ fun GamepadSettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item(key = "__title") {
|
||||
ConsoleHeader("Settings", horizontalInset = false)
|
||||
// "Default settings", not "Settings": this screen edits the base layer only. The
|
||||
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
|
||||
// bare "Settings" would quietly imply it changes whatever that host streams with.
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
@@ -263,10 +268,12 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
private fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
av1Capable: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
@@ -304,9 +311,36 @@ private fun buildSettingsRows(
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
// Grouped and ordered by the cross-client category map (General / Display / Audio /
|
||||
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
|
||||
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
|
||||
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
|
||||
// symmetry would be parity in name only.
|
||||
return listOf(
|
||||
choice(
|
||||
"resolution", "Stream", "Resolution",
|
||||
"hud", "General · Statistics", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", "General · Session", "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", "General · Library", "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", "General · Interface", "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"resolution", "Display · Resolution", "Resolution",
|
||||
"The host creates a virtual display at exactly this size — no scaling. " +
|
||||
"Custom sizes are typed in the touch settings.",
|
||||
// A custom size (typed in the touch settings) leads the list so it stays visible and
|
||||
@@ -323,33 +357,36 @@ private fun buildSettingsRows(
|
||||
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
|
||||
choice(
|
||||
"bitrate", null, "Bitrate",
|
||||
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
|
||||
"bitrate", "Display · Quality", "Bitrate",
|
||||
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
|
||||
"link and set an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"compositor", null, "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"codec", "Video", "Video codec",
|
||||
"codec", null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
CODEC_OPTIONS, s.codec,
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
|
||||
toggle(
|
||||
"lowLatency", null, "Low-latency mode",
|
||||
"lowLatency", "Display · Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"compositor", "Display · Host output", "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
@@ -360,9 +397,9 @@ private fun buildSettingsRows(
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"padType", "Controller", "Controller type",
|
||||
"padType", "Controllers", "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
@@ -376,26 +413,13 @@ private fun buildSettingsRows(
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
choice(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
toggle(
|
||||
"library", null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"autoWake", null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture,
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
@@ -53,6 +54,9 @@ suspend fun connectToHost(
|
||||
// the user's soft codec preference — the host resolves the emitted codec from both.
|
||||
VideoDecoders.decodableCodecBits(), settings.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.
|
||||
Build.MODEL ?: "Android",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.cos
|
||||
@@ -85,7 +86,7 @@ private sealed class LibState {
|
||||
fun LibraryScreen(
|
||||
host: KnownHost,
|
||||
settings: Settings,
|
||||
onLaunched: (Long) -> Unit,
|
||||
onLaunched: (ActiveSession) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
) {
|
||||
@@ -142,7 +143,11 @@ fun LibraryScreen(
|
||||
host.address, host.port, host.fpHex, launch = game.id,
|
||||
)
|
||||
launching = false
|
||||
if (handle != 0L) onLaunched(handle)
|
||||
if (handle != 0L) {
|
||||
onLaunched(
|
||||
ActiveSession(handle, settings, host.clipboardSync),
|
||||
)
|
||||
}
|
||||
else Toast.makeText(
|
||||
context,
|
||||
"Launch failed — check the host and try again.",
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.view.InputDevice
|
||||
import android.view.KeyCharacterMap
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -27,6 +28,10 @@ import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
|
||||
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
|
||||
@@ -96,6 +101,17 @@ class MainActivity : ComponentActivity() {
|
||||
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||
private set
|
||||
|
||||
/**
|
||||
* A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or
|
||||
* re-entered) this activity, cleared by whoever handles it. Compose observes it.
|
||||
*
|
||||
* Read in BOTH [onCreate] and [onNewIntent] on purpose: `launchMode` is `standard`, so a second
|
||||
* link usually arrives as a fresh activity instance (onCreate) and only sometimes as a new
|
||||
* intent on this one (a caller that set `FLAG_ACTIVITY_SINGLE_TOP`). A link arriving while an
|
||||
* earlier one is still unhandled replaces it — the user's latest intent is the live one.
|
||||
*/
|
||||
var pendingDeepLink by mutableStateOf<String?>(null)
|
||||
|
||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||
private var highRefreshModeId = 0
|
||||
|
||||
@@ -125,6 +141,28 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// A URL may never preempt a live session (design/client-deep-links.md §3.2). With
|
||||
// `launchMode = standard` a link normally arrives as a NEW activity instance in a new task
|
||||
// — the streaming one gets backgrounded, and backgrounding ends a session — so the refusal
|
||||
// has to happen HERE, before this instance is resumed, not inside the composition (which
|
||||
// only ever sees the rare `onNewIntent` case). Finishing now leaves the streaming task in
|
||||
// front, untouched.
|
||||
val live = liveStream
|
||||
if (live != null && deepLinkFrom(intent) != null) {
|
||||
// Pointing at the host already being streamed is the one exception, and its right
|
||||
// answer is to do nothing: the intent has already brought the app forward, which is
|
||||
// what "focus it" means here.
|
||||
if (!targetsHost(intent, live)) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Already streaming — end this session first.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
finish()
|
||||
return
|
||||
}
|
||||
pendingDeepLink = deepLinkFrom(intent)
|
||||
lastPadIsGamepad = !isTvDevice(this)
|
||||
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
|
||||
resolveHighRefreshMode()
|
||||
@@ -185,6 +223,20 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Keep `getIntent()` truthful for anything that reads it later (the gamepad-UI dev flag).
|
||||
setIntent(intent)
|
||||
deepLinkFrom(intent)?.let { pendingDeepLink = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `punktfunk://` URL of a VIEW intent, or null. Only VIEW: the launcher's MAIN intent
|
||||
* carries no data, and nothing else may inject a URL into the router.
|
||||
*/
|
||||
private fun deepLinkFrom(intent: Intent?): String? =
|
||||
intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data?.toString()
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
startSc2MenuNav()
|
||||
@@ -541,4 +593,29 @@ class MainActivity : ComponentActivity() {
|
||||
-> true
|
||||
else -> KeyEvent.isGamepadButton(kc)
|
||||
}
|
||||
|
||||
/** Does [intent]'s link resolve to the host [live] is already streaming? */
|
||||
private fun targetsHost(intent: Intent?, live: LiveStream): Boolean {
|
||||
val url = deepLinkFrom(intent) ?: return false
|
||||
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
|
||||
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
|
||||
return target is HostResolution.Known && target.host.id == live.hostId
|
||||
}
|
||||
|
||||
/** The host a live stream is on — see [liveStream]. */
|
||||
data class LiveStream(val hostId: String?)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The live stream, PROCESS-wide (null = not streaming), published by the composition that
|
||||
* owns it.
|
||||
*
|
||||
* Deliberately not per-instance state: `launchMode` is `standard`, so a `punktfunk://`
|
||||
* link arrives as a second activity instance that knows nothing about the first — and the
|
||||
* one thing it must know is that a session is already running. Static state is what
|
||||
* crosses that gap; the process dying resets it, which is also correct.
|
||||
*/
|
||||
@Volatile
|
||||
var liveStream: LiveStream? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The scope switcher: the one new settings concept. Selecting a profile puts the WHOLE settings
|
||||
* surface into that profile's scope — there is one settings UI, never a second parallel editor
|
||||
* that drifts from it. "Default settings" is the base layer every profile inherits from.
|
||||
*
|
||||
* A chips row rather than a menu, because on touch the scopes are worth seeing at a glance and
|
||||
* there are rarely more than a handful. Managing a profile lives ON its chip: the selected one
|
||||
* grows a chevron, and tapping it again opens Edit / Duplicate / Delete anchored under it. That
|
||||
* replaced a lone overflow button parked after the LAST chip — which meant scrolling past every
|
||||
* profile to reach an action that applied to one of them, with nothing on screen saying which.
|
||||
*
|
||||
* With no profiles at all the row is just "Default settings" and a "New profile" chip, which is
|
||||
* all the clutter a user who never wants this feature ever sees.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProfileScopeChips(
|
||||
profiles: List<StreamProfile>,
|
||||
selectedId: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
onNew: () -> Unit,
|
||||
onEdit: (StreamProfile) -> Unit,
|
||||
onDuplicate: (StreamProfile) -> Unit,
|
||||
onDelete: (StreamProfile) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Which chip's menu is open — one at a time, and it closes itself when the scope changes.
|
||||
var menuFor by remember { mutableStateOf<String?>(null) }
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedId == null,
|
||||
onClick = { onSelect(null) },
|
||||
label = { Text("Default settings") },
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
val isSelected = selectedId == p.id
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
// Tap to select; tap the selected one — the one wearing the chevron — to manage
|
||||
// it. The action is on the object it acts on, which is the whole point.
|
||||
onClick = { if (isSelected) menuFor = p.id else onSelect(p.id) },
|
||||
// The dot and the chevron ride INSIDE the label, not in the `leadingIcon` /
|
||||
// `trailingIcon` slots: those reserve an 18dp icon and shrink the chip's padding
|
||||
// to suit, so a chip with an accent (or with the chevron) would sit differently
|
||||
// from "Default settings" beside it. In the label every chip keeps the same
|
||||
// padding and the spacing is ours to set.
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
accentColor(p.accent)?.let { dot ->
|
||||
AccentDot(dot, size = 8)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(p.name)
|
||||
if (isSelected) {
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Manage “${p.name}”",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenu(expanded = menuFor == p.id, onDismissRequest = { menuFor = null }) {
|
||||
DropdownMenuItem(text = { Text("Edit…") }, onClick = { menuFor = null; onEdit(p) })
|
||||
DropdownMenuItem(
|
||||
text = { Text("Duplicate") },
|
||||
onClick = { menuFor = null; onDuplicate(p) },
|
||||
)
|
||||
DropdownMenuItem(text = { Text("Delete…") }, onClick = { menuFor = null; onDelete(p) })
|
||||
}
|
||||
}
|
||||
}
|
||||
AssistChip(
|
||||
onClick = onNew,
|
||||
label = { Text("New profile") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or edit a profile: its name and its colour, decided together. They were two flows —
|
||||
* a name dialog at creation, "Change colour…" afterwards — which meant every profile started
|
||||
* colourless-looking until the user went hunting for a menu item, and the accent is exactly the
|
||||
* signal that has to be there from the first moment (it is all a bound host card's chip and a
|
||||
* pinned card's tint have to go on).
|
||||
*
|
||||
* Names must be unique case-insensitively: two "Work" chips in a menu are ambiguous, and a
|
||||
* `punktfunk://…?profile=Work` link would have to refuse rather than guess. [taken] is the live
|
||||
* duplicate check, which lets an edit keep its own name (and change only its case).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProfileEditorDialog(
|
||||
title: String,
|
||||
confirmLabel: String,
|
||||
initialName: String,
|
||||
initialAccent: String?,
|
||||
creating: Boolean,
|
||||
taken: (String) -> Boolean,
|
||||
onConfirm: (name: String, accent: String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var accent by remember { mutableStateOf(initialAccent) }
|
||||
val trimmed = name.trim()
|
||||
val duplicate = trimmed.isNotEmpty() && taken(trimmed)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
ProfileEditorFields(
|
||||
name = name,
|
||||
accent = accent,
|
||||
duplicate = duplicate,
|
||||
creating = creating,
|
||||
onNameChange = { name = it },
|
||||
onAccentChange = { accent = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = trimmed.isNotEmpty() && !duplicate,
|
||||
onClick = { onConfirm(trimmed, accent) },
|
||||
) { Text(confirmLabel) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's body. Extracted from [ProfileEditorDialog] so the screenshot harness can render
|
||||
* exactly these — a focused text field inside a Dialog window never reaches idle under Robolectric,
|
||||
* so the dialog itself is uncapturable, and an eyeballed-only layout is how this shipped once with
|
||||
* the field and its caption touching.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun ProfileEditorFields(
|
||||
name: String,
|
||||
accent: String?,
|
||||
duplicate: Boolean,
|
||||
creating: Boolean,
|
||||
onNameChange: (String) -> Unit,
|
||||
onAccentChange: (String?) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text("e.g. Game, Work, Travel") },
|
||||
singleLine = true,
|
||||
isError = duplicate,
|
||||
)
|
||||
Text(
|
||||
when {
|
||||
duplicate -> "A profile called “${name.trim()}” already exists."
|
||||
creating -> "A profile starts out inheriting every default setting. Whatever you " +
|
||||
"change while it's selected becomes an override."
|
||||
else -> "The colour marks this profile on host cards, where its name doesn't fit."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (duplicate) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Text(
|
||||
"Colour",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
// A fixed 4×2 grid rather than a flow: eight colours wrapping to whatever fits the dialog
|
||||
// landed 6-then-2, which reads as a mistake. Two even rows read as a palette. The order is
|
||||
// the hue sweep from PROFILE_ACCENTS, so it looks like a spectrum rather than a bag.
|
||||
//
|
||||
// Each row FILLS the width, its swatches sharing it equally, so the palette's edges line up
|
||||
// with the name field above it and every row is the same length. A fixed swatch size left
|
||||
// the rows short of the dialog's edge and wrapped unevenly, which read as the grid having
|
||||
// run out rather than as a deliberate block.
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(SWATCH_GAP),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
PROFILE_ACCENTS.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(SWATCH_GAP),
|
||||
) {
|
||||
row.forEach { hex ->
|
||||
Swatch(
|
||||
colour = accentColor(hex),
|
||||
selected = accent?.equals(hex, ignoreCase = true) == true,
|
||||
onClick = { onAccentChange(hex) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// "No colour" is a real choice, not only an initial state — the chip then falls back to the
|
||||
// theme's own accent, which is what a profile made before colours existed shows. It sits
|
||||
// apart from the grid and says so in words, rather than hiding as a ninth, colourless
|
||||
// circle that breaks the palette's rhythm.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { onAccentChange(null) }
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Swatch(colour = null, selected = accent == null, onClick = { onAccentChange(null) })
|
||||
Text(
|
||||
"No colour",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One colour choice. The selected one keeps its size and grows a ring OUTSIDE the disc with a gap
|
||||
* between the two, plus a check — a border drawn on the disc's own edge reads as a heavier circle
|
||||
* rather than as a selection, and colour-plus-check survives a reader who can't tell two of these
|
||||
* hues apart. The ring's space is always reserved, so picking never nudges the grid.
|
||||
*
|
||||
* `null` is "no colour": the surface's own variant, outlined so it reads as an empty slot rather
|
||||
* than a dark swatch.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Swatch(
|
||||
colour: Color?,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier.size(SWATCH_TOTAL),
|
||||
) {
|
||||
val fill = colour ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.then(
|
||||
if (selected) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = if (colour == null) "No colour" else "Colour" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
// Padding, not a fixed size: the disc has to scale with a swatch that shares its
|
||||
// row's width, while the gap that makes the selection ring read stays constant.
|
||||
.fillMaxSize()
|
||||
.padding(RING_GAP)
|
||||
.clip(CircleShape)
|
||||
.background(fill)
|
||||
.then(
|
||||
if (colour == null) {
|
||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
// These hues are all light enough that a near-black check is the readable one;
|
||||
// the empty slot is dark, so it takes the surface's foreground instead.
|
||||
tint = if (colour == null) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
Color(0xFF1B1633)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette's geometry. [SWATCHES_PER_ROW] divides [PROFILE_ACCENTS] exactly — that is the whole
|
||||
* reason the palette has ten colours — so both rows are full. [SWATCH_TOTAL] is only the fallback
|
||||
* footprint for a swatch outside the grid (the "no colour" one); in the grid a swatch takes an
|
||||
* equal share of the row instead.
|
||||
*/
|
||||
private const val SWATCHES_PER_ROW = 5
|
||||
private val SWATCH_TOTAL = 44.dp
|
||||
private val RING_GAP = 5.dp
|
||||
private val SWATCH_GAP = 10.dp
|
||||
|
||||
/**
|
||||
* Deleting a profile is not destructive to anything but the profile — a host bound to it falls
|
||||
* back to the default settings and a card pinned to it disappears, neither of which is an error.
|
||||
* The warning counts both so the consequence is stated rather than discovered.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DeleteProfileDialog(
|
||||
profile: StreamProfile,
|
||||
boundHosts: Int,
|
||||
pinnedCards: Int,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Delete “${profile.name}”?") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val consequences = buildList {
|
||||
if (boundHosts > 0) {
|
||||
add("$boundHosts ${plural(boundHosts, "host", "hosts")} will fall back to the default settings")
|
||||
}
|
||||
if (pinnedCards > 0) {
|
||||
add("$pinnedCards pinned ${plural(pinnedCards, "card", "cards")} will disappear")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
if (consequences.isEmpty()) {
|
||||
"Nothing uses this profile."
|
||||
} else {
|
||||
consequences.joinToString(", and ") + "."
|
||||
},
|
||||
)
|
||||
Text(
|
||||
"The settings it overrides aren't lost anywhere else — the defaults stay " +
|
||||
"exactly as they are.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun plural(n: Int, one: String, many: String) = if (n == 1) one else many
|
||||
|
||||
/**
|
||||
* The per-host half of profiles, inside the host's Edit sheet: which profile a plain tap uses
|
||||
* (the binding — the one thing that IS sticky; "Connect with ▸" on a card never rebinds), and which
|
||||
* profiles get their own card in the host list.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun HostProfileBinding(
|
||||
profiles: List<StreamProfile>,
|
||||
boundId: String?,
|
||||
onBind: (String?) -> Unit,
|
||||
pins: List<String>,
|
||||
onTogglePin: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val bound = profiles.firstOrNull { it.id == boundId }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = bound?.name ?: "Default settings",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Profile") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Default settings") },
|
||||
onClick = { onBind(null); expanded = false },
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(p.name) },
|
||||
onClick = { onBind(p.id); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"What a tap on this host connects with.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"Pinned cards",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
"A pinned profile gets its own card beside this host — one tap instead of a menu. " +
|
||||
"Pinning changes nothing about which profile is the default.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(p.name, modifier = Modifier.weight(1f))
|
||||
Checkbox(checked = p.id in pins, onCheckedChange = { onTogglePin(p.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The accent marker a profile's chip and its pinned cards wear. */
|
||||
@Composable
|
||||
internal fun AccentDot(color: Color, size: Int = 10) {
|
||||
Box(Modifier.size(size.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
|
||||
/** `#RRGGBB` → a Compose colour, or null when the stored string isn't one (never a crash). */
|
||||
internal fun accentColor(hex: String?): Color? {
|
||||
val h = hex?.removePrefix("#") ?: return null
|
||||
if (h.length != 6 || !h.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) return null
|
||||
return runCatching { Color(h.toLong(16) or 0xFF000000L) }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import java.security.SecureRandom
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Client settings profiles — named bundles of setting overrides applied on top of the global
|
||||
* [Settings] (design/client-settings-profiles.md §4). The Kotlin mirror of
|
||||
* `crates/pf-client-core/src/profiles.rs`; the model is the same on every client, so get it right
|
||||
* here rather than re-deciding it.
|
||||
*
|
||||
* A profile overrides only the fields the user touched; everything else keeps following the global
|
||||
* defaults **live**, so fixing a global once fixes it everywhere. That is why an overlay is sparse
|
||||
* nullable fields rather than a snapshot copy, and why a value is written on touch and cleared only
|
||||
* on an explicit "reset to default" — never by diffing against the current global at save time. A
|
||||
* stored value that happens to equal today's global is a legitimate *pin*: the profile keeps it
|
||||
* when the global later moves.
|
||||
*
|
||||
* Only tier-P settings are here. Device facts (which pad this device forwards, whether its console
|
||||
* UI is on) and host facts (clipboard sync, which lives on the host record) are deliberately absent
|
||||
* — see the design's §3 curation.
|
||||
*
|
||||
* Values are stored exactly as [SettingsStore] persists them — ints for the compositor/gamepad wire
|
||||
* bytes, enum names for the rest — so there is one encoding of a setting on this platform rather
|
||||
* than two. The catalog is client-local (v1 has no profile sync or export), so nothing else reads
|
||||
* it.
|
||||
*/
|
||||
data class SettingsOverlay(
|
||||
val width: Int? = null,
|
||||
val height: Int? = null,
|
||||
val hz: Int? = null,
|
||||
val bitrateKbps: Int? = null,
|
||||
val renderScale: Double? = null,
|
||||
val codec: String? = null,
|
||||
val hdrEnabled: Boolean? = null,
|
||||
val compositor: Int? = null,
|
||||
val audioChannels: Int? = null,
|
||||
val micEnabled: Boolean? = null,
|
||||
val touchMode: TouchMode? = null,
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
* else, but here it is the one knob a marginal link wants turned off per host.
|
||||
*/
|
||||
val lowLatencyMode: Boolean? = 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
|
||||
* must not erase what a newer one stored.
|
||||
*/
|
||||
val extra: Map<String, Any> = emptyMap(),
|
||||
) {
|
||||
/** The one resolution seam: this overlay on top of [base]. Pure, so it is fully testable. */
|
||||
fun apply(base: Settings): Settings = base.copy(
|
||||
width = width ?: base.width,
|
||||
height = height ?: base.height,
|
||||
hz = hz ?: base.hz,
|
||||
bitrateKbps = bitrateKbps ?: base.bitrateKbps,
|
||||
renderScale = renderScale ?: base.renderScale,
|
||||
codec = codec ?: base.codec,
|
||||
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
|
||||
compositor = compositor ?: base.compositor,
|
||||
audioChannels = audioChannels ?: base.audioChannels,
|
||||
micEnabled = micEnabled ?: base.micEnabled,
|
||||
touchMode = touchMode ?: base.touchMode,
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
)
|
||||
|
||||
/**
|
||||
* Record, as overrides, every tier-P field that differs between two settings snapshots.
|
||||
*
|
||||
* The settings UI commits a whole `Settings` per control (`update(s.copy(codec = …))`), so it
|
||||
* can't hand over a list of touched fields — it hands over "what the control was showing" and
|
||||
* "what it shows now", and the only field that can differ is the one the user just touched.
|
||||
*
|
||||
* This is NOT the diff-on-save the design rejects: the comparison is against the EFFECTIVE
|
||||
* settings the control was displaying, not against the globals, so setting a value back to
|
||||
* whatever the global happens to be still records an override — the pin. It only ever adds
|
||||
* overrides; removing one is [clear], a different, explicit operation.
|
||||
*/
|
||||
fun absorb(before: Settings, after: Settings): SettingsOverlay = copy(
|
||||
width = if (after.width != before.width) after.width else width,
|
||||
height = if (after.height != before.height) after.height else height,
|
||||
hz = if (after.hz != before.hz) after.hz else hz,
|
||||
bitrateKbps = if (after.bitrateKbps != before.bitrateKbps) after.bitrateKbps else bitrateKbps,
|
||||
renderScale = if (after.renderScale != before.renderScale) after.renderScale else renderScale,
|
||||
codec = if (after.codec != before.codec) after.codec else codec,
|
||||
hdrEnabled = if (after.hdrEnabled != before.hdrEnabled) after.hdrEnabled else hdrEnabled,
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drop one override by its field name, putting the row back to inheriting. [FIELD_RESOLUTION]
|
||||
* is the one alias, covering the width/height pair a single control drives. An unknown name is
|
||||
* a no-op.
|
||||
*/
|
||||
fun clear(field: String): SettingsOverlay = when (field) {
|
||||
FIELD_RESOLUTION -> copy(width = null, height = null)
|
||||
"refresh_hz" -> copy(hz = null)
|
||||
"bitrate_kbps" -> copy(bitrateKbps = null)
|
||||
"render_scale" -> copy(renderScale = null)
|
||||
"codec" -> copy(codec = null)
|
||||
"hdr_enabled" -> copy(hdrEnabled = null)
|
||||
"compositor" -> copy(compositor = null)
|
||||
"audio_channels" -> copy(audioChannels = null)
|
||||
"mic_enabled" -> copy(micEnabled = 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)
|
||||
else -> this
|
||||
}
|
||||
|
||||
/** The field names this overlay overrides — what the settings rows draw their markers from. */
|
||||
fun overridden(): Set<String> = buildSet {
|
||||
if (width != null || height != null) add(FIELD_RESOLUTION)
|
||||
if (hz != null) add("refresh_hz")
|
||||
if (bitrateKbps != null) add("bitrate_kbps")
|
||||
if (renderScale != null) add("render_scale")
|
||||
if (codec != null) add("codec")
|
||||
if (hdrEnabled != null) add("hdr_enabled")
|
||||
if (compositor != null) add("compositor")
|
||||
if (audioChannels != null) add("audio_channels")
|
||||
if (micEnabled != null) add("mic_enabled")
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the profile overrides nothing — "inherits everything", the state a freshly created
|
||||
* profile starts in. A profile holding only a newer build's field is NOT empty.
|
||||
*/
|
||||
fun isEmpty(): Boolean = overridden().isEmpty() && extra.isEmpty()
|
||||
|
||||
internal fun toJson(): JSONObject {
|
||||
val j = JSONObject()
|
||||
// Unknown keys first, so a modelled field always wins over a stale carried-through one.
|
||||
extra.forEach { (k, v) -> j.put(k, v) }
|
||||
width?.let { j.put("width", it) }
|
||||
height?.let { j.put("height", it) }
|
||||
hz?.let { j.put("refresh_hz", it) }
|
||||
bitrateKbps?.let { j.put("bitrate_kbps", it) }
|
||||
renderScale?.let { j.put("render_scale", it) }
|
||||
codec?.let { j.put("codec", it) }
|
||||
hdrEnabled?.let { j.put("hdr_enabled", it) }
|
||||
compositor?.let { j.put("compositor", it) }
|
||||
audioChannels?.let { j.put("audio_channels", it) }
|
||||
micEnabled?.let { j.put("mic_enabled", 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) }
|
||||
return j
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The width/height pair, which one control drives — the reset alias, as on every client. */
|
||||
const val FIELD_RESOLUTION = "resolution"
|
||||
|
||||
/** 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",
|
||||
)
|
||||
|
||||
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
|
||||
width = j.optIntOrNull("width"),
|
||||
height = j.optIntOrNull("height"),
|
||||
hz = j.optIntOrNull("refresh_hz"),
|
||||
bitrateKbps = j.optIntOrNull("bitrate_kbps"),
|
||||
renderScale = if (j.has("render_scale")) j.optDouble("render_scale") else null,
|
||||
codec = j.optStringOrNull("codec"),
|
||||
hdrEnabled = j.optBooleanOrNull("hdr_enabled"),
|
||||
compositor = j.optIntOrNull("compositor"),
|
||||
audioChannels = j.optIntOrNull("audio_channels"),
|
||||
micEnabled = j.optBooleanOrNull("mic_enabled"),
|
||||
touchMode = j.optStringOrNull("touch_mode")
|
||||
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
|
||||
mouseMode = j.optStringOrNull("mouse_mode")
|
||||
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One named bundle of overrides. [id] is stable across renames — host bindings, pinned cards and
|
||||
* `punktfunk://` links all point at it, never at the name.
|
||||
*/
|
||||
data class StreamProfile(
|
||||
val id: String,
|
||||
/** User-facing and editable; unique case-insensitively (menus are ambiguous otherwise). */
|
||||
val name: String,
|
||||
/** `#RRGGBB` chip colour. Reserved by the schema; pinned cards tint their subtitle with it. */
|
||||
val accent: String? = null,
|
||||
val overrides: SettingsOverlay = SettingsOverlay(),
|
||||
/** Profile keys a newer build wrote — preserved across a load→save round-trip. */
|
||||
val extra: Map<String, Any> = emptyMap(),
|
||||
)
|
||||
|
||||
/** What a `profile=` / one-off reference resolved to. Ambiguity is reported, never guessed. */
|
||||
enum class ProfileResolution { FOUND, NOT_FOUND, AMBIGUOUS }
|
||||
|
||||
/**
|
||||
* The profile catalog — client-wide, not per host: "Work" applied to three hosts is one profile,
|
||||
* and the per-host part is only the binding on the host record ([KnownHost.profileId]).
|
||||
*
|
||||
* Stored one JSON string per profile keyed by id in its own `punktfunk_profiles` prefs file — the
|
||||
* `KnownHostStore` pattern, and deliberately not inside the settings file, which is rewritten
|
||||
* wholesale by several writers.
|
||||
*/
|
||||
class ProfileStore(context: Context) {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences("punktfunk_profiles", Context.MODE_PRIVATE)
|
||||
|
||||
/** Every profile, name-sorted — the order the scope switcher and the menus show. */
|
||||
fun all(): List<StreamProfile> = prefs.all.values
|
||||
.mapNotNull { (it as? String)?.let(::parse) }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
|
||||
fun byId(id: String): StreamProfile? = prefs.getString(id, null)?.let(::parse)
|
||||
|
||||
fun save(profile: StreamProfile) {
|
||||
prefs.edit().putString(profile.id, encode(profile)).apply()
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
prefs.edit().remove(id).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a reference the way every surface must: exact id first, then a unique
|
||||
* case-insensitive name. Two profiles sharing a name resolve to [ProfileResolution.AMBIGUOUS]
|
||||
* — a link or a flag naming two profiles must refuse, not pick whichever came first.
|
||||
*/
|
||||
fun resolve(reference: String): Pair<StreamProfile?, ProfileResolution> {
|
||||
if (reference.isEmpty()) return null to ProfileResolution.NOT_FOUND
|
||||
byId(reference)?.let { return it to ProfileResolution.FOUND }
|
||||
val hits = all().filter { it.name.equals(reference, ignoreCase = true) }
|
||||
return when (hits.size) {
|
||||
1 -> hits[0] to ProfileResolution.FOUND
|
||||
0 -> null to ProfileResolution.NOT_FOUND
|
||||
else -> null to ProfileResolution.AMBIGUOUS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this name already used (case-insensitively) by a *different* profile? The create/rename
|
||||
* guard — [except] is the profile being renamed, so renaming "Work" to "work" is allowed.
|
||||
*/
|
||||
fun nameTaken(name: String, except: String? = null): Boolean =
|
||||
all().any { it.name.equals(name, ignoreCase = true) && it.id != except }
|
||||
|
||||
/**
|
||||
* The profile a connect to [host] should use: the one-off pick, else the host's binding, else
|
||||
* none. [oneOff] is a reference (id or unique name); the empty string means "force the global
|
||||
* defaults" — a real choice ("Connect with ▸ Default settings" on a bound host), not "unset",
|
||||
* which is why it must survive as a value all the way down here. A binding whose profile was
|
||||
* deleted resolves as none: never an error, never a blocked connect.
|
||||
*/
|
||||
fun resolveFor(host: KnownHost?, oneOff: String?): StreamProfile? = when {
|
||||
oneOff != null -> resolve(oneOff).first
|
||||
else -> host?.profileId?.let(::byId)
|
||||
}
|
||||
|
||||
/** [host]'s pinned profiles, in card order, with duplicates and deleted profiles dropped. */
|
||||
fun pinsFor(host: KnownHost): List<StreamProfile> =
|
||||
host.pinnedProfileIds.distinct().mapNotNull(::byId)
|
||||
|
||||
private fun parse(s: String): StreamProfile? = runCatching {
|
||||
val j = JSONObject(s)
|
||||
StreamProfile(
|
||||
id = j.getString("id"),
|
||||
name = j.getString("name"),
|
||||
accent = j.optStringOrNull("accent"),
|
||||
overrides = SettingsOverlay.fromJson(j.optJSONObject("overrides") ?: JSONObject()),
|
||||
extra = j.keys().asSequence()
|
||||
.filter { it !in setOf("id", "name", "accent", "overrides") }
|
||||
.associateWith { j.get(it) },
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun encode(p: StreamProfile): String {
|
||||
val j = JSONObject()
|
||||
p.extra.forEach { (k, v) -> j.put(k, v) }
|
||||
j.put("id", p.id)
|
||||
j.put("name", p.name)
|
||||
p.accent?.let { j.put("accent", it) }
|
||||
j.put("overrides", p.overrides.toJson())
|
||||
return j.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip colours a profile can wear. Chosen to stay legible on a dark surface and to be
|
||||
* distinguishable from each other at the size they are actually used — a 6dp dot on a chip and a
|
||||
* tint on a pinned card — and held at one saturation and lightness so no single swatch shouts
|
||||
* over its neighbours. Deliberately NOT the presence green ([HostCard]'s online dot), which means
|
||||
* something else entirely.
|
||||
*
|
||||
* **Ordered by hue**, so the picker reads as one sweep of the colour wheel rather than a bag of
|
||||
* colours; the degrees are in the comments to keep it that way when one is swapped out. That order
|
||||
* is also the order [nextAccent] hands them out in, so a user creating profiles one after another
|
||||
* walks the spectrum instead of getting an arbitrary sequence.
|
||||
*/
|
||||
val PROFILE_ACCENTS = listOf(
|
||||
"#FF8A4C", // orange 21°
|
||||
"#FBBF24", // amber 45°
|
||||
"#A3E635", // lime 82°
|
||||
"#34D399", // green 160°
|
||||
"#22D3EE", // cyan 187°
|
||||
"#60A5FA", // blue 213°
|
||||
"#818CF8", // indigo 239°
|
||||
"#A78BFA", // violet 258°
|
||||
"#F472B6", // pink 330°
|
||||
"#FB7185", // rose 350°
|
||||
)
|
||||
|
||||
/** The first accent no existing profile is using, so two profiles don't look alike by accident. */
|
||||
fun nextAccent(existing: List<StreamProfile>): String {
|
||||
val taken = existing.mapNotNull { it.accent?.lowercase() }.toSet()
|
||||
return PROFILE_ACCENTS.firstOrNull { it.lowercase() !in taken } ?: PROFILE_ACCENTS.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* A new, empty profile: it inherits everything, which is the right creation default under
|
||||
* inherit-by-exception (Duplicate covers "start from that other profile"). The id is 12 lowercase
|
||||
* hex characters — the shape the Rust `new_profile_id` mints.
|
||||
*
|
||||
* [accent] is presentation, not a setting, so it does NOT inherit — a profile with no colour would
|
||||
* be indistinguishable from the defaults everywhere the accent is the whole signal (a bound card's
|
||||
* chip, a pinned card's tint). Callers creating a profile from the UI pass [nextAccent].
|
||||
*/
|
||||
fun newProfile(name: String, accent: String? = null): StreamProfile =
|
||||
StreamProfile(id = newProfileId(), name = name, accent = accent)
|
||||
|
||||
private val PROFILE_ID_RNG = SecureRandom()
|
||||
|
||||
fun newProfileId(): String {
|
||||
val b = ByteArray(6)
|
||||
PROFILE_ID_RNG.nextBytes(b)
|
||||
return b.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings a connect to [host] should use: the resolved profile's overrides on top of these
|
||||
* globals, resolved ONCE per connect (matching the latch-at-connect model the "applies from the
|
||||
* next session" footers promise). See [ProfileStore.resolveFor] for the precedence.
|
||||
*/
|
||||
fun Settings.effectiveFor(profile: StreamProfile?): Settings =
|
||||
profile?.overrides?.apply(this) ?: this
|
||||
|
||||
// ---- org.json null-vs-absent helpers (optInt and friends can't tell 0 from "not there") ---------
|
||||
|
||||
private fun JSONObject.optIntOrNull(key: String): Int? = if (has(key)) optInt(key) else null
|
||||
private fun JSONObject.optBooleanOrNull(key: String): Boolean? =
|
||||
if (has(key)) optBoolean(key) else null
|
||||
|
||||
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||
if (has(key)) optString(key).ifEmpty { null } else null
|
||||
@@ -111,30 +111,41 @@ data class Settings(
|
||||
val sc2Capture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward
|
||||
* raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at
|
||||
* stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord
|
||||
* works even with this off). Off (default): a mouse points absolutely, desktop-style.
|
||||
* 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
|
||||
* to the stream ([android.view.View.requestPointerCapture]) and forwards raw relative motion.
|
||||
* Read once per session by StreamScreen; Ctrl+Alt+Shift+Q flips the capture live either way.
|
||||
*/
|
||||
val pointerCapture: Boolean = false,
|
||||
val mouseMode: MouseMode = MouseMode.DESKTOP,
|
||||
|
||||
/**
|
||||
* Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with
|
||||
* the Apple/GTK clients' "Invert scroll direction".
|
||||
*/
|
||||
val invertScroll: Boolean = false,
|
||||
|
||||
/**
|
||||
* Sync text copied on this device to the host and vice versa while streaming (the desktop
|
||||
* clients' shared clipboard, text-only here). Only effective when the host advertises the
|
||||
* clipboard capability; the protocol is opt-in per session either way.
|
||||
*/
|
||||
val clipboardSync: Boolean = true,
|
||||
// NOTE: clipboard sync is NOT here. It is a decision about a HOST, not about this device or
|
||||
// this stream (design/client-settings-profiles.md §3, tier H), so it lives on the host record
|
||||
// — see `KnownHost.clipboardSync`. It used to be a global here; `KnownHostStore.migrate`
|
||||
// copied that value onto every saved host and retired the key.
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (the Rust `MouseMode`,
|
||||
* persisted as the same lowercase names). Only meaningful with a mouse attached.
|
||||
* - [CAPTURE] — pointer lock: relative deltas, the local cursor hidden, the host's cursor the only
|
||||
* one you see. The game model, and the desktop clients' default.
|
||||
* - [DESKTOP] — uncaptured absolute pointing: the cursor enters and leaves the stream freely. The
|
||||
* remote-desktop model, and Android's default (a phone/TV is far more often driven by touch or a
|
||||
* pad than by a locked mouse, and this is what the platform did before the setting existed).
|
||||
*/
|
||||
enum class MouseMode(val storedName: String, val label: String) {
|
||||
CAPTURE("capture", "Capture (games)"),
|
||||
DESKTOP("desktop", "Desktop (absolute)"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
|
||||
* of the previous one, so toning down never hides a number a lower tier keeps:
|
||||
@@ -193,9 +204,13 @@ class SettingsStore(context: Context) {
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
|
||||
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
|
||||
// default was false, which IS `desktop` — so an install that never touched the toggle
|
||||
// lands where it already was.
|
||||
?: if (prefs.getBoolean(K_POINTER_CAPTURE, false)) MouseMode.CAPTURE else MouseMode.DESKTOP,
|
||||
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
|
||||
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -219,9 +234,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -260,9 +274,11 @@ class SettingsStore(context: Context) {
|
||||
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_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
const val K_POINTER_CAPTURE = "pointer_capture"
|
||||
const val K_INVERT_SCROLL = "invert_scroll"
|
||||
const val K_CLIPBOARD_SYNC = "clipboard_sync"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
@@ -406,21 +422,47 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
|
||||
8 to "7.1 Surround",
|
||||
)
|
||||
|
||||
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row
|
||||
* only makes sense on a device with a real AV1 decoder — SettingsScreen filters it out otherwise. */
|
||||
/**
|
||||
* (stored value, label) for the preferred video codec — the cross-client table (the Rust
|
||||
* `CODECS`), so a value another client or a profile stored is always representable here.
|
||||
* `"auto"` = host decides.
|
||||
*
|
||||
* Two rows are capability-gated by [codecOptionsFor] rather than dropped from the table: `"av1"`
|
||||
* needs a real `video/av01` decoder on this device, and `"pyrowave"` needs a PyroWave decoder,
|
||||
* which this platform does not have at all (it is a Vulkan-compute codec living in `pf-presenter`;
|
||||
* the JNI client decodes through MediaCodec and never advertises the bit, so preferring it would
|
||||
* be a dead setting that silently resolves to HEVC).
|
||||
*/
|
||||
val CODEC_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"hevc" to "HEVC (H.265)",
|
||||
"h264" to "H.264 (AVC)",
|
||||
"av1" to "AV1",
|
||||
"pyrowave" to "PyroWave (wired LAN)",
|
||||
)
|
||||
|
||||
/**
|
||||
* [CODEC_OPTIONS] minus the rows this device can't decode — a preference the client never
|
||||
* advertises is a setting that does nothing. [stored] is the currently persisted value, which is
|
||||
* always kept selectable so the selection can be rendered (the don't-clobber rule: a codec chosen
|
||||
* on another device, or by a newer build, must survive being looked at here).
|
||||
*/
|
||||
fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, String>> =
|
||||
CODEC_OPTIONS.filter { (v, _) ->
|
||||
when (v) {
|
||||
"av1" -> av1Capable || stored == "av1"
|
||||
"pyrowave" -> stored == "pyrowave" // no PyroWave decoder on Android — see above
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4. */
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
"h264" -> 1
|
||||
"hevc" -> 2
|
||||
"av1" -> 4
|
||||
"pyrowave" -> 8
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@@ -456,11 +498,20 @@ val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TOUCH to "Touch passthrough",
|
||||
)
|
||||
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
/** (mode, label) for the physical-mouse model. */
|
||||
val MOUSE_MODE_OPTIONS = MouseMode.entries.map { it to it.label }
|
||||
|
||||
/**
|
||||
* (GamepadPref wire byte, label) for the emulated pad the host creates. NOT positional: the wire
|
||||
* bytes are `punktfunk_core::config::GamepadPref` (see `Gamepad.PREF_*`), and Steam Deck is `6`
|
||||
* with `5` (the classic Steam Controller) deliberately not offered — the same subset the desktop
|
||||
* clients' picker shows.
|
||||
*/
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_AUTO to "Automatic",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_XBOX360 to "Xbox 360",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSENSE to "DualSense",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_XBOXONE to "Xbox One",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The network speed test: measure the path to a host **over the real data plane** — connect, ask
|
||||
* the host to burst filler for two seconds, report goodput and loss, and offer to apply a
|
||||
* recommended bitrate in one tap.
|
||||
*
|
||||
* The measurement is the easy half. The half that was wrong everywhere for a long time is *where
|
||||
* the answer goes*: a measured bitrate belongs in the layer the tested host actually resolves
|
||||
* bitrate from (design/client-settings-profiles.md §5.3). Writing it to the global — the
|
||||
* long-standing behaviour — meant measuring the slow retro box downstairs quietly re-tuned the
|
||||
* desktop too. [SpeedTestTarget] is that decision, and because it depends only on the host it is
|
||||
* known *before* the result lands, so the button can say where it will write.
|
||||
*/
|
||||
sealed interface SpeedTestTarget {
|
||||
/** No profile in play — the global default, i.e. what has always happened. */
|
||||
data object Global : SpeedTestTarget
|
||||
|
||||
/** The profile this host uses already overrides bitrate, so that override is what it reads. */
|
||||
data class Profile(val profile: StreamProfile) : SpeedTestTarget
|
||||
|
||||
/**
|
||||
* The host uses a profile, but that profile inherits bitrate. Writing either layer is
|
||||
* defensible, so the user gets both buttons rather than us guessing which they meant.
|
||||
*/
|
||||
data class Ask(val profile: StreamProfile) : SpeedTestTarget
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Resolved exactly the way a connect resolves it (see [ProfileStore.resolveFor]): the
|
||||
* one-off pick this test was started from — a pinned card carries one — else the host's
|
||||
* binding. A dangling binding resolves as no profile here too.
|
||||
*/
|
||||
fun resolve(
|
||||
host: KnownHost?,
|
||||
oneOffProfile: String?,
|
||||
profiles: ProfileStore,
|
||||
): SpeedTestTarget {
|
||||
val profile = profiles.resolveFor(host, oneOffProfile) ?: return Global
|
||||
return if (profile.overrides.bitrateKbps != null) Profile(profile) else Ask(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the speed test is: it connects, it measures, then it has an answer or a reason. */
|
||||
sealed interface SpeedTestPhase {
|
||||
data object Connecting : SpeedTestPhase
|
||||
data object Measuring : SpeedTestPhase
|
||||
data class Failed(val message: String) : SpeedTestPhase
|
||||
|
||||
/**
|
||||
* [recommendedKbps] is 70 % of the measured throughput — headroom for the FEC overhead and for
|
||||
* the loss a real stream will meet, the same margin the desktop clients apply.
|
||||
*/
|
||||
data class Done(
|
||||
val throughputKbps: Int,
|
||||
val lossPct: Double,
|
||||
val recommendedKbps: Int,
|
||||
) : SpeedTestPhase {
|
||||
val measuredMbps: Double get() = throughputKbps / 1000.0
|
||||
val recommendedMbps: Double get() = recommendedKbps / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to [host]:[port], run one burst, and report. Blocking-ish (it suspends on IO) — call
|
||||
* from a coroutine; [onPhase] is invoked as it progresses so the dialog can narrate.
|
||||
*
|
||||
* The connect is deliberately minimal: 1280×720@60, no launch, host-default bitrate. Nothing here
|
||||
* presents a frame, and asking a host to spin up a 4K encode for a three-second measurement would
|
||||
* be rude to it and slower for us.
|
||||
*/
|
||||
suspend fun runSpeedTest(
|
||||
context: Context,
|
||||
identity: ClientIdentity,
|
||||
host: String,
|
||||
port: Int,
|
||||
pinHex: String,
|
||||
onPhase: (SpeedTestPhase) -> Unit,
|
||||
) {
|
||||
onPhase(SpeedTestPhase.Connecting)
|
||||
val probeSettings = Settings(
|
||||
width = 1280,
|
||||
height = 720,
|
||||
hz = 60,
|
||||
bitrateKbps = 0, // the host's default: this measures the link, not an encoder setting
|
||||
hdrEnabled = false,
|
||||
audioChannels = 2,
|
||||
)
|
||||
val handle = connectToHost(
|
||||
context, probeSettings, identity, host, port, pinHex,
|
||||
launch = null, timeoutMs = SPEED_TEST_CONNECT_TIMEOUT_MS,
|
||||
)
|
||||
if (handle == 0L) {
|
||||
onPhase(
|
||||
SpeedTestPhase.Failed(
|
||||
ConnectErrors.connectMessage(NativeBridge.nativeTakeLastError(), requestAccess = false),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
onPhase(SpeedTestPhase.Measuring)
|
||||
if (!NativeBridge.nativeSpeedTest(handle, TARGET_KBPS, BURST_MS)) {
|
||||
onPhase(SpeedTestPhase.Failed("The host wouldn't start a measurement."))
|
||||
return
|
||||
}
|
||||
var waited = 0
|
||||
while (waited < POLL_BUDGET_MS) {
|
||||
delay(POLL_INTERVAL_MS.toLong())
|
||||
waited += POLL_INTERVAL_MS
|
||||
val r = NativeBridge.nativeProbeResult(handle)
|
||||
if (r == null || r.size < 3) {
|
||||
onPhase(SpeedTestPhase.Failed("The session ended before the measurement finished."))
|
||||
return
|
||||
}
|
||||
if (r[0] == 0.0) continue
|
||||
// Let the last UDP shards land before tearing the session down, or the tail of the
|
||||
// burst is counted as loss that never happened.
|
||||
delay(SETTLE_MS)
|
||||
val settled = NativeBridge.nativeProbeResult(handle) ?: r
|
||||
val kbps = settled[1].toInt()
|
||||
onPhase(
|
||||
SpeedTestPhase.Done(
|
||||
throughputKbps = kbps,
|
||||
lossPct = settled[2],
|
||||
// Integer arithmetic in this order (not `* 0.7`) so the recommendation matches
|
||||
// the desktop clients' to the kilobit.
|
||||
recommendedKbps = kbps / 10 * 7,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
onPhase(SpeedTestPhase.Failed("The measurement timed out."))
|
||||
} finally {
|
||||
withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a measured bitrate into the layer [target] names. [toProfile] picks the side of a
|
||||
* [SpeedTestTarget.Ask]; it is ignored for the other targets, which have only one answer. Returns
|
||||
* a human phrase naming where it went, for the confirmation.
|
||||
*/
|
||||
fun applySpeedTestResult(
|
||||
kbps: Int,
|
||||
target: SpeedTestTarget,
|
||||
toProfile: Boolean,
|
||||
profiles: ProfileStore,
|
||||
settings: Settings,
|
||||
onGlobalChange: (Settings) -> Unit,
|
||||
): String {
|
||||
val profile = when (target) {
|
||||
is SpeedTestTarget.Profile -> target.profile
|
||||
is SpeedTestTarget.Ask -> target.profile.takeIf { toProfile }
|
||||
SpeedTestTarget.Global -> null
|
||||
}
|
||||
return if (profile == null) {
|
||||
onGlobalChange(settings.copy(bitrateKbps = kbps))
|
||||
"the default bitrate"
|
||||
} else {
|
||||
// Only the bitrate moves — a speed test has nothing to say about the rest of the profile.
|
||||
// Re-read rather than trusting the copy this dialog was opened with, so a rename or another
|
||||
// edit in between isn't clobbered.
|
||||
val live = profiles.byId(profile.id) ?: profile
|
||||
profiles.save(live.copy(overrides = live.overrides.copy(bitrateKbps = kbps)))
|
||||
"“${live.name}”"
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask for far more than any real link can carry, so the link is what limits the answer. */
|
||||
private const val TARGET_KBPS = 3_000_000
|
||||
|
||||
/** Long enough to fill the pipe and settle, short enough not to interrupt anyone for long. */
|
||||
private const val BURST_MS = 2_000
|
||||
private const val POLL_INTERVAL_MS = 250
|
||||
private const val POLL_BUDGET_MS = 10_000
|
||||
private const val SETTLE_MS = 400L
|
||||
private const val SPEED_TEST_CONNECT_TIMEOUT_MS = 15_000
|
||||
@@ -40,6 +40,12 @@ internal fun StatsOverlay(
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
codecLabel: String = "",
|
||||
/**
|
||||
* The settings profile this session resolved, appended to the first line when there is one —
|
||||
* the in-stream answer to "which profile am I on?", as on the other clients. Absent (the
|
||||
* common case: no profile) the line is exactly what it always was.
|
||||
*/
|
||||
profileName: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
@@ -56,13 +62,17 @@ internal fun StatsOverlay(
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
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), Color.White)
|
||||
statLine(compactLine(s, latValid) + profileTag, Color.White)
|
||||
return@Column
|
||||
}
|
||||
|
||||
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||
statLine(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
|
||||
Color.White,
|
||||
)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
|
||||
@@ -59,11 +59,21 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
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 kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The immersive stream. Everything it reads about the session comes from [session] — the settings
|
||||
* the connect actually resolved (globals, or a profile's overrides on top of them) and the HOST's
|
||||
* clipboard decision — rather than from a fresh `SettingsStore` load, which could disagree with
|
||||
* the connect that produced this handle.
|
||||
*/
|
||||
@Composable
|
||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val handle = session.handle
|
||||
val initialSettings = session.settings
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
val window = activity?.window
|
||||
@@ -85,7 +95,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
|
||||
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
|
||||
// blanks the numbers for a poll interval.
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var codecLabel by remember { mutableStateOf("") }
|
||||
@@ -246,7 +255,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val mouse = MouseForwarder(
|
||||
handle,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
captureWanted = initialSettings.pointerCapture,
|
||||
captureWanted = initialSettings.mouseMode == MouseMode.CAPTURE,
|
||||
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
|
||||
)
|
||||
mouse.onRequestCapture = {
|
||||
@@ -276,7 +285,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.remotePointer = remote
|
||||
// Shared clipboard (text v1): only when the user setting is on AND the host has a
|
||||
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
|
||||
val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
||||
val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
||||
ClipboardSync(context, handle).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
@@ -461,7 +470,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
StatsOverlay(
|
||||
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
|
||||
@@ -9,16 +9,22 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -32,6 +38,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -48,9 +57,26 @@ fun SectionLabel(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of a host card's overflow menu. [startsSection] draws a divider above it, which is how
|
||||
* the profile actions ("Connect with: …", "Pin as card: …") stay legible next to the host actions
|
||||
* in one flat menu — Compose has no submenus, and the Windows client made the same call.
|
||||
*/
|
||||
data class HostMenuItem(
|
||||
val label: String,
|
||||
val startsSection: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
|
||||
* saved hosts) an overflow menu with Wake / Edit / Forget plus whatever [menuItems] adds. Tapping
|
||||
* the card connects.
|
||||
*
|
||||
* [profileLabel] names the settings profile this card connects with. On a host's own card that is
|
||||
* its default binding, drawn as a quiet chip — the card says what a tap will do. On a **pinned
|
||||
* card** ([profileProminent]) the host name is still the title, but the profile is the loud part,
|
||||
* because the pin exists to make that one combination a single tap.
|
||||
*/
|
||||
@Composable
|
||||
fun HostCard(
|
||||
@@ -58,11 +84,25 @@ fun HostCard(
|
||||
address: String,
|
||||
status: HostStatus,
|
||||
online: Boolean = false,
|
||||
/** OS-identity chain (mDNS `os` TXT / stored), for the address line's OS mark. "" = none. */
|
||||
os: String = "",
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onWake: (() -> Unit)? = null,
|
||||
profileLabel: String? = null,
|
||||
profileProminent: Boolean = false,
|
||||
accent: Color? = null,
|
||||
menuItems: List<HostMenuItem> = emptyList(),
|
||||
/**
|
||||
* Keep the profile chip's space even on a card that has no profile. `LazyVerticalGrid` sizes a
|
||||
* row to its tallest item but does NOT stretch the others, so a card that grew a chip would
|
||||
* leave its neighbour visibly short — a row of cards stepping up and down reads as broken
|
||||
* layout. The caller passes true when ANY card in that section carries a chip, so a user with
|
||||
* no profiles never pays for the slot.
|
||||
*/
|
||||
reserveProfileSlot: Boolean = false,
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||
@@ -89,8 +129,8 @@ fun HostCard(
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
HostAvatar(name)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HostAvatar(name, online)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -98,25 +138,49 @@ fun HostCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PresencePill(online)
|
||||
StatusPill(status)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// The OS mark leads the address line; absent entirely for a host that
|
||||
// doesn't advertise one, so those cards render exactly as they always did.
|
||||
val osIcon = resolveOsIcon(os)
|
||||
if (osIcon != null) {
|
||||
Icon(
|
||||
osIcon,
|
||||
contentDescription = os,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
if (profileLabel != null || reserveProfileSlot) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
Modifier.heightIn(min = PROFILE_CHIP_SLOT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (profileLabel != null) {
|
||||
ProfileChip(profileLabel, accent, prominent = profileProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onForget != null || onEdit != null || onWake != null) {
|
||||
// Trust state lives in the free top-left corner, mirroring the overflow on the right —
|
||||
// it costs no height, and it is a state you glance at rather than read. The label is
|
||||
// still there for TalkBack, and the trust DECISION is made in a dialog that spells all
|
||||
// of this out; on the card it only has to say "this one is settled" vs "this one will
|
||||
// ask something of you".
|
||||
TrustBadge(status, Modifier.align(Alignment.TopStart))
|
||||
|
||||
if (onForget != null || onEdit != null || onWake != null || menuItems.isNotEmpty()) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -155,6 +219,16 @@ fun HostCard(
|
||||
},
|
||||
)
|
||||
}
|
||||
menuItems.forEach { item ->
|
||||
if (item.startsSection) HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(item.label) },
|
||||
onClick = {
|
||||
menu = false
|
||||
item.onClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,59 +236,122 @@ fun HostCard(
|
||||
}
|
||||
}
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
/**
|
||||
* The profile a card connects with. Quiet on a bound host's own card (it is a note about what a tap
|
||||
* does); filled and tinted on a pinned card, where the profile IS the reason the card exists — the
|
||||
* accent field the schema reserves earns its keep here.
|
||||
*/
|
||||
@Composable
|
||||
fun HostAvatar(name: String) {
|
||||
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
Box(
|
||||
private fun ProfileChip(label: String, accent: Color?, prominent: Boolean) {
|
||||
val tint = accent ?: MaterialTheme.colorScheme.primary
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
letter,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
label,
|
||||
style = if (prominent) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium
|
||||
},
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small dot + label for live presence: green Online when the host advertises on mDNS OR answers
|
||||
* the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed
|
||||
* Offline otherwise.
|
||||
* Reserved height for the profile chip — the one part of a card that varies. `LazyVerticalGrid`
|
||||
* sizes a row to its tallest item and does NOT stretch the others, so a card that grew a chip its
|
||||
* neighbour lacks would leave the row stepping up and down.
|
||||
*
|
||||
* `heightIn(min =)`, not a fixed height: at a large accessibility font scale the chip must be
|
||||
* allowed to grow rather than clip, and the reservation is sized with room to spare because the
|
||||
* equal-height guarantee only holds while every card fits INSIDE it.
|
||||
*/
|
||||
private val PROFILE_CHIP_SLOT = 26.dp
|
||||
|
||||
/** Live presence, on any dynamic scheme: green reads as "up" to everyone, and Material You's
|
||||
* primary might be any hue at all — including a green that would then mean nothing. */
|
||||
private val PRESENCE_ONLINE = Color(0xFF4ADE80)
|
||||
|
||||
/**
|
||||
* The host's letter avatar (Apple-contact style) with its presence as a dot on the corner — the
|
||||
* idiom every contact list already uses, and one fewer labelled badge on a small card.
|
||||
*
|
||||
* [online] is true when the host advertises on mDNS OR answers the reachability probe, so a
|
||||
* routed/VPN host that never advertises still reads as up. Online is a FILLED green dot, offline a
|
||||
* hollow grey ring: the difference is a shape as well as a colour, so it survives both a
|
||||
* colour-blind reader and a screenshot in greyscale. TalkBack gets the word either way.
|
||||
*/
|
||||
@Composable
|
||||
fun PresencePill(online: Boolean) {
|
||||
val color =
|
||||
if (online) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
if (online) "Online" else "Offline",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
fun HostAvatar(name: String, online: Boolean = false) {
|
||||
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val cardColor = CardDefaults.elevatedCardColors().containerColor
|
||||
Box {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
letter,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(13.dp)
|
||||
.clip(CircleShape)
|
||||
// A ring in the card's own colour is what makes the dot read as sitting ON the
|
||||
// avatar rather than beside it.
|
||||
.background(cardColor)
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape)
|
||||
.then(
|
||||
if (online) {
|
||||
Modifier.background(PRESENCE_ONLINE)
|
||||
} else {
|
||||
Modifier
|
||||
.background(cardColor)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
|
||||
},
|
||||
)
|
||||
.semantics { contentDescription = if (online) "Online" else "Offline" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small colored dot + label for the host's trust state. */
|
||||
/**
|
||||
* The host's trust state as a corner glyph: locked (paired — nothing more to do), a key (this host
|
||||
* will ask for a PIN), or an open lock (trust-on-first-use, the weakest of the three). The full
|
||||
* label rides along as the content description, and the dialogs that actually make the decision
|
||||
* spell it out in sentences.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPill(status: HostStatus) {
|
||||
val color = when (status) {
|
||||
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
|
||||
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
|
||||
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
|
||||
private fun TrustBadge(status: HostStatus, modifier: Modifier = Modifier) {
|
||||
val (icon, tint) = when (status) {
|
||||
HostStatus.PAIRED -> Icons.Filled.Lock to MaterialTheme.colorScheme.primary
|
||||
HostStatus.PAIRING -> Icons.Filled.Key to MaterialTheme.colorScheme.tertiary
|
||||
HostStatus.TOFU -> Icons.Filled.LockOpen to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = status.label,
|
||||
tint = tint.copy(alpha = 0.85f),
|
||||
modifier = modifier.padding(14.dp).size(18.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Shown when there are no saved or discovered hosts. */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package io.unom.punktfunk.components
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
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
|
||||
|
||||
/**
|
||||
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
|
||||
* e.g. "linux/fedora/bazzite"): [resolveOsIcon] walks the chain most-specific-first
|
||||
* (kit's [osIconTokens] — the shared order + brand aliases) and returns the first mark we
|
||||
* 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.
|
||||
*/
|
||||
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",
|
||||
),
|
||||
"apple" to OsGlyph(
|
||||
viewportWidth = 384f,
|
||||
viewportHeight = 512f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 448f,
|
||||
viewportHeight = 512f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 496f,
|
||||
viewportHeight = 512f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 496f,
|
||||
viewportHeight = 512f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 448f,
|
||||
viewportHeight = 512f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
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" to OsGlyph(
|
||||
viewportWidth = 640f,
|
||||
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",
|
||||
),
|
||||
)
|
||||
|
||||
private val built = mutableMapOf<String, ImageVector>()
|
||||
|
||||
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
|
||||
fun resolveOsIcon(chain: String): ImageVector? =
|
||||
osIconTokens(chain).firstNotNullOfOrNull { token ->
|
||||
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
|
||||
}
|
||||
|
||||
private fun OsGlyph.build(token: String): ImageVector =
|
||||
ImageVector.Builder(
|
||||
name = "OsIcon.$token",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
).apply {
|
||||
// Fill colour is irrelevant — Icon() tints via LocalContentColor, like Material icons.
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(d).toNodes(),
|
||||
fill = SolidColor(Color.Black),
|
||||
)
|
||||
}.build()
|
||||
@@ -25,10 +25,44 @@ data class PendingTrust(
|
||||
val name: String,
|
||||
val advertisedFp: String?,
|
||||
val kind: Kind,
|
||||
/**
|
||||
* What the connect on the far side of this decision should carry — a `punktfunk://` link's
|
||||
* one-off profile and library id. A link to an unknown host goes through the confirmation
|
||||
* first, and the user's stated intent must survive that detour rather than being silently
|
||||
* dropped on the way to a plain desktop session.
|
||||
*/
|
||||
val profile: String? = null,
|
||||
val launch: String? = null,
|
||||
) {
|
||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stream session that just opened, and the state the stream screen needs about it.
|
||||
*
|
||||
* [settings] is the settings the connect ACTUALLY used, resolved once at connect time — not
|
||||
* "whatever the settings store says now". Every post-connect read (the stats tier, the touch and
|
||||
* mouse models, the low-latency pipeline, rumble, SC2 capture) takes it, so the stream can never
|
||||
* disagree with the connect that produced it. [clipboardSync] comes from the host record, because
|
||||
* clipboard sync is a decision about that host rather than about this device.
|
||||
*/
|
||||
data class ActiveSession(
|
||||
val handle: Long,
|
||||
val settings: io.unom.punktfunk.Settings,
|
||||
val clipboardSync: Boolean,
|
||||
/**
|
||||
* The settings profile this session resolved, if any — shown on the stats overlay's first line
|
||||
* so "which profile am I on?" is answerable from inside the stream, as on the other clients.
|
||||
*/
|
||||
val profileName: String? = null,
|
||||
/**
|
||||
* The stable id of the host being streamed, when it is a saved one — so a `punktfunk://` link
|
||||
* that arrives mid-stream can tell "this same host" (a no-op; the intent already focused us)
|
||||
* from "a different host" (a notice; a URL may never preempt a live session).
|
||||
*/
|
||||
val hostId: String? = null,
|
||||
)
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
enum class HostStatus(val label: String) {
|
||||
PAIRED("Paired"),
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The profile model — the part of this feature that is wrong-or-right rather than pretty-or-ugly.
|
||||
* A profile is a named bundle of OVERRIDES, not a snapshot: an untouched field keeps following the
|
||||
* global live, a touched one is recorded even when it equals today's global (a pin), and the only
|
||||
* way back to inheriting is an explicit reset. These tests are the Kotlin twin of the Rust
|
||||
* `profiles.rs` suite, so the two can't drift.
|
||||
*
|
||||
* `sdk = [36]` for the same reason the screenshot tests pin it: Robolectric ships android-all jars
|
||||
* only up to API 36 while the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class ProfilesTest {
|
||||
private val base = Settings(
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
bitrateKbps = 20_000,
|
||||
codec = "hevc",
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
mouseMode = MouseMode.DESKTOP,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun overlayAppliesOnlyWhatItOverrides() {
|
||||
val empty = SettingsOverlay()
|
||||
assertTrue(empty.isEmpty())
|
||||
assertEquals(base, empty.apply(base))
|
||||
|
||||
val overlay = SettingsOverlay(
|
||||
width = 3840,
|
||||
height = 2160,
|
||||
hz = 120,
|
||||
bitrateKbps = 80_000,
|
||||
renderScale = 1.5,
|
||||
codec = "av1",
|
||||
hdrEnabled = false,
|
||||
compositor = 4,
|
||||
audioChannels = 6,
|
||||
micEnabled = true,
|
||||
touchMode = TouchMode.POINTER,
|
||||
mouseMode = MouseMode.CAPTURE,
|
||||
invertScroll = true,
|
||||
gamepad = 6,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
lowLatencyMode = false,
|
||||
)
|
||||
assertFalse(overlay.isEmpty())
|
||||
val out = overlay.apply(base)
|
||||
assertEquals(Triple(3840, 2160, 120), Triple(out.width, out.height, out.hz))
|
||||
assertEquals(80_000, out.bitrateKbps)
|
||||
assertEquals(1.5, out.renderScale, 0.0)
|
||||
assertEquals("av1", out.codec)
|
||||
assertFalse(out.hdrEnabled)
|
||||
assertEquals(4, out.compositor)
|
||||
assertEquals(6, out.audioChannels)
|
||||
assertTrue(out.micEnabled)
|
||||
assertEquals(TouchMode.POINTER, out.touchMode)
|
||||
assertEquals(MouseMode.CAPTURE, out.mouseMode)
|
||||
assertTrue(out.invertScroll)
|
||||
assertEquals(6, out.gamepad)
|
||||
assertEquals(StatsVerbosity.DETAILED, out.statsVerbosity)
|
||||
assertFalse(out.lowLatencyMode)
|
||||
|
||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverrideEqualToTheGlobalIsAPinThatSurvivesTheGlobalMoving() {
|
||||
val pin = SettingsOverlay(bitrateKbps = 20_000) // exactly what `base` says today
|
||||
assertFalse(pin.isEmpty())
|
||||
assertEquals(20_000, pin.apply(base.copy(bitrateKbps = 50_000)).bitrateKbps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absorbRecordsTheTouchedFieldOnly() {
|
||||
var o = SettingsOverlay()
|
||||
|
||||
// One control fires: before = what it was showing, after = what the user picked.
|
||||
var before = o.apply(base)
|
||||
o = o.absorb(before, before.copy(codec = "av1"))
|
||||
assertEquals("av1", o.codec)
|
||||
assertNull("nothing else may be recorded", o.bitrateKbps)
|
||||
|
||||
// Setting it BACK to the global's value is still an override — the pin case, and the whole
|
||||
// difference between this and diffing against the globals at save time.
|
||||
before = o.apply(base)
|
||||
o = o.absorb(before, before.copy(codec = "hevc"))
|
||||
assertEquals("hevc", o.codec)
|
||||
assertEquals("hevc", o.apply(base.copy(codec = "h264")).codec)
|
||||
|
||||
// Identical snapshots record nothing.
|
||||
before = o.apply(base)
|
||||
assertEquals(o, o.absorb(before, before))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearDropsOneOverride() {
|
||||
val o = SettingsOverlay(width = 3840, height = 2160, codec = "av1")
|
||||
assertEquals(setOf(SettingsOverlay.FIELD_RESOLUTION, "codec"), o.overridden())
|
||||
assertNull(o.clear("codec").codec)
|
||||
// Width and height are one control, so they reset together.
|
||||
val reset = o.clear(SettingsOverlay.FIELD_RESOLUTION)
|
||||
assertNull(reset.width)
|
||||
assertNull(reset.height)
|
||||
assertEquals(o, o.clear("no_such_field")) // unknown names are a no-op, never a crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun catalogRoundTripsAndPreservesWhatItCannotRepresent() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val game = newProfile("Game").copy(
|
||||
accent = "#ff8800",
|
||||
overrides = SettingsOverlay(
|
||||
width = 3840,
|
||||
height = 2160,
|
||||
hz = 120,
|
||||
// A codec string this build's picker can't show is still stored and still applied:
|
||||
// the host is the component that decides what it can encode.
|
||||
codec = "vvc-from-the-future",
|
||||
extra = mapOf("some_new_axis" to 7),
|
||||
),
|
||||
extra = mapOf("future_profile_key" to "kept"),
|
||||
)
|
||||
store.save(game)
|
||||
store.save(newProfile("Work"))
|
||||
|
||||
val loaded = store.byId(game.id)!!
|
||||
assertEquals("Game", loaded.name)
|
||||
assertEquals("#ff8800", loaded.accent)
|
||||
assertEquals("vvc-from-the-future", loaded.overrides.codec)
|
||||
assertEquals(3840, loaded.overrides.width)
|
||||
// The don't-clobber rule: an older build must not erase a newer one's keys by opening it.
|
||||
assertEquals(mapOf<String, Any>("some_new_axis" to 7), loaded.overrides.extra)
|
||||
assertEquals(mapOf<String, Any>("future_profile_key" to "kept"), loaded.extra)
|
||||
assertEquals("vvc-from-the-future", loaded.overrides.apply(base).codec)
|
||||
|
||||
// A profile that overrides nothing is the "inherits everything" one a create starts at.
|
||||
assertTrue(store.all().first { it.name == "Work" }.overrides.isEmpty())
|
||||
assertEquals(listOf("Game", "Work"), store.all().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePrefersIdsAndRefusesAmbiguity() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val work = newProfile("Work")
|
||||
val work2 = newProfile("work") // saved directly: the UI's name guard is what prevents this
|
||||
val game = newProfile("Game")
|
||||
listOf(work, work2, game).forEach(store::save)
|
||||
|
||||
assertEquals(ProfileResolution.FOUND, store.resolve(work.id).second)
|
||||
assertEquals(work.id, store.resolve(work.id).first!!.id)
|
||||
// Two profiles carry this name — refuse rather than pick whichever came first.
|
||||
assertEquals(ProfileResolution.AMBIGUOUS, store.resolve("Work").second)
|
||||
assertNull(store.resolve("Work").first)
|
||||
assertEquals(game.id, store.resolve("GAME").first!!.id) // names match case-insensitively
|
||||
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("nope").second)
|
||||
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("").second)
|
||||
|
||||
assertTrue(store.nameTaken("GAME"))
|
||||
assertFalse(store.nameTaken("GAME", except = game.id)) // renaming in place is allowed
|
||||
assertFalse(store.nameTaken("Travel"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profilePrecedenceIsOneOffThenBindingThenNone() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val work = newProfile("Work")
|
||||
val game = newProfile("Game")
|
||||
listOf(work, game).forEach(store::save)
|
||||
val bound = host().copy(profileId = work.id)
|
||||
|
||||
// A plain tap follows the binding…
|
||||
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
|
||||
// …a one-off wins over it, by id or by unique name, and never rebinds anything…
|
||||
assertEquals(game.id, store.resolveFor(bound, oneOff = game.id)!!.id)
|
||||
assertEquals(game.id, store.resolveFor(bound, oneOff = "game")!!.id)
|
||||
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
|
||||
// …and the empty reference is a real choice — "force the global defaults" — not "unset".
|
||||
assertNull(store.resolveFor(bound, oneOff = ""))
|
||||
// An unbound host is today's behaviour: the globals.
|
||||
assertNull(store.resolveFor(host(), oneOff = null))
|
||||
assertNull(store.resolveFor(null, oneOff = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeletedProfileLeavesNoErrorBehind() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val work = newProfile("Work")
|
||||
store.save(work)
|
||||
val h = host().copy(profileId = work.id, pinnedProfileIds = listOf(work.id, work.id))
|
||||
assertEquals(1, store.pinsFor(h).size) // a duplicate pin is one card, not two
|
||||
|
||||
store.delete(work.id)
|
||||
// A dangling binding resolves as "no profile" — never an error, never a blocked connect —
|
||||
// and its pinned card simply stops rendering.
|
||||
assertNull(store.resolveFor(h, oneOff = null))
|
||||
assertTrue(store.pinsFor(h).isEmpty())
|
||||
assertEquals(base, base.effectiveFor(store.resolveFor(h, oneOff = null)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile created from the UI gets a colour, and a distinct one — the accent is the WHOLE
|
||||
* signal on a bound host card's chip and a pinned card's tint, so two profiles sharing it (or
|
||||
* having none) makes those surfaces say less than they look like they're saying.
|
||||
*/
|
||||
@Test
|
||||
fun creationHandsOutADistinctColour() {
|
||||
val made = mutableListOf<StreamProfile>()
|
||||
repeat(PROFILE_ACCENTS.size) { made += newProfile("p$it", nextAccent(made)) }
|
||||
assertEquals(PROFILE_ACCENTS, made.map { it.accent })
|
||||
// Past the palette it wraps rather than handing out nothing — a duplicate colour beats an
|
||||
// invisible chip, and the picker is right there.
|
||||
assertEquals(PROFILE_ACCENTS.first(), nextAccent(made))
|
||||
// A gap is reused before wrapping.
|
||||
assertEquals(PROFILE_ACCENTS[2], nextAccent(made.filter { it.accent != PROFILE_ACCENTS[2] }))
|
||||
// The colour is presentation, so it never reaches the resolved settings.
|
||||
assertEquals(base, made.first().overrides.apply(base))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mintedIdsAreWellFormed() {
|
||||
val id = newProfileId()
|
||||
assertEquals(12, id.length)
|
||||
assertTrue(id.all { it.isDigit() || it in 'a'..'f' })
|
||||
assertNotEquals(id, newProfileId())
|
||||
}
|
||||
|
||||
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* An edit must land in the scope the chips say is selected — the one thing the two-layer settings
|
||||
* surface can get wrong without looking wrong.
|
||||
*
|
||||
* The regression this pins: `update` reached the rows as `::update`, and two callable references
|
||||
* compare EQUAL however different the scope they captured, so Compose skipped the whole detail page
|
||||
* on a scope switch that moved nothing on screen (the ordinary case — a profile inherits the globals
|
||||
* until it overrides something). Each edit then wrote to the scope the user had just left: change a
|
||||
* default, switch to a profile, change the same row — the globals moved again and the profile
|
||||
* recorded nothing — and back on the defaults the next edit went into the profile, which reads as
|
||||
* "the default settings can't be changed any more". It needs the real Compose runtime to catch, so
|
||||
* this drives the actual screen rather than the model underneath it.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
|
||||
* the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class SettingsScopeTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
/**
|
||||
* "Invert scroll direction" is the row under test: Input is profileable end to end, and that
|
||||
* toggle is the only toggleable node on the page, so a click needs no fragile lookup.
|
||||
*/
|
||||
private fun toggleTheRow() {
|
||||
compose.onNode(isToggleable()).performClick()
|
||||
compose.waitForIdle()
|
||||
}
|
||||
|
||||
private fun selectScope(chip: String) {
|
||||
compose.onNodeWithText(chip).performClick()
|
||||
compose.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editsFollowTheSelectedScope() {
|
||||
val profiles = ProfileStore(context)
|
||||
profiles.save(newProfile("Work", PROFILE_ACCENTS.first()))
|
||||
|
||||
// Mirrors App.kt: the screen is fed from state the host recomposes it with.
|
||||
var saved = Settings()
|
||||
compose.setContent {
|
||||
var settings by remember { mutableStateOf(saved) }
|
||||
SettingsScreen(
|
||||
initial = settings,
|
||||
onChange = { settings = it; saved = it },
|
||||
onBack = {},
|
||||
initialCategory = SettingsCategory.Input,
|
||||
)
|
||||
}
|
||||
|
||||
// 1. On the defaults, the globals move and no profile records anything.
|
||||
toggleTheRow()
|
||||
assertEquals(true, saved.invertScroll)
|
||||
assertNull(profiles.all().single().overrides.invertScroll)
|
||||
|
||||
// 2. In profile scope the SAME row — untouched by the profile, so it still shows the global
|
||||
// value and nothing on the page changed — must record an override and leave the globals
|
||||
// alone. This is the step that used to write straight through to the globals.
|
||||
selectScope("Work")
|
||||
toggleTheRow()
|
||||
assertEquals("the globals must not move while a profile is selected", true, saved.invertScroll)
|
||||
assertEquals(false, profiles.all().single().overrides.invertScroll)
|
||||
|
||||
// 3. Back on the defaults the row is editable again, and the profile keeps its override.
|
||||
selectScope("Default settings")
|
||||
toggleTheRow()
|
||||
assertEquals(false, saved.invertScroll)
|
||||
assertEquals(
|
||||
"the profile's override must survive an edit made on the defaults",
|
||||
false,
|
||||
profiles.all().single().overrides.invertScroll,
|
||||
)
|
||||
}
|
||||
|
||||
/** A reset puts the row back to inheriting — and, like an edit, it must obey the live scope. */
|
||||
@Test
|
||||
fun resetClearsTheSelectedProfilesOverride() {
|
||||
val profiles = ProfileStore(context)
|
||||
profiles.save(newProfile("Work", PROFILE_ACCENTS.first()))
|
||||
|
||||
compose.setContent {
|
||||
var settings by remember { mutableStateOf(Settings()) }
|
||||
SettingsScreen(
|
||||
initial = settings,
|
||||
onChange = { settings = it },
|
||||
onBack = {},
|
||||
initialCategory = SettingsCategory.Input,
|
||||
initialProfileId = profiles.all().single().id,
|
||||
)
|
||||
}
|
||||
|
||||
toggleTheRow()
|
||||
assertEquals(true, profiles.all().single().overrides.invertScroll)
|
||||
|
||||
compose.onNodeWithText("Reset").performClick()
|
||||
compose.waitForIdle()
|
||||
assertNull(profiles.all().single().overrides.invertScroll)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Where a measured bitrate lands. The measurement itself is the host's job; the decision this code
|
||||
* makes is which layer to write — and the long-standing wrong answer (always the global) is exactly
|
||||
* what made measuring the slow box downstairs re-tune the desktop too
|
||||
* (design/client-settings-profiles.md §5.3).
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class SpeedTestTest {
|
||||
private val store get() = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
|
||||
|
||||
@Test
|
||||
fun anUnboundHostTargetsTheGlobalDefault() {
|
||||
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host(), null, store))
|
||||
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(null, null, store))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aProfileThatSetsBitrateIsTheLayerThatHostReads() {
|
||||
val s = store
|
||||
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
|
||||
s.save(game)
|
||||
val target = SpeedTestTarget.resolve(host().copy(profileId = game.id), null, s)
|
||||
assertEquals(game.id, (target as SpeedTestTarget.Profile).profile.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aProfileThatInheritsBitrateAsksWhichLayer() {
|
||||
val s = store
|
||||
val work = newProfile("Work") // overrides nothing
|
||||
s.save(work)
|
||||
val target = SpeedTestTarget.resolve(host().copy(profileId = work.id), null, s)
|
||||
// Both layers are defensible here, so the user picks — we don't guess.
|
||||
assertEquals(work.id, (target as SpeedTestTarget.Ask).profile.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theOneOffPickWinsAndTheEmptyOneForcesTheDefaults() {
|
||||
val s = store
|
||||
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
|
||||
val work = newProfile("Work")
|
||||
listOf(game, work).forEach(s::save)
|
||||
val bound = host().copy(profileId = work.id)
|
||||
|
||||
// Testing from a pinned card measures — and writes — that card's profile.
|
||||
assertEquals(game.id, (SpeedTestTarget.resolve(bound, game.id, s) as SpeedTestTarget.Profile).profile.id)
|
||||
// "Connect with: Default settings" is a real choice, so its speed test targets the global.
|
||||
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(bound, "", s))
|
||||
// A dangling binding resolves as no profile everywhere else; here too.
|
||||
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host().copy(profileId = "gone"), null, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyingWritesOnlyTheBitrate_andOnlyToTheChosenLayer() {
|
||||
val s = store
|
||||
val game = newProfile("Game").copy(
|
||||
overrides = SettingsOverlay(bitrateKbps = 50_000, width = 3840, height = 2160),
|
||||
)
|
||||
s.save(game)
|
||||
val globals = Settings(bitrateKbps = 20_000, codec = "hevc")
|
||||
var savedGlobals: Settings? = null
|
||||
|
||||
val where = applySpeedTestResult(
|
||||
kbps = 84_000,
|
||||
target = SpeedTestTarget.Profile(game),
|
||||
toProfile = true,
|
||||
profiles = s,
|
||||
settings = globals,
|
||||
onGlobalChange = { savedGlobals = it },
|
||||
)
|
||||
assertEquals("“Game”", where)
|
||||
assertNull("the global must not move when a profile was the target", savedGlobals)
|
||||
val after = s.byId(game.id)!!.overrides
|
||||
assertEquals(84_000, after.bitrateKbps)
|
||||
// Nothing else in the overlay is a speed test's business.
|
||||
assertEquals(3840, after.width)
|
||||
assertEquals(2160, after.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAskCaseHonoursWhichButtonWasPressed() {
|
||||
val s = store
|
||||
val work = newProfile("Work")
|
||||
s.save(work)
|
||||
val globals = Settings(bitrateKbps = 20_000)
|
||||
var savedGlobals: Settings? = null
|
||||
|
||||
// "Set as default" writes the global and leaves the profile inheriting.
|
||||
val whereGlobal = applySpeedTestResult(
|
||||
42_000, SpeedTestTarget.Ask(work), toProfile = false, profiles = s,
|
||||
settings = globals, onGlobalChange = { savedGlobals = it },
|
||||
)
|
||||
assertEquals("the default bitrate", whereGlobal)
|
||||
assertEquals(42_000, savedGlobals!!.bitrateKbps)
|
||||
assertNull(s.byId(work.id)!!.overrides.bitrateKbps)
|
||||
|
||||
// "Set in Work" records the override instead — and now that profile stops inheriting.
|
||||
savedGlobals = null
|
||||
val whereProfile = applySpeedTestResult(
|
||||
42_000, SpeedTestTarget.Ask(work), toProfile = true, profiles = s,
|
||||
settings = globals, onGlobalChange = { savedGlobals = it },
|
||||
)
|
||||
assertEquals("“Work”", whereProfile)
|
||||
assertNull(savedGlobals)
|
||||
assertEquals(42_000, s.byId(work.id)!!.overrides.bitrateKbps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRecommendationLeavesHeadroom() {
|
||||
// 70 % of measured, in the desktop clients' integer order — a stream needs room for the
|
||||
// FEC overhead and for the loss a burst measurement doesn't see.
|
||||
val done = SpeedTestPhase.Done(throughputKbps = 100_000, lossPct = 0.4, recommendedKbps = 100_000 / 10 * 7)
|
||||
assertEquals(70_000, done.recommendedKbps)
|
||||
assertEquals(100.0, done.measuredMbps, 0.001)
|
||||
assertEquals(70.0, done.recommendedMbps, 0.001)
|
||||
assertTrue(done.recommendedKbps < done.throughputKbps)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,21 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun settings() = shootRoot("settings") { SettingsScene() }
|
||||
|
||||
// One category page per shot: the sub-section headers, the caption-under-control fields and
|
||||
// the "applies from the next session" footers live inside a category, not on the root list.
|
||||
@Test
|
||||
fun settingsDisplay() = shootRoot("settings-display") {
|
||||
SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Display)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsInput() = shootRoot("settings-input") {
|
||||
SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Input)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsProfile() = shootRoot("settings-profile") { SettingsProfileScene() }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
@@ -97,6 +112,15 @@ class ScreenshotTest {
|
||||
TrustDialog()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newProfile() = shootRoot("new-profile") { NewProfileScene() }
|
||||
|
||||
@Test
|
||||
fun speedTest() = shootScreen("speed-test") {
|
||||
HostsScene()
|
||||
SpeedTestScene()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pair() = shootScreen("pair") {
|
||||
HostsScene()
|
||||
|
||||
@@ -19,10 +19,12 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
@@ -31,11 +33,20 @@ import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsCategory
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.ProfileEditorFields
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
import io.unom.punktfunk.SettingsOverlay
|
||||
import io.unom.punktfunk.SpeedTestDialog
|
||||
import io.unom.punktfunk.SpeedTestPhase
|
||||
import io.unom.punktfunk.SpeedTestTarget
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.newProfile
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
|
||||
// The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced
|
||||
@@ -48,15 +59,31 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
private data class MockHost(val name: String, val address: String, val status: HostStatus)
|
||||
private data class MockHost(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val status: HostStatus,
|
||||
val profile: String? = null,
|
||||
val pin: String? = null,
|
||||
val accent: Color? = null,
|
||||
val online: Boolean = false,
|
||||
)
|
||||
|
||||
// Ordered so an UNCHIPPED card sits beside a CHIPPED one in the same grid row, and a long trust
|
||||
// label ("Trust on first use") beside a short one ("Paired"). Both are what used to make cards in a
|
||||
// row step up and down — the grid sizes a row to its tallest item and doesn't stretch the rest — so
|
||||
// this arrangement is the regression net for it.
|
||||
private val SAVED = listOf(
|
||||
MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED),
|
||||
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
|
||||
MockHost(
|
||||
"Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED,
|
||||
profile = "Game", pin = "Work", accent = Color(0xFFFF8A4C), online = true,
|
||||
),
|
||||
)
|
||||
private val DISCOVERED = listOf(
|
||||
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING),
|
||||
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU),
|
||||
// Discovered ⇒ advertising right now, so both are online.
|
||||
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING, online = true),
|
||||
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU, online = true),
|
||||
)
|
||||
|
||||
/** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */
|
||||
@@ -86,42 +113,170 @@ internal fun HostsScene() {
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||
items(SAVED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
|
||||
// A pinned card is its OWN grid cell right after its host — the same flat list the
|
||||
// connect screen builds, not a second card crammed into the host's cell.
|
||||
SAVED.forEach { h ->
|
||||
item {
|
||||
HostCard(
|
||||
h.name, h.address, h.status, online = h.online, enabled = true,
|
||||
onConnect = {}, onForget = {}, onEdit = {},
|
||||
// The bound profile is a quiet chip: the card says what a tap will do.
|
||||
profileLabel = h.profile,
|
||||
accent = h.accent,
|
||||
menuItems = listOf(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {},
|
||||
HostMenuItem("Connect with: Game") {},
|
||||
),
|
||||
// One card in this section has a chip, so every card reserves its space —
|
||||
// the shot is here to catch a row that steps.
|
||||
reserveProfileSlot = true,
|
||||
)
|
||||
}
|
||||
if (h.pin != null) {
|
||||
item {
|
||||
HostCard(
|
||||
h.name, h.address, h.status, online = h.online, enabled = true,
|
||||
onConnect = {}, onForget = null,
|
||||
profileLabel = h.pin, profileProminent = true, accent = h.accent,
|
||||
menuItems = listOf(HostMenuItem("Unpin card", startsSection = true) {}),
|
||||
reserveProfileSlot = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(DISCOVERED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = null)
|
||||
HostCard(
|
||||
h.name, h.address, h.status, online = h.online,
|
||||
enabled = true, onConnect = {}, onForget = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The real SettingsScreen, fed a representative non-default Settings. */
|
||||
/** A representative non-default settings state, shared by the settings scenes. */
|
||||
private val SHOT_SETTINGS = Settings(
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
hz = 120,
|
||||
bitrateKbps = 50_000,
|
||||
compositor = 1,
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
)
|
||||
|
||||
/**
|
||||
* The real SettingsScreen at its root — the shared category map (General / Display / Input /
|
||||
* Audio / Controllers / About) every client now presents.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsScene() {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
SettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One category page, seeded through `initialCategory` — the sub-section headers, the
|
||||
* caption-under-control fields and the "applies from the next session" footer only exist inside a
|
||||
* category, so the root shot alone can't regress-catch them. Display is the richest page.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsCategoryScene(category: SettingsCategory) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
SettingsScreen(
|
||||
initial = Settings(
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
hz = 120,
|
||||
bitrateKbps = 50_000,
|
||||
compositor = 1,
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
),
|
||||
initial = SHOT_SETTINGS,
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
initialCategory = category,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same settings surface in a PROFILE's scope: the scope chips with "Game" selected, only
|
||||
* profileable rows, every row showing the effective value, and the overridden ones carrying their
|
||||
* marker and reset. One settings UI, two layers — this shot is what proves it stayed one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsProfileScene() {
|
||||
val store = ProfileStore(LocalContext.current)
|
||||
val profile = remember {
|
||||
val p = newProfile("Game").copy(
|
||||
accent = "#FF8A4C",
|
||||
// A representative mix: a resolution and refresh the profile pins, and a codec — the
|
||||
// rest of the page keeps following the defaults, visibly unmarked.
|
||||
overrides = SettingsOverlay(width = 3840, height = 2160, hz = 120, codec = "h264"),
|
||||
)
|
||||
store.save(p)
|
||||
store.save(newProfile("Work"))
|
||||
p
|
||||
}
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
SettingsScreen(
|
||||
initial = SHOT_SETTINGS,
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
initialCategory = SettingsCategory.Display,
|
||||
initialProfileId = profile.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The speed test's result, in its most interesting shape: a host bound to a profile that INHERITS
|
||||
* bitrate, so both layers are defensible and both buttons are offered. The note under the numbers
|
||||
* is what stops "Apply" from being a write in an unknown direction.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeedTestScene() {
|
||||
SpeedTestDialog(
|
||||
hostName = "Living Room PC",
|
||||
target = SpeedTestTarget.Ask(newProfile("Game")),
|
||||
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
|
||||
onApply = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating a profile. Small, but it is the first thing a user meets when they reach for this
|
||||
* feature — and dialogs only get a shot each because a layout slip inside one is invisible from
|
||||
* every other scene (this one shipped with the field and its caption touching).
|
||||
*/
|
||||
@Composable
|
||||
internal fun NewProfileScene() {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("New profile", style = MaterialTheme.typography.headlineSmall)
|
||||
// The dialog's own body, not a rebuild of it — the layout under test is the real one.
|
||||
ProfileEditorFields(
|
||||
name = "Travel",
|
||||
accent = "#60A5FA",
|
||||
duplicate = false,
|
||||
creating = true,
|
||||
onNameChange = {},
|
||||
onAccentChange = {},
|
||||
)
|
||||
Text("Duplicate name", style = MaterialTheme.typography.headlineSmall)
|
||||
ProfileEditorFields(
|
||||
name = "Game",
|
||||
accent = "#FF8A4C",
|
||||
duplicate = true,
|
||||
creating = false,
|
||||
onNameChange = {},
|
||||
onAccentChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
|
||||
@Composable
|
||||
internal fun TrustDialog() {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// org.jetbrains.kotlin.android (it's an error under AGP 9). The Compose compiler plugin is declared
|
||||
// here (version + apply false) so modules can apply it version-less; its version pins the build's
|
||||
// Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched.
|
||||
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
|
||||
// Toolchain: AGP 9.3.1 · Gradle 9.5.0 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
|
||||
// 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28.
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("com.android.library") version "9.2.1" apply false
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("com.android.library") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
@@ -33,7 +33,11 @@ dependencies {
|
||||
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
|
||||
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
||||
testImplementation("junit:junit:4.13.2") // JVM unit tests for the pure parsers/migrations
|
||||
// A REAL org.json on the unit-test classpath. android.jar's org.json is stubs that throw
|
||||
// "Stub!", so the host-store migration test — which asserts over the very JSON blobs the store
|
||||
// reads and writes — cannot run without it. Explicit test deps precede the mockable android.jar.
|
||||
testImplementation("org.json:json:20250107")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -57,6 +57,10 @@ object NativeBridge {
|
||||
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
|
||||
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
|
||||
launch: String?,
|
||||
/** This device's display name (rides the Hello as `name`) — what the host's pending-approval
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -145,6 +149,24 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean
|
||||
|
||||
/**
|
||||
* Start a bandwidth speed test on [handle]: the host bursts filler over the real data plane at
|
||||
* [targetKbps] of goodput for [durationMs] (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
* **briefly pausing video**. Measuring over the stream's own path is the point — the answer is
|
||||
* about the link this host's stream will take, not about generic throughput.
|
||||
*
|
||||
* Non-blocking: poll [nativeProbeResult] until it reports done. Starting a probe resets any
|
||||
* prior measurement. Returns false on a dead handle. Cheap; safe on the main thread.
|
||||
*/
|
||||
external fun nativeSpeedTest(handle: Long, targetKbps: Int, durationMs: Int): Boolean
|
||||
|
||||
/**
|
||||
* The current speed-test measurement, partial until `[0] != 0.0`:
|
||||
* `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`. Zeros before any
|
||||
* probe, null on a dead handle. Cheap (one lock + a copy); safe to poll on the main thread.
|
||||
*/
|
||||
external fun nativeProbeResult(handle: Long): DoubleArray?
|
||||
|
||||
/**
|
||||
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
|
||||
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
|
||||
|
||||
@@ -18,16 +18,17 @@ data class DiscoveredHost(
|
||||
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
||||
val pairingRequired: Boolean = false,
|
||||
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||
val os: String = "", // TXT "os" (OS-identity chain, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
)
|
||||
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
|
||||
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
|
||||
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol
|
||||
* gate and address selection, so this is just field marshaling.
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os`),
|
||||
* or null if it's malformed. Fields past the 6th are optional — an older native lib omits them
|
||||
* (`mac` 7th, `os` 8th). Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
*/
|
||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
val f = record.split(FIELD_SEP)
|
||||
@@ -44,9 +45,41 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
pairingRequired = f[5] == "required",
|
||||
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
else emptyList(),
|
||||
os = if (f.size > 7) sanitizeOsChain(f[7]) else "",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a raw `os` TXT value to the trusted grammar (pf-client-core's `sanitize_os`, mirrored):
|
||||
* lowercase slash-separated tokens of `[a-z0-9._-]`, each ≤ 32 chars, at most 5. mDNS is
|
||||
* unauthenticated input; a value that sanitizes to nothing becomes "" (no icon, like an older host).
|
||||
*/
|
||||
fun sanitizeOsChain(raw: String): String =
|
||||
raw.lowercase()
|
||||
.split('/')
|
||||
.map { token -> token.filter { it in 'a'..'z' || it in '0'..'9' || it in "._-" }.take(32) }
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(5)
|
||||
.joinToString("/")
|
||||
|
||||
/**
|
||||
* The icon-lookup order for a chain: sanitized tokens most-specific-first, brand aliases applied
|
||||
* (`macos` → `apple` art, `steamos` → `steam` art) — pf-client-core's `os_icon_tokens`, mirrored.
|
||||
* The UI takes the first token it has art for; empty means "no OS icon" (older host / garbage).
|
||||
*/
|
||||
fun osIconTokens(chain: String): List<String> =
|
||||
sanitizeOsChain(chain)
|
||||
.split('/')
|
||||
.filter { it.isNotEmpty() }
|
||||
.reversed()
|
||||
.map {
|
||||
when (it) {
|
||||
"macos" -> "apple"
|
||||
"steamos" -> "steam"
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
|
||||
* Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
package io.unom.punktfunk.kit.link
|
||||
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* The `punktfunk://` URL grammar (design/client-deep-links.md §2). A **port**, not a new design:
|
||||
* the Rust `crates/pf-client-core/src/deeplink.rs` is the reference, Swift keeps a third copy, and
|
||||
* all three are held together by `clients/shared/deeplink-vectors.json`, which each language's test
|
||||
* suite runs verbatim — so the three parsers cannot drift into three different security postures.
|
||||
*
|
||||
* ```text
|
||||
* punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
|
||||
* [&profile=<ref>][&name=<label>]
|
||||
* ```
|
||||
*
|
||||
* The invariant the grammar exists to keep: **a URL may only ever do what a click on an existing
|
||||
* card could do, minus trust decisions.** So it carries *references* to things that already exist
|
||||
* on this device — a host record, a settings profile, a library id — and never values: no
|
||||
* resolution, no bitrate, no codec. A web page must not be able to shape a session beyond picking
|
||||
* among the user's own configurations. `pair` is deliberately not a route and never will be;
|
||||
* pairing stays an interactive ceremony.
|
||||
*
|
||||
* `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever *emits*
|
||||
* or registers it.
|
||||
*/
|
||||
object DeepLinks {
|
||||
/** Hostile-input caps. Generous for a real link, small enough that a pasted megabyte stops here. */
|
||||
const val MAX_URL_LEN = 2048
|
||||
const val MAX_HOST_REF_LEN = 128
|
||||
const val MAX_LAUNCH_LEN = 128
|
||||
const val MAX_PROFILE_LEN = 64
|
||||
const val MAX_NAME_LEN = 64
|
||||
|
||||
/** The default native port, as everywhere else in the clients. */
|
||||
const val DEFAULT_PORT = 9777
|
||||
|
||||
/**
|
||||
* Parse a `punktfunk://` (or `pf://`) URL. Everything hostile is rejected here, once: over-long
|
||||
* input, malformed escapes, control characters, out-of-charset launch ids and fingerprints that
|
||||
* aren't fingerprints. What the caller still has to do is *resolve* — the references may name
|
||||
* things that don't exist on this device (see [resolveHost]).
|
||||
*/
|
||||
fun parse(url: String): DeepLinkResult {
|
||||
if (url.length > MAX_URL_LEN) return DeepLinkResult.Refused(LinkError.TOO_LONG)
|
||||
val sep = url.indexOf("://")
|
||||
if (sep < 0) return DeepLinkResult.Refused(LinkError.NOT_OUR_SCHEME)
|
||||
val scheme = url.substring(0, sep)
|
||||
if (!scheme.equals("punktfunk", ignoreCase = true) && !scheme.equals("pf", ignoreCase = true)) {
|
||||
return DeepLinkResult.Refused(LinkError.NOT_OUR_SCHEME)
|
||||
}
|
||||
// A fragment is never part of this grammar; drop it rather than folding it into the last
|
||||
// parameter, where it would smuggle unvalidated text past the caps.
|
||||
val rest = url.substring(sep + 3).substringBefore('#')
|
||||
val path = rest.substringBefore('?').trimEnd('/')
|
||||
val query = if (rest.contains('?')) rest.substringAfter('?') else ""
|
||||
|
||||
val slash = path.indexOf('/')
|
||||
val routeWord: String
|
||||
val hostRefRaw: String
|
||||
when {
|
||||
slash >= 0 -> {
|
||||
routeWord = path.substring(0, slash)
|
||||
hostRefRaw = path.substring(slash + 1)
|
||||
}
|
||||
// A single segment: Apple's shipped links are always `connect/<uuid>`, but a bare
|
||||
// reference is unambiguous as long as it isn't one of the route words — those stay
|
||||
// routes (with a missing reference), so `punktfunk://pair` refuses instead of hunting
|
||||
// for a host called "pair".
|
||||
isRouteWord(path) -> {
|
||||
routeWord = path
|
||||
hostRefRaw = ""
|
||||
}
|
||||
else -> {
|
||||
routeWord = "connect"
|
||||
hostRefRaw = path
|
||||
}
|
||||
}
|
||||
val route = when (routeWord.lowercase()) {
|
||||
"connect" -> LinkRoute.CONNECT
|
||||
"wake" -> LinkRoute.WAKE
|
||||
"browse" -> LinkRoute.BROWSE
|
||||
"pair" -> return DeepLinkResult.Refused(LinkError.PAIR_REFUSED)
|
||||
else -> return DeepLinkResult.Refused(LinkError.UNKNOWN_ROUTE, routeWord)
|
||||
}
|
||||
|
||||
val hostRef = when (val d = decode(hostRefRaw)) {
|
||||
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
|
||||
is Decoded.Ok -> d.text
|
||||
}
|
||||
if (hostRef.isEmpty()) return DeepLinkResult.Refused(LinkError.MISSING_HOST_REF)
|
||||
if (scalarCount(hostRef) > MAX_HOST_REF_LEN) {
|
||||
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "host-ref")
|
||||
}
|
||||
|
||||
var fp: String? = null
|
||||
var host: Pair<String, Int>? = null
|
||||
var launch: String? = null
|
||||
var profile: String? = null
|
||||
var name: String? = null
|
||||
for (pair in query.split('&')) {
|
||||
if (pair.isEmpty()) continue
|
||||
val eq = pair.indexOf('=')
|
||||
val rawKey = if (eq >= 0) pair.substring(0, eq) else pair
|
||||
val rawValue = if (eq >= 0) pair.substring(eq + 1) else ""
|
||||
val key = when (val d = decode(rawKey)) {
|
||||
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
|
||||
is Decoded.Ok -> d.text.lowercase()
|
||||
}
|
||||
val value = when (val d = decode(rawValue)) {
|
||||
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
|
||||
is Decoded.Ok -> d.text
|
||||
}
|
||||
// `?launch=` with nothing after it is "not given", not an error.
|
||||
if (value.isEmpty()) continue
|
||||
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter must
|
||||
// not turn an otherwise valid link into a refusal, and appending a second `fp=` must
|
||||
// not be able to override the first.
|
||||
when {
|
||||
key == "fp" && fp == null -> {
|
||||
val hex = value.lowercase()
|
||||
if (hex.length != 64 || !hex.all { it.isDigit() || it in 'a'..'f' }) {
|
||||
return DeepLinkResult.Refused(LinkError.BAD_FINGERPRINT)
|
||||
}
|
||||
fp = hex
|
||||
}
|
||||
key == "host" && host == null ->
|
||||
host = parseAddrPort(value) ?: return DeepLinkResult.Refused(LinkError.BAD_HOST_PARAM)
|
||||
key == "launch" && launch == null -> {
|
||||
if (value.toByteArray(StandardCharsets.UTF_8).size > MAX_LAUNCH_LEN) {
|
||||
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "launch")
|
||||
}
|
||||
if (!isSafeLaunchId(value)) return DeepLinkResult.Refused(LinkError.BAD_LAUNCH_ID)
|
||||
launch = value
|
||||
}
|
||||
key == "profile" && profile == null -> {
|
||||
if (scalarCount(value) > MAX_PROFILE_LEN) {
|
||||
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "profile")
|
||||
}
|
||||
profile = value
|
||||
}
|
||||
key == "name" && name == null -> {
|
||||
if (scalarCount(value) > MAX_NAME_LEN) {
|
||||
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "name")
|
||||
}
|
||||
name = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return DeepLinkResult.Parsed(DeepLink(route, hostRef, fp, host, launch, profile, name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a link's host reference against the local store, in the documented order: stable
|
||||
* record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
|
||||
* the recovery path — a self-emitted shortcut that outlived the record it was written from
|
||||
* still lands on the right box (degraded to the confirmation sheet).
|
||||
*/
|
||||
fun resolveHost(link: DeepLink, hosts: List<KnownHost>): HostResolution {
|
||||
hosts.firstOrNull { it.id == link.hostRef }?.let { return HostResolution.Known(it) }
|
||||
val byName = hosts.filter { it.name.equals(link.hostRef, ignoreCase = true) }
|
||||
when (byName.size) {
|
||||
1 -> return HostResolution.Known(byName[0])
|
||||
0 -> Unit
|
||||
else -> return HostResolution.Ambiguous
|
||||
}
|
||||
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
|
||||
// other per-host lookup matches. The literal is only considered when the reference COULD be
|
||||
// an address: a stale record id must fall through to `host=` (or to a refusal), never be
|
||||
// offered as a box to dial.
|
||||
val literal = if (looksLikeAddress(link.hostRef)) parseAddrPort(link.hostRef) else null
|
||||
for ((addr, port) in listOfNotNull(literal, link.host)) {
|
||||
hosts.firstOrNull { it.address == addr && it.port == port }
|
||||
?.let { return HostResolution.Known(it) }
|
||||
}
|
||||
val fallback = literal ?: link.host ?: return HostResolution.Unresolvable
|
||||
return HostResolution.Unknown(fallback.first, fallback.second, link.name, link.fp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The self-emitted form for a saved host: id first (address-independent), with the address and
|
||||
* pin alongside so the link degrades to a confirmation sheet instead of a dead click when the
|
||||
* record is gone.
|
||||
*/
|
||||
fun forHost(host: KnownHost, launch: String? = null, profile: String? = null) = DeepLink(
|
||||
route = LinkRoute.CONNECT,
|
||||
hostRef = host.id,
|
||||
fp = host.fpHex.ifEmpty { null },
|
||||
host = host.address to host.port,
|
||||
launch = launch,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
/** The reserved first path segments — plus `pair`, reserved precisely so it can be refused. */
|
||||
private fun isRouteWord(s: String) = s.lowercase() in setOf("connect", "wake", "browse", "pair")
|
||||
|
||||
/**
|
||||
* Could this reference be a network address (an IP literal or a host name) rather than a record
|
||||
* id or a display name? Only then may an unmatched reference become "an unknown host at this
|
||||
* address". A stale record id is NOT an address: offering to dial a UUID as a hostname would
|
||||
* turn a wiped store into a confusing dead end instead of the `host=`-driven recovery.
|
||||
*/
|
||||
private fun looksLikeAddress(s: String): Boolean {
|
||||
val uuidShaped = s.length == 36 && s.withIndex().all { (i, c) ->
|
||||
if (i in setOf(8, 13, 18, 23)) c == '-' else isHex(c)
|
||||
}
|
||||
return !uuidShaped && s.isNotEmpty() &&
|
||||
s.all { it.isLetterOrDigit() && it.code < 128 || it in ".-_:[]" }
|
||||
}
|
||||
|
||||
/**
|
||||
* `addr`, `addr:port`, `[v6]`, `[v6]:port` — null when the port isn't a number. A bare IPv6
|
||||
* literal (`::1`) keeps its colons and takes the default port; anything else splits at the last
|
||||
* colon, like every other host-parsing site in the clients.
|
||||
*/
|
||||
private fun parseAddrPort(s: String): Pair<String, Int>? {
|
||||
if (s.isEmpty()) return null
|
||||
if (s.startsWith("[")) {
|
||||
val close = s.indexOf(']', 1)
|
||||
if (close < 0) return null
|
||||
val addr = s.substring(1, close)
|
||||
if (addr.isEmpty()) return null
|
||||
val tail = s.substring(close + 1)
|
||||
if (tail.isEmpty()) return addr to DEFAULT_PORT
|
||||
if (!tail.startsWith(":")) return null
|
||||
return addr to (tail.substring(1).toIntOrNull()?.takeIf { it in 1..65535 } ?: return null)
|
||||
}
|
||||
val lastColon = s.lastIndexOf(':')
|
||||
if (lastColon < 0) return s to DEFAULT_PORT
|
||||
val head = s.substring(0, lastColon)
|
||||
// `::1` and friends: the head still has a colon, so this isn't a port separator.
|
||||
if (head.contains(':')) return s to DEFAULT_PORT
|
||||
if (head.isEmpty()) return null
|
||||
val port = s.substring(lastColon + 1).toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
|
||||
return head to port
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch-id charset the whole product already agrees on: printable, non-space ASCII with no
|
||||
* shell metacharacters (Decky rides ids through Steam launch options as an env token, so a
|
||||
* quote or a backtick genuinely breaks something downstream). Validation only — the id is
|
||||
* opaque and the host matches it verbatim against its own library.
|
||||
*/
|
||||
private fun isSafeLaunchId(id: String): Boolean =
|
||||
id.isNotEmpty() && id.toByteArray(StandardCharsets.UTF_8)
|
||||
.all { it in 0x21..0x7e && it.toInt().toChar() !in "\"'\\$`" }
|
||||
|
||||
/**
|
||||
* Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
|
||||
* UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray newline
|
||||
* or a half-escape end up inside a filename or a log line.
|
||||
*/
|
||||
private fun decode(s: String): Decoded {
|
||||
val bytes = s.toByteArray(StandardCharsets.UTF_8)
|
||||
val out = java.io.ByteArrayOutputStream(bytes.size)
|
||||
var i = 0
|
||||
while (i < bytes.size) {
|
||||
val b = bytes[i]
|
||||
if (b == '%'.code.toByte()) {
|
||||
if (i + 2 >= bytes.size) return Decoded.Err(LinkError.BAD_ESCAPE)
|
||||
val hi = hexValue(bytes[i + 1].toInt().toChar())
|
||||
val lo = hexValue(bytes[i + 2].toInt().toChar())
|
||||
if (hi < 0 || lo < 0) return Decoded.Err(LinkError.BAD_ESCAPE)
|
||||
out.write(hi * 16 + lo)
|
||||
i += 3
|
||||
} else {
|
||||
out.write(b.toInt())
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
// REPORT, not the default REPLACE: `%FF` must be a refusal, never a U+FFFD that survives.
|
||||
val decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
val text = runCatching { decoder.decode(ByteBuffer.wrap(out.toByteArray())).toString() }
|
||||
.getOrNull() ?: return Decoded.Err(LinkError.BAD_ESCAPE)
|
||||
// `Char.isISOControl` is exactly Unicode's Cc category (C0 + DEL + C1), which is what the
|
||||
// Rust side rejects.
|
||||
if (text.any { it.isISOControl() }) return Decoded.Err(LinkError.CONTROL_CHAR)
|
||||
return Decoded.Ok(text)
|
||||
}
|
||||
|
||||
/** [decode]'s outcome — the decoded text, or which refusal it is. */
|
||||
private sealed interface Decoded {
|
||||
data class Ok(val text: String) : Decoded
|
||||
data class Err(val error: LinkError) : Decoded
|
||||
}
|
||||
|
||||
private fun hexValue(c: Char): Int = when (c) {
|
||||
in '0'..'9' -> c - '0'
|
||||
in 'a'..'f' -> c - 'a' + 10
|
||||
in 'A'..'F' -> c - 'A' + 10
|
||||
else -> -1
|
||||
}
|
||||
|
||||
private fun isHex(c: Char) = hexValue(c) >= 0
|
||||
|
||||
/** Unicode scalars, matching the Rust caps (which count `chars()`, not UTF-16 units). */
|
||||
private fun scalarCount(s: String) = s.codePointCount(0, s.length)
|
||||
|
||||
/**
|
||||
* Percent-encode for emission: unreserved characters plus `:` (legal in a query value and left
|
||||
* alone by Apple's `URLComponents`, so the three emitters agree on `steam:570`).
|
||||
*/
|
||||
internal fun encode(s: String): String {
|
||||
val out = StringBuilder(s.length)
|
||||
for (b in s.toByteArray(StandardCharsets.UTF_8)) {
|
||||
val c = b.toInt().toChar()
|
||||
if (c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c in "-._~:") {
|
||||
out.append(c)
|
||||
} else {
|
||||
out.append('%').append("%02X".format(b.toInt() and 0xFF))
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/** What the URL asks for. `WAKE`/`BROWSE` are reserved in the grammar and parse today. */
|
||||
enum class LinkRoute(val word: String) {
|
||||
CONNECT("connect"),
|
||||
WAKE("wake"),
|
||||
BROWSE("browse"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a URL was rejected. The [code] strings are the cross-language contract — the vector file names
|
||||
* them, and Swift and Rust report the same code for the same input.
|
||||
*/
|
||||
enum class LinkError(val code: String) {
|
||||
/** Not a `punktfunk://` (or `pf://`) URL at all — ignore it, don't warn. */
|
||||
NOT_OUR_SCHEME("not-our-scheme"),
|
||||
TOO_LONG("too-long"),
|
||||
UNKNOWN_ROUTE("unknown-route"),
|
||||
|
||||
/** `punktfunk://pair/…` — pairing is an interactive ceremony, never a link. */
|
||||
PAIR_REFUSED("pair-refused"),
|
||||
MISSING_HOST_REF("missing-host-ref"),
|
||||
|
||||
/** A `%` escape that isn't two hex digits, or a decode that isn't UTF-8. */
|
||||
BAD_ESCAPE("bad-escape"),
|
||||
|
||||
/** A control character survived decoding — no legitimate field contains one. */
|
||||
CONTROL_CHAR("control-char"),
|
||||
PARAM_TOO_LONG("param-too-long"),
|
||||
BAD_FINGERPRINT("bad-fingerprint"),
|
||||
BAD_HOST_PARAM("bad-host-param"),
|
||||
BAD_LAUNCH_ID("bad-launch-id"),
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed, validated link. Every field is already length- and charset-checked, so a consumer never
|
||||
* has to re-validate hostile input.
|
||||
*/
|
||||
data class DeepLink(
|
||||
val route: LinkRoute = LinkRoute.CONNECT,
|
||||
/** The host reference as written: a stable record id, a host name, or `addr[:port]`. */
|
||||
val hostRef: String,
|
||||
/** Expected host certificate fingerprint, lowercase hex (64 chars). */
|
||||
val fp: String? = null,
|
||||
/** Recovery address for a stable id that no longer resolves (store wiped, reinstall). */
|
||||
val host: Pair<String, Int>? = null,
|
||||
/** A store-qualified library id (`steam:570`) for the host to launch on arrival. */
|
||||
val launch: String? = null,
|
||||
/** A settings-profile reference (id, or a unique name) — one-off, never rebinding. */
|
||||
val profile: String? = null,
|
||||
/** Display label for the unknown-host confirmation sheet (external emitters). */
|
||||
val name: String? = null,
|
||||
) {
|
||||
/** The canonical URL for this link — always `punktfunk://`, never the `pf://` alias. */
|
||||
fun toUrl(): String {
|
||||
val sb = StringBuilder("punktfunk://${route.word}/${DeepLinks.encode(hostRef)}")
|
||||
var sep = '?'
|
||||
fun push(key: String, value: String) {
|
||||
sb.append(sep).append(key).append('=').append(DeepLinks.encode(value))
|
||||
sep = '&'
|
||||
}
|
||||
fp?.let { push("fp", it) }
|
||||
host?.let { (addr, port) ->
|
||||
push(
|
||||
"host",
|
||||
when {
|
||||
port == DeepLinks.DEFAULT_PORT -> addr
|
||||
addr.contains(':') -> "[$addr]:$port" // literal IPv6 needs its brackets back
|
||||
else -> "$addr:$port"
|
||||
},
|
||||
)
|
||||
}
|
||||
launch?.let { push("launch", it) }
|
||||
profile?.let { push("profile", it) }
|
||||
name?.let { push("name", it) }
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this link's `fp` contradicts what we have pinned for that host — the link is stale
|
||||
* or lying, and the only safe answer is a hard refusal.
|
||||
*/
|
||||
fun pinConflict(host: KnownHost): Boolean =
|
||||
fp != null && host.fpHex.isNotEmpty() && !fp.equals(host.fpHex, ignoreCase = true)
|
||||
}
|
||||
|
||||
/** A parse outcome: a link, or a refusal carrying the shared code. */
|
||||
sealed interface DeepLinkResult {
|
||||
data class Parsed(val link: DeepLink) : DeepLinkResult
|
||||
|
||||
data class Refused(val error: LinkError, val detail: String? = null) : DeepLinkResult {
|
||||
/**
|
||||
* A sentence for the notice a refusing front-end shows. Deliberately names what failed: a
|
||||
* shortcut that can't honour its reference says so instead of streaming with the wrong one.
|
||||
*/
|
||||
fun message(): String = when (error) {
|
||||
LinkError.NOT_OUR_SCHEME -> "That isn't a Punktfunk link."
|
||||
LinkError.TOO_LONG -> "That link is too long to be genuine."
|
||||
LinkError.UNKNOWN_ROUTE -> "Punktfunk links can't do “$detail”."
|
||||
LinkError.PAIR_REFUSED ->
|
||||
"Pairing can't be done from a link — pair the host in Punktfunk first."
|
||||
LinkError.MISSING_HOST_REF -> "That link doesn't say which host to use."
|
||||
LinkError.BAD_ESCAPE, LinkError.CONTROL_CHAR -> "That link is malformed and was ignored."
|
||||
LinkError.PARAM_TOO_LONG -> "That link's “$detail” value is too long."
|
||||
LinkError.BAD_FINGERPRINT -> "That link's host fingerprint isn't a valid one."
|
||||
LinkError.BAD_HOST_PARAM -> "That link's host address isn't valid."
|
||||
LinkError.BAD_LAUNCH_ID -> "That link's game id isn't a valid one."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the local host store made of a link's references. */
|
||||
sealed interface HostResolution {
|
||||
/** A record we already trust (subject to [DeepLink.pinConflict]). */
|
||||
data class Known(val host: KnownHost) : HostResolution
|
||||
|
||||
/**
|
||||
* No record, but the link says where to dial: the confirmation sheet's input, from which the
|
||||
* normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
|
||||
*/
|
||||
data class Unknown(
|
||||
val address: String,
|
||||
val port: Int,
|
||||
val name: String?,
|
||||
val fp: String?,
|
||||
) : HostResolution
|
||||
|
||||
/** The name matched more than one saved host — refuse with a notice, never guess. */
|
||||
data object Ambiguous : HostResolution
|
||||
|
||||
/** A reference that resolves to nothing and carries no address to fall back on. */
|
||||
data object Unresolvable : HostResolution
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
package io.unom.punktfunk.kit.security
|
||||
|
||||
import android.content.Context
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A host the user has trusted (pinned). [fpHex] is the pinned host-cert SHA-256 (64-hex); [paired]
|
||||
* is true when trust was established via the SPAKE2 PIN ceremony (vs trust-on-first-use).
|
||||
*
|
||||
* [id] is the record's **stable identity** — minted once, never changed, and the key this record is
|
||||
* stored under. Everything that needs to point AT a host (a settings-profile binding, a pinned
|
||||
* card, a `punktfunk://` link) points at the id, so renaming a host or moving it to a new address
|
||||
* doesn't strand those references. Mirrors the Apple client's `StoredHost.id` and the Rust
|
||||
* `KnownHost.id`; the shape is a lowercase UUID v4, one grammar on every platform.
|
||||
*/
|
||||
data class KnownHost(
|
||||
val address: String,
|
||||
@@ -18,37 +26,85 @@ data class KnownHost(
|
||||
* online, so the client can wake it once it sleeps. Empty until first learned.
|
||||
*/
|
||||
val mac: List<String> = emptyList(),
|
||||
/**
|
||||
* The host's OS-identity chain (`windows` | `linux/<family>/<id>`, ...) learned from its mDNS
|
||||
* `os` TXT while online, so the card's OS icon survives the host going to sleep. Empty until
|
||||
* first learned (or forever, against an older host).
|
||||
*/
|
||||
val os: String = "",
|
||||
/** Stable record identity — see the class doc. Minted here for a genuinely new record. */
|
||||
val id: String = newRecordId(),
|
||||
/**
|
||||
* Sync text copied on this device to this host and back while streaming. **A property of the
|
||||
* host, not of the stream** (design/client-settings-profiles.md §3, tier H): it is a trust
|
||||
* decision about that machine, so it is never in a settings profile and never global — the
|
||||
* work box and the couch box get their own answers. Only effective when the host advertises
|
||||
* the clipboard capability; the protocol is opt-in per session either way.
|
||||
*/
|
||||
val clipboardSync: Boolean = true,
|
||||
/**
|
||||
* The settings profile a plain tap on this host connects with — `null` (or an id whose profile
|
||||
* was deleted) means the global defaults, i.e. today's behaviour. A dangling id is never an
|
||||
* error and never blocks a connect.
|
||||
*/
|
||||
val profileId: String? = null,
|
||||
/**
|
||||
* Profiles pinned as their own cards for this host (design §5.2a). Presentation only: order is
|
||||
* card order, and this is NOT the default binding ([profileId] is). Duplicates and profiles
|
||||
* that no longer exist are dropped when the cards are rendered.
|
||||
*/
|
||||
val pinnedProfileIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Persists trusted hosts — the pinned-fingerprint store *and* the saved-hosts list — keyed by
|
||||
* `address:port`. Replaces the old fp-only PinStore so a discovered and a manually-typed connection
|
||||
* to the same host share one trust record (and so saved hosts can be listed + reconnected). Plain
|
||||
* `SharedPreferences` in app-private storage: pinned fingerprints are public host identities, not
|
||||
* secrets; the property we need is integrity, which app sandboxing provides.
|
||||
* [KnownHost.id]. Plain `SharedPreferences` in app-private storage: pinned fingerprints are public
|
||||
* host identities, not secrets; the property we need is integrity, which app sandboxing provides.
|
||||
*
|
||||
* Records used to be keyed by `"address:port"`, which meant editing a host's address had to
|
||||
* re-key its record (delete + write) or leave a ghost behind, and meant nothing could hold a
|
||||
* durable reference to a host. Keying by the minted stable id retires both. [migrate] moves an
|
||||
* existing store over in one pass — see its doc for what else rides along.
|
||||
*/
|
||||
class KnownHostStore(context: Context) {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences("punktfunk_hosts", Context.MODE_PRIVATE)
|
||||
context.applicationContext.getSharedPreferences(PREFS_HOSTS, Context.MODE_PRIVATE)
|
||||
|
||||
// The pref key is just a unique id; address/port are also stored in the value so an IPv6
|
||||
// address (which contains colons) round-trips without parsing the key.
|
||||
private fun key(address: String, port: Int) = "$address:$port"
|
||||
init {
|
||||
migrateIfNeeded(context)
|
||||
}
|
||||
|
||||
/** The trusted record for [address]:[port], or `null` if this host has never been trusted. */
|
||||
fun get(address: String, port: Int): KnownHost? =
|
||||
prefs.getString(key(address, port), null)?.let(::parse)
|
||||
all().firstOrNull { it.address == address && it.port == port }
|
||||
|
||||
/** Pin (or update) a trusted host — upsert by `address:port`. */
|
||||
/** The trusted record with this stable [id], or `null` — the lookup a binding or link uses. */
|
||||
fun byId(id: String): KnownHost? = prefs.getString(id, null)?.let(::parse)
|
||||
|
||||
/**
|
||||
* Pin (or update) a trusted host — upsert by [KnownHost.id]. An edit that moves the address or
|
||||
* port is a plain save now: the key is the identity, not the address.
|
||||
*/
|
||||
fun save(host: KnownHost) {
|
||||
val json = JSONObject()
|
||||
.put("addr", host.address)
|
||||
.put("port", host.port)
|
||||
.put("name", host.name)
|
||||
.put("fp", host.fpHex.lowercase())
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
||||
prefs.edit().putString(host.id, encode(host)).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust (or re-trust) the host at [address]:[port] with the fingerprint it presented.
|
||||
*
|
||||
* When a record already exists there — a re-pair after the host's identity changed, an
|
||||
* approval that upgrades a TOFU record to paired — it keeps its identity and everything the
|
||||
* user set on it: the stable [KnownHost.id] (so profile bindings, pinned cards and any
|
||||
* `punktfunk://` shortcut still point at it), the per-host clipboard decision, the binding,
|
||||
* the pins and the learned MACs. Only the name, pin and paired flag are refreshed. Returns the
|
||||
* stored record.
|
||||
*/
|
||||
fun trust(address: String, port: Int, name: String, fpHex: String, paired: Boolean): KnownHost {
|
||||
val existing = get(address, port)
|
||||
val host = existing?.copy(name = name, fpHex = fpHex, paired = paired)
|
||||
?: KnownHost(address, port, name, fpHex, paired)
|
||||
save(host)
|
||||
return host
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,30 +119,56 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(mac = mac))
|
||||
}
|
||||
|
||||
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(address: String, port: Int) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
}
|
||||
|
||||
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
|
||||
fun rename(address: String, port: Int, newName: String) {
|
||||
val h = get(address, port) ?: return
|
||||
save(h.copy(name = newName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
|
||||
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
|
||||
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
|
||||
* Learn/refresh a saved host's OS-identity chain from its live advert — same contract as
|
||||
* [learnMac]: no-op when unsaved, empty, or unchanged.
|
||||
*/
|
||||
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||
save(updated)
|
||||
fun learnOs(address: String, port: Int, os: String) {
|
||||
if (os.isEmpty()) return
|
||||
val h = get(address, port) ?: return
|
||||
if (h.os == os) return
|
||||
save(h.copy(os = os))
|
||||
}
|
||||
|
||||
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(host: KnownHost) {
|
||||
prefs.edit().remove(host.id).apply()
|
||||
}
|
||||
|
||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||
fun all(): List<KnownHost> =
|
||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||
fun all(): List<KnownHost> = prefs.all
|
||||
.filterKeys { it != K_SCHEMA }
|
||||
.values
|
||||
.mapNotNull { (it as? String)?.let(::parse) }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
|
||||
/**
|
||||
* One-time move from the `"address:port"`-keyed schema to id-keyed records, run on first
|
||||
* construction after the upgrade and never again ([K_SCHEMA] records that it happened).
|
||||
*
|
||||
* It is deliberately ONE pass, not three: the store is being rewritten anyway, and every extra
|
||||
* migration pass is another chance to strand somebody's hosts. So the same pass mints the
|
||||
* stable id, re-keys the record onto it, and copies the retiring GLOBAL clipboard-sync setting
|
||||
* onto every host — behaviour-preserving, since every host was following that one value.
|
||||
*/
|
||||
private fun migrateIfNeeded(context: Context) {
|
||||
if (prefs.getInt(K_SCHEMA, 0) >= SCHEMA_VERSION) return
|
||||
val settings =
|
||||
context.applicationContext.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE)
|
||||
val result = migrate(prefs.all, settings.getBoolean(K_GLOBAL_CLIPBOARD_SYNC, true))
|
||||
// `commit`, not `apply`: the re-keyed records and the schema flag are one atomic write to
|
||||
// disk, and the global below is only retired once that write has landed. With `apply` a
|
||||
// process death in between could drop the old global while the hosts that were supposed to
|
||||
// inherit it were still only in memory. Once, on one small file, on an upgrade.
|
||||
val written = prefs.edit().apply {
|
||||
result.removals.forEach(::remove)
|
||||
result.writes.forEach { (k, v) -> putString(k, v) }
|
||||
putInt(K_SCHEMA, SCHEMA_VERSION)
|
||||
}.commit()
|
||||
if (written && settings.contains(K_GLOBAL_CLIPBOARD_SYNC)) {
|
||||
settings.edit().remove(K_GLOBAL_CLIPBOARD_SYNC).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(s: String): KnownHost? = runCatching {
|
||||
val j = JSONObject(s)
|
||||
@@ -97,10 +179,67 @@ class KnownHostStore(context: Context) {
|
||||
fpHex = j.getString("fp"),
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
os = j.optString("os", ""),
|
||||
// A record without an id can only be one this build wrote before the migration ran, or
|
||||
// a hand-edited file; minting here keeps the parse total rather than dropping a host.
|
||||
id = j.optString("id", "").ifEmpty { newRecordId() },
|
||||
clipboardSync = j.optBoolean("clip", true),
|
||||
profileId = j.optString("profile", "").ifEmpty { null },
|
||||
pinnedProfileIds = stringList(j.optJSONArray("pins")),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
/** The prefs file holding the host records. */
|
||||
private const val PREFS_HOSTS = "punktfunk_hosts"
|
||||
|
||||
/** The app's settings file — read once by [migrate] for the retiring global. */
|
||||
private const val PREFS_SETTINGS = "punktfunk_settings"
|
||||
|
||||
/**
|
||||
* The global clipboard-sync key this migration retires. Clipboard sync is a decision about
|
||||
* a HOST (design §3, tier H), so it lives on the record now; the global is read once, to
|
||||
* seed every host, and then deleted.
|
||||
*/
|
||||
private const val K_GLOBAL_CLIPBOARD_SYNC = "clipboard_sync"
|
||||
|
||||
/** Schema marker inside the hosts file. Reserved — never a host record. */
|
||||
private const val K_SCHEMA = "__schema"
|
||||
|
||||
/** 1 = id-keyed records with per-host clipboard sync, profile binding and pins. */
|
||||
private const val SCHEMA_VERSION = 1
|
||||
|
||||
/** What [migrate] decided: entries to write, and (old) keys to drop. */
|
||||
data class Migration(val writes: Map<String, String>, val removals: Set<String>)
|
||||
|
||||
/**
|
||||
* The pure half of the store migration, over a raw prefs snapshot ([entries] as returned by
|
||||
* `SharedPreferences.all`) — so it can be tested against a real pre-migration blob without
|
||||
* an Android runtime.
|
||||
*
|
||||
* Every host record survives with its address, port, name, pin, paired flag and MACs
|
||||
* intact, gains a minted [KnownHost.id], moves to that key, and takes
|
||||
* [globalClipboardSync] as its own [KnownHost.clipboardSync]. Entries that aren't parsable
|
||||
* host records are left alone: they were already invisible (`all()` skipped them), and
|
||||
* deleting things we don't understand is not this pass's job.
|
||||
*/
|
||||
fun migrate(entries: Map<String, Any?>, globalClipboardSync: Boolean): Migration {
|
||||
val writes = mutableMapOf<String, String>()
|
||||
val removals = mutableSetOf<String>()
|
||||
for ((key, raw) in entries) {
|
||||
if (key == K_SCHEMA) continue
|
||||
val json = (raw as? String)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
?: continue
|
||||
if (!json.has("addr") || !json.has("port")) continue
|
||||
val id = json.optString("id", "").ifEmpty { newRecordId() }
|
||||
json.put("id", id)
|
||||
json.put("clip", json.optBoolean("clip", globalClipboardSync))
|
||||
writes[id] = json.toString()
|
||||
if (key != id) removals += key
|
||||
}
|
||||
return Migration(writes, removals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
|
||||
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
|
||||
@@ -116,5 +255,32 @@ class KnownHostStore(context: Context) {
|
||||
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored JSON for one record — also the shape [migrate] upgrades into. */
|
||||
internal fun encode(host: KnownHost): String = JSONObject()
|
||||
.put("id", host.id)
|
||||
.put("addr", host.address)
|
||||
.put("port", host.port)
|
||||
.put("name", host.name)
|
||||
.put("fp", host.fpHex.lowercase())
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
.put("os", host.os)
|
||||
.put("clip", host.clipboardSync)
|
||||
.put("profile", host.profileId ?: "")
|
||||
.put("pins", JSONArray(host.pinnedProfileIds))
|
||||
.toString()
|
||||
|
||||
private fun stringList(a: JSONArray?): List<String> {
|
||||
if (a == null) return emptyList()
|
||||
return (0 until a.length()).mapNotNull { a.optString(it, "").ifEmpty { null } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh stable record identity: a lowercase UUID v4, the shape the Apple client's `StoredHost.id`
|
||||
* and the Rust `KnownHost.id` already use, so a `punktfunk://` host reference is one grammar
|
||||
* everywhere.
|
||||
*/
|
||||
fun newRecordId(): String = UUID.randomUUID().toString()
|
||||
|
||||
@@ -33,6 +33,39 @@ class ParseRecordTest {
|
||||
assertEquals(false, h.pairingRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sevenFieldRecordHasNoOs() {
|
||||
// A native lib predating the 8th field: `os` defaults empty, everything else parses.
|
||||
val h = parseHostRecord(rec("k", "n", "10.0.0.5", "9777", "", "optional", "aa:bb:cc:dd:ee:ff"))!!
|
||||
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), h.mac)
|
||||
assertEquals("", h.os)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eighthFieldCarriesTheOsChain() {
|
||||
val h = parseHostRecord(
|
||||
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/fedora/bazzite"),
|
||||
)!!
|
||||
assertEquals("linux/fedora/bazzite", h.os)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun osChainIsSanitizedAsUntrustedInput() {
|
||||
// mDNS is unauthenticated: junk is dropped, case folds, token/count caps apply.
|
||||
val h = parseHostRecord(rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "Linux/Fe do!ra"))!!
|
||||
assertEquals("linux/fedora", h.os)
|
||||
assertEquals("", sanitizeOsChain("///!!!"))
|
||||
assertEquals("a/b/c/d/e", sanitizeOsChain("a/b/c/d/e/f/g"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconWalkIsMostSpecificFirstWithAliases() {
|
||||
assertEquals(listOf("bazzite", "fedora", "linux"), osIconTokens("linux/fedora/bazzite"))
|
||||
assertEquals(listOf("steam", "arch", "linux"), osIconTokens("linux/arch/steamos"))
|
||||
assertEquals(listOf("apple"), osIconTokens("macos"))
|
||||
assertTrue(osIconTokens("").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyKeyFallsBackToAddrPort() {
|
||||
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package io.unom.punktfunk.kit.link
|
||||
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* **The cross-language contract.** `clients/shared/deeplink-vectors.json` is consumed verbatim by
|
||||
* the Rust, Swift and Kotlin suites, so the three parsers cannot drift into three different
|
||||
* security postures — a URL that Rust refuses as a control-character smuggle must not quietly
|
||||
* parse here. Any new case belongs in that file, not in this one.
|
||||
*/
|
||||
class DeepLinkVectorTest {
|
||||
private val vectors: JSONObject by lazy {
|
||||
// Gradle runs a unit test with the module directory as its working directory, so the shared
|
||||
// file is two levels up (clients/android/kit → clients/shared). Resolved rather than
|
||||
// copied: a copy would be a fourth contract, free to go stale.
|
||||
val file = File("../../shared/deeplink-vectors.json")
|
||||
assertTrue(
|
||||
"the shared vector file must be reachable at ${file.absolutePath}",
|
||||
file.isFile,
|
||||
)
|
||||
JSONObject(file.readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everySharedVectorAgrees() {
|
||||
val cases = vectors.getJSONArray("cases")
|
||||
assertTrue("the vector file is the contract; keep it rich", cases.length() > 20)
|
||||
for (i in 0 until cases.length()) {
|
||||
val case = cases.getJSONObject(i)
|
||||
val name = case.getString("name")
|
||||
val result = DeepLinks.parse(case.getString("url"))
|
||||
if (case.has("error")) {
|
||||
val refusal = result as? DeepLinkResult.Refused
|
||||
?: throw AssertionError("$name: expected ${case.getString("error")}, parsed ok")
|
||||
assertEquals(name, case.getString("error"), refusal.error.code)
|
||||
assertTrue("$name: a refusal must be explainable", refusal.message().isNotEmpty())
|
||||
continue
|
||||
}
|
||||
val link = (result as? DeepLinkResult.Parsed)?.link
|
||||
?: throw AssertionError("$name: refused, expected a parse — $result")
|
||||
val want = case.getJSONObject("expect")
|
||||
assertEquals(name, want.getString("route"), link.route.word)
|
||||
assertEquals(name, want.getString("host_ref"), link.hostRef)
|
||||
assertEquals("$name fp", want.optStringOrNull("fp"), link.fp)
|
||||
assertEquals("$name launch", want.optStringOrNull("launch"), link.launch)
|
||||
assertEquals("$name profile", want.optStringOrNull("profile"), link.profile)
|
||||
assertEquals("$name name", want.optStringOrNull("name"), link.name)
|
||||
assertEquals("$name host_addr", want.optStringOrNull("host_addr"), link.host?.first)
|
||||
assertEquals(
|
||||
"$name host_port",
|
||||
if (want.has("host_port")) want.getInt("host_port") else null,
|
||||
link.host?.second,
|
||||
)
|
||||
if (case.has("emit")) assertEquals("$name emit", case.getString("emit"), link.toUrl())
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||
if (has(key)) getString(key) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution and emission — the half the vector file can't cover, because it depends on what is in
|
||||
* THIS device's host store. The rules are the one-click contract in resolution form: an id beats a
|
||||
* name beats an address, an ambiguous name refuses rather than guesses, and a link whose record is
|
||||
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying.
|
||||
*/
|
||||
class DeepLinkResolutionTest {
|
||||
private val fp = "a".repeat(64)
|
||||
private val desk = host("Desk", "192.168.1.50", "11111111-2222-4333-8444-555555555555", fp)
|
||||
private val hosts = listOf(
|
||||
desk,
|
||||
host("Couch", "192.168.1.60", "66666666-7777-4888-8999-aaaaaaaaaaaa", ""),
|
||||
host("Couch", "192.168.1.61", "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", ""),
|
||||
)
|
||||
|
||||
private fun resolve(url: String) =
|
||||
DeepLinks.resolveHost((DeepLinks.parse(url) as DeepLinkResult.Parsed).link, hosts)
|
||||
|
||||
@Test
|
||||
fun idBeatsNameBeatsAddress() {
|
||||
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Known).host)
|
||||
// Two hosts answer to "Couch" — refuse with a notice, never pick one.
|
||||
assertEquals(HostResolution.Ambiguous, resolve("punktfunk://connect/couch"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleIdRecoversThroughTheHostParameter() {
|
||||
val stale = "00000000-0000-4000-8000-000000000000"
|
||||
assertEquals(
|
||||
desk,
|
||||
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Known).host,
|
||||
)
|
||||
// …but a stale id is NOT a hostname: dialing "00000000-…" would be a confusing dead end
|
||||
// rather than the recovery the grammar specifies.
|
||||
assertEquals(HostResolution.Unresolvable, resolve("punktfunk://connect/$stale"))
|
||||
// Neither is a display name that can't be an address.
|
||||
assertEquals(HostResolution.Unresolvable, resolve("punktfunk://connect/Basement%20PC"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownHostBecomesTheConfirmationSheetsInput() {
|
||||
val r = resolve("punktfunk://connect/10.0.0.9:7000?name=Studio&fp=$fp")
|
||||
assertEquals(HostResolution.Unknown("10.0.0.9", 7000, "Studio", fp), r)
|
||||
// An mDNS/DNS name we've never saved is offered the same way — the sheet, never a connect.
|
||||
assertEquals(
|
||||
HostResolution.Unknown("nas.local", DeepLinks.DEFAULT_PORT, null, null),
|
||||
resolve("punktfunk://connect/nas.local"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPinThatContradictsTheStoredOneIsTheLinkLying() {
|
||||
fun link(url: String) = (DeepLinks.parse(url) as DeepLinkResult.Parsed).link
|
||||
assertTrue(link("punktfunk://connect/desk?fp=${"b".repeat(64)}").pinConflict(desk))
|
||||
assertFalse(link("punktfunk://connect/desk?fp=$fp").pinConflict(desk))
|
||||
// No pin stored (an address-only record) → nothing to contradict; the trust flow runs.
|
||||
assertFalse(link("punktfunk://connect/desk?fp=${"b".repeat(64)}").pinConflict(hosts[1]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfEmittedLinksRoundTripAndSurviveAWipedStore() {
|
||||
val h = desk.copy(port = 7777)
|
||||
val link = DeepLinks.forHost(h, launch = "steam:570", profile = "aaaaaaaaaaaa")
|
||||
val url = link.toUrl()
|
||||
assertEquals(
|
||||
"punktfunk://connect/${h.id}?fp=$fp&host=192.168.1.50:7777" +
|
||||
"&launch=steam:570&profile=aaaaaaaaaaaa",
|
||||
url,
|
||||
)
|
||||
assertEquals(link, (DeepLinks.parse(url) as DeepLinkResult.Parsed).link)
|
||||
|
||||
// Names with spaces and non-ASCII survive the round trip.
|
||||
val labelled = DeepLink(hostRef = "Wohnzimmer PC", name = "Büro · Mac")
|
||||
assertTrue(labelled.toUrl().startsWith("punktfunk://connect/Wohnzimmer%20PC?"))
|
||||
assertEquals(labelled, (DeepLinks.parse(labelled.toUrl()) as DeepLinkResult.Parsed).link)
|
||||
|
||||
// An emitted IPv6 host parameter comes back bracketed, so it parses again.
|
||||
val v6 = DeepLink(hostRef = "x", host = "::1" to 1234)
|
||||
assertEquals(v6.host, (DeepLinks.parse(v6.toUrl()) as DeepLinkResult.Parsed).link.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHostWithNoPinEmitsNoFingerprint() {
|
||||
assertNull(DeepLinks.forHost(hosts[1]).fp)
|
||||
}
|
||||
|
||||
private fun host(name: String, addr: String, id: String, fp: String) =
|
||||
KnownHost(addr, DeepLinks.DEFAULT_PORT, name, fp, paired = true, id = id)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package io.unom.punktfunk.kit.security
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
|
||||
@@ -30,4 +34,119 @@ class KnownHostStoreTest {
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
|
||||
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodedRecordCarriesTheOsChainAndLegacyRecordsReadBackEmpty() {
|
||||
val h = KnownHost("10.0.0.5", 9777, "HTPC", "a".repeat(64), true, os = "linux/fedora/bazzite")
|
||||
val j = JSONObject(KnownHostStore.encode(h))
|
||||
assertEquals("linux/fedora/bazzite", j.getString("os"))
|
||||
// A record written before the field existed has no "os" key; the parse contract
|
||||
// (optString) reads it back as empty — same additive rule as every late field.
|
||||
val legacy = JSONObject().put("addr", "10.0.0.5").put("port", 9777).toString()
|
||||
assertEquals("", JSONObject(legacy).optString("os", ""))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The store migration, run against a REAL pre-migration prefs blob — records exactly as the
|
||||
* `"address:port"`-keyed store wrote them, IPv6 and all. It runs once against live user data on
|
||||
* every upgraded install, so the property under test is blunt: **every host survives**, with its
|
||||
* trust intact and the retiring global clipboard setting carried onto it.
|
||||
*/
|
||||
class KnownHostMigrationTest {
|
||||
/** Verbatim shape of the old writer: no `id`, no `clip`, MACs as a comma-joined string. */
|
||||
private fun legacy(addr: String, port: Int, name: String, fp: String, paired: Boolean, mac: String) =
|
||||
JSONObject()
|
||||
.put("addr", addr)
|
||||
.put("port", port)
|
||||
.put("name", name)
|
||||
.put("fp", fp)
|
||||
.put("paired", paired)
|
||||
.put("mac", mac)
|
||||
.toString()
|
||||
|
||||
private val preMigration: Map<String, Any?> = mapOf(
|
||||
"192.168.1.42:9777" to legacy("192.168.1.42", 9777, "Living Room PC", "a".repeat(64), true, "aa:bb:cc:dd:ee:ff"),
|
||||
"192.168.1.50:9777" to legacy("192.168.1.50", 9777, "Office", "b".repeat(64), false, ""),
|
||||
// An IPv6 host: the old key contains colons of its own, which is exactly why the record
|
||||
// always carried its address in the VALUE rather than parsing it back out of the key.
|
||||
"fd00::1:9777" to legacy("fd00::1", 9777, "Basement", "c".repeat(64), true, ""),
|
||||
)
|
||||
|
||||
private fun migrated(globalClipboard: Boolean): List<JSONObject> =
|
||||
KnownHostStore.migrate(preMigration, globalClipboard).writes.values.map { JSONObject(it) }
|
||||
|
||||
@Test
|
||||
fun everyHostSurvivesWithItsTrustIntact() {
|
||||
val result = KnownHostStore.migrate(preMigration, globalClipboardSync = true)
|
||||
assertEquals(3, result.writes.size)
|
||||
// Every old key is dropped — none of them is a valid new key (they aren't ids).
|
||||
assertEquals(preMigration.keys, result.removals)
|
||||
|
||||
val byName = result.writes.values.map { JSONObject(it) }.associateBy { it.getString("name") }
|
||||
assertEquals(setOf("Living Room PC", "Office", "Basement"), byName.keys)
|
||||
|
||||
val living = byName.getValue("Living Room PC")
|
||||
assertEquals("192.168.1.42", living.getString("addr"))
|
||||
assertEquals(9777, living.getInt("port"))
|
||||
assertEquals("a".repeat(64), living.getString("fp"))
|
||||
assertTrue(living.getBoolean("paired"))
|
||||
assertEquals("aa:bb:cc:dd:ee:ff", living.getString("mac"))
|
||||
|
||||
// The IPv6 address round-trips out of the value, untouched by the key rewrite.
|
||||
assertEquals("fd00::1", byName.getValue("Basement").getString("addr"))
|
||||
assertFalse(byName.getValue("Office").getBoolean("paired"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachRecordIsRekeyedOntoItsOwnMintedId() {
|
||||
val result = KnownHostStore.migrate(preMigration, globalClipboardSync = true)
|
||||
// The key IS the record's id, and the ids are distinct — two hosts must never collide onto
|
||||
// one record (which would silently lose one of them).
|
||||
result.writes.forEach { (key, json) -> assertEquals(key, JSONObject(json).getString("id")) }
|
||||
assertEquals(3, result.writes.keys.size)
|
||||
// The minted shape is the cross-platform one: a lowercase UUID.
|
||||
result.writes.keys.forEach { id ->
|
||||
assertEquals(36, id.length)
|
||||
assertEquals(id.lowercase(), id)
|
||||
assertEquals(listOf(8, 4, 4, 4, 12), id.split("-").map { it.length })
|
||||
}
|
||||
assertNotEquals(newRecordId(), newRecordId())
|
||||
}
|
||||
|
||||
/**
|
||||
* The behaviour-preserving half: clipboard sync was one global that every host followed, so
|
||||
* after the migration every host must still be following the value that global held — on or
|
||||
* off. This is the assertion that would catch a migration silently defaulting everyone to on.
|
||||
*/
|
||||
@Test
|
||||
fun theRetiringGlobalClipboardSettingLandsOnEveryHost() {
|
||||
migrated(globalClipboard = true).forEach { assertTrue(it.getBoolean("clip")) }
|
||||
migrated(globalClipboard = false).forEach { assertFalse(it.getBoolean("clip")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationIsIdempotentOverItsOwnOutput() {
|
||||
val once = KnownHostStore.migrate(preMigration, globalClipboardSync = false)
|
||||
val twice = KnownHostStore.migrate(once.writes, globalClipboardSync = true)
|
||||
// Already-keyed records keep their ids and their per-host value: a second pass (a downgrade
|
||||
// then re-upgrade, a schema flag lost) must not re-mint ids that bindings point at, and must
|
||||
// not resurrect the global over a per-host answer the user has since changed.
|
||||
assertEquals(once.writes, twice.writes)
|
||||
assertTrue(twice.removals.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entriesThatArentHostRecordsAreLeftAlone() {
|
||||
val junk = mapOf(
|
||||
"some_unrelated_flag" to true,
|
||||
"half_a_record" to JSONObject().put("name", "no address").toString(),
|
||||
"not_even_json" to "{{{",
|
||||
)
|
||||
val result = KnownHostStore.migrate(junk, globalClipboardSync = true)
|
||||
// They were already invisible to `all()`; deleting what we don't understand isn't this
|
||||
// pass's job, and writing them as hosts would invent records out of nothing.
|
||||
assertTrue(result.writes.isEmpty())
|
||||
assertTrue(result.removals.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ 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::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
||||
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
|
||||
|
||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||
@@ -253,6 +253,12 @@ pub(super) fn run_async(
|
||||
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||
let mut work_accum_ns: i64 = 0;
|
||||
let mut fatal = false;
|
||||
// No-output backstop (see [`NO_OUTPUT_PATIENCE`]): the last time the decoder handed us a frame,
|
||||
// and how many AUs it had been fed by then. Silence only counts while AUs are actually going in,
|
||||
// so an idle stream never asks for anything. Seeded at start so a decoder that never produces a
|
||||
// first frame — the missed opening IDR — is caught by the same window.
|
||||
let mut last_output = Instant::now();
|
||||
let mut fed_at_output: u64 = 0;
|
||||
|
||||
while !shutdown.load(Ordering::Relaxed) && !fatal {
|
||||
// Block for the next event (idle wait — excluded from the work tally). The short timeout
|
||||
@@ -344,6 +350,12 @@ pub(super) fn run_async(
|
||||
h.report_actual(work_accum_ns);
|
||||
}
|
||||
work_accum_ns = 0;
|
||||
// The one line that separates "the stream never reached glass" from "it reached glass
|
||||
// and looked wrong" — the periodic tally below only starts at 300 frames, which is no
|
||||
// help at all on a session that renders none.
|
||||
if rendered == 1 {
|
||||
log::info!("decode: first frame presented (fed={fed} discarded={discarded})");
|
||||
}
|
||||
if rendered > 0 && rendered % 300 == 0 {
|
||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||
}
|
||||
@@ -356,7 +368,27 @@ pub(super) fn run_async(
|
||||
if aus_dropped > 0 {
|
||||
gate.arm(now);
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||
// Fed but silent: the decoder is holding nothing it can decode — the opening IDR never
|
||||
// reached it, or its reference chain is gone. Ask for a fresh one and arm the freeze, so the
|
||||
// concealment it may start emitting on the way back is withheld until a clean re-anchor
|
||||
// (`gate.poll` keeps re-asking on the deadline until one arrives).
|
||||
let starved = !had_output
|
||||
&& fed > fed_at_output
|
||||
&& now.duration_since(last_output) >= NO_OUTPUT_PATIENCE;
|
||||
if had_output {
|
||||
last_output = now;
|
||||
fed_at_output = fed;
|
||||
} else if starved {
|
||||
log::warn!(
|
||||
"decode: no output for {} ms with {} AU(s) fed — requesting a re-anchor keyframe",
|
||||
now.duration_since(last_output).as_millis(),
|
||||
fed - fed_at_output
|
||||
);
|
||||
gate.arm(now);
|
||||
last_output = now; // one request per patience window, not per iteration
|
||||
fed_at_output = fed;
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0 || starved)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
|
||||
@@ -128,7 +128,9 @@ pub(super) fn install_render_callback(
|
||||
/// deleting the codec stops its internal threads, so no callback can still be running (or run
|
||||
/// later) against this pointer.
|
||||
pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
||||
drop(Arc::from_raw(ud));
|
||||
// SAFETY: `ud` is the pointer `install_render_callback` leaked from `Arc::into_raw`, and this
|
||||
// function's contract is that it is reclaimed exactly once, after the codec is gone.
|
||||
unsafe { drop(Arc::from_raw(ud)) };
|
||||
}
|
||||
|
||||
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
|
||||
@@ -146,7 +148,10 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
media_time_us: i64,
|
||||
system_nano: i64,
|
||||
) {
|
||||
let t = &*(userdata as *const DisplayTracker);
|
||||
// SAFETY: the platform hands back exactly the `userdata` registered with the callback — the
|
||||
// `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)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,27 @@ const PENDING_SPLIT_CAP: usize = 256;
|
||||
/// gets evicted.
|
||||
const RENDERED_CAP: usize = 64;
|
||||
|
||||
/// How long the decoder may be FED while producing nothing before we treat it as un-anchored and
|
||||
/// ask the host for a fresh IDR.
|
||||
///
|
||||
/// The shared gate's per-AU streak ([`punktfunk_core::reanchor::ReanchorGate::on_no_output`]) can't
|
||||
/// be used verbatim here: it counts one-in/one-out decodes (the desktop clients' `LOW_DELAY`
|
||||
/// libavcodec path and Apple's VideoToolbox), while MediaCodec is pipelined — inputs and outputs
|
||||
/// don't pair up, so "this AU produced no output" isn't a thing this loop can observe. A wall-clock
|
||||
/// silence window is the same signal in the shape Android can measure.
|
||||
///
|
||||
/// Why it matters: the host opens a stream with an IDR and, under infinite GOP, sends no other one
|
||||
/// unless asked. Miss that one — the decode thread only starts at `surfaceCreated`, so a slow TV box
|
||||
/// can be handed the stream mid-GOP — and every later AU references a picture the decoder never had.
|
||||
/// A hardware decoder doesn't error on that; it simply emits nothing. Without this backstop the
|
||||
/// session sat there forever: AUs arriving, a healthy HUD, and a black surface, because nothing in
|
||||
/// the Android loops ever asked for the keyframe that would re-anchor it.
|
||||
///
|
||||
/// 500 ms because it must never fire on a decoder that is merely slow to spin up: even the pokiest
|
||||
/// hardware decoder emits its first frame within a couple of frame periods, and a wedge that only
|
||||
/// costs half a second before it self-heals is not a bug the user reports.
|
||||
const NO_OUTPUT_PATIENCE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
||||
|
||||
@@ -24,7 +24,7 @@ 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::{DecodeOptions, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
|
||||
use super::{DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
|
||||
|
||||
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
|
||||
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
|
||||
@@ -144,6 +144,12 @@ pub(super) fn run_sync(
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
let mut discarded: u64 = 0;
|
||||
// No-output backstop (see [`NO_OUTPUT_PATIENCE`]): when the decoder last handed us a frame, and
|
||||
// how many AUs it had been fed by then. Silence only counts while AUs are going in, so an idle
|
||||
// stream asks for nothing; seeded at start so a decoder that never produces a FIRST frame — the
|
||||
// missed opening IDR — is caught by the same window.
|
||||
let mut last_output = Instant::now();
|
||||
let mut fed_at_output: u64 = 0;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
||||
@@ -313,6 +319,11 @@ pub(super) fn run_sync(
|
||||
);
|
||||
rendered += r;
|
||||
discarded += d;
|
||||
// The one line that separates "the stream never reached glass" from "it reached glass and
|
||||
// looked wrong"; the tally above counts AUs FED, which a black session racks up happily.
|
||||
if r > 0 && rendered == r {
|
||||
log::info!("decode: first frame presented (fed={fed} discarded={discarded})");
|
||||
}
|
||||
|
||||
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
|
||||
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
|
||||
@@ -355,8 +366,29 @@ pub(super) fn run_sync(
|
||||
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
||||
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
||||
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
||||
//
|
||||
// Fed but silent is its own recovery trigger (see [`NO_OUTPUT_PATIENCE`]): a decoder that
|
||||
// never got the opening IDR emits nothing at all and errors on nothing, so none of the
|
||||
// signals above ever fire and the surface stays black for the life of the session.
|
||||
let now = Instant::now();
|
||||
if gate.poll(client.frames_dropped(), now)
|
||||
let had_output = r + d > 0;
|
||||
let starved = !had_output
|
||||
&& fed > fed_at_output
|
||||
&& now.duration_since(last_output) >= NO_OUTPUT_PATIENCE;
|
||||
if had_output {
|
||||
last_output = now;
|
||||
fed_at_output = fed;
|
||||
} else if starved {
|
||||
log::warn!(
|
||||
"decode: no output for {} ms with {} AU(s) fed — requesting a re-anchor keyframe",
|
||||
now.duration_since(last_output).as_millis(),
|
||||
fed - fed_at_output
|
||||
);
|
||||
gate.arm(now);
|
||||
last_output = now; // one request per patience window, not per iteration
|
||||
fed_at_output = fed;
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || starved)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
|
||||
@@ -31,9 +31,10 @@ const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]).
|
||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||
/// every field so no value can break it.
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os`
|
||||
/// (`␟` = [`FIELD_SEP`]). Records are newline-joined in a poll snapshot; [`Host::encode`] strips
|
||||
/// the framing bytes from every field so no value can break it. New fields append (the Kotlin
|
||||
/// parser tolerates both arities), never reorder.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Host {
|
||||
key: String,
|
||||
@@ -44,6 +45,9 @@ struct Host {
|
||||
pair: String,
|
||||
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent.
|
||||
mac: String,
|
||||
/// OS-identity chain from the mDNS `os` TXT (`linux/fedora/bazzite`, ...), for the host
|
||||
/// card's OS icon. Empty if absent (older host).
|
||||
os: String,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -56,7 +60,7 @@ impl Host {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
@@ -64,6 +68,7 @@ impl Host {
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
clean(&self.mac),
|
||||
clean(&self.os),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -186,6 +191,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
mac: val("mac"),
|
||||
os: val("os"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,7 +212,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
@@ -268,10 +274,11 @@ mod tests {
|
||||
fp: "ab".repeat(32),
|
||||
pair: "required".into(),
|
||||
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||
os: "linux/fedora/bazzite".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 7);
|
||||
assert_eq!(fields.len(), 8);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
@@ -279,6 +286,7 @@ mod tests {
|
||||
assert_eq!(fields[4], "ab".repeat(32));
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(fields[7], "linux/fedora/bazzite");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
@@ -297,12 +305,13 @@ mod tests {
|
||||
fp: "ab\u{1f}cd".into(),
|
||||
pair: "required\n".into(),
|
||||
mac: "aa:bb\u{1f}cc".into(),
|
||||
os: "linux\u{1f}evil/arch".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(
|
||||
encoded.matches(FIELD_SEP).count(),
|
||||
6,
|
||||
"exactly seven fields"
|
||||
7,
|
||||
"exactly eight fields"
|
||||
);
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
@@ -310,5 +319,6 @@ mod tests {
|
||||
assert_eq!(fields[1], "evilhost");
|
||||
assert_eq!(fields[4], "abcd");
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[7], "linuxevil/arch");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
|
||||
/// deviceName): Long`.
|
||||
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
|
||||
/// `deviceName` (empty ⇒ none) rides the Hello as `name` — what the host's pending-approval list
|
||||
/// and trust store show for this device (Kotlin passes `Build.MODEL`, its `nativePair` convention).
|
||||
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
||||
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
||||
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
||||
@@ -117,6 +120,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
preferred_codec: jint,
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -135,6 +139,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
.map(Into::into)
|
||||
.ok()
|
||||
.filter(|s: &String| !s.is_empty());
|
||||
// The host's approval-list / trust-store label for this device; null / blank ⇒ None (the host
|
||||
// falls back to its fingerprint-derived "device abcd1234" placeholder).
|
||||
let device_name: Option<String> = env
|
||||
.get_string(&device_name)
|
||||
.map(Into::into)
|
||||
.ok()
|
||||
.map(|s: String| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
||||
None
|
||||
@@ -204,8 +216,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// 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,
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
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
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
|
||||
//!
|
||||
//! Split by concern: [`connect`] (identity + connect/close + the trust surface), [`planes`]
|
||||
//! (video/audio/mic start/stop + the stats drain), [`input`] (the input-plane shims). This module
|
||||
//! keeps the shared infrastructure they all deref through.
|
||||
//! (video/audio/mic start/stop + the stats drain), [`input`] (the input-plane shims), [`probe`]
|
||||
//! (the bandwidth speed test). This module keeps the shared infrastructure they all deref through.
|
||||
//!
|
||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||
@@ -21,6 +21,7 @@ mod clipboard;
|
||||
mod connect;
|
||||
mod input;
|
||||
mod planes;
|
||||
mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! The bandwidth speed test over an established session.
|
||||
//!
|
||||
//! The host bursts filler over the real data plane — the same path a stream uses, so the answer is
|
||||
//! about the link the stream will actually take rather than about some generic throughput. It is
|
||||
//! deliberately *measure-only*: which layer a measured bitrate belongs in (the global default, or a
|
||||
//! host's bound profile) is a decision the UI makes with the user
|
||||
//! (design/client-settings-profiles.md §5.3), never one this shim makes for them.
|
||||
//!
|
||||
//! Two calls: start, then poll. Both are cheap and non-blocking, so Kotlin can drive them from a
|
||||
//! coroutine on the main thread the way it polls the stats HUD.
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
use jni::objects::JObject;
|
||||
use jni::sys::{jboolean, jdoubleArray, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
|
||||
/// The `DoubleArray` [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] returns. Kept in
|
||||
/// one place because Kotlin indexes it positionally; see the Kotlin doc for the field order.
|
||||
const PROBE_RESULT_LEN: usize = 6;
|
||||
|
||||
/// `NativeBridge.nativeSpeedTest(handle, targetKbps, durationMs): Boolean` — ask the host to burst
|
||||
/// filler at `targetKbps` of goodput for `durationMs` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
/// **briefly pausing video**. Non-blocking: poll
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
|
||||
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
target_kbps: jint,
|
||||
duration_ms: jint,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let target = target_kbps.clamp(0, i32::MAX) as u32;
|
||||
let duration = duration_ms.clamp(0, i32::MAX) as u32;
|
||||
match h.client.request_probe(target, duration) {
|
||||
Ok(()) => 1,
|
||||
Err(e) => {
|
||||
log::warn!("speed test: could not ask the host to probe: {e:?}");
|
||||
0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeProbeResult(handle): DoubleArray?` — the current measurement, partial until
|
||||
/// `[0] == 1`. Safe to poll; before any probe it reports zeros. `null` on a `0` handle.
|
||||
///
|
||||
/// Layout (doubles so one array carries both the counts and the percentages):
|
||||
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jdoubleArray {
|
||||
jni_guard(JObject::null().into_raw(), || {
|
||||
if handle == 0 {
|
||||
return JObject::null().into_raw();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let r = h.client.probe_result();
|
||||
let values: [f64; PROBE_RESULT_LEN] = [
|
||||
f64::from(u8::from(r.done)),
|
||||
f64::from(r.throughput_kbps),
|
||||
f64::from(r.loss_pct),
|
||||
f64::from(r.host_drop_pct),
|
||||
f64::from(r.elapsed_ms),
|
||||
r.recv_bytes as f64,
|
||||
];
|
||||
match env.new_double_array(PROBE_RESULT_LEN as i32) {
|
||||
Ok(arr) => {
|
||||
if env.set_double_array_region(&arr, 0, &values).is_err() {
|
||||
return JObject::null().into_raw();
|
||||
}
|
||||
arr.into_raw()
|
||||
}
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -34,6 +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.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
|
||||
@@ -20,36 +20,32 @@ import SwiftUI
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var store = HostStore()
|
||||
/// The settings-profile catalog (design/client-settings-profiles.md §4.2) — read at every
|
||||
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
|
||||
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.renderScale) private var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
// The raw string is what @AppStorage observes (so cycles from any surface re-render this
|
||||
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
|
||||
/// The tier the overlay actually shows: the live session's (its profile's, then whatever the
|
||||
/// ⌃⌥⇧S/three-finger cycle moved it to) while streaming, the persisted global otherwise.
|
||||
private var statsVerbosity: StatsVerbosity {
|
||||
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||
model.connection != nil
|
||||
? model.statsVerbosity
|
||||
: (StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal)
|
||||
}
|
||||
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
||||
private var preferredCodecByte: UInt8 {
|
||||
switch codec {
|
||||
case "h264": return PunktfunkConnection.codecH264
|
||||
case "hevc": return PunktfunkConnection.codecHEVC
|
||||
case "av1": return PunktfunkConnection.codecAV1
|
||||
case "pyrowave": return PunktfunkConnection.codecPyroWave
|
||||
default: return 0
|
||||
}
|
||||
/// Fullscreen-while-streaming is profileable (a Game profile goes fullscreen, a Work one
|
||||
/// doesn't), so a live session obeys ITS value and the host list obeys the global.
|
||||
private var fullscreenForSession: Bool {
|
||||
model.connection != nil ? model.settings.fullscreenWhileStreaming : fullscreenWhileStreaming
|
||||
}
|
||||
@State private var showAddHost = false
|
||||
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored — unknown host, or
|
||||
@@ -120,7 +116,66 @@ struct ContentView: View {
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
}
|
||||
|
||||
// The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and
|
||||
// the prompt chain below. Not a style choice: as ONE expression this blew Swift's
|
||||
// type-checker budget on the iOS slice ("unable to type-check in reasonable time"), which
|
||||
// macOS builds never reveal. Keep new modifiers on whichever half is shorter.
|
||||
var body: some View {
|
||||
driven
|
||||
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||||
// alert) so it never collides with the wait alert below. "Request Access" is the
|
||||
// no-PIN delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The
|
||||
// follow-on presentation is deferred a tick so this dialog is fully dismissed first.
|
||||
.confirmationDialog(
|
||||
"Pairing required",
|
||||
isPresented: approvalChoicePresented,
|
||||
titleVisibility: .visible,
|
||||
presenting: approvalChoice
|
||||
) { req in
|
||||
Button("Request Access") {
|
||||
DispatchQueue.main.async { requestAccess(req) }
|
||||
}
|
||||
Button("Pair with PIN…") {
|
||||
DispatchQueue.main.async { pairingTarget = req.host }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { req in
|
||||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
|
||||
+ "pair with the 4-digit PIN it can display.")
|
||||
}
|
||||
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher)
|
||||
// and platform — SessionModel funnels all connect/session errors into `errorMessage`.
|
||||
.alert("Connection failed", isPresented: connectionErrorPresented) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
// The delegated-approval wait: the host holds the connection open until the operator
|
||||
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
|
||||
// and its late result is discarded by SessionModel's connect guard (disconnect resets
|
||||
// the phase/host it checks).
|
||||
.alert(
|
||||
"Waiting for approval",
|
||||
isPresented: awaitingApprovalPresented,
|
||||
presenting: awaitingApproval
|
||||
) { _ in
|
||||
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||
} message: { req in
|
||||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||
+ "console (port 47992 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
// Informational deep-link outcome (unknown host, a refused profile, already
|
||||
// streaming). Not an error.
|
||||
.alert("Can't open", isPresented: deepLinkNoticePresented) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(deepLinkNotice ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var driven: some View {
|
||||
Group {
|
||||
// The stream view's structural identity MUST be stable across the
|
||||
// awaiting-trust → streaming transition: recreating it restarts the pump,
|
||||
@@ -144,6 +199,12 @@ struct ContentView: View {
|
||||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||||
// parallel session — this drives the one `model` ContentView owns.
|
||||
.onOpenURL { handleDeepLink($0) }
|
||||
// A live stats-overlay cycle from ANY surface (⌃⌥⇧S, the three-finger tap, the Stream
|
||||
// menu) writes the global; push it into the session so the overlay follows it from there
|
||||
// on, whatever tier the session's profile started on.
|
||||
.onChange(of: statsVerbosityRaw) { _, raw in
|
||||
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||||
// ignored so neither branch fires for a Control-Center pull.
|
||||
@@ -198,16 +259,19 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
|
||||
// this — tear the session down deliberately (quit-close the host).
|
||||
// this — tear the session down deliberately (quit-close the host). iOS-only along with
|
||||
// the intent itself (LiveActivityIntent is ActivityKit's world).
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
|
||||
model.disconnect(deliberate: true)
|
||||
}
|
||||
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler
|
||||
// as a widget tap.
|
||||
#endif
|
||||
// Connect App Intent (Siri/Shortcuts/Spotlight): route its punktfunk:// URL through the
|
||||
// same handler a widget tap uses. NOT iOS-gated — the Connect intent compiles on macOS and
|
||||
// tvOS too, and an intent that posts to nobody would be a shortcut that silently does
|
||||
// nothing.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
|
||||
if let url = note.object as? URL { handleDeepLink(url) }
|
||||
}
|
||||
#endif
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
@@ -260,7 +324,7 @@ struct ContentView: View {
|
||||
// `isFullscreen` (the user can toggle it manually), which drives the session view's
|
||||
// safe-area handling below.
|
||||
.background(FullscreenController(
|
||||
active: fullscreenWhileStreaming && model.connection != nil,
|
||||
active: fullscreenForSession && model.connection != nil,
|
||||
isFullscreen: $isFullscreen))
|
||||
#endif
|
||||
// On the outer Group so the sheet survives the trust-prompt → home transition
|
||||
@@ -295,88 +359,44 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||||
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
|
||||
// delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on
|
||||
// presentation is deferred a tick so this dialog is fully dismissed first.
|
||||
.confirmationDialog(
|
||||
"Pairing required",
|
||||
isPresented: Binding(
|
||||
get: { approvalChoice != nil },
|
||||
set: { if !$0 { approvalChoice = nil } }),
|
||||
titleVisibility: .visible,
|
||||
presenting: approvalChoice
|
||||
) { req in
|
||||
Button("Request Access") {
|
||||
DispatchQueue.main.async { requestAccess(req) }
|
||||
}
|
||||
Button("Pair with PIN…") {
|
||||
DispatchQueue.main.async { pairingTarget = req.host }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { req in
|
||||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
|
||||
+ "pair with the 4-digit PIN it can display.")
|
||||
}
|
||||
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
|
||||
// platform — SessionModel funnels all connect/session errors into `errorMessage`.
|
||||
.alert(
|
||||
"Connection failed",
|
||||
isPresented: Binding(
|
||||
get: {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||||
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||||
// presenting it now strands the window fullscreen on the home screen after a
|
||||
// session error (a deliberate disconnect sets no `errorMessage`, which is why
|
||||
// it never stuck). Tearing the session down already flipped `active`→false;
|
||||
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||||
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||||
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||||
if fullscreenWhileStreaming && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
},
|
||||
set: { if !$0 { model.errorMessage = nil } })
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
// The delegated-approval wait: the host holds the connection open until the operator
|
||||
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
|
||||
// and its late result is discarded by SessionModel's connect guard (disconnect resets the
|
||||
// phase/host it checks).
|
||||
.alert(
|
||||
"Waiting for approval",
|
||||
isPresented: Binding(
|
||||
get: { awaitingApproval != nil },
|
||||
set: { if !$0 { awaitingApproval = nil } }),
|
||||
presenting: awaitingApproval
|
||||
) { _ in
|
||||
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||
} message: { req in
|
||||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||
+ "console (port 47992 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||
.alert("Can't open", isPresented: deepLinkNoticePresented) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(deepLinkNotice ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation flag for the informational deep-link alert. Extracted from the `.alert` call so
|
||||
/// the manual get/set Binding type-checks on its own instead of inflating the body chain's
|
||||
/// budget (adding it inline tips SwiftUI's per-expression limit — see the split sections idiom).
|
||||
// Presentation flags for the prompt chain, extracted from their `.alert`/`.confirmationDialog`
|
||||
// calls so each manual get/set Binding type-checks on its own instead of inflating the body's
|
||||
// budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom).
|
||||
|
||||
private var deepLinkNoticePresented: Binding<Bool> {
|
||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
private var approvalChoicePresented: Binding<Bool> {
|
||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
|
||||
private var awaitingApprovalPresented: Binding<Bool> {
|
||||
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
|
||||
}
|
||||
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||||
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||||
// presenting it now strands the window fullscreen on the home screen after a
|
||||
// session error (a deliberate disconnect sets no `errorMessage`, which is why
|
||||
// it never stuck). Tearing the session down already flipped `active`→false;
|
||||
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||||
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||||
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
},
|
||||
set: { if !$0 { model.errorMessage = nil } })
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
||||
private func currentModeLine() -> String {
|
||||
@@ -396,25 +416,81 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design):
|
||||
/// unknown host → notice + no-op; a live session is up → ignore if it's the same host, else
|
||||
/// tell the user to end the current one first (NEVER tear down a live session on a background
|
||||
/// tap); otherwise the normal `connect` — trust policy, WoL and the approval sheet all apply.
|
||||
/// Route a `punktfunk://` deep link into the existing connect path — the whole §2 grammar
|
||||
/// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
|
||||
/// `fp`/`host` recovery parameters and a one-off `profile`.
|
||||
///
|
||||
/// The security posture is the parser's plus three rules that live here, and none of them
|
||||
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
|
||||
/// confirmation, not a connect), never preempts a live session (same host → focus, different
|
||||
/// host → say so; NEVER tear one down on a background tap), and carries only references — a
|
||||
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return }
|
||||
guard let host = store.hosts.first(where: { $0.id == hostID }) else {
|
||||
deepLinkNotice = "That host isn't saved on this device."
|
||||
let link: DeepLink
|
||||
do {
|
||||
link = try DeepLink(url: url)
|
||||
} catch DeepLinkError.notOurScheme {
|
||||
return // not ours — ignore it silently rather than warning about someone else's URL
|
||||
} catch {
|
||||
deepLinkNotice = (error as? DeepLinkError)?.message
|
||||
?? "That link is malformed and was ignored."
|
||||
return
|
||||
}
|
||||
if model.phase != .idle {
|
||||
guard model.activeHost?.id == hostID else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
guard link.route == .connect else {
|
||||
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
|
||||
// neither, and saying so beats silently connecting instead.
|
||||
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
|
||||
return
|
||||
}
|
||||
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
|
||||
// must refuse, not degrade to the host's binding (§10.6).
|
||||
var selection = ProfileSelection.inherit
|
||||
if let reference = link.profile {
|
||||
let (profile, resolution) = profiles.catalog.resolve(reference)
|
||||
switch resolution {
|
||||
case .found:
|
||||
selection = .profile(profile?.id ?? "")
|
||||
case .notFound:
|
||||
deepLinkNotice = "No settings profile called “\(reference)” on this device."
|
||||
return
|
||||
case .ambiguous:
|
||||
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
|
||||
+ "Rename one, or link to it by its id."
|
||||
return
|
||||
}
|
||||
return // deep-linked to the host we're already on — nothing to do
|
||||
}
|
||||
connect(host, launchID: launchID)
|
||||
switch link.resolveHost(in: store.hosts) {
|
||||
case .known(let host):
|
||||
guard !link.pinConflict(with: host) else {
|
||||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||
return
|
||||
}
|
||||
guard model.phase == .idle else {
|
||||
guard model.activeHost?.id == host.id else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
}
|
||||
return // deep-linked to the host we're already on — nothing to do
|
||||
}
|
||||
connect(host, launchID: link.launch, profile: selection)
|
||||
case .unknown(let address, let port, let name, let fp):
|
||||
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
|
||||
// the user makes the trust decision with their eyes on it.
|
||||
guard model.phase == .idle else {
|
||||
deepLinkNotice = "Already streaming. End that session first."
|
||||
return
|
||||
}
|
||||
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
||||
+ "Add it with the + button — the link points at \(address):\(String(port))"
|
||||
+ (fp == nil ? "." : ", and carries a fingerprint to verify it against.")
|
||||
case .ambiguous:
|
||||
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
|
||||
+ "Rename one, or link to it by its address."
|
||||
case .unresolvable:
|
||||
deepLinkNotice = "That host isn't saved on this device."
|
||||
}
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
@@ -447,13 +523,13 @@ struct ContentView: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
@@ -463,7 +539,7 @@ struct ContentView: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||
@@ -487,7 +563,7 @@ struct ContentView: View {
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
showSettings: $showSettings,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
@@ -731,7 +807,14 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Connect
|
||||
|
||||
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
|
||||
/// `profile` is this connect's one-off pick ("Connect with ▸", a pinned card, a link's
|
||||
/// `profile=`). `.inherit` — the default, and what a plain card tap passes — falls through to
|
||||
/// the host's binding. A one-off NEVER rebinds the host: rebinding is always an explicit act
|
||||
/// in the edit sheet (design §5.2).
|
||||
private func connect(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
profile: ProfileSelection = .inherit, allowTofu: Bool? = nil
|
||||
) {
|
||||
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
||||
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
||||
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
||||
@@ -750,20 +833,22 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
}
|
||||
startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil)
|
||||
startSession(
|
||||
host, launchID: launchID, profile: profile, allowTofu: host.pinnedSHA256 == nil)
|
||||
}
|
||||
|
||||
/// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The
|
||||
/// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the
|
||||
/// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN
|
||||
/// delegated-approval connect (host parks it until the operator approves).
|
||||
/// Resolve the stream mode + input prefs and hand off to the session model. The gamepad-type
|
||||
/// setting resolves NOW (Automatic → match the active physical controller): the host's virtual
|
||||
/// pad backend is fixed per session. `requestAccess` opens the no-PIN delegated-approval
|
||||
/// connect (host parks it until the operator approves).
|
||||
private func startSession(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
profile: ProfileSelection = .inherit,
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||
) {
|
||||
let go = {
|
||||
startSessionDirect(
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
host, launchID: launchID, profile: profile, allowTofu: allowTofu,
|
||||
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||
}
|
||||
// Not advertising and we can wake it? DIAL FIRST anyway — no mDNS advert does NOT mean
|
||||
@@ -778,7 +863,7 @@ struct ContentView: View {
|
||||
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
discovery.start() // so the wake-wait can observe it reappear
|
||||
startSessionDirect(
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
host, launchID: launchID, profile: profile, allowTofu: allowTofu,
|
||||
requestAccess: requestAccess, approvalReq: approvalReq,
|
||||
onUnreachable: {
|
||||
waker.start(
|
||||
@@ -794,19 +879,9 @@ struct ContentView: View {
|
||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||||
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||||
/// The stream mode to request = the chosen resolution × the render scale, aspect-preserved,
|
||||
/// even, and clamped to the codec's max dimension. > 1 supersamples for sharpness (the presenter
|
||||
/// downscales the larger decoded frame to this display); < 1 renders under native and upscales.
|
||||
/// The match-window path applies the SAME scale to the live window size in `MatchWindowFollower`.
|
||||
private func scaledMode() -> (width: UInt32, height: UInt32) {
|
||||
RenderScale.apply(
|
||||
baseWidth: width, baseHeight: height,
|
||||
scale: renderScale,
|
||||
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||
}
|
||||
|
||||
private func startSessionDirect(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
profile: ProfileSelection = .inherit,
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||||
onUnreachable: (@MainActor () -> Void)? = nil
|
||||
) {
|
||||
@@ -814,19 +889,17 @@ struct ContentView: View {
|
||||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||
// here (after any wake), not before, so it never stacks under the "Waking…" overlay.
|
||||
if let approvalReq { awaitingApproval = approvalReq }
|
||||
// THE resolution point (design §4.4): the globals plus this connect's profile, once, here.
|
||||
// The model latches the result for the whole session, so nothing downstream can end up
|
||||
// applying a profile to half of it.
|
||||
let effective = EffectiveSettings.resolve(
|
||||
host: host, selection: profile, catalog: profiles.catalog)
|
||||
model.connect(
|
||||
to: host,
|
||||
width: scaledMode().width, height: scaledMode().height,
|
||||
hz: UInt32(clamping: hz),
|
||||
compositor: PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||
effective: effective,
|
||||
gamepad: GamepadManager.shared.resolveType(
|
||||
setting: PunktfunkConnection.GamepadType(
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
preferredCodec: preferredCodecByte,
|
||||
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
|
||||
launchID: launchID,
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess,
|
||||
@@ -843,6 +916,7 @@ struct ContentView: View {
|
||||
private func prepareWake(for host: StoredHost) {
|
||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||
store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark
|
||||
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||||
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||||
@@ -897,7 +971,8 @@ struct ContentView: View {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(
|
||||
name: d.name, address: d.host, port: d.port,
|
||||
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses)
|
||||
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses,
|
||||
osChain: d.osChain.isEmpty ? nil : d.osChain)
|
||||
store.add(host)
|
||||
if d.allowsTofu {
|
||||
connect(host, allowTofu: true)
|
||||
@@ -979,36 +1054,25 @@ struct ContentView: View {
|
||||
hz = dims[2]
|
||||
}
|
||||
}
|
||||
var pref = PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: compositor)) ?? .auto
|
||||
// The dev levers layer over the globals (no host record, so no binding to resolve).
|
||||
var effective = EffectiveSettings(defaults: .standard)
|
||||
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"],
|
||||
let c = PunktfunkConnection.Compositor(name: name) {
|
||||
pref = c
|
||||
effective.compositor = Int(c.rawValue)
|
||||
}
|
||||
var pad = GamepadManager.shared.resolveType(
|
||||
setting: PunktfunkConnection.GamepadType(
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
|
||||
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto)
|
||||
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
|
||||
let g = PunktfunkConnection.GamepadType(name: name) {
|
||||
// Back through resolveType so the lever is adopted as the session's setting: the
|
||||
// per-pad arrivals declare it too, which is what the host actually builds from.
|
||||
pad = GamepadManager.shared.resolveType(setting: g)
|
||||
}
|
||||
var bitrate = UInt32(clamping: bitrateKbps)
|
||||
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
|
||||
let v = UInt32(kbps) {
|
||||
bitrate = v
|
||||
let v = Int(kbps) {
|
||||
effective.bitrateKbps = v
|
||||
}
|
||||
model.connect(
|
||||
to: host,
|
||||
width: scaledMode().width, height: scaledMode().height,
|
||||
hz: UInt32(clamping: hz),
|
||||
compositor: pref,
|
||||
gamepad: pad,
|
||||
bitrateKbps: bitrate,
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
preferredCodec: preferredCodecByte,
|
||||
autoTrust: true)
|
||||
model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ struct AddHostSheet: View {
|
||||
@State private var address: String
|
||||
@State private var port: Int
|
||||
@State private var mac: String
|
||||
#if !os(tvOS)
|
||||
/// This host's DEFAULT settings profile — what a plain click/tap uses. Empty = Default
|
||||
/// settings (design/client-settings-profiles.md §5.2). Changing it here is the only way the
|
||||
/// default moves; "Connect with ▸" is deliberately a one-off.
|
||||
@State private var profileID: String
|
||||
/// Profiles pinned as their own cards for this host (§5.2a) — presentation only, and
|
||||
/// independent of the default above.
|
||||
@State private var pinnedIDs: Set<String>
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
#endif
|
||||
#if os(macOS)
|
||||
/// Share the clipboard with this host (macOS sessions only; design
|
||||
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
|
||||
@@ -34,6 +44,21 @@ struct AddHostSheet: View {
|
||||
@State private var editingField: EditField?
|
||||
#endif
|
||||
|
||||
/// A field's placeholder, which is not the same job on both platforms.
|
||||
///
|
||||
/// macOS draws a `TextField`'s title as a leading label, so the prompt only has to hint at the
|
||||
/// VALUE. On iOS that title becomes an accessibility label the moment a prompt exists and
|
||||
/// nothing is drawn — the prompt is the field's entire visible identity, so it has to name the
|
||||
/// field as well as hint at it. Writing one string for both leaves you a Mac panel that says
|
||||
/// everything twice or a phone form of unlabelled boxes.
|
||||
private static func prompt(touch: String, desktop: String) -> Text {
|
||||
#if os(macOS)
|
||||
Text(desktop)
|
||||
#else
|
||||
Text(touch)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var isEditing: Bool { existing != nil }
|
||||
private var actionTitle: String { isEditing ? "Save" : "Add Host" }
|
||||
private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
@@ -50,6 +75,10 @@ struct AddHostSheet: View {
|
||||
#if os(macOS)
|
||||
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
_profileID = State(initialValue: existing?.profileID ?? "")
|
||||
_pinnedIDs = State(initialValue: Set(existing?.pinnedProfileIDs ?? []))
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -60,7 +89,9 @@ struct AddHostSheet: View {
|
||||
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
|
||||
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
|
||||
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
|
||||
TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac }
|
||||
TVFieldRow(
|
||||
label: "MAC address", value: mac,
|
||||
placeholder: "For Wake-on-LAN, optional") { editingField = .mac }
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button(actionTitle) { save() }.disabled(!canSave)
|
||||
@@ -97,10 +128,18 @@ struct AddHostSheet: View {
|
||||
#else
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
||||
TextField(
|
||||
"Name", text: $name,
|
||||
prompt: Self.prompt(
|
||||
touch: "Name (optional, e.g. Living Room)",
|
||||
desktop: "Optional — e.g. Living Room"))
|
||||
TextField("Address", text: $address, prompt: Text("IP or hostname"))
|
||||
TextField("Port", value: $port, format: .number.grouping(.never))
|
||||
TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known"))
|
||||
TextField(
|
||||
"MAC address", text: $mac,
|
||||
prompt: Self.prompt(
|
||||
touch: "MAC address (for Wake-on-LAN, optional)",
|
||||
desktop: "For Wake-on-LAN, optional"))
|
||||
.autocorrectionDisabled()
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -108,16 +147,23 @@ struct AddHostSheet: View {
|
||||
#if os(macOS)
|
||||
Toggle("Share clipboard with this host", isOn: $clipboardSync)
|
||||
#endif
|
||||
profileRows
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
// The grouped form's default system text is oversized next to the app's Geist
|
||||
// typography — bring it down and on-brand so the panel doesn't read out of place.
|
||||
.font(.geist(12, relativeTo: .callout))
|
||||
.controlSize(.small)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.scrollDisabled(true)
|
||||
// As before: the sheet is sized to its content, so there is nothing to scroll. Only
|
||||
// the edit sheet's profile rows can outgrow that, and only they turn it back on.
|
||||
.scrollDisabled(!showsProfileRows)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// macOS ONLY: the grouped form's default system text is oversized next to the app's
|
||||
// Geist typography, so the panel reads out of place at full size. iOS keeps the app's
|
||||
// body size — a touch target and a field label there are not a Mac panel's, and 12pt
|
||||
// made this the one sheet in the app you had to squint at.
|
||||
.font(.geist(12, relativeTo: .callout))
|
||||
.controlSize(.small)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
@@ -142,8 +188,8 @@ struct AddHostSheet: View {
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
// Four fields + the action row — a touch taller than the 3-field add sheet used to be.
|
||||
.presentationDetents([.height(392)])
|
||||
// Sized to its content (see `sheetHeight`).
|
||||
.presentationDetents([.height(sheetHeight)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@@ -153,6 +199,83 @@ struct AddHostSheet: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Four fields + the action row — a touch taller than the 3-field add sheet used to be. The
|
||||
/// edit sheet's profile rows are the only thing that can outgrow it, and they say by how much;
|
||||
/// a single fixed number is what clipped them.
|
||||
private var sheetHeight: CGFloat {
|
||||
var height: CGFloat = 392
|
||||
if showsProfileRows {
|
||||
height += 116 // the Profile picker and its footnote
|
||||
height += 96 + CGFloat(profiles.profiles.count) * 44 // the pins, their header + footer
|
||||
}
|
||||
return height
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Whether this sheet shows the per-host profile rows at all.
|
||||
///
|
||||
/// Only when EDITING, and only once profiles exist. Adding a host is about reaching it — the
|
||||
/// address, and whether we can wake it; which settings it streams with is a decision for the
|
||||
/// host you already have, and stacking it onto the add flow made the first thing a new user
|
||||
/// sees a longer form than the one they came for. Editing is one context-menu item away.
|
||||
private var showsProfileRows: Bool {
|
||||
#if os(tvOS)
|
||||
return false
|
||||
#else
|
||||
return isEditing && !profiles.profiles.isEmpty
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The per-host profile rows: which profile this host uses by default, and which extra ones
|
||||
/// get their own card in the grid.
|
||||
///
|
||||
/// Two plain sections, no disclosure. A collapsed group had to animate its own height AND the
|
||||
/// sheet's, and got both wrong; with a profile or three these are a couple of rows, and rows
|
||||
/// that are simply there can't be clipped or fail to expand.
|
||||
@ViewBuilder private var profileRows: some View {
|
||||
#if !os(tvOS)
|
||||
if showsProfileRows {
|
||||
Section {
|
||||
Picker("Profile", selection: $profileID) {
|
||||
Text("Default settings").tag("")
|
||||
ForEach(profiles.profiles) { profile in
|
||||
Text(profile.name).tag(profile.id)
|
||||
}
|
||||
// A binding whose profile was deleted resolves as Default settings anyway;
|
||||
// saying so beats an empty picker, and saving cleans the field up.
|
||||
if !profileID.isEmpty, profiles.profile(id: profileID) == nil {
|
||||
Text("Default settings (profile deleted)").tag(profileID)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("The settings a plain tap on this host streams with.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
ForEach(profiles.profiles) { profile in
|
||||
Toggle(profile.name, isOn: Binding(
|
||||
get: { pinnedIDs.contains(profile.id) },
|
||||
set: { on in
|
||||
if on {
|
||||
pinnedIDs.insert(profile.id)
|
||||
} else {
|
||||
pinnedIDs.remove(profile.id)
|
||||
}
|
||||
}))
|
||||
}
|
||||
} header: {
|
||||
Text("Pinned cards")
|
||||
} footer: {
|
||||
Text("A pinned profile gets its own card next to this host — one tap, no menu.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func save() {
|
||||
var host = existing ?? StoredHost(name: "", address: "")
|
||||
host.name = name.trimmingCharacters(in: .whitespaces)
|
||||
@@ -164,6 +287,17 @@ struct AddHostSheet: View {
|
||||
// opted in" and "opted out" read the same — off).
|
||||
host.clipboardSync = clipboardSync ? true : nil
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
// nil rather than "" for the same forward-compat reason, and a dangling binding is
|
||||
// cleaned up on this save (§6) instead of lingering as a stale id forever.
|
||||
host.profileID = profiles.profile(id: profileID) == nil ? nil : profileID
|
||||
// Keep the stored ORDER (it is card order) and drop what this sheet unpinned; anything
|
||||
// newly ticked goes on the end.
|
||||
let kept = (host.pinnedProfileIDs ?? []).filter { pinnedIDs.contains($0) }
|
||||
let added = pinnedIDs.filter { !kept.contains($0) }.sorted()
|
||||
let pins = kept + added
|
||||
host.pinnedProfileIDs = pins.isEmpty ? nil : pins
|
||||
#endif
|
||||
onSave(host)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import GameController
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||
/// action. Hashable so it can be the carousel's scroll-position identity.
|
||||
private enum GamepadHomeTarget: Hashable {
|
||||
case saved(UUID)
|
||||
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a
|
||||
/// controller-first surface are THE profile affordance: focus and press, no menus.
|
||||
case saved(UUID, profile: String?)
|
||||
case discovered(String)
|
||||
case addHost
|
||||
}
|
||||
@@ -37,6 +39,12 @@ private struct HomeTile: Identifiable {
|
||||
let id: GamepadHomeTarget
|
||||
let title: String
|
||||
let subtitle: String
|
||||
/// The profile this tile connects with — shown as a tinted chip. Set on a pinned card; nil on
|
||||
/// a plain host tile unless the host is bound to one (then it answers "what will A do?").
|
||||
var profile: StreamProfile?
|
||||
/// This tile IS a pinned host+profile card, not a host wearing its binding's chip — the chip
|
||||
/// reads loud on the former (it is the reason the tile exists) and quiet on the latter.
|
||||
var isPinnedCard = false
|
||||
var isOnline = false
|
||||
var isPaired = false
|
||||
var isConnecting = false
|
||||
@@ -59,9 +67,12 @@ struct GamepadHomeView: View {
|
||||
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
||||
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||
@ObservedObject var waker: HostWaker
|
||||
let connect: (StoredHost) -> Void
|
||||
let connect: (StoredHost, ProfileSelection) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
|
||||
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
|
||||
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||
/// Settings "Game library" toggle opts out).
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@@ -250,22 +261,35 @@ struct GamepadHomeView: View {
|
||||
/// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never
|
||||
/// empty and manual entry is always one press away).
|
||||
private var tiles: [HomeTile] {
|
||||
let saved = store.hosts.map { host in
|
||||
HomeTile(
|
||||
id: .saved(host.id),
|
||||
title: host.displayName,
|
||||
subtitle: "\(host.address):\(String(host.port))",
|
||||
// Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host
|
||||
// never advertises); the wake item is offered only when neither holds.
|
||||
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||
isPaired: host.pinnedSHA256 != nil,
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
filled: true,
|
||||
hasLibrary: true,
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||
&& !host.wakeMacs.isEmpty,
|
||||
activate: { connect(host) })
|
||||
var saved: [HomeTile] = []
|
||||
for host in store.hosts {
|
||||
// Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host
|
||||
// never advertises); the wake item is offered only when neither holds.
|
||||
let online = discovery.advertises(host) || store.probedOnline.contains(host.id)
|
||||
let bound = profiles.binding(for: host)
|
||||
let connecting = model.phase == .connecting && model.activeHost?.id == host.id
|
||||
// The host's own tile, then one per pinned profile — the same order the touch grid
|
||||
// uses, so a pin reads as belonging to its host on both surfaces.
|
||||
for profile in [StreamProfile?.none] + profiles.pinned(for: host).map(Optional.some) {
|
||||
saved.append(HomeTile(
|
||||
id: .saved(host.id, profile: profile?.id),
|
||||
title: host.displayName,
|
||||
subtitle: "\(host.address):\(String(host.port))",
|
||||
profile: profile ?? bound,
|
||||
isPinnedCard: profile != nil,
|
||||
isOnline: online,
|
||||
isPaired: host.pinnedSHA256 != nil,
|
||||
isConnecting: connecting,
|
||||
filled: true,
|
||||
// A pinned card is a shortcut, not a second host — Y (library) stays on the
|
||||
// host's own tile, where the host-level actions live.
|
||||
hasLibrary: profile == nil,
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !online && !host.wakeMacs.isEmpty,
|
||||
activate: {
|
||||
connect(host, profile.map { .profile($0.id) } ?? .inherit)
|
||||
}))
|
||||
}
|
||||
}
|
||||
let discovered = discovery.unsaved(among: store.hosts).map { d in
|
||||
HomeTile(
|
||||
@@ -287,7 +311,7 @@ struct GamepadHomeView: View {
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
|
||||
private func openLibraryForSelected() {
|
||||
guard libraryEnabled, case .saved(let id) = selection,
|
||||
guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
else { return }
|
||||
libraryTarget = host
|
||||
@@ -353,6 +377,11 @@ private struct GamepadHostTile: View {
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
if let profile = tile.profile {
|
||||
ProfileChip(
|
||||
profile: profile, size: Self.statusFont, prominent: tile.isPinnedCard)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
Text(tile.subtitle)
|
||||
.font(.geist(Self.subtitleFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(tvOS)
|
||||
// The tvOS slide transition ships with the Xcode PROJECT only — its manifest breaks SwiftPM's
|
||||
// whole-graph validation, so `swift build` never sees it. `canImport` rather than `os(tvOS)` so
|
||||
// the tvOS sources still TYPECHECK from the command line (the hand recipe in the Apple client's
|
||||
// README), where the module is genuinely absent; in the app it is present and the transition
|
||||
// applies exactly as before.
|
||||
#if os(tvOS) && canImport(SwiftUINavigationTransitions)
|
||||
import SwiftUINavigationTransitions
|
||||
#endif
|
||||
|
||||
@@ -13,6 +18,9 @@ struct HomeView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
/// The profile catalog — the source of the card chips, the "Connect with ▸" menu, and the
|
||||
/// pinned host+profile cards the grid renders alongside their host (design §5.2a).
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
@Binding var showAddHost: Bool
|
||||
@Binding var pairingTarget: StoredHost?
|
||||
@Binding var speedTestTarget: StoredHost?
|
||||
@@ -20,7 +28,9 @@ struct HomeView: View {
|
||||
#if !os(macOS)
|
||||
@Binding var showSettings: Bool
|
||||
#endif
|
||||
let connect: (StoredHost) -> Void
|
||||
/// Start a session with this host, using the given profile selection — `.inherit` for a plain
|
||||
/// card tap (the host's binding), an explicit pick from "Connect with ▸" or a pinned card.
|
||||
let connect: (StoredHost, ProfileSelection) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
@@ -34,6 +44,10 @@ struct HomeView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
// How this device shows its own list. `.added` is the default because it is what the grid
|
||||
// did before it could sort at all — an update should not rearrange anyone's hosts.
|
||||
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
|
||||
@AppStorage(DefaultsKey.hostGrouping) private var groupingRaw = HostGrouping.none.rawValue
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -43,9 +57,16 @@ struct HomeView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(store.hosts) { host in
|
||||
hostCard(host)
|
||||
LazyVStack(alignment: .leading, spacing: gridSpacing) {
|
||||
ForEach(hostGroups) { group in
|
||||
if let title = group.title {
|
||||
groupHeader(title, accent: group.accent)
|
||||
}
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(group.cards) { card in
|
||||
hostCard(card.host, pinned: card.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -127,8 +148,17 @@ struct HomeView: View {
|
||||
#if os(iOS)
|
||||
// Adjacent trailing items share one glass pill (the system default).
|
||||
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
arrangeMenu
|
||||
.help("Sort and group the host list")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
@@ -146,7 +176,7 @@ struct HomeView: View {
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
#if os(tvOS) && canImport(SwiftUINavigationTransitions)
|
||||
// The Settings-app slide for every push in this stack (top-level routes AND
|
||||
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
|
||||
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
|
||||
@@ -180,22 +210,77 @@ struct HomeView: View {
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
private func hostCard(_ host: StoredHost) -> some View {
|
||||
/// The grid's bands, ordered and divided per this device's preference — cards and all, so a
|
||||
/// pinned card can be filed under the profile it connects with rather than under its host's
|
||||
/// binding (`HostArrangement`).
|
||||
private var hostGroups: [HostGroup] {
|
||||
HostArrangement.groups(
|
||||
hosts: store.hosts, catalog: profiles.catalog,
|
||||
online: Set(store.hosts.filter(isOnline).map(\.id)),
|
||||
sort: HostSort(rawValue: sortRaw) ?? .added,
|
||||
grouping: HostGrouping(rawValue: groupingRaw) ?? .none)
|
||||
}
|
||||
|
||||
/// Online = advertising on mDNS OR answered the reachability probe (a routed/VPN host never
|
||||
/// advertises). One definition, used by the cards and by the Status grouping alike.
|
||||
private func isOnline(_ host: StoredHost) -> Bool {
|
||||
discovery.advertises(host) || store.probedOnline.contains(host.id)
|
||||
}
|
||||
|
||||
private func groupHeader(_ title: String, accent: String?) -> some View {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(Color(hex: accent ?? "") ?? Color.secondary.opacity(0.5))
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityHidden(true) // the title says it
|
||||
Text(title)
|
||||
.font(.geist(13, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.6)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
|
||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||
// A pinned card connects with ITS profile; the primary card follows the binding.
|
||||
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
|
||||
return HostCardView(
|
||||
host: host,
|
||||
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||
isOnline: isOnline(host),
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
isMostRecent: host.id == mostRecentHostID,
|
||||
// The accent ring marks the most recent HOST; pinned cards stay quiet so the grid
|
||||
// doesn't grow several "most recent" bars for one machine.
|
||||
isMostRecent: pinned == nil && host.id == mostRecentHostID,
|
||||
isBusy: model.isBusy,
|
||||
onConnect: { connect(host) },
|
||||
onConnect: { connect(host, selection) },
|
||||
onPair: { if !model.isBusy { pairingTarget = host } },
|
||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||
onForget: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onBrowseLibrary: onBrowseLibrary,
|
||||
onWake: { wake(host) },
|
||||
onEdit: { editTarget = host })
|
||||
onEdit: { editTarget = host },
|
||||
profileMenu: profileMenu(for: host),
|
||||
pinnedProfile: pinned)
|
||||
}
|
||||
|
||||
/// The profile affordances every host card carries (§5.2/§5.2a).
|
||||
private func profileMenu(for host: StoredHost) -> HostProfileMenu {
|
||||
HostProfileMenu(
|
||||
profiles: profiles.profiles,
|
||||
boundID: host.profileID,
|
||||
pinnedIDs: host.pinnedProfileIDs ?? [],
|
||||
connectWith: { selection in connect(host, selection) },
|
||||
setDefault: { store.setProfile(host.id, profileID: $0) },
|
||||
togglePin: { id in
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(id)
|
||||
store.setPinned(host.id, profileID: id, pinned: !pinned)
|
||||
},
|
||||
copyLink: { profileID in
|
||||
LinkClipboard.copy(DeepLink.forHost(host, profile: profileID).urlString)
|
||||
})
|
||||
}
|
||||
|
||||
private var discoveredSection: some View {
|
||||
@@ -260,6 +345,41 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One host has no order and nothing to divide, so the control stays out of the way until
|
||||
/// there is a list to arrange.
|
||||
private var showsArrangeMenu: Bool { store.hosts.count > 1 }
|
||||
|
||||
/// Sort and group, as two inline pickers in one menu — both are about the same list, and
|
||||
/// splitting them across two toolbar items would say otherwise.
|
||||
private var arrangeMenu: some View {
|
||||
Menu {
|
||||
Picker("Sort By", selection: $sortRaw) {
|
||||
ForEach(HostSort.allCases) { option in
|
||||
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
Divider()
|
||||
Picker("Group By", selection: $groupingRaw) {
|
||||
ForEach(HostGrouping.allCases) { option in
|
||||
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
} label: {
|
||||
Label("Sort and Group", systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
#if os(macOS)
|
||||
// A Menu draws its label in the ACCENT colour where a Button draws it in the label
|
||||
// colour, so next to Add Host and Settings this one came out brand-purple. macOS only:
|
||||
// on iOS every toolbar item is accent-tinted, and pinning this one to primary would make
|
||||
// it the odd one out there instead.
|
||||
.tint(.primary)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
private var settingsButton: some View {
|
||||
Button {
|
||||
|
||||
@@ -70,8 +70,32 @@ private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, fi
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a card's profile affordances need: the catalog to offer, what this host is bound
|
||||
/// and pinned to, and the acts a menu can perform (design/client-settings-profiles.md §5.2/§5.2a).
|
||||
///
|
||||
/// Passed as one value rather than eight closures because every surface that renders a host card
|
||||
/// has to offer the SAME set — a menu that quietly lacks "Pin as card" on one screen is how a
|
||||
/// feature becomes folklore.
|
||||
struct HostProfileMenu {
|
||||
var profiles: [StreamProfile]
|
||||
/// The host's default profile — the chip, and the checkmark in "Connect with ▸".
|
||||
var boundID: String?
|
||||
var pinnedIDs: [String]
|
||||
/// A ONE-OFF connect. Never rebinds: rebinding is `setDefault`, an explicit act (§5.2).
|
||||
var connectWith: (ProfileSelection) -> Void
|
||||
var setDefault: (String?) -> Void
|
||||
var togglePin: (String) -> Void
|
||||
/// Copy a `punktfunk://` link for this host, optionally carrying a profile.
|
||||
var copyLink: (String?) -> Void
|
||||
}
|
||||
|
||||
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||
///
|
||||
/// The same view renders a PINNED host+profile card (§5.2a): same host, same live status, with
|
||||
/// the profile as the prominent subtitle. A pinned card is a shortcut, not a second host, so its
|
||||
/// menu carries only connect-shaped actions — edit/pair/forget/remove stay on the primary card,
|
||||
/// where the thing they act on actually lives.
|
||||
struct HostCardView: View {
|
||||
let host: StoredHost
|
||||
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
|
||||
@@ -92,6 +116,18 @@ struct HostCardView: View {
|
||||
var onWake: (() -> Void)? = nil
|
||||
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||
var onEdit: (() -> Void)? = nil
|
||||
/// This card's profile affordances — nil on surfaces that don't offer them.
|
||||
var profileMenu: HostProfileMenu? = nil
|
||||
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
|
||||
/// which follows the binding.
|
||||
var pinnedProfile: StreamProfile? = nil
|
||||
|
||||
/// The profile this card announces: a pinned card's own, else the host's binding.
|
||||
private var shownProfile: StreamProfile? {
|
||||
pinnedProfile ?? profileMenu.flatMap { menu in
|
||||
menu.boundID.flatMap { id in menu.profiles.first { $0.id == id } }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
@@ -99,17 +135,35 @@ struct HostCardView: View {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
// The chip rides the TITLE line, anchored to the card's trailing edge — not
|
||||
// trailing the name, where it read as part of the title, and not on a line of
|
||||
// its own, where it made cards with a profile taller than cards without and a
|
||||
// pinned card stuck out of its grid row. The spacer is what anchors it: the
|
||||
// name truncates against that gap instead of ever running into the chip.
|
||||
HStack(spacing: 6) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if let profile = shownProfile {
|
||||
ProfileChip(
|
||||
profile: profile, size: m.status,
|
||||
prominent: pinnedProfile != nil)
|
||||
// The name gives up width first: a truncated host name still reads,
|
||||
// a truncated profile name is the one thing the chip exists to say.
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
statusRow(m)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
// Fills the card so the title row's trailing spacer reaches the real edge; without
|
||||
// it the text column hugs its content and "trailing" means "just after the name".
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -138,10 +192,32 @@ struct HostCardView: View {
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
.contextMenu {
|
||||
.contextMenu { menuItems }
|
||||
}
|
||||
|
||||
@ViewBuilder private var menuItems: some View {
|
||||
if let pinned = pinnedProfile, let menu = profileMenu {
|
||||
// A pinned card is a shortcut, not a second host: only connect-shaped actions, plus
|
||||
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
|
||||
// the host's default binding.
|
||||
connectWithMenu(menu)
|
||||
if LinkClipboard.isAvailable {
|
||||
Button("Copy Link") { menu.copyLink(pinned.id) }
|
||||
}
|
||||
Button("Unpin Card", systemImage: "pin.slash", role: .destructive) {
|
||||
menu.togglePin(pinned.id)
|
||||
}
|
||||
} else {
|
||||
if let onEdit {
|
||||
Button("Edit…", systemImage: "pencil", action: onEdit)
|
||||
}
|
||||
if let menu = profileMenu {
|
||||
connectWithMenu(menu)
|
||||
pinMenu(menu)
|
||||
if LinkClipboard.isAvailable {
|
||||
Button("Copy Link") { menu.copyLink(nil) }
|
||||
}
|
||||
}
|
||||
Button("Pair with PIN…", action: onPair)
|
||||
Button("Test Network Speed…", action: onSpeedTest)
|
||||
if let onBrowseLibrary {
|
||||
@@ -159,10 +235,76 @@ struct HostCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Connect with ▸" — a ONE-OFF pick that never rebinds the host, with a checkmark on what a
|
||||
/// plain click would use. Its last item is the explicit way to rebind, for the users who do
|
||||
/// want that from here.
|
||||
@ViewBuilder private func connectWithMenu(_ menu: HostProfileMenu) -> some View {
|
||||
if !menu.profiles.isEmpty {
|
||||
Menu("Connect with") {
|
||||
Button {
|
||||
menu.connectWith(.defaults)
|
||||
} label: {
|
||||
checkable("Default settings", on: menu.boundID == nil)
|
||||
}
|
||||
ForEach(menu.profiles) { profile in
|
||||
Button {
|
||||
menu.connectWith(.profile(profile.id))
|
||||
} label: {
|
||||
checkable(profile.name, on: menu.boundID == profile.id)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Menu("Set Default Profile") {
|
||||
Button("Default settings") { menu.setDefault(nil) }
|
||||
ForEach(menu.profiles) { profile in
|
||||
Button(profile.name) { menu.setDefault(profile.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Pin as card ▸" — a host+profile combo gets its own one-click card in the grid, which is
|
||||
/// what turns a regularly-used profile from a menu dive into a press (§5.2a).
|
||||
@ViewBuilder private func pinMenu(_ menu: HostProfileMenu) -> some View {
|
||||
if !menu.profiles.isEmpty {
|
||||
Menu("Pin as Card") {
|
||||
ForEach(menu.profiles) { profile in
|
||||
Button {
|
||||
menu.togglePin(profile.id)
|
||||
} label: {
|
||||
checkable(profile.name, on: menu.pinnedIDs.contains(profile.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu row that carries a checkmark when it is the current choice. Built as two shapes
|
||||
/// rather than one `Label` with an empty symbol name — `Image(systemName: "")` is not a
|
||||
/// blank image, it is an invalid one.
|
||||
@ViewBuilder private func checkable(_ title: String, on: Bool) -> some View {
|
||||
if on {
|
||||
Label(title, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
|
||||
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
// The host's OS mark leads the row (template asset — tints like an SF Symbol);
|
||||
// absent entirely for a host that never advertised one, so those cards render
|
||||
// exactly as they always did.
|
||||
if let mark = osIconImage(for: host.osChain) {
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: m.status + 2, height: m.status + 2)
|
||||
.accessibilityLabel(host.osChain ?? "")
|
||||
}
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
@@ -181,6 +323,36 @@ struct HostCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile a card connects with, as a tinted pill. Quiet on a bound primary card (it only
|
||||
/// answers "what will a click do?"); prominent on a pinned card, where the profile IS the reason
|
||||
/// the card exists — which is where the catalog's `accent` earns its keep.
|
||||
///
|
||||
/// Prominence is fill and weight only, never TYPE SIZE: the chip sits on the card's title line,
|
||||
/// and a chip taller than the name would make pinned cards taller than their host's.
|
||||
struct ProfileChip: View {
|
||||
let profile: StreamProfile
|
||||
let size: CGFloat
|
||||
var prominent = false
|
||||
|
||||
var body: some View {
|
||||
let tint = profile.accentColor
|
||||
return HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(tint)
|
||||
.frame(width: size * 0.55, height: size * 0.55)
|
||||
.accessibilityHidden(true) // the name is right there
|
||||
Text(profile.name)
|
||||
.font(.geist(size, prominent ? .bold : .semibold, relativeTo: .caption2))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(tint)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(tint.opacity(prominent ? 0.24 : 0.12)))
|
||||
.accessibilityLabel("Profile \(profile.name)")
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
@@ -203,6 +375,14 @@ struct DiscoveredCardView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
// Same leading OS mark as a saved card's status row — live from the advert.
|
||||
if let mark = osIconImage(for: discovered.osChain) {
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: m.status + 2, height: m.status + 2)
|
||||
.accessibilityLabel(discovered.osChain)
|
||||
}
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
|
||||
@@ -34,10 +34,10 @@ struct SpeedTestSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let host: StoredHost
|
||||
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
/// The catalog, so the Apply button can write to the layer this host actually reads its
|
||||
/// bitrate from (design/client-settings-profiles.md §5.3).
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
private enum Phase: Equatable {
|
||||
case connecting
|
||||
@@ -87,14 +87,7 @@ struct SpeedTestSheet: View {
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
if case .done(let result) = phase, let rec = Self.recommendedKbps(result) {
|
||||
Button("Use \(Self.mbpsLabel(kbps: rec))") {
|
||||
bitrateKbps = rec
|
||||
dismiss()
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
applyButtons(rec)
|
||||
}
|
||||
if case .failed = phase {
|
||||
Button("Retry") { run() }
|
||||
@@ -122,6 +115,45 @@ struct SpeedTestSheet: View {
|
||||
.onDisappear { token.cancelled = true }
|
||||
}
|
||||
|
||||
/// Apply writes to **the layer this host actually resolves its bitrate from** (§5.3) — the
|
||||
/// sharpest symptom of the missing feature was a per-host measurement overwriting the global.
|
||||
///
|
||||
/// • unbound host → the global, exactly as before;
|
||||
/// • bound to a profile that already overrides bitrate → that override;
|
||||
/// • bound to a profile that INHERITS bitrate → both are offered, because either is a
|
||||
/// defensible answer and guessing would silently pick one.
|
||||
///
|
||||
/// Every button names its target, so what a click changes is never inferred from context.
|
||||
@ViewBuilder private func applyButtons(_ recommended: Int) -> some View {
|
||||
let bound = profiles.binding(for: host)
|
||||
let label = Self.mbpsLabel(kbps: recommended)
|
||||
if let bound {
|
||||
Button("Apply to “\(bound.name)”") {
|
||||
profiles.setOverride(bound.id, \.bitrateKbps, recommended)
|
||||
dismiss()
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
if bound.overrides.bitrateKbps == nil {
|
||||
Button("Set as default (\(label))") {
|
||||
bitrateKbps = recommended
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Use \(label)") {
|
||||
bitrateKbps = recommended
|
||||
dismiss()
|
||||
}
|
||||
.glassProminentButtonStyle()
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var phaseIsFinal: Bool {
|
||||
switch phase {
|
||||
case .done, .failed: return true
|
||||
@@ -190,7 +222,12 @@ struct SpeedTestSheet: View {
|
||||
let address = host.address
|
||||
let port = host.port
|
||||
let pin = host.pinnedSHA256
|
||||
let (w, h, fps) = (UInt32(clamping: width), UInt32(clamping: height), UInt32(clamping: hz))
|
||||
// Probe at the mode this host would actually stream at — its profile's, if it is bound to
|
||||
// one. The measurement IS the streaming path, so it should be the streaming path's mode.
|
||||
let mode = EffectiveSettings.resolve(host: host, catalog: profiles.catalog)
|
||||
let (w, h, fps) = (
|
||||
UInt32(clamping: mode.width), UInt32(clamping: mode.height),
|
||||
UInt32(clamping: mode.refreshHz))
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// Connect (blocking) — same identity/trust as a session, but TOFU results are
|
||||
// NOT persisted from here.
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an
|
||||
// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process
|
||||
// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them.
|
||||
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
|
||||
// Deliberately thin: every action already has an internal entry point — the deep-link router
|
||||
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
|
||||
// the existing Wake-on-LAN path — so these intents only wrap them.
|
||||
//
|
||||
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent
|
||||
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the
|
||||
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in
|
||||
// PunktfunkShared so the widget's configuration intent can share it.
|
||||
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
|
||||
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
|
||||
// ask. Only the AppShortcutsProvider stays iOS-gated, because it bundles `EndStreamIntent` — a
|
||||
// LiveActivityIntent, and ActivityKit is iPhone/iPad only.
|
||||
//
|
||||
// `HostEntity` and `ProfileEntity` (the parameter types) live in PunktfunkShared so a widget's
|
||||
// configuration intent, which executes in the EXTENSION process, can share them.
|
||||
|
||||
#if os(iOS)
|
||||
#if canImport(AppIntents)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
@@ -21,9 +25,10 @@ private func loadStoredHost(_ id: UUID) -> StoredHost? {
|
||||
return hosts.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and
|
||||
/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval
|
||||
/// sheet all apply, and its guards (unknown host, already-streaming) hold.
|
||||
/// Start a session with a stored host (optionally launching a title, optionally with a settings
|
||||
/// profile). Foregrounds the app and routes through the SAME `.onOpenURL` path a widget tap uses —
|
||||
/// trust policy, WoL and the approval sheet all apply, and its guards (unknown host, ambiguous or
|
||||
/// unknown profile, already-streaming) hold. One router, not a second connect path.
|
||||
struct ConnectToHostIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Connect to Host"
|
||||
static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
|
||||
@@ -32,9 +37,13 @@ struct ConnectToHostIntent: AppIntent {
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
@Parameter(title: "Game ID", description: "Optional store id like steam:570")
|
||||
var launchID: String?
|
||||
@Parameter(
|
||||
title: "Profile",
|
||||
description: "Which settings profile to use — leave empty for the host's default")
|
||||
var profile: ProfileEntity?
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let url = DeepLink.connect(host: host.id, launchID: launchID).url
|
||||
let url = DeepLink.connect(host: host.id, launchID: launchID, profile: profile?.id).url
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
||||
}
|
||||
@@ -74,8 +83,11 @@ enum IntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
|
||||
/// well under the 10-shortcut cap.
|
||||
/// well under the 10-shortcut cap. Phrases stay host-parameterized — App Shortcut phrases can't
|
||||
/// embed a second arbitrary `AppEntity`, so the profile is picked in the Shortcuts editor. No
|
||||
/// phrase regression.
|
||||
struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
@@ -100,3 +112,4 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -114,7 +114,7 @@ private struct ShotHome: View {
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
connect: { _, _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||
#else
|
||||
HomeView(
|
||||
@@ -122,7 +122,7 @@ private struct ShotHome: View {
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
showSettings: .constant(false),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
connect: { _, _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
|
||||
#endif
|
||||
}
|
||||
@@ -141,7 +141,7 @@ private struct ShotGamepadHome: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ private struct ShotConnect: View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ final class SessionModel: ObservableObject {
|
||||
@Published private(set) var connection: PunktfunkConnection?
|
||||
/// The host this session is for (a value copy; identity = id).
|
||||
@Published private(set) var activeHost: StoredHost?
|
||||
/// The settings THIS session runs on — the globals with its profile overlaid, resolved once at
|
||||
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
|
||||
/// the readers that live in PunktfunkKit and can't see this model.
|
||||
@Published private(set) var settings = EffectiveSettings()
|
||||
/// The stats-overlay tier for this session: the resolved one at connect, then whatever the
|
||||
/// live cycle surfaces (⌃⌥⇧S, the three-finger tap) move it to. Separate from the @AppStorage
|
||||
/// global so a profile that overrides the tier actually gets it, without the cycle breaking.
|
||||
@Published var statsVerbosity: StatsVerbosity = .normal
|
||||
@Published var errorMessage: String?
|
||||
@Published var fps = 0
|
||||
@Published var mbps = 0.0
|
||||
@@ -218,13 +226,13 @@ final class SessionModel: ObservableObject {
|
||||
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
|
||||
/// advertising). It never fires for the delegated-approval path, whose failure text carries
|
||||
/// its own instructions.
|
||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||
compositor: PunktfunkConnection.Compositor = .auto,
|
||||
/// `effective` is the whole stream mode + input/audio configuration for this session, already
|
||||
/// resolved from the globals and the session's profile by the caller — the ONE place that
|
||||
/// resolution happens (§4.4). It is latched into `SessionSettings` here so the kit-side
|
||||
/// readers (the presenter, the input paths, the match-window follower) see the same values
|
||||
/// this connect asked the host for, instead of re-reading the globals mid-session.
|
||||
func connect(to host: StoredHost, effective: EffectiveSettings,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
bitrateKbps: UInt32 = 0,
|
||||
audioChannels: UInt8 = 2,
|
||||
hdrEnabled: Bool = true,
|
||||
preferredCodec: UInt8 = 0,
|
||||
launchID: String? = nil,
|
||||
allowTofu: Bool = false,
|
||||
autoTrust: Bool = false,
|
||||
@@ -234,6 +242,21 @@ final class SessionModel: ObservableObject {
|
||||
phase = .connecting
|
||||
activeHost = host
|
||||
errorMessage = nil
|
||||
settings = effective
|
||||
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
|
||||
SessionSettings.begin(effective)
|
||||
let mode = RenderScale.apply(
|
||||
baseWidth: effective.width, baseHeight: effective.height,
|
||||
scale: effective.renderScale,
|
||||
maxDimension: RenderScale.maxDimension(codec: effective.codec))
|
||||
let (width, height) = (mode.width, mode.height)
|
||||
let hz = UInt32(clamping: effective.refreshHz)
|
||||
let compositor = PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
|
||||
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||||
let audioChannels = UInt8(clamping: effective.audioChannels)
|
||||
let hdrEnabled = effective.hdrEnabled
|
||||
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
|
||||
let pin = host.pinnedSHA256
|
||||
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||
@@ -266,7 +289,7 @@ final class SessionModel: ObservableObject {
|
||||
// doesn't visibly need, and the encode/decode pixel rate rises. The host allows it by
|
||||
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
|
||||
// hardware-decode probe below still gates what can actually be advertised.
|
||||
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? false
|
||||
let want444 = effective.enable444
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||
// actor. The persistent identity is presented on every connect so a paired
|
||||
@@ -313,9 +336,7 @@ final class SessionModel: ObservableObject {
|
||||
// NSCursor. Capture-mode sessions keep today's composited pointer.
|
||||
#if os(macOS)
|
||||
let clientCaps: UInt8 =
|
||||
(MouseInputMode(
|
||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "")
|
||||
?? .capture) == .desktop ? 0x01 : 0
|
||||
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
|
||||
#else
|
||||
let clientCaps: UInt8 = 0
|
||||
#endif
|
||||
@@ -340,6 +361,9 @@ final class SessionModel: ObservableObject {
|
||||
if case .success(let conn) = result {
|
||||
Task.detached { conn.close() } // joins Rust threads — off-main
|
||||
}
|
||||
// A LATER connect has already latched its own settings; only release the
|
||||
// latch when nothing took over, or this would blank the live session's.
|
||||
if self.phase == .idle { SessionSettings.end() }
|
||||
return
|
||||
}
|
||||
switch result {
|
||||
@@ -362,12 +386,14 @@ final class SessionModel: ObservableObject {
|
||||
Task.detached { conn.close() } // joins Rust threads — off-main
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
SessionSettings.end() // no session, so nothing may keep its settings latched
|
||||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||||
+ "Pair with its PIN before streaming."
|
||||
}
|
||||
case .failure(let error):
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
SessionSettings.end() // the dial failed — back to the plain globals
|
||||
if case PunktfunkClientError.rejected(let rejection) = error {
|
||||
// The host answered and stated its reason (declined / approval timed
|
||||
// out / busy / versions differ) — show that, and never wake-retry a
|
||||
@@ -443,6 +469,16 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func setStatsVerbosity(_ tier: StatsVerbosity) {
|
||||
guard statsVerbosity != tier else { return }
|
||||
statsVerbosity = tier
|
||||
settings.statsVerbosity = tier.rawValue
|
||||
SessionSettings.setStatsVerbosity(tier.rawValue)
|
||||
}
|
||||
|
||||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||
func confirmTrust() -> Data? {
|
||||
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
||||
@@ -460,6 +496,9 @@ final class SessionModel: ObservableObject {
|
||||
func disconnect(deliberate: Bool = true) {
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
// Release the session's resolved settings: from here every reader falls back to the plain
|
||||
// globals, which is exactly what they saw before profiles existed.
|
||||
SessionSettings.end()
|
||||
// No-op when this session never reached `.streaming` (a refused/aborted connect).
|
||||
displaySleepGuard.release()
|
||||
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
||||
@@ -559,15 +598,15 @@ final class SessionModel: ObservableObject {
|
||||
phase = .streaming
|
||||
displaySleepGuard.acquire()
|
||||
// Audio starts with streaming, not during the trust prompt — no host sound (or
|
||||
// mic uplink!) before the user trusted the host. Devices come from Settings;
|
||||
// "" = system default.
|
||||
let defaults = UserDefaults.standard
|
||||
// mic uplink!) before the user trusted the host. Devices and the mic switch come from the
|
||||
// session's resolved settings ("" = system default), so a profile that turns the mic on
|
||||
// for work calls applies to the uplink too.
|
||||
let audio = SessionAudio(connection: conn)
|
||||
audio.start(
|
||||
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
|
||||
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
|
||||
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||
speakerUID: settings.speakerUID,
|
||||
micUID: settings.micUID,
|
||||
micChannel: settings.micChannel,
|
||||
micEnabled: settings.micEnabled)
|
||||
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
|
||||
|
||||
@@ -43,17 +43,13 @@ extension FocusedValues {
|
||||
|
||||
struct StreamCommands: Commands {
|
||||
@FocusedValue(\.sessionFocus) private var session
|
||||
// The raw string so @AppStorage observes the shared key; the absent-key default runs the
|
||||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
|
||||
var body: some Commands {
|
||||
CommandMenu("Stream") {
|
||||
Button("Cycle Statistics") {
|
||||
let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||
statsVerbosityRaw = current.next().rawValue
|
||||
}
|
||||
// Through the shared cycle so it advances from the LIVE session's tier — a profile
|
||||
// that starts a session on Detailed must cycle to Off from here, not from whatever
|
||||
// the global default happens to be.
|
||||
Button("Cycle Statistics") { StatsVerbosity.cycle() }
|
||||
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
||||
// Reaches the key window's stream view via NotificationCenter — capture is view
|
||||
// state the Scene can't touch directly. (Captured, the combo is handled by
|
||||
|
||||
@@ -85,6 +85,16 @@ struct StreamHUDView: View {
|
||||
.frame(width: 7, height: 7)
|
||||
Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
// Which settings profile this session resolved to, if any (design §5.2). Near-zero
|
||||
// cost, and it answers "which profile am I on?" without leaving the stream —
|
||||
// otherwise the only evidence is the settings themselves, which is a guessing game.
|
||||
if let profile = model.settings.profileName {
|
||||
Text("· \(profile)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(
|
||||
Color(hex: model.settings.profileAccent ?? "") ?? Color.accentColor)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
if model.endToEndValid {
|
||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||
|
||||