Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
491a344f23 |
@@ -1,12 +1,9 @@
|
||||
# The root build context is used by web/Dockerfile (which needs web/ and
|
||||
# api/openapi.json) and by ci/rust-ci-arm64cross.Dockerfile (which needs the toolchain
|
||||
# pin). Allowlist those; keep everything else (target/, .git, crates) out of the
|
||||
# context upload.
|
||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
||||
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||
# out of the context upload.
|
||||
*
|
||||
!web
|
||||
!api/openapi.json
|
||||
!rust-toolchain.toml
|
||||
!ci/pf-host-cc
|
||||
web/node_modules
|
||||
web/.output
|
||||
web/dist
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
# `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:
|
||||
@@ -26,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -47,10 +39,7 @@ jobs:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
# 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') }}
|
||||
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }}
|
||||
restore-keys: android-screenshots-
|
||||
|
||||
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
|
||||
|
||||
@@ -8,42 +8,15 @@
|
||||
# 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:
|
||||
@@ -53,7 +26,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.3 runs on JDK 17–21, not the host default)
|
||||
- name: JDK 21 (AGP 9.2 runs on JDK 17–21, not the host default)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -73,23 +46,13 @@ jobs:
|
||||
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
||||
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
with:
|
||||
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain
|
||||
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred).
|
||||
# That download was the single flakiest piece of this job: the shared runner fleet drops
|
||||
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated
|
||||
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice).
|
||||
packages: platform-tools
|
||||
|
||||
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
||||
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
||||
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||
# auto-download it if needed during the build.
|
||||
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
|
||||
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
|
||||
# attempt leaves no partial package behind, so a plain re-invoke is safe.
|
||||
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||
|
||||
- name: Caches (cargo + gradle)
|
||||
uses: actions/cache@v4
|
||||
@@ -100,8 +63,7 @@ jobs:
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
target
|
||||
# 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') }}
|
||||
key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts') }}
|
||||
restore-keys: android-
|
||||
|
||||
- name: cargo-ndk
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Announce a stable release to the Discord #releases channel.
|
||||
#
|
||||
# This is the deliberate "go" step for a release. Release notes live in the repo at
|
||||
# docs/releases/<tag>.md and are seeded into the Gitea release body at creation by the build
|
||||
# workflows (scripts/ci/gitea-release.sh), so the release is never noteless. Once every
|
||||
# platform's CI is green for a tag, dispatch this workflow with that tag: it re-asserts the notes
|
||||
# file over the live release and posts a formatted embed to #releases.
|
||||
#
|
||||
# Manual on purpose — pressing "go" is the quality gate that says "all platforms built, notes are
|
||||
# final, tell the community." It is NOT wired to the tag push, so a half-built or failed release
|
||||
# is never announced. Stable-only: a -rc/pre-release tag is refused unless allow_prerelease=true.
|
||||
#
|
||||
# Requires the repo secret DISCORD_RELEASE_WEBHOOK (the #releases channel webhook URL); GITEA auth
|
||||
# reuses REGISTRY_TOKEN like the other release workflows.
|
||||
name: announce
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to announce (e.g. v0.18.0)"
|
||||
required: true
|
||||
allow_prerelease:
|
||||
description: "Announce even if the tag is a pre-release (-rc)"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Post release announcement to Discord
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
||||
@@ -8,40 +8,11 @@
|
||||
# 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,36 +14,10 @@
|
||||
# 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.
|
||||
@@ -138,48 +112,10 @@ jobs:
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
|
||||
# completely different dependency set, published into the same repo so `pacman -S
|
||||
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
||||
#
|
||||
# CACHED on `packaging/gamescope/**`: it depends on nothing else in this repo, so a normal
|
||||
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
|
||||
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
|
||||
# against newer system libs is the same risk the distro's own package carries between rebuilds.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: dist-gamescope
|
||||
key: punktfunk-gamescope-arch-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build punktfunk-gamescope (makepkg)
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort: punktfunk-host works without it (SDR on the gamescope backend), and a
|
||||
# failure building gamescope must not cost the packages this workflow exists to publish.
|
||||
run: |
|
||||
set -x
|
||||
pacman -S --noconfirm --needed \
|
||||
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
|
||||
libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
|
||||
hwdata luajit pipewire seatd sdl2-compat vulkan-icd-loader wayland \
|
||||
xcb-util-errors xcb-util-wm xorg-xwayland \
|
||||
meson cmake glm wayland-protocols benchmark libxcursor || true
|
||||
mkdir -p dist-gamescope && chown builder: dist-gamescope
|
||||
chown -R builder: packaging/gamescope
|
||||
if sudo -u builder env PKGDEST="$GITHUB_WORKSPACE/dist-gamescope" \
|
||||
bash -c 'cd packaging/gamescope && makepkg -f -d --holdver'; then
|
||||
ls -lh dist-gamescope
|
||||
else
|
||||
echo "::warning::punktfunk-gamescope failed to build — Arch boxes stay SDR on the gamescope backend this run"
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
# The gamescope companion rides the same loop (same repo, same channel).
|
||||
cp -f dist-gamescope/*.pkg.tar.zst dist/ 2>/dev/null || true
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
echo "uploading $pkg"
|
||||
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
|
||||
|
||||
@@ -1,64 +1,32 @@
|
||||
# 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).
|
||||
# Supply-chain advisory scan for BOTH dependency trees the project ships to users:
|
||||
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
|
||||
# * 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.
|
||||
# * 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.
|
||||
# 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'
|
||||
- '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'
|
||||
paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cargo-audit:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
image: git.unom.io/unom/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/bin
|
||||
/usr/local/cargo/registry
|
||||
path: /usr/local/cargo
|
||||
key: cargo-audit-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-audit-
|
||||
- name: cargo audit
|
||||
@@ -68,17 +36,13 @@ 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: ${{ matrix.tree }}
|
||||
working-directory: web
|
||||
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).
|
||||
@@ -86,75 +50,9 @@ 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 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).
|
||||
# `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).
|
||||
- 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
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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,58 +1,23 @@
|
||||
# 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,
|
||||
# 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,
|
||||
# PipeWire, GL/GBM, libcuda link stub, pinned-channel rustup) so the workspace links the
|
||||
# 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.
|
||||
# same libs as the dev boxes. Apple client CI lives in apple.yml (macOS runner).
|
||||
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: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
image: git.unom.io/unom/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.
|
||||
@@ -61,30 +26,6 @@ jobs:
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
|
||||
# The committed pf-zerocopy SPIR-V blobs are pulled in with include_bytes! and rebuilt only
|
||||
# by hand — edit a .comp, forget the rebuild, and the OLD kernel ships with no compile error
|
||||
# or failing test. Recompile each shader and diff the disassembly. Filtering OpSourceExtension
|
||||
# (+ --no-header) is exactly what absorbs the shaderc-vs-glslang generator difference; every
|
||||
# instruction, ID and constant must match.
|
||||
#
|
||||
# Disassemble to FILES rather than `diff <(…) <(…)`: Gitea's runner executes a step's `run:`
|
||||
# under `sh -e`, not bash, and dash has no process substitution — the shell rejected the
|
||||
# script at PARSE time, so the gate never compared anything. Worse, it took the whole `rust`
|
||||
# job with it: Format, Clippy, Build, Test and every gate below were skipped on each of the
|
||||
# 35 commits between the gate landing (143a707f) and this fix. A gate that cannot run is
|
||||
# indistinguishable from one that passes, which is exactly the failure it exists to prevent.
|
||||
- name: Shader SPIR-V drift gate (pf-zerocopy)
|
||||
run: |
|
||||
apt-get install -y --no-install-recommends glslang-tools spirv-tools
|
||||
for s in rgb2nv12_buf cursor_blend; do
|
||||
d=crates/pf-zerocopy/src/imp
|
||||
glslangValidator -V "$d/$s.comp" -o "/tmp/$s.spv" >/dev/null
|
||||
spirv-dis --no-header "$d/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.committed"
|
||||
spirv-dis --no-header "/tmp/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.rebuilt"
|
||||
diff "/tmp/$s.committed" "/tmp/$s.rebuilt" \
|
||||
|| { echo "::error::$d/$s.spv is stale — rebuild it from $s.comp"; exit 1; }
|
||||
done
|
||||
|
||||
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
@@ -120,49 +61,9 @@ jobs:
|
||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
|
||||
# `cargo build`, where warnings are not errors, so pf-encode's own
|
||||
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
|
||||
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
|
||||
# `default = ["pyrowave"]`, so the steps above already cover it.)
|
||||
#
|
||||
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
|
||||
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
|
||||
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
|
||||
# (E0061 x10) without any job noticing.
|
||||
#
|
||||
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
|
||||
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
|
||||
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
|
||||
# can only type-check these tests via clippy — see the note there.)
|
||||
#
|
||||
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
|
||||
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
|
||||
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
|
||||
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
|
||||
# re-run its entire test suite a second time for no extra coverage.
|
||||
#
|
||||
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
|
||||
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
|
||||
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
|
||||
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
|
||||
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
|
||||
# that combination is what deserves the lint.
|
||||
- name: Clippy + test the feature-gated Linux encode backends
|
||||
run: |
|
||||
cargo clippy -p pf-encode --all-targets --locked \
|
||||
--features nvenc,vulkan-encode,pyrowave -- -D warnings
|
||||
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
|
||||
|
||||
- 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
|
||||
@@ -170,63 +71,6 @@ jobs:
|
||||
git diff --exit-code include/punktfunk_core.h \
|
||||
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
||||
|
||||
# The client stack cross-checked for aarch64. NOT an artifact job — deb.yml ships those —
|
||||
# this exists so a portability defect fails here instead of surfacing in a release build or
|
||||
# on a user's board. It earns its runtime: the bug that motivated it (a Vulkan extension
|
||||
# array typed `*const i8`, where `c_char` is signed on x86_64 and UNSIGNED on aarch64)
|
||||
# compiled cleanly on every target CI built at the time.
|
||||
#
|
||||
# Client crates only, listed explicitly: the host's encode stack is x86 (NVENC/QSV/AMF) and
|
||||
# `--workspace` would drag it in. Runs in the cross image (amd64 toolchain + arm64 sysroot,
|
||||
# ci/rust-ci-arm64cross.Dockerfile) on the ordinary runner — no arm64 runner involved.
|
||||
rust-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
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
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Clippy for aarch64 (deny warnings)
|
||||
run: |
|
||||
cargo clippy --target aarch64-unknown-linux-gnu --all-targets --locked \
|
||||
-p punktfunk-core -p pf-client-core -p pf-presenter -p pf-console-ui \
|
||||
-p punktfunk-client-session -p punktfunk-client-linux \
|
||||
-- -D warnings
|
||||
|
||||
# The minimal embedded build — no Skia, no PyroWave — is what a small image installs, so
|
||||
# it has to keep compiling on its own, not just as a subset of the default features.
|
||||
- name: Build the session binary, minimal features
|
||||
run: |
|
||||
cargo build --release --target aarch64-unknown-linux-gnu --locked \
|
||||
-p punktfunk-client-session --no-default-features
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
@@ -275,3 +119,25 @@ 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
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
|
||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||
# apt distribution/component:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
# build-publish-client-arm64
|
||||
# — the same client package for arm64, CROSS-compiled on the same amd64
|
||||
# runner in the rust-ci-arm64cross image. No host counterpart: the Linux
|
||||
# host's encode stack is x86 (NVENC/QSV/AMF).
|
||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||
@@ -23,36 +19,10 @@
|
||||
#
|
||||
# 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
|
||||
@@ -64,35 +34,16 @@ 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: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
image: git.unom.io/unom/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
|
||||
@@ -147,15 +98,10 @@ jobs:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
|
||||
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
|
||||
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
|
||||
# three; leaving punktfunk-cli out here made it fall over on `install: No such file or
|
||||
# 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
|
||||
# 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
|
||||
|
||||
- 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
|
||||
@@ -234,21 +180,11 @@ jobs:
|
||||
build-publish-host:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
|
||||
image: git.unom.io/unom/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"
|
||||
@@ -294,18 +230,11 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
|
||||
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
|
||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). ffmpeg-sys-next links the image's
|
||||
# bundled FFmpeg 8 via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||
#
|
||||
# punktfunk-tray is deliberately NOT in this invocation — build-deb.sh builds it separately,
|
||||
# and that split is load-bearing (see the identical note in the RPM spec / Arch PKGBUILD):
|
||||
# cargo unifies features across one build, so co-building the tray with the host pulls the
|
||||
# host's ashpd -> zbus/tokio onto the tray's shared zbus and the tray panics at every launch
|
||||
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
|
||||
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
|
||||
# Arch packages — which already split it — were fine.
|
||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
|
||||
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
|
||||
# via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host
|
||||
-p punktfunk-host -p punktfunk-tray
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
@@ -340,104 +269,3 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
||||
# ci/rust-ci-arm64cross.Dockerfile); there is no arm64 runner in the fleet and none is needed.
|
||||
# Client only, by decision: the Linux host encodes with NVENC/QSV/AMF, all x86.
|
||||
# Publishes to the same distribution/component as the amd64 jobs — the apt registry keys pool
|
||||
# entries by arch, so `apt` on an arm64 box picks this one up with no client-side configuration.
|
||||
build-publish-client-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
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
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "package version $V -> apt distribution '$DIST' (arm64)"
|
||||
|
||||
# dpkg-shlibdeps + dpkg-deb. The arm64 link deps themselves are the cross image's whole
|
||||
# point and are already baked in; python3 is for scripts/ci/gitea-release.sh.
|
||||
- name: dpkg-dev
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
|
||||
# and must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build the arm64 client .deb
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
ARCH=arm64 TARGET=aarch64-unknown-linux-gnu \
|
||||
bash packaging/debian/build-client-deb.sh
|
||||
# Fail here rather than shipping an amd64 binary under an arm64 package name.
|
||||
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
|
||||
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/*.deb; do
|
||||
echo "uploading $DEB"
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published arm64 client to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the arm64 .deb to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
#
|
||||
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
||||
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
||||
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
|
||||
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
|
||||
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
|
||||
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
|
||||
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
|
||||
# top: the {channel, manifest} pointer the plugin's self-update check polls.
|
||||
# We build the frontend with pnpm and assemble the store-layout zip by hand:
|
||||
#
|
||||
# punktfunk.zip
|
||||
# punktfunk/ <- single top-level dir == plugin.json "name"
|
||||
# plugin.json [required]
|
||||
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
||||
# main.py [required: python backend]
|
||||
# dist/index.js [required: rollup output]
|
||||
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
||||
# README.md (recommended)
|
||||
# LICENSE [required by the plugin store]
|
||||
#
|
||||
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||
@@ -20,25 +25,10 @@
|
||||
#
|
||||
# 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:
|
||||
|
||||
@@ -100,27 +90,28 @@ jobs:
|
||||
- name: Assemble store-layout zip
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
|
||||
# so an image change can't silently break the build.
|
||||
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
|
||||
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
|
||||
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
|
||||
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
|
||||
bash clients/decky/scripts/package.sh
|
||||
DEST="clients/decky/out/$PLUGIN"
|
||||
# CI-only addition: the self-update channel pointer the backend reads (main.py
|
||||
# check_update). It points at THIS channel's manifest.json (published below); that
|
||||
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
|
||||
# across future alias re-uploads.
|
||||
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||
STAGE="$RUNNER_TEMP/decky"
|
||||
DEST="$STAGE/$PLUGIN"
|
||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
cp clients/decky/plugin.json "$DEST/"
|
||||
cp clients/decky/package.json "$DEST/"
|
||||
cp clients/decky/main.py "$DEST/"
|
||||
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||
cp clients/decky/README.md "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
||||
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
||||
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||
cp LICENSE-MIT "$DEST/LICENSE"
|
||||
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
||||
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
||||
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
||||
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
|
||||
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
|
||||
controller_config/punktfunk.vdf update.json; do
|
||||
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
|
||||
done
|
||||
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||
|
||||
@@ -99,41 +99,3 @@ jobs:
|
||||
mkdir -p ~/unom-flatpak/site/repo
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
|
||||
winget:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync winget source compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
|
||||
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
|
||||
target: "~/unom-winget"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start winget REST source
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
|
||||
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
|
||||
# source exists so the container starts; it serves 503 until the first catalogue lands.
|
||||
mkdir -p ~/unom-winget/data
|
||||
cd ~/unom-winget
|
||||
docker compose -f compose.production.yml up -d
|
||||
# Surface a missing catalogue here rather than letting winget report "no package found".
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
# 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.
|
||||
#
|
||||
# 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-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
|
||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
|
||||
# the LAN registry is unauthenticated inside the LAN).
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
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:
|
||||
@@ -42,142 +22,9 @@ on:
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
CI_REGISTRY: 192.168.1.58:5010
|
||||
|
||||
jobs:
|
||||
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:
|
||||
build-push:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
@@ -189,6 +36,23 @@ 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
|
||||
|
||||
@@ -203,7 +67,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 \
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
|
||||
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
|
||||
@@ -222,7 +86,7 @@ jobs:
|
||||
# unom-ci-deploy key).
|
||||
deploy-docs:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: apps
|
||||
needs: build-push
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -19,14 +19,6 @@
|
||||
#
|
||||
# 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:
|
||||
@@ -81,21 +73,8 @@ jobs:
|
||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||
# there, right before the first `flatpak` network call.
|
||||
- name: Fix container DNS (drop nss-resolve)
|
||||
run: |
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
|
||||
# the runner fleet's Docker embedded resolver dropped UDP lookups under parallel-job
|
||||
# load (investigated 2026-07-11; v0.15.0/v0.16.0 each burned retry.sh's whole budget).
|
||||
# That root cause is now fixed at the infra level (2026-07-22): the runner host runs a
|
||||
# local dnsmasq cache on the docker bridge and daemon.json points every job container
|
||||
# at it, so lookups terminate on-box instead of crossing the saturated uplink — the
|
||||
# UDP path is reliable again. The TCP path through the same chain proved FLAKY under
|
||||
# fleet concurrency (flatpak remote-add failed 10/10 with instant NXDOMAIN while dnf
|
||||
# in the same container resolved fine), so `use-vc` flipped from mitigation to sole
|
||||
# cause of this leg's failures — removed. retry.sh (10×) stays as the backstop for
|
||||
# genuine upstream blips.
|
||||
cat /etc/resolv.conf || true
|
||||
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
|
||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||
@@ -135,25 +114,6 @@ 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,55 +5,23 @@
|
||||
# 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: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
image: git.unom.io/unom/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
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry
|
||||
# (https://git.unom.io/api/packages/unom/npm/).
|
||||
#
|
||||
# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"),
|
||||
# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags.
|
||||
#
|
||||
# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be
|
||||
# built BEFORE the kit's `bun install` copies it.
|
||||
#
|
||||
# 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:
|
||||
tags: ['plugin-kit-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
|
||||
# fetch needs git + ca-certificates, and the version-guard step below uses node.
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build the SDK (file:../sdk dependency source)
|
||||
working-directory: sdk
|
||||
run: |
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
# dangling self-reference. `dist/` therefore arrives intact while the manifest that points at
|
||||
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
|
||||
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
|
||||
# this step once bun links `file:` deps correctly again.
|
||||
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
|
||||
working-directory: plugin-kit
|
||||
run: |
|
||||
# -f follows the link, so this is true only when the manifest actually resolves.
|
||||
if test -f node_modules/@punktfunk/host/package.json; then
|
||||
echo "bun linked it correctly — this step can go"
|
||||
else
|
||||
rm -rf node_modules/@punktfunk/host
|
||||
cp -R ../sdk node_modules/@punktfunk/host
|
||||
fi
|
||||
test -f node_modules/@punktfunk/host/package.json
|
||||
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: plugin-kit
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test
|
||||
working-directory: plugin-kit
|
||||
run: bun test
|
||||
|
||||
- name: Build (dist/ JS + .d.ts + theme.css)
|
||||
working-directory: plugin-kit
|
||||
run: bun run build
|
||||
|
||||
- name: Tag matches package version
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
working-directory: plugin-kit
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#plugin-kit-v}"
|
||||
PKG="$(node -p "require('./package.json').version")"
|
||||
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
|
||||
|
||||
- name: Publish to Gitea registry
|
||||
working-directory: plugin-kit
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
||||
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
||||
bun publish
|
||||
@@ -56,14 +56,6 @@
|
||||
# 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:
|
||||
@@ -157,26 +149,6 @@ jobs:
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pin + prune Xcode DerivedData
|
||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
||||
run: |
|
||||
DD="$HOME/ci/derived-data/release"
|
||||
mkdir -p "$DD"
|
||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
@@ -204,7 +176,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
@@ -302,7 +273,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -366,7 +336,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -401,76 +370,6 @@ jobs:
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — export .ipa (Gitea release + run artifact)
|
||||
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
|
||||
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
|
||||
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
|
||||
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
|
||||
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
|
||||
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
id: ios_ipa
|
||||
run: |
|
||||
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
|
||||
if [ ! -d "$ARCHIVE" ]; then
|
||||
echo "::warning::iOS archive not found — skipping .ipa export"
|
||||
exit 0
|
||||
fi
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
|
||||
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
|
||||
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>export</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-ipa"
|
||||
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
|
||||
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
|
||||
mv "$SRC" "$IPA"
|
||||
echo "IPA=$IPA" >> "$GITHUB_ENV"
|
||||
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
|
||||
echo "exported $IPA"
|
||||
|
||||
- name: Attach .ipa to the workflow run
|
||||
if: steps.ios_ipa.outputs.ipa != ''
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
||||
# (same reason as android.yml / apple.yml). Download is a zip of the .ipa.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-ios-ipa
|
||||
path: ${{ steps.ios_ipa.outputs.ipa }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Attach .ipa to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
|
||||
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" "$IPA" "Punktfunk-$VERSION.ipa"
|
||||
|
||||
- name: tvOS — archive + upload to TestFlight
|
||||
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||
@@ -495,7 +394,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
|
||||
@@ -9,33 +9,10 @@
|
||||
#
|
||||
# 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.
|
||||
@@ -45,15 +22,6 @@ 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:
|
||||
@@ -72,23 +40,13 @@ jobs:
|
||||
group: fedora-44
|
||||
fedver: 44
|
||||
container:
|
||||
image: 192.168.1.58:5010/${{ matrix.image }}:latest
|
||||
image: git.unom.io/unom/${{ 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
|
||||
@@ -101,11 +59,6 @@ jobs:
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
|
||||
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
|
||||
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
|
||||
# luajit / seatd / SDL2 in. Cheap, and it tracks gamescope's dep list for us.
|
||||
dnf -y install gamescope || true
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
command -v bun >/dev/null || {
|
||||
@@ -117,8 +70,8 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: cargo-home-fedora-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-fedora-
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- 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>
|
||||
@@ -144,9 +97,7 @@ 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
|
||||
|
||||
# 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
|
||||
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
||||
env:
|
||||
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
|
||||
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
||||
@@ -173,87 +124,27 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# The HDR-capable gamescope the sysext carries (packaging/gamescope) — what lets the
|
||||
# gamescope backend stream 10-bit BT.2020 PQ instead of 8-bit SDR.
|
||||
#
|
||||
# CACHED, and that is the whole reason this is affordable: it is a ~10-minute C++ meson build
|
||||
# of an entirely separate tree that depends on NOTHING in this repo except
|
||||
# `packaging/gamescope/**` (the patches and the upstream pin, which lives in the build
|
||||
# script). So the key is that directory's hash and a normal push restores a binary instead of
|
||||
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
|
||||
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-f${{ matrix.fedver }}-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the HDR gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort ON PURPOSE. The sysext is the primary Bazzite delivery path and works without
|
||||
# this binary (the host just stays SDR on the gamescope backend, which is what every
|
||||
# release before this one did) — so a hiccup building someone else's tree must not cost the
|
||||
# whole image. It is loud, though: the warning below, and `--gamescope` silently absent
|
||||
# downstream is impossible because build-sysext.sh verifies the +pfhdr marker itself.
|
||||
run: |
|
||||
set -x
|
||||
# `dnf builddep` resolves Fedora's PACKAGED gamescope, which is older than the master we
|
||||
# pin, so it can come up short — xorg-x11-server-Xwayland-devel is the one that actually
|
||||
# bites (wlroots' configure dies on a missing xserver.wrap several minutes in).
|
||||
dnf -y install dnf-plugins-core meson ninja-build glslc || true
|
||||
dnf builddep -y gamescope || true
|
||||
dnf -y install xorg-x11-server-Xwayland-devel || true
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
- name: Build the sysext image
|
||||
run: |
|
||||
# Execute it here rather than only handing it over: build-sysext.sh treats an unusable
|
||||
# --version as fatal (rightly — it is how the +pfhdr marker is read), and a cached binary
|
||||
# whose runtime libs are missing from this container must cost the image its HDR, not the
|
||||
# image itself.
|
||||
gs=()
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
gs=(--gamescope gs-cache/punktfunk-gamescope)
|
||||
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
"${gs[@]}" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
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; OTHER="f${{ matrix.fedver }}" ;;
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0; OTHER="f${{ matrix.fedver }}-canary" ;;
|
||||
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
|
||||
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
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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,14 +7,6 @@
|
||||
# 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,14 +6,6 @@
|
||||
# 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,14 +11,6 @@
|
||||
# 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:
|
||||
@@ -161,9 +153,9 @@ jobs:
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
run: |
|
||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
#
|
||||
# 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). 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.
|
||||
# (import once to LocalMachine\TrustedPublisher). 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
|
||||
@@ -40,14 +38,6 @@
|
||||
# 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:
|
||||
@@ -60,23 +50,6 @@ on:
|
||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
||||
- 'crates/pf-encode/**'
|
||||
- 'crates/libvpl-sys/**'
|
||||
# …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
|
||||
# the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
|
||||
# adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
|
||||
# Windows host through one of THEM reached main with no Windows build at all — the same
|
||||
# Cargo.lock-luck gap the two lines above were added to close.
|
||||
- 'crates/pf-frame/**'
|
||||
- 'crates/pf-gpu/**'
|
||||
- 'crates/pf-zerocopy/**'
|
||||
- 'crates/pf-host-config/**'
|
||||
- 'crates/pf-capture/**'
|
||||
- 'crates/pf-win-display/**'
|
||||
- 'crates/pf-vdisplay/**'
|
||||
- 'crates/pf-inject/**'
|
||||
- 'crates/pf-paths/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'crates/pf-clipboard/**'
|
||||
- 'crates/pyrowave-sys/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -91,15 +64,6 @@ 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:
|
||||
@@ -132,9 +96,10 @@ 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; via GITHUB_ENV (pwsh Out-File utf8 = no BOM, unlike Windows
|
||||
# PowerShell 5.1 — keeps the first line clean).
|
||||
# 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).
|
||||
"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
|
||||
@@ -189,81 +154,10 @@ jobs:
|
||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
#
|
||||
# pf-encode, pf-capture and pf-vdisplay are linted SEPARATELY with --all-targets so their
|
||||
# Windows `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
|
||||
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
|
||||
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
|
||||
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
|
||||
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
|
||||
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
|
||||
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
|
||||
# feature juggling and pulls in no extra dep tree.
|
||||
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
|
||||
# vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
|
||||
# link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
|
||||
# only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
|
||||
# and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
|
||||
# build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
|
||||
# not worth it while ci.yml executes the same tests.
|
||||
#
|
||||
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
|
||||
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
|
||||
# links against nothing this runner lacks, and it reuses the release artifacts the steps
|
||||
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
|
||||
# truth table, the IDD generation masking — are Windows-only code that NO other job can
|
||||
# execute, so linting them was leaving real coverage on the table. See the run step below.
|
||||
run: |
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
||||
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
|
||||
cargo clippy --release -p pf-vdisplay --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-vdisplay clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Test (pf-capture, Windows)
|
||||
shell: pwsh
|
||||
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
|
||||
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
|
||||
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
|
||||
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
|
||||
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
|
||||
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
|
||||
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
|
||||
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
|
||||
# the whole set was type-checked by the clippy line above and nothing more.
|
||||
#
|
||||
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
|
||||
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
|
||||
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
|
||||
#
|
||||
# The link question this step turns on was settled empirically before it was added: the same
|
||||
# command was run on a Windows dev box against a workspace checkout and linked + executed
|
||||
# cleanly, building in ~51 s off an existing release target dir.
|
||||
run: |
|
||||
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
|
||||
|
||||
- name: Test (pf-vdisplay, Windows)
|
||||
shell: pwsh
|
||||
# pf-vdisplay's Windows half is ~3,400 lines (manager.rs, pf_vdisplay.rs, ddc.rs, the three
|
||||
# manager/ submodules) that NO other job compiles — the host lint above builds the crate as
|
||||
# a dependency, so its test targets were reaching no compiler anywhere. Worse, the only two
|
||||
# Windows `#[test]`s were `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`
|
||||
# early-returns, so an unrun hardware test reported `ok`. They are `#[ignore]`d now and this
|
||||
# step reports them as `ignored`, which is the truth.
|
||||
#
|
||||
# The link objection recorded for the host and pf-encode above does NOT apply here, for the
|
||||
# same reason it does not apply to pf-capture: `pf-encode` is `default = []`, and nothing in
|
||||
# `-p pf-vdisplay`'s graph turns on `nvenc`/`amf-qsv`/`qsv`, so no nvidia/ffmpeg/libvpl
|
||||
# import libs are ever asked for. Settled empirically before this step was added, to the
|
||||
# same standard as pf-capture's: the exact two commands were run on this runner against a
|
||||
# checkout at C:\temp\pf-vd-check — clippy clean, then 46 passed / 2 ignored in 0.19 s.
|
||||
#
|
||||
# --release to reuse C:\t\release rather than spawning a debug tree (the C1069 trigger).
|
||||
# Note this DOES build a second, featureless pf-encode; it is small precisely because none
|
||||
# of the encoder features are on.
|
||||
run: |
|
||||
cargo test --release -p pf-vdisplay; if ($LASTEXITCODE) { throw "pf-vdisplay tests" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||
@@ -312,15 +206,7 @@ jobs:
|
||||
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||
}
|
||||
Push-Location web
|
||||
# `--ignore-scripts` like every other web install in CI (ci.yml, web-screenshots.yml,
|
||||
# sdk/plugin-kit-publish, and the SDK install further down this same file). This step was
|
||||
# the one site that ran lifecycle scripts, and web's `postinstall` is `bun2nix -o bun.nix`
|
||||
# — a NIX codegen step that shells out to `bun` on PATH. CI runs a fetched PORTABLE bun by
|
||||
# absolute path (`$env:BUN_EXE`), so PATH has none, and bun2nix aborted the install:
|
||||
# error: bun is not installed in %PATH% ... postinstall script exited with 255
|
||||
# Nothing here needs those scripts — `build` re-runs its own `prebuild` codegen — and
|
||||
# bun.nix is a Nix artifact this job neither consumes nor commits.
|
||||
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
||||
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||
@@ -360,12 +246,6 @@ 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
|
||||
@@ -414,90 +294,3 @@ jobs:
|
||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
# winget manifests for the release just attached above. Runs AFTER the attach step so the
|
||||
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
|
||||
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
|
||||
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
|
||||
# alias has nothing it could point at.
|
||||
- name: Emit + attach winget manifests (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
& scripts/ci/winget-manifest.ps1 `
|
||||
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
|
||||
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
|
||||
}
|
||||
|
||||
# Republish the winget REST source on unom-1 once the release above carries its manifests.
|
||||
#
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
|
||||
# so the result cannot drift and re-running any tag reproduces it byte for byte.
|
||||
- name: Build + test the source catalogue
|
||||
working-directory: packaging/winget/server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install --no-audit --no-fund
|
||||
node build-data.mjs --out data/data.json
|
||||
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
|
||||
# Gate on the suite before anything reaches the box.
|
||||
node test.mjs
|
||||
|
||||
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
|
||||
# flatpak repo's content and config deploy on separate paths.
|
||||
- name: Ship the catalogue to unom-1
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "packaging/winget/server/data/data.json"
|
||||
target: "~/unom-winget/data"
|
||||
strip_components: 4
|
||||
overwrite: true
|
||||
|
||||
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
|
||||
# one actually being served, and fails the release if it is not.
|
||||
- name: Verify the served catalogue
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
curl -fsS http://127.0.0.1:3240/healthz
|
||||
echo
|
||||
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
|
||||
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
|
||||
| grep -q "${GITHUB_REF_NAME#v}" \
|
||||
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
|
||||
echo "winget source serving ${GITHUB_REF_NAME#v}"
|
||||
# `env:` below populates the RUNNER's environment; this action runs `script` on the
|
||||
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
|
||||
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
|
||||
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
|
||||
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
|
||||
# step schema, so appleboy/ssh-action never received it as an input and the step kept
|
||||
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
|
||||
envs: GITHUB_REF_NAME
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ gitea.ref_name }}
|
||||
|
||||
@@ -21,23 +21,12 @@
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# 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.
|
||||
# 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).
|
||||
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:
|
||||
@@ -60,15 +49,6 @@ 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:
|
||||
@@ -101,9 +81,11 @@ jobs:
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
# 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.
|
||||
# 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=${{ 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,6 +22,8 @@
|
||||
# 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
|
||||
@@ -32,18 +34,10 @@
|
||||
# - 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 (that
|
||||
# var silently never gets set). pwsh writes no BOM.
|
||||
# `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.
|
||||
# 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:
|
||||
@@ -73,20 +67,6 @@ 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 /
|
||||
@@ -117,6 +97,7 @@ 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
|
||||
|
||||
@@ -656,30 +656,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -715,7 +691,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -945,19 +920,6 @@ 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"
|
||||
@@ -1033,13 +995,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
@@ -1479,16 +1434,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1598,12 +1543,6 @@ 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"
|
||||
@@ -2220,7 +2159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2325,7 +2264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2360,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2849,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2865,12 +2804,11 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2882,7 +2820,6 @@ dependencies = [
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"rand 0.9.4",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
@@ -2890,12 +2827,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2913,7 +2850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2934,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2944,7 +2881,6 @@ dependencies = [
|
||||
"libvpl-sys",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"pf-capture",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
@@ -2958,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2967,7 +2903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2979,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2993,11 +2929,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3008,7 +2944,6 @@ dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"pf-win-display",
|
||||
"punktfunk-core",
|
||||
"reis",
|
||||
"tokio",
|
||||
@@ -3026,14 +2961,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3048,11 +2983,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -3070,18 +3004,16 @@ dependencies = [
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3093,7 +3025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3102,7 +3034,6 @@ dependencies = [
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3205,17 +3136,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -3299,20 +3219,9 @@ 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"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3328,11 +3237,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"glib-build-tools",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"pf-client-core",
|
||||
@@ -3345,18 +3253,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.21.0"
|
||||
version = "0.15.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",
|
||||
@@ -3365,7 +3268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3374,10 +3277,9 @@ dependencies = [
|
||||
"punktfunk-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"test_reactor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-reactor",
|
||||
"windows-reactor-setup",
|
||||
"winresource",
|
||||
@@ -3385,12 +3287,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
"cbindgen",
|
||||
"chacha20poly1305",
|
||||
"criterion",
|
||||
"fec-rs",
|
||||
"hmac",
|
||||
@@ -3417,7 +3318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3462,13 +3363,11 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"reis",
|
||||
"ring",
|
||||
"roxmltree",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rusty_enet",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3501,7 +3400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3515,7 +3414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3538,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -4577,18 +4476,6 @@ 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"
|
||||
@@ -5433,26 +5320,16 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
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-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-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-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"
|
||||
@@ -5465,20 +5342,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"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",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5497,13 +5363,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"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)",
|
||||
"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)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5520,11 +5386,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"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)",
|
||||
"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)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5541,7 +5407,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5562,7 +5428,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5578,7 +5444,7 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
@@ -5593,40 +5459,38 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"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-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)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-reactor"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"rustc-hash",
|
||||
"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-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-reference",
|
||||
"windows-threading 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=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-reactor-setup"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-reference"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-time",
|
||||
]
|
||||
|
||||
@@ -5642,9 +5506,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5670,9 +5534,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5780,26 +5644,18 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-time"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
|
||||
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f"
|
||||
dependencies = [
|
||||
"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)",
|
||||
"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)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5990,23 +5846,6 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
|
||||
@@ -24,13 +24,10 @@ 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",
|
||||
]
|
||||
@@ -51,42 +48,13 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.21.0"
|
||||
version = "0.15.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["unom"]
|
||||
repository = "https://git.unom.io/unom/punktfunk"
|
||||
|
||||
# The `unsafe` discipline the `packaging/windows/drivers/*` crates already run, extended to the
|
||||
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
|
||||
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
|
||||
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
|
||||
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
|
||||
# edition migration.)
|
||||
#
|
||||
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
|
||||
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
|
||||
# red on every platform for a day without the level in this file ever saying `deny`. A level that
|
||||
# lies about its own severity is worse than a strict one, so this now states what CI already does,
|
||||
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
|
||||
#
|
||||
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
|
||||
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
|
||||
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
|
||||
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
|
||||
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
|
||||
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
|
||||
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
|
||||
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
|
||||
#
|
||||
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
|
||||
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
|
||||
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
|
||||
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
|
||||
@@ -56,38 +56,6 @@ 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,15 +37,13 @@ accepted = [
|
||||
ignore-build-dependencies = true
|
||||
ignore-dev-dependencies = true
|
||||
|
||||
# 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"]
|
||||
# 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"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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.
|
||||
@@ -1,10 +0,0 @@
|
||||
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.
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 640 B |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 841 B |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 880 B |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 937 B |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- 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>
|
||||
|
Before Width: | Height: | Size: 344 B |
@@ -66,11 +66,3 @@ 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
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Host-side C compiler wrapper for the aarch64 cross image (ci/rust-ci-arm64cross.Dockerfile).
|
||||
#
|
||||
# Why this exists: ffmpeg-sys-next's build script compiles a probe it intends to RUN — it
|
||||
# executes the binary to read the libav* version macros — so it forces `.target(HOST)` with
|
||||
# the comment "don't cross-compile this", but still hands that host compile the TARGET's
|
||||
# pkg-config include paths. `-I/usr/include/aarch64-linux-gnu` then shadows the host's own
|
||||
# multiarch libc headers and the x86 compiler dies inside bits/math-vector.h on NEON/SVE
|
||||
# types it has never heard of.
|
||||
#
|
||||
# Prepending the host's multiarch dir does NOT fix it: GCC drops a `-I` that duplicates a
|
||||
# directory already on its system include path (keeping it in the original, later position),
|
||||
# so the arm64 dir stays in front. The reliable fix is to remove the target include dirs from
|
||||
# the host compile entirely — the probe only wants FFmpeg's version macros, and the amd64
|
||||
# libav*-dev headers are installed and on the default search path, at the same version (both
|
||||
# come from this Ubuntu release).
|
||||
#
|
||||
# Scope: only ever invoked as CC for the HOST triple (CC_x86_64_unknown_linux_gnu). Target
|
||||
# compiles go to aarch64-linux-gnu-gcc and never pass through here.
|
||||
set -euo pipefail
|
||||
|
||||
declare -a out=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
# `-I dir` as two arguments — the form cc's Command building and ffmpeg-sys both emit.
|
||||
-I)
|
||||
if [[ ${2-} == *aarch64-linux-gnu* ]]; then
|
||||
shift 2
|
||||
continue
|
||||
fi
|
||||
out+=("$1" "${2-}")
|
||||
shift 2
|
||||
;;
|
||||
# `-Idir` glued into one argument.
|
||||
-I*aarch64-linux-gnu*)
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
out+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exec /usr/bin/cc "${out[@]}"
|
||||
@@ -1,81 +0,0 @@
|
||||
# Cross-compiling CI builder: amd64 host toolchain + an arm64 multiarch sysroot, for the
|
||||
# aarch64 Linux CLIENT artifacts (punktfunk-client + punktfunk-session).
|
||||
#
|
||||
# docker build -f ci/rust-ci-arm64cross.Dockerfile -t punktfunk-rust-ci-arm64cross .
|
||||
#
|
||||
# Derived from punktfunk-rust-ci so the Rust toolchain, clang, and CMake are byte-identical
|
||||
# to the amd64 legs — this image only adds the target side. Kept as a SEPARATE image rather
|
||||
# than folded into the base because the :arm64 dev libs are ~1 GB that every other CI job
|
||||
# would otherwise pull for nothing.
|
||||
#
|
||||
# Client only: the Linux HOST stays amd64 (its encode stack is NVENC/QSV/AMF), so none of the
|
||||
# host's CUDA/GBM link deps are mirrored here.
|
||||
#
|
||||
# Ubuntu splits archives by architecture: amd64 lives on archive.ubuntu.com, every port
|
||||
# (arm64 included) on ports.ubuntu.com. Both stanzas therefore have to be pinned with an
|
||||
# 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 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 1. Pin the stock sources to amd64, add ports.ubuntu.com for arm64.
|
||||
RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& . /etc/os-release \
|
||||
&& printf 'Types: deb\nArchitectures: arm64\nURIs: http://ports.ubuntu.com/ubuntu-ports/\nSuites: %s %s-updates %s-backports %s-security\nComponents: main universe restricted multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \
|
||||
"$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" \
|
||||
> /etc/apt/sources.list.d/ubuntu-ports-arm64.sources \
|
||||
&& dpkg --add-architecture arm64
|
||||
|
||||
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
|
||||
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
|
||||
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
crossbuild-essential-arm64 \
|
||||
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 \
|
||||
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
|
||||
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
|
||||
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
|
||||
# default. The base image bakes whatever `stable` was at its build time, while every build
|
||||
# in the repo switches to the exact channel in rust-toolchain.toml; adding the target to
|
||||
# the default toolchain instead leaves the pinned one without an aarch64 std, and the build
|
||||
# dies on `can't find crate for core` a few hundred crates in. Running rustup from a
|
||||
# directory that contains the pin file resolves the right toolchain (and pre-downloads it,
|
||||
# which every workspace job would otherwise pay for on first use).
|
||||
COPY rust-toolchain.toml /opt/pf-toolchain/
|
||||
WORKDIR /opt/pf-toolchain
|
||||
RUN rustup target add aarch64-unknown-linux-gnu && rustup show
|
||||
WORKDIR /
|
||||
|
||||
# 4. Cross wiring. Everything in this image is a cross build, so the plain (un-suffixed)
|
||||
# variables are safe and cover the crates that roll their own pkg-config/bindgen calls
|
||||
# instead of going through the target-scoped lookups.
|
||||
# * PKG_CONFIG uses Debian's multiarch wrapper, which resolves the arm64 .pc files and
|
||||
# rewrites -I/-L into the sysroot without per-crate cooperation.
|
||||
# * BINDGEN_EXTRA_CLANG_ARGS: clang defaults to the host triple, so bindgen would parse
|
||||
# arm64 headers with amd64 type layouts (silently wrong, not a build error) — the
|
||||
# explicit --target plus the multiarch include dir is what keeps the layouts honest.
|
||||
# * CC_x86_64_unknown_linux_gnu routes HOST-targeted compiles through a wrapper that
|
||||
# strips the arm64 include dirs — see ci/pf-host-cc for the ffmpeg-sys-next probe it
|
||||
# exists for.
|
||||
COPY ci/pf-host-cc /usr/local/bin/pf-host-cc
|
||||
RUN chmod 0755 /usr/local/bin/pf-host-cc
|
||||
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
|
||||
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
|
||||
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
|
||||
CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc \
|
||||
PKG_CONFIG=aarch64-linux-gnu-pkg-config \
|
||||
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=aarch64-unknown-linux-gnu -I/usr/include/aarch64-linux-gnu"
|
||||
|
||||
# Fail the BUILD, not some later CI job, if the wrapper or a sysroot .pc is missing.
|
||||
RUN command -v aarch64-linux-gnu-pkg-config \
|
||||
&& aarch64-linux-gnu-pkg-config --cflags libavcodec sdl3 gtk4 libpipewire-0.3 \
|
||||
&& aarch64-linux-gnu-gcc -dumpmachine | grep -q aarch64
|
||||
@@ -83,11 +83,3 @@ 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,11 +50,3 @@ 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,21 +90,6 @@
|
||||
<!-- 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,9 +31,8 @@ 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
|
||||
@@ -43,23 +42,14 @@ 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()) }
|
||||
// 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 streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
@@ -68,53 +58,21 @@ 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 = session,
|
||||
targetState = streamHandle != 0L,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "StreamTransition"
|
||||
) { active ->
|
||||
if (active != null) {
|
||||
) { isStreaming ->
|
||||
if (isStreaming) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(active, onDisconnect = { session = null })
|
||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
onConnected = { session = it },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
onConnected = { streamHandle = it },
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
@@ -145,13 +103,7 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
label = "TabTransition"
|
||||
) { targetTab ->
|
||||
when (targetTab) {
|
||||
Tab.Connect -> ConnectScreen(
|
||||
settings = settings,
|
||||
onConnected = { session = it },
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
)
|
||||
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
||||
Tab.Settings -> SettingsScreen(
|
||||
initial = settings,
|
||||
onChange = { settings = it; settingsStore.save(it) },
|
||||
@@ -215,9 +167,7 @@ private enum class GamepadScreen { Home, Settings, Library }
|
||||
fun GamepadShell(
|
||||
settings: Settings,
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
onConnected: (Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
@@ -244,9 +194,6 @@ 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 },
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
/**
|
||||
* Text clipboard sync for the active session (the desktop-client model, text-only v1):
|
||||
* * **Device → host**: a local copy (the primary-clip listener, plus one probe at start) is
|
||||
* announced as a lazy offer — the text crosses only when the host actually pastes (a
|
||||
* `fetch:` event, answered with the clipboard's current content).
|
||||
* * **Host → device**: a host copy arrives as an `offer:` event and is fetched eagerly into
|
||||
* the system clipboard (Android apps can't lazily materialize a paste from the network
|
||||
* without a content-provider round-trip that isn't worth it here).
|
||||
*
|
||||
* Loop guard: text set from a host fetch is remembered ([lastFromHost]) so the resulting
|
||||
* primary-clip-changed callback doesn't bounce it straight back as a new offer. Clipboard reads
|
||||
* happen while the stream is foreground (Android only allows focused-app reads). The native
|
||||
* events are drained on a dedicated thread and applied on the main thread; [stop] joins it.
|
||||
*/
|
||||
class ClipboardSync(
|
||||
private val context: Context,
|
||||
private val handle: Long,
|
||||
) {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
|
||||
@Volatile private var running = true
|
||||
private var seq = 0
|
||||
private var lastOffered: String? = null
|
||||
private var lastFromHost: String? = null
|
||||
private var pendingFetch = -1
|
||||
private var thread: Thread? = null
|
||||
|
||||
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { offerLocal() }
|
||||
|
||||
fun start() {
|
||||
NativeBridge.nativeClipControl(handle, true)
|
||||
cm.addPrimaryClipChangedListener(clipListener)
|
||||
thread = Thread({ pollLoop() }, "pf-clipboard").also { it.start() }
|
||||
offerLocal() // whatever is already on the clipboard is pasteable host-side right away
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
cm.removePrimaryClipChangedListener(clipListener)
|
||||
thread?.join(600) // one poll timeout (250 ms) + slack
|
||||
thread = null
|
||||
}
|
||||
|
||||
/** Announce the current local text (if it's new and not an echo of a host copy). */
|
||||
private fun offerLocal() {
|
||||
if (!running) return
|
||||
val text = currentClipText() ?: return
|
||||
if (text == lastOffered || text == lastFromHost) return
|
||||
lastOffered = text
|
||||
seq += 1
|
||||
NativeBridge.nativeClipOfferText(handle, seq)
|
||||
}
|
||||
|
||||
private fun currentClipText(): String? = runCatching {
|
||||
cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
|
||||
?.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun pollLoop() {
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextClip(handle) ?: continue
|
||||
if (ev == "closed") return
|
||||
main.post { handleEvent(ev) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(ev: String) {
|
||||
if (!running) return
|
||||
val parts = ev.split(":", limit = 3)
|
||||
when (parts[0]) {
|
||||
"offer" -> {
|
||||
val offerSeq = parts.getOrNull(1)?.toIntOrNull() ?: return
|
||||
if (parts.getOrNull(2) == "1") {
|
||||
pendingFetch = NativeBridge.nativeClipFetchText(handle, offerSeq)
|
||||
}
|
||||
}
|
||||
"fetch" -> {
|
||||
val req = parts.getOrNull(1)?.toIntOrNull() ?: return
|
||||
val text = currentClipText()
|
||||
if (text != null) {
|
||||
NativeBridge.nativeClipServeText(handle, req, text)
|
||||
} else {
|
||||
NativeBridge.nativeClipCancel(handle, req)
|
||||
}
|
||||
}
|
||||
"data" -> {
|
||||
val xfer = parts.getOrNull(1)?.toIntOrNull() ?: return
|
||||
if (xfer != pendingFetch) return // stale/unknown transfer
|
||||
pendingFetch = -1
|
||||
val text = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return
|
||||
lastFromHost = text
|
||||
runCatching { cm.setPrimaryClip(ClipData.newPlainText("Punktfunk", text)) }
|
||||
}
|
||||
// "state"/"cancel"/"error": nothing to drive in the text-only v1.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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
|
||||
@@ -354,18 +353,16 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
internal fun EditHostDialog(
|
||||
target: KnownHost,
|
||||
suggestedMacs: List<String>,
|
||||
profiles: List<StreamProfile>,
|
||||
onSave: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -375,13 +372,6 @@ 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") },
|
||||
@@ -417,31 +407,6 @@ 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 = {
|
||||
@@ -454,9 +419,6 @@ internal fun EditHostDialog(
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: target.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
clipboardSync = clipboard,
|
||||
profileId = boundId,
|
||||
pinnedProfileIds = pins,
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -467,103 +429,3 @@ 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,23 +53,16 @@ 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
|
||||
@@ -108,10 +101,7 @@ private class ConnectAttempt(val hostName: String) {
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
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 = {},
|
||||
onConnected: (Long) -> 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).
|
||||
@@ -119,11 +109,6 @@ 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
|
||||
@@ -132,10 +117,6 @@ 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) }
|
||||
@@ -214,11 +195,6 @@ 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.
|
||||
@@ -237,13 +213,6 @@ 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
|
||||
}
|
||||
@@ -289,7 +258,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<HostCardEntry?>(null) }
|
||||
var optionsTarget by remember { mutableStateOf<KnownHost?>(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
|
||||
@@ -298,44 +267,15 @@ 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,
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
// 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?,
|
||||
profile: StreamProfile?,
|
||||
launch: String? = null,
|
||||
onFailure: (() -> Unit)? = null,
|
||||
) {
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
@@ -344,11 +284,9 @@ 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, profile, launch)
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||
// 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()) {
|
||||
@@ -358,14 +296,13 @@ 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()) {
|
||||
record = knownHostStore.trust(targetHost, targetPort, name, fp, paired = false)
|
||||
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
|
||||
}
|
||||
}
|
||||
onConnected(session(handle, record, profile))
|
||||
onConnected(handle)
|
||||
} else {
|
||||
discovery.start()
|
||||
val token = NativeBridge.nativeTakeLastError()
|
||||
@@ -402,22 +339,12 @@ 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?,
|
||||
oneOffProfile: String?,
|
||||
launch: String? = null,
|
||||
) {
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
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
|
||||
@@ -428,7 +355,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, profile, launch, onFailure = {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
@@ -441,18 +368,15 @@ 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.save(kh.copy(address = live.host, port = live.port))
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(
|
||||
live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex,
|
||||
profile, launch,
|
||||
)
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
},
|
||||
)
|
||||
})
|
||||
} else {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch)
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,12 +401,7 @@ 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 ?: ""
|
||||
// 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,
|
||||
)
|
||||
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS)
|
||||
// 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()) {
|
||||
@@ -495,12 +414,11 @@ 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()) {
|
||||
record = knownHostStore.trust(target.host, target.port, target.name, fp, paired = true)
|
||||
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
onConnected(session(handle, record, profile = null))
|
||||
onConnected(handle)
|
||||
} else {
|
||||
// Cause-specific: an operator denial, an approval timeout, and a request that
|
||||
// never reached the host are different problems with different fixes.
|
||||
@@ -523,12 +441,6 @@ 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.
|
||||
@@ -544,195 +456,18 @@ 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, oneOffProfile, launch)
|
||||
doConnect(targetHost, targetPort, known.name, known.fpHex)
|
||||
// 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,
|
||||
oneOffProfile, launch,
|
||||
)
|
||||
known != null -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
|
||||
// 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,
|
||||
oneOffProfile, launch,
|
||||
)
|
||||
dh?.pairingRequired == false -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||
// 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,
|
||||
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."
|
||||
else -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,15 +478,11 @@ 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.id}",
|
||||
id = "saved-${kh.address}:${kh.port}",
|
||||
title = kh.name,
|
||||
// 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}",
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
@@ -759,23 +490,6 @@ 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(
|
||||
@@ -812,11 +526,7 @@ fun ConnectScreen(
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOptions = { tile ->
|
||||
tile.knownHost?.let { kh ->
|
||||
optionsTarget = HostCardEntry(kh, tile.pinnedProfileId?.let(profileStore::byId))
|
||||
}
|
||||
},
|
||||
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -838,23 +548,6 @@ 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
|
||||
@@ -919,45 +612,24 @@ fun ConnectScreen(
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
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 } }
|
||||
items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh ->
|
||||
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,
|
||||
// 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)
|
||||
}
|
||||
onConnect = { connect(kh.address, kh.port) },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
// 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 }),
|
||||
onEdit = { 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 (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
{
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
@@ -976,11 +648,6 @@ fun ConnectScreen(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profileLabel = pin?.name ?: bound?.name,
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
reserveProfileSlot = anyProfileChip,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -996,7 +663,6 @@ 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,
|
||||
@@ -1075,15 +741,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.trust(pt.host, pt.port, pt.name, fp, paired = true)
|
||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
}
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW ->
|
||||
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 })
|
||||
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 })
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
@@ -1108,9 +774,7 @@ fun ConnectScreen(
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
optionsTarget?.let { kh ->
|
||||
val offline = !kh.isOnline(discovered, reachable)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
@@ -1130,56 +794,27 @@ 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 && pin == null) {
|
||||
onLibrary = if (settings.libraryEnabled) {
|
||||
{ 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)
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
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.save(updated)
|
||||
knownHostStore.update(kh.address, kh.port, updated)
|
||||
savedHosts = knownHostStore.all()
|
||||
editTarget = null
|
||||
}
|
||||
@@ -1197,7 +832,6 @@ fun ConnectScreen(
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = suggested,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
@@ -1238,15 +872,6 @@ 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,92 +213,19 @@ 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 = if (profileName != null) "$hostName · $profileName" else hostName,
|
||||
title = 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(
|
||||
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))
|
||||
}
|
||||
}
|
||||
DialogText("Manage this saved host.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,17 +73,11 @@ 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 && pinnedProfileId == null
|
||||
val hasLibrary: Boolean get() = knownHost != null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,9 +87,7 @@ 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 }
|
||||
// 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)
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, ::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
|
||||
@@ -141,10 +139,7 @@ fun GamepadSettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item(key = "__title") {
|
||||
// "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)
|
||||
ConsoleHeader("Settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
@@ -268,12 +263,10 @@ 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); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
|
||||
private fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
av1Capable: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
@@ -311,36 +304,9 @@ 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(
|
||||
"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",
|
||||
"resolution", "Stream", "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
|
||||
@@ -357,36 +323,33 @@ 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", "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", null, "Bitrate",
|
||||
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"codec", null, "Video codec",
|
||||
"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",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
CODEC_OPTIONS, 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", "Display · Decoding", "Low-latency mode",
|
||||
"lowLatency", null, "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,
|
||||
@@ -397,9 +360,9 @@ private fun buildSettingsRows(
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"padType", "Controllers", "Controller type",
|
||||
"padType", "Controller", "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
@@ -413,13 +376,26 @@ private fun buildSettingsRows(
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// 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.
|
||||
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)) },
|
||||
toggle(
|
||||
"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)) },
|
||||
"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)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -54,9 +53,6 @@ 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,7 +63,6 @@ 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
|
||||
@@ -86,7 +85,7 @@ private sealed class LibState {
|
||||
fun LibraryScreen(
|
||||
host: KnownHost,
|
||||
settings: Settings,
|
||||
onLaunched: (ActiveSession) -> Unit,
|
||||
onLaunched: (Long) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
) {
|
||||
@@ -143,11 +142,7 @@ fun LibraryScreen(
|
||||
host.address, host.port, host.fpHex, launch = game.id,
|
||||
)
|
||||
launching = false
|
||||
if (handle != 0L) {
|
||||
onLaunched(
|
||||
ActiveSession(handle, settings, host.clipboardSync),
|
||||
)
|
||||
}
|
||||
if (handle != 0L) onLaunched(handle)
|
||||
else Toast.makeText(
|
||||
context,
|
||||
"Launch failed — check the host and try again.",
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
@@ -28,10 +27,6 @@ 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"
|
||||
@@ -59,21 +54,6 @@ class MainActivity : ComponentActivity() {
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
/**
|
||||
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
|
||||
* [gamepadRouter]): uncaptured hover/click/wheel forwards as absolute cursor input, captured
|
||||
* ([android.view.View.requestPointerCapture]) raw deltas as relative mouse-look. The dispatch
|
||||
* overrides below route every SOURCE_MOUSE event here while streaming. Null while not streaming.
|
||||
*/
|
||||
var mouseForwarder: MouseForwarder? = null
|
||||
|
||||
/**
|
||||
* TV remote-as-pointer for the active session (StreamScreen builds it on TV devices only):
|
||||
* hold SELECT to toggle, then the D-pad glides the host cursor. Consulted first for
|
||||
* non-gamepad keys while streaming. Null while not streaming or not a TV.
|
||||
*/
|
||||
var remotePointer: RemotePointer? = null
|
||||
|
||||
/**
|
||||
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||
* couch user with no keyboard/Back can always leave a stream.
|
||||
@@ -101,17 +81,6 @@ 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
|
||||
|
||||
@@ -141,28 +110,6 @@ 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()
|
||||
@@ -223,20 +170,6 @@ 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()
|
||||
@@ -391,47 +324,9 @@ class MainActivity : ComponentActivity() {
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
|
||||
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
remotePointer?.let { if (it.onKey(event)) return true }
|
||||
}
|
||||
// Ctrl+Alt+Shift+Q — the cross-client pointer-capture toggle chord. Swallow both
|
||||
// edges of the Q (the modifiers already went over the wire, exactly like desktop).
|
||||
if (event.keyCode == KeyEvent.KEYCODE_Q &&
|
||||
event.isCtrlPressed && event.isAltPressed && event.isShiftPressed
|
||||
) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
mouseForwarder?.toggleCapture()
|
||||
}
|
||||
return true
|
||||
}
|
||||
when (event.keyCode) {
|
||||
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
|
||||
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
|
||||
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
|
||||
// either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
|
||||
// to the BackHandler and exits.
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Leave these to the system even while streaming.
|
||||
// (BACK above → BackHandler leaves the stream.)
|
||||
KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream
|
||||
KeyEvent.KEYCODE_VOLUME_UP,
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||
KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
@@ -493,44 +388,12 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
|
||||
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
|
||||
*
|
||||
* A mouse that carries its side buttons on the HID consumer page (AC Back / AC Forward) reaches
|
||||
* us only as `KEYCODE_BACK`/`KEYCODE_FORWARD`, with no `BUTTON_BACK`/`BUTTON_FORWARD` motion
|
||||
* edge behind it — on those, the motion path alone leaves the side buttons dead. The event may
|
||||
* even be stamped SOURCE_KEYBOARD rather than SOURCE_MOUSE, because the consumer-page collection
|
||||
* is a separate sub-device, so the DEVICE is what we ask: it has to be able to be a mouse.
|
||||
*
|
||||
* A D-pad-capable device is excluded even when it also reports a pointer: that is an air-mouse
|
||||
* remote, whose BACK is the couch user's way out of the stream and must stay navigation.
|
||||
* FLAG_FALLBACK events are excluded too — those are a duplicate the framework raises after an
|
||||
* unconsumed BUTTON_* press, i.e. one the motion path already forwarded.
|
||||
*/
|
||||
private fun mouseSideButton(event: KeyEvent): Boolean? {
|
||||
val back = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BACK -> true
|
||||
KeyEvent.KEYCODE_FORWARD -> false
|
||||
else -> return null
|
||||
}
|
||||
if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return null
|
||||
val device = event.device ?: return null
|
||||
if (!device.supportsSource(InputDevice.SOURCE_MOUSE)) return null
|
||||
if (device.supportsSource(InputDevice.SOURCE_DPAD)) return null
|
||||
return back
|
||||
}
|
||||
|
||||
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
|
||||
private var lastNavDir = 0
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (streamHandle != 0L) {
|
||||
if (gamepadRouter?.onMotion(event) == true) return true
|
||||
// Physical mouse (uncaptured): hover motion, wheel, button edges.
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
||||
mouseForwarder?.let { if (it.onGenericMotion(event)) return true }
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||
@@ -568,24 +431,6 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse clicks/drags ride the TOUCH stream (the pointer is "down"). While streaming they
|
||||
* belong to the mouse forwarder, never to the Compose touch-gesture layer — a physical
|
||||
* mouse click must be a real click at the cursor, not a synthesized trackpad tap.
|
||||
*/
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
if (streamHandle != 0L && ev.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
||||
mouseForwarder?.let { if (it.onTouchEvent(ev)) return true }
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
/** The OS is the source of truth for pointer capture (it releases on focus loss). */
|
||||
override fun onPointerCaptureChanged(hasCapture: Boolean) {
|
||||
super.onPointerCaptureChanged(hasCapture)
|
||||
mouseForwarder?.onCaptureChanged(hasCapture)
|
||||
}
|
||||
|
||||
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
@@ -593,29 +438,4 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** True when any connected input device is a pointer (USB/BT mouse, or a touchpad driving one). */
|
||||
fun hasPhysicalMouse(): Boolean = InputDevice.getDeviceIds().any { id ->
|
||||
InputDevice.getDevice(id)?.supportsSource(InputDevice.SOURCE_MOUSE) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical mouse → wire, in two modes (the iPadOS/desktop model):
|
||||
* * **uncaptured** (default): hover/drag positions forward as absolute cursor moves
|
||||
* (`MouseMoveAbs`, host-normalized against the window size) — desktop-style pointing. The
|
||||
* local cursor is hidden over the stream (StreamScreen sets a TYPE_NULL pointer icon); the
|
||||
* host's own cursor, composited into the video, is the one you see.
|
||||
* * **captured**: the OS pointer is grabbed ([android.view.View.requestPointerCapture]) and raw
|
||||
* relative deltas forward as `MouseMove` — FPS mouse-look. Engaged at stream start / by
|
||||
* clicking into the stream when the "Capture pointer for games" setting is on, and toggled
|
||||
* any time by Ctrl+Alt+Shift+Q (the cross-client chord). Focus loss releases it (the OS
|
||||
* guarantees that); a click re-engages.
|
||||
*
|
||||
* Buttons ride [MotionEvent.ACTION_BUTTON_PRESS]/RELEASE edges (left/middle/right/back/forward →
|
||||
* wire 1/2/3/4/5), the wheel rides [MotionEvent.ACTION_SCROLL] with fractional accumulation so
|
||||
* high-resolution wheels don't lose sub-notch travel. Held buttons are tracked and flushed on
|
||||
* capture loss / stream exit so nothing sticks on the host. Events reach this class from
|
||||
* MainActivity's dispatch overrides (uncaptured) and the capture view's captured-pointer listener.
|
||||
*/
|
||||
class MouseForwarder(
|
||||
private val handle: Long,
|
||||
private val invertScroll: Boolean,
|
||||
private val captureWanted: Boolean,
|
||||
private val surfaceSize: () -> Pair<Int, Int>,
|
||||
) {
|
||||
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
|
||||
var onRequestCapture: (() -> Unit)? = null
|
||||
var onReleaseCapture: (() -> Unit)? = null
|
||||
|
||||
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
|
||||
var captured = false
|
||||
private set
|
||||
|
||||
/** Chord-released: no auto re-engage (start / click) until the user opts back in. */
|
||||
private var userReleased = false
|
||||
|
||||
private val heldButtons = mutableSetOf<Int>()
|
||||
private var scrollAccV = 0f
|
||||
private var scrollAccH = 0f
|
||||
private var moveAccX = 0f
|
||||
private var moveAccY = 0f
|
||||
|
||||
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
|
||||
fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (captureWanted && !captured && !userReleased) {
|
||||
// The engaging click: grab the pointer and swallow the click (desktop
|
||||
// parity — the click that captures never reaches the host). The paired
|
||||
// BUTTON_RELEASE is dropped by the held-set guard in [button].
|
||||
onRequestCapture?.invoke()
|
||||
return true
|
||||
}
|
||||
sendAbs(ev)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> sendAbs(ev)
|
||||
// Button edges are documented on the generic stream, but be robust to either.
|
||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
|
||||
fun onGenericMotion(ev: MotionEvent): Boolean {
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
|
||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
||||
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_EXIT -> {}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Captured-pointer events (the view holds [android.view.View.requestPointerCapture]): x/y ARE
|
||||
* the relative deltas ([InputDevice.SOURCE_MOUSE_RELATIVE]), batched samples included. A
|
||||
* captured touchpad reports absolute finger coordinates instead — not handled (the touch
|
||||
* gesture layer is the touchpad story); returning false leaves those to the framework.
|
||||
*/
|
||||
fun onCapturedPointer(ev: MotionEvent): Boolean {
|
||||
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
var dx = 0f
|
||||
var dy = 0f
|
||||
for (i in 0 until ev.historySize) {
|
||||
dx += ev.getHistoricalX(i)
|
||||
dy += ev.getHistoricalY(i)
|
||||
}
|
||||
dx += ev.x
|
||||
dy += ev.y
|
||||
moveAccX += dx
|
||||
moveAccY += dy
|
||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept w/ sign
|
||||
val oy = moveAccY.toInt()
|
||||
if (ox != 0 || oy != 0) {
|
||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
||||
moveAccX -= ox
|
||||
moveAccY -= oy
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Ctrl+Alt+Shift+Q: release the grab, or (re-)engage it — works even when auto-capture is off. */
|
||||
fun toggleCapture() {
|
||||
if (captured) {
|
||||
userReleased = true
|
||||
onReleaseCapture?.invoke()
|
||||
} else {
|
||||
userReleased = false
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-engage at stream start (setting on + a mouse actually present). */
|
||||
fun engageFromStart() {
|
||||
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/** From [android.app.Activity.onPointerCaptureChanged] — the OS is the source of truth. */
|
||||
fun onCaptureChanged(has: Boolean) {
|
||||
captured = has
|
||||
// Losing the grab (focus loss, chord) must not leave buttons held on the host.
|
||||
if (!has) flushButtons()
|
||||
}
|
||||
|
||||
/** Stream teardown: lift anything held and let the grab go. */
|
||||
fun release() {
|
||||
flushButtons()
|
||||
if (captured) onReleaseCapture?.invoke()
|
||||
}
|
||||
|
||||
private fun sendAbs(ev: MotionEvent) {
|
||||
val (w, h) = surfaceSize()
|
||||
if (w <= 0 || h <= 0) return
|
||||
NativeBridge.nativeSendPointerAbs(
|
||||
handle,
|
||||
ev.x.roundToInt().coerceIn(0, w - 1),
|
||||
ev.y.roundToInt().coerceIn(0, h - 1),
|
||||
w,
|
||||
h,
|
||||
)
|
||||
}
|
||||
|
||||
private fun wheel(ev: MotionEvent) {
|
||||
val dir = if (invertScroll) -1f else 1f
|
||||
// Android: AXIS_VSCROLL + = up/away, AXIS_HSCROLL + = right — the wire's convention too.
|
||||
scrollAccV += ev.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120f * dir
|
||||
scrollAccH += ev.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120f * dir
|
||||
val v = scrollAccV.toInt()
|
||||
if (v != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 0, v)
|
||||
scrollAccV -= v
|
||||
}
|
||||
val h = scrollAccH.toInt()
|
||||
if (h != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 1, h)
|
||||
scrollAccH -= h
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A mouse side button that arrived as a KEY event rather than a BUTTON_* motion edge.
|
||||
*
|
||||
* Not every mouse reports its side buttons the same way. One that puts them on the HID button
|
||||
* page (BTN_SIDE/BTN_EXTRA) gets `BUTTON_BACK`/`BUTTON_FORWARD` in the motion button state and
|
||||
* lands in [button]. One that puts them on the consumer page (AC Back / AC Forward — common on
|
||||
* Bluetooth mice, and the shape Android TV boxes tend to see) produces ONLY synthesized
|
||||
* `KEYCODE_BACK`/`KEYCODE_FORWARD` key events, so [button] never fires and the side buttons are
|
||||
* dead on the wire. This is the key-shaped entry point for those.
|
||||
*
|
||||
* Devices that report BOTH send the key first and the motion edge second (that is the order the
|
||||
* input reader synthesizes them in), so both paths funnel into the same held-set and the
|
||||
* add/remove guard collapses the pair into a single wire press.
|
||||
*/
|
||||
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
|
||||
|
||||
private fun button(actionButton: Int, down: Boolean) {
|
||||
val b = when (actionButton) {
|
||||
MotionEvent.BUTTON_PRIMARY -> 1
|
||||
MotionEvent.BUTTON_TERTIARY -> 2
|
||||
MotionEvent.BUTTON_SECONDARY -> 3
|
||||
MotionEvent.BUTTON_BACK -> 4
|
||||
MotionEvent.BUTTON_FORWARD -> 5
|
||||
else -> return
|
||||
}
|
||||
press(b, down)
|
||||
}
|
||||
|
||||
private fun press(b: Int, down: Boolean) {
|
||||
if (down) {
|
||||
// add() is false when the button is already held — the second delivery of a button
|
||||
// this device reports on two paths at once. Sending the down again would double-press
|
||||
// it on the host.
|
||||
if (heldButtons.add(b)) NativeBridge.nativeSendPointerButton(handle, b, true)
|
||||
} else if (heldButtons.remove(b)) {
|
||||
// Only release what we pressed — drops the release of a swallowed engaging click
|
||||
// and anything that raced a capture transition.
|
||||
NativeBridge.nativeSendPointerButton(handle, b, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushButtons() {
|
||||
heldButtons.forEach { NativeBridge.nativeSendPointerButton(handle, it, false) }
|
||||
heldButtons.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
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
|
||||
@@ -1,193 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Choreographer
|
||||
import android.view.KeyEvent
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.hypot
|
||||
|
||||
// Hold this long on SELECT (pointer-mode toggle) / PLAY-PAUSE (keyboard toggle) for the long-press
|
||||
// action instead of the tap action.
|
||||
private const val LONG_PRESS_MS = 800L
|
||||
|
||||
// D-pad glide ballistics, in screen-widths per second: start slow enough to hit a close button,
|
||||
// ramp over RAMP_S seconds of continuous hold so crossing the desktop doesn't take all day.
|
||||
private const val SPEED_MIN = 0.14f
|
||||
private const val SPEED_MAX = 0.70f
|
||||
private const val RAMP_S = 1.2f
|
||||
|
||||
/**
|
||||
* Android TV remote as a pointer — the Android analogue of the Apple client's Siri-remote pointer,
|
||||
* adapted for D-pad-only remotes (most Android TV remotes have no touch surface). For the
|
||||
* "TV as a desktop client" use case, where a plain remote is often the only thing in hand.
|
||||
*
|
||||
* While streaming on a TV, **hold SELECT ≈ 0.8 s** to toggle pointer mode. While active:
|
||||
* * D-pad (held) glides the host cursor with ramping acceleration (relative `MouseMove`,
|
||||
* Choreographer-paced, diagonal-normalized);
|
||||
* * SELECT tap = left click; PLAY/PAUSE tap = right click (Siri-remote parity);
|
||||
* * PLAY/PAUSE held = toggle the on-screen keyboard; BACK = leave pointer mode
|
||||
* (a second BACK then leaves the stream as usual).
|
||||
* While inactive, everything except the SELECT long-press passes through untouched (D-pad =
|
||||
* arrow keys, SELECT tap = Enter — synthesized on release, since the down was held back to
|
||||
* disambiguate the long-press).
|
||||
*
|
||||
* Only consulted for non-gamepad key events on TV devices (MainActivity gates the calls); all
|
||||
* state lives on the main thread.
|
||||
*/
|
||||
class RemotePointer(
|
||||
private val handle: Long,
|
||||
private val surfaceWidth: () -> Int,
|
||||
private val onActiveChanged: (Boolean) -> Unit,
|
||||
private val onKeyboardToggle: () -> Unit,
|
||||
) {
|
||||
var active = false
|
||||
private set
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
|
||||
private var moveAccX = 0f
|
||||
private var moveAccY = 0f
|
||||
private var lastFrameNs = 0L
|
||||
private var rampSec = 0f
|
||||
private var tickerRunning = false
|
||||
private var centerLongFired = false
|
||||
private var playLongFired = false
|
||||
|
||||
private val centerLong = Runnable {
|
||||
centerLongFired = true
|
||||
toggle()
|
||||
}
|
||||
private val playLong = Runnable {
|
||||
playLongFired = true
|
||||
onKeyboardToggle()
|
||||
}
|
||||
|
||||
private val frame = object : Choreographer.FrameCallback {
|
||||
override fun doFrame(nowNs: Long) {
|
||||
if (!tickerRunning) return
|
||||
if (held.isEmpty() || !active) {
|
||||
tickerRunning = false
|
||||
return
|
||||
}
|
||||
val dt = if (lastFrameNs == 0L) {
|
||||
1f / 60f
|
||||
} else {
|
||||
((nowNs - lastFrameNs) / 1e9f).coerceIn(0.001f, 0.1f)
|
||||
}
|
||||
lastFrameNs = nowNs
|
||||
rampSec += dt
|
||||
var vx = 0f
|
||||
var vy = 0f
|
||||
if (KeyEvent.KEYCODE_DPAD_LEFT in held) vx -= 1f
|
||||
if (KeyEvent.KEYCODE_DPAD_RIGHT in held) vx += 1f
|
||||
if (KeyEvent.KEYCODE_DPAD_UP in held) vy -= 1f
|
||||
if (KeyEvent.KEYCODE_DPAD_DOWN in held) vy += 1f
|
||||
val mag = hypot(vx, vy)
|
||||
if (mag > 0f) {
|
||||
val w = surfaceWidth().coerceAtLeast(640)
|
||||
val speed = w * (SPEED_MIN + (SPEED_MAX - SPEED_MIN) * (rampSec / RAMP_S).coerceAtMost(1f))
|
||||
moveAccX += vx / mag * speed * dt
|
||||
moveAccY += vy / mag * speed * dt
|
||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept
|
||||
val oy = moveAccY.toInt()
|
||||
if (ox != 0 || oy != 0) {
|
||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
||||
moveAccX -= ox
|
||||
moveAccY -= oy
|
||||
}
|
||||
}
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
}
|
||||
}
|
||||
|
||||
/** One remote key event; true = consumed. Ignore key repeats — the ticker owns motion. */
|
||||
fun onKey(event: KeyEvent): Boolean {
|
||||
val down = event.action == KeyEvent.ACTION_DOWN
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
if (down) {
|
||||
if (event.repeatCount == 0) {
|
||||
centerLongFired = false
|
||||
handler.postDelayed(centerLong, LONG_PRESS_MS)
|
||||
}
|
||||
} else {
|
||||
handler.removeCallbacks(centerLong)
|
||||
if (!centerLongFired) {
|
||||
if (active) {
|
||||
click(1)
|
||||
} else {
|
||||
// The down was held back to disambiguate the long-press, so the
|
||||
// normal path never saw it — synthesize the Enter here instead.
|
||||
NativeBridge.nativeSendKey(handle, 0x0D, true, 0)
|
||||
NativeBridge.nativeSendKey(handle, 0x0D, false, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
-> {
|
||||
if (!active) return false
|
||||
if (down) {
|
||||
if (held.add(event.keyCode) && held.size == 1) startTicker()
|
||||
} else {
|
||||
held.remove(event.keyCode)
|
||||
}
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
if (!active) return false // inactive: the media-key VK path owns it
|
||||
if (down) {
|
||||
if (event.repeatCount == 0) {
|
||||
playLongFired = false
|
||||
handler.postDelayed(playLong, LONG_PRESS_MS)
|
||||
}
|
||||
} else {
|
||||
handler.removeCallbacks(playLong)
|
||||
if (!playLongFired) click(3)
|
||||
}
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
if (!active) return false
|
||||
if (!down) toggle() // leave pointer mode; the next BACK leaves the stream
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream teardown: stop timers/ticker; nothing wire-held to flush (clicks are edges). */
|
||||
fun release() {
|
||||
handler.removeCallbacks(centerLong)
|
||||
handler.removeCallbacks(playLong)
|
||||
active = false
|
||||
held.clear()
|
||||
tickerRunning = false
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
active = !active
|
||||
if (!active) {
|
||||
held.clear()
|
||||
tickerRunning = false
|
||||
}
|
||||
onActiveChanged(active)
|
||||
}
|
||||
|
||||
private fun startTicker() {
|
||||
rampSec = 0f
|
||||
lastFrameNs = 0L
|
||||
if (!tickerRunning) {
|
||||
tickerRunning = true
|
||||
Choreographer.getInstance().postFrameCallback(frame)
|
||||
}
|
||||
}
|
||||
|
||||
private fun click(button: Int) {
|
||||
NativeBridge.nativeSendPointerButton(handle, button, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, button, false)
|
||||
}
|
||||
}
|
||||
@@ -109,43 +109,11 @@ data class Settings(
|
||||
* setup where the OS-level pad (lizard mode) is preferred.
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
* 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 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,
|
||||
// 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:
|
||||
@@ -204,13 +172,6 @@ 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),
|
||||
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),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -234,8 +195,6 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -274,11 +233,6 @@ 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"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
@@ -422,47 +376,21 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
|
||||
8 to "7.1 Surround",
|
||||
)
|
||||
|
||||
/**
|
||||
* (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).
|
||||
*/
|
||||
/** (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. */
|
||||
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, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
* AV1=4. */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
"h264" -> 1
|
||||
"hevc" -> 2
|
||||
"av1" -> 4
|
||||
"pyrowave" -> 8
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@@ -498,20 +426,11 @@ val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TOUCH to "Touch passthrough",
|
||||
)
|
||||
|
||||
/** (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.
|
||||
*/
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
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",
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
)
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
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,12 +40,6 @@ 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
|
||||
@@ -62,17 +56,13 @@ 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) + profileTag, Color.White)
|
||||
statLine(compactLine(s, latValid), Color.White)
|
||||
return@Column
|
||||
}
|
||||
|
||||
statLine(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
|
||||
Color.White,
|
||||
)
|
||||
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
@@ -50,30 +49,17 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
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(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val handle = session.handle
|
||||
val initialSettings = session.settings
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
val window = activity?.window
|
||||
@@ -95,6 +81,7 @@ fun StreamScreen(session: ActiveSession, 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("") }
|
||||
@@ -189,14 +176,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
||||
var exitArming by remember { mutableStateOf(false) }
|
||||
|
||||
// True while the TV remote is acting as a pointer (hold SELECT toggles) — drives the mode hint.
|
||||
var remotePointerOn by remember { mutableStateOf(false) }
|
||||
|
||||
// Focus anchor the soft keyboard is summoned onto AND the pointer-capture grab target (a grab
|
||||
// needs a focusable view; captured-pointer events land on it). Declared before the effect
|
||||
// below so the capture callbacks can reach the view once it exists.
|
||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
wifiLocks.forEach { lock ->
|
||||
@@ -242,54 +221,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
||||
// the video, is the one the user sees (twin of the desktop clients' hidden cursor).
|
||||
val decor = window?.decorView
|
||||
val priorPointerIcon = decor?.pointerIcon
|
||||
decor?.pointerIcon = android.view.PointerIcon.getSystemIcon(
|
||||
context,
|
||||
android.view.PointerIcon.TYPE_NULL,
|
||||
)
|
||||
val mouse = MouseForwarder(
|
||||
handle,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
captureWanted = initialSettings.mouseMode == MouseMode.CAPTURE,
|
||||
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
|
||||
)
|
||||
mouse.onRequestCapture = {
|
||||
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
|
||||
// request racing view attach/focus settles on the next frame.
|
||||
keyCapture?.let { v ->
|
||||
v.post {
|
||||
v.requestFocus()
|
||||
v.requestPointerCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
mouse.onReleaseCapture = { keyCapture?.releasePointerCapture() }
|
||||
activity?.mouseForwarder = mouse
|
||||
// TV remote-as-pointer: hold SELECT ≈ 0.8 s to toggle; the D-pad then glides the host
|
||||
// cursor (see RemotePointer). TV only — a phone's remote-less keys stay on the VK path.
|
||||
val remote = if (isTv) {
|
||||
RemotePointer(
|
||||
handle,
|
||||
surfaceWidth = { decor?.width ?: 1920 },
|
||||
onActiveChanged = { on -> remotePointerOn = on },
|
||||
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
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 (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
||||
ClipboardSync(context, handle).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
@@ -355,7 +286,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
||||
feedback.onHidRaw = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
@@ -363,12 +293,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
||||
mouse.release()
|
||||
activity?.mouseForwarder = null
|
||||
remote?.release()
|
||||
activity?.remotePointer = null
|
||||
decor?.pointerIcon = priorPointerIcon
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||
@@ -396,32 +320,8 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
|
||||
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
|
||||
// suspend a process for going to background, so without this the native worker kept running and
|
||||
// its QUIC connection kept answering the host's keep-alives — the user was long gone but the
|
||||
// host still saw a live client and held the session (and its display + encoder) open until the
|
||||
// OS eventually reclaimed the process, which on a TV box is effectively never.
|
||||
//
|
||||
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
|
||||
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
|
||||
// so the host should linger the display and make coming straight back a fast reconnect.
|
||||
DisposableEffect(handle) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
onDisconnect()
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
onDispose { lifecycle?.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
|
||||
// Delayed a beat: the grab needs window focus and the capture view attached.
|
||||
LaunchedEffect(handle) {
|
||||
delay(400)
|
||||
activity?.mouseForwarder?.engageFromStart()
|
||||
}
|
||||
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
|
||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
@@ -470,10 +370,7 @@ fun StreamScreen(session: ActiveSession, 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, session.profileName,
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, 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
|
||||
@@ -482,49 +379,23 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
if (exitArming) {
|
||||
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||
}
|
||||
// Remote-pointer mode hint — the remote's keys are remapped while it's on, so say so.
|
||||
if (remotePointerOn) {
|
||||
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||
}
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
|
||||
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
|
||||
// touches, it just owns IME focus and receives captured-pointer events.
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
|
||||
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||
AndroidView(
|
||||
modifier = Modifier.size(1.dp),
|
||||
factory = { ctx ->
|
||||
KeyCaptureView(ctx).also { v ->
|
||||
keyCapture = v
|
||||
// Real IME text path when the host types committed text (see KeyCaptureView).
|
||||
v.textHandle =
|
||||
if (NativeBridge.nativeTextInputSupported(handle)) handle else 0L
|
||||
v.setOnCapturedPointerListener { _, ev ->
|
||||
(ctx as? MainActivity)?.mouseForwarder?.onCapturedPointer(ev) ?: false
|
||||
}
|
||||
}
|
||||
},
|
||||
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||
)
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
|
||||
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
|
||||
// stationary held stroke alive (and its cancellation lifts everything on teardown).
|
||||
val stylus = remember(handle) {
|
||||
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
|
||||
}
|
||||
if (stylus != null) {
|
||||
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle)
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
stylus,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||
)
|
||||
@@ -552,35 +423,14 @@ private fun ExitChordHint(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote-pointer mode cue: while active the remote's keys are remapped (D-pad glides the host
|
||||
* cursor, SELECT clicks), so the overlay both confirms the toggle and teaches the vocabulary.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemotePointerHint(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
"Remote pointer — SELECT click · play/pause right-click · hold SELECT to exit",
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||
* onto this view. Two IME models, picked by the host's capabilities:
|
||||
* * **Text path** ([textHandle] set — the host advertised `HOST_CAP_TEXT_INPUT`): a real
|
||||
* editable [HostTextConnection], so the IME gives autocorrect, gesture typing, non-Latin
|
||||
* composition and emoji, all mirrored to the host as committed text + diffs.
|
||||
* * **Fallback** (older host): `TYPE_NULL` puts the IME in "dumb keyboard" mode — raw
|
||||
* [KeyEvent]s flow through `MainActivity.dispatchKeyEvent` → `Keymap.toVk` → the host, the
|
||||
* exact path a hardware keyboard takes (with the IME-shift wrap documented there).
|
||||
*
|
||||
* Doubles as the pointer-capture grab target: a grab needs a focusable view, and captured-pointer
|
||||
* events are delivered to it (routed to [MouseForwarder.onCapturedPointer] via the listener the
|
||||
* stream screen installs).
|
||||
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
|
||||
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
|
||||
* `MainActivity.dispatchKeyEvent`).
|
||||
*/
|
||||
private class KeyCaptureView(context: Context) : View(context) {
|
||||
init {
|
||||
@@ -588,171 +438,22 @@ private class KeyCaptureView(context: Context) : View(context) {
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
/** The session handle when the host types committed text; `0` = VK-only fallback. */
|
||||
var textHandle: Long = 0L
|
||||
override fun onCheckIsTextEditor(): Boolean = true
|
||||
|
||||
/** Whether [setImeVisible] last showed the IME — for toggle-style callers (remote pointer). */
|
||||
var imeShown = false
|
||||
private set
|
||||
|
||||
override fun onCheckIsTextEditor(): Boolean = imeShown
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||
// Only an editor while the user has SUMMONED the keyboard (gesture / remote toggle).
|
||||
// This view holds focus for the whole stream (it's the capture anchor), and with an
|
||||
// always-live editable connection the IME counts input as active on it — TV IMEs then
|
||||
// pop their UI the moment a PHYSICAL keyboard key arrives. With no connection, hardware
|
||||
// typing stays on the raw dispatchKeyEvent → Keymap → wire path and no keyboard appears.
|
||||
if (!imeShown) return null
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
||||
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_ENTER_ACTION
|
||||
return if (textHandle != 0L) {
|
||||
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
|
||||
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
||||
HostTextConnection(this, textHandle)
|
||||
} else {
|
||||
outAttrs.inputType = InputType.TYPE_NULL
|
||||
BaseInputConnection(this, false)
|
||||
}
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||
outAttrs.inputType = InputType.TYPE_NULL
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||
return BaseInputConnection(this, false)
|
||||
}
|
||||
|
||||
fun setImeVisible(show: Boolean) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
?: return
|
||||
imeShown = show
|
||||
if (show) {
|
||||
requestFocus()
|
||||
// The view may already be focused from a null-connection state — restart so the
|
||||
// framework re-queries onCreateInputConnection with the gate now open.
|
||||
imm.restartInput(this)
|
||||
imm.showSoftInput(this, 0)
|
||||
} else {
|
||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||
imm.restartInput(this) // gate closed — drop the editable connection
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BACK while the summoned keyboard is up: the IME consumes it pre-IME to dismiss itself, so
|
||||
* [setImeVisible] never hears about it — sync the gate here or a stale `imeShown` leaves the
|
||||
* editable connection live and physical typing re-pops the keyboard.
|
||||
*/
|
||||
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && imeShown && event.action == KeyEvent.ACTION_UP) {
|
||||
imeShown = false
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
|
||||
?.restartInput(this)
|
||||
}
|
||||
return super.onKeyPreIme(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IME → host text bridge (the `HOST_CAP_TEXT_INPUT` path): a real **editable** connection, so
|
||||
* the IME runs its full machinery (autocorrect, gesture typing, non-Latin composition), mirrored
|
||||
* to the host as it happens. The one piece of host-side state tracked is *what the host currently
|
||||
* shows of the active composition* ([sentComposition]): composing updates send a common-prefix
|
||||
* diff (backspaces + the new suffix) so corrections materialize live on the host; a commit
|
||||
* settles it. [setComposingRegion] adopts already-committed text as the active composition
|
||||
* (autocorrect-revert / backspace-into-word flows), so the next update diffs against it instead
|
||||
* of retyping. Newlines become Enter taps; [deleteSurroundingText] becomes Backspace/Delete taps.
|
||||
*
|
||||
* Known approximation: diff lengths are counted in Unicode scalars, assuming one host Backspace
|
||||
* deletes one scalar — true for the composition text IMEs actually produce (emoji and other
|
||||
* multi-unit graphemes commit directly rather than composing).
|
||||
*/
|
||||
private class HostTextConnection(
|
||||
view: KeyCaptureView,
|
||||
private val handle: Long,
|
||||
) : BaseInputConnection(view, true) {
|
||||
/** What the host currently shows of the active composition ("" = none). */
|
||||
private var sentComposition = ""
|
||||
|
||||
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
|
||||
retype(text.toString())
|
||||
sentComposition = ""
|
||||
val ok = super.commitText(text, newCursorPosition)
|
||||
trimEditable()
|
||||
return ok
|
||||
}
|
||||
|
||||
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
|
||||
retype(text.toString())
|
||||
return super.setComposingText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
override fun finishComposingText(): Boolean {
|
||||
// The composition text stands as committed — the host already shows it verbatim.
|
||||
sentComposition = ""
|
||||
return super.finishComposingText()
|
||||
}
|
||||
|
||||
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
||||
val e = editable
|
||||
if (e != null) {
|
||||
val a = start.coerceIn(0, e.length)
|
||||
val b = end.coerceIn(0, e.length)
|
||||
sentComposition = e.subSequence(minOf(a, b), maxOf(a, b)).toString()
|
||||
}
|
||||
return super.setComposingRegion(start, end)
|
||||
}
|
||||
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||
repeat(beforeLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_BACK) }
|
||||
repeat(afterLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_DELETE) }
|
||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun performEditorAction(actionCode: Int): Boolean {
|
||||
tapVk(VK_RETURN)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Replace the host's view of the composition with [text] via a common-prefix diff. */
|
||||
private fun retype(text: String) {
|
||||
var common = sentComposition.commonPrefixWith(text)
|
||||
// Never split a surrogate pair mid-diff — back off to the pair boundary.
|
||||
if (common.isNotEmpty() && common.last().isHighSurrogate()) {
|
||||
common = common.dropLast(1)
|
||||
}
|
||||
val stale = sentComposition.substring(common.length)
|
||||
repeat(stale.codePointCount(0, stale.length).coerceAtMost(MAX_TAPS)) { tapVk(VK_BACK) }
|
||||
sendText(text.substring(common.length))
|
||||
sentComposition = text
|
||||
}
|
||||
|
||||
/** Forward literal text, turning newlines into Enter taps (control chars never ride text). */
|
||||
private fun sendText(s: String) {
|
||||
var chunk = StringBuilder()
|
||||
for (ch in s) {
|
||||
if (ch == '\n') {
|
||||
if (chunk.isNotEmpty()) {
|
||||
NativeBridge.nativeSendText(handle, chunk.toString())
|
||||
chunk = StringBuilder()
|
||||
}
|
||||
tapVk(VK_RETURN)
|
||||
} else {
|
||||
chunk.append(ch)
|
||||
}
|
||||
}
|
||||
if (chunk.isNotEmpty()) NativeBridge.nativeSendText(handle, chunk.toString())
|
||||
}
|
||||
|
||||
private fun tapVk(vk: Int) {
|
||||
NativeBridge.nativeSendKey(handle, vk, true, 0)
|
||||
NativeBridge.nativeSendKey(handle, vk, false, 0)
|
||||
}
|
||||
|
||||
/** Bound the mirror buffer: once nothing is composing, old text serves no purpose. */
|
||||
private fun trimEditable() {
|
||||
val e = editable ?: return
|
||||
if (getComposingSpanStart(e) == -1 && e.length > 4000) e.clear()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VK_BACK = 0x08
|
||||
const val VK_RETURN = 0x0D
|
||||
const val VK_DELETE = 0x2E
|
||||
const val MAX_TAPS = 256
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// Wire PEN_* state bits (punktfunk_core::quic::pen; mirrored, asserted by the Rust shim's docs).
|
||||
private const val PEN_IN_RANGE = 1f
|
||||
private const val PEN_TOUCHING = 2f
|
||||
private const val PEN_BARREL1 = 4f
|
||||
private const val PEN_BARREL2 = 8f
|
||||
private const val STRIDE = 10
|
||||
private const val MAX_SAMPLES = 8
|
||||
|
||||
/**
|
||||
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
|
||||
* (`AXIS_TILT`, radians from the surface normal), azimuth (`AXIS_ORIENTATION` — Android's 0 =
|
||||
* "pointed away from the user" IS the wire's north, no offset needed), hover with
|
||||
* `AXIS_DISTANCE`, the eraser tool, both stylus barrel buttons, and historical (coalesced)
|
||||
* samples batched oldest-first for full capture-rate fidelity. Android has no barrel-roll
|
||||
* axis — roll stays unknown on this client.
|
||||
*
|
||||
* Both touch loops call [intercept] first; stylus/eraser pointers are consumed here (against a
|
||||
* pen-capable host) and never reach the finger paths, independent of the touch-input mode.
|
||||
* [heartbeatLoop] implements the ≤100 ms keepalive wire contract: a stationary held stylus is
|
||||
* silent in Android's input pipeline, and the host force-releases a stroke after 200 ms
|
||||
* without samples.
|
||||
*/
|
||||
internal class StylusStream(private val handle: Long) {
|
||||
private var inRange = false
|
||||
private var touching = false
|
||||
private var sawHover = false
|
||||
private val last = FloatArray(STRIDE)
|
||||
private val batch = FloatArray(MAX_SAMPLES * STRIDE)
|
||||
|
||||
init {
|
||||
idle(last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the event's stylus pointers into pen samples. Returns true when this event
|
||||
* carried any (the caller's finger/gesture handling must then skip those changes).
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun intercept(ev: PointerEvent, size: IntSize): Boolean {
|
||||
val stylusChanges = ev.changes.filter {
|
||||
it.type == PointerType.Stylus || it.type == PointerType.Eraser
|
||||
}
|
||||
if (stylusChanges.isEmpty()) return false
|
||||
stylusChanges.forEach { it.consume() }
|
||||
val me = ev.motionEvent ?: return true
|
||||
if (size.width <= 0 || size.height <= 0) return true
|
||||
// At most one stylus exists — find its pointer index by tool type.
|
||||
val idx = (0 until me.pointerCount).firstOrNull {
|
||||
me.getToolType(it) == MotionEvent.TOOL_TYPE_STYLUS ||
|
||||
me.getToolType(it) == MotionEvent.TOOL_TYPE_ERASER
|
||||
} ?: return true
|
||||
|
||||
when (me.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN,
|
||||
MotionEvent.ACTION_MOVE,
|
||||
-> {
|
||||
touching = true
|
||||
inRange = true
|
||||
emitSamples(me, idx, size)
|
||||
}
|
||||
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> {
|
||||
sawHover = true
|
||||
inRange = true
|
||||
touching = false
|
||||
emitSamples(me, idx, size)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
|
||||
touching = false
|
||||
// Hover-capable hardware keeps proximity (HOVER_EXIT owns the leave);
|
||||
// anything else leaves range on lift — the host never parks a phantom pen.
|
||||
inRange = sawHover
|
||||
emitSamples(me, idx, size)
|
||||
}
|
||||
MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_CANCEL -> release()
|
||||
else -> {}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Session/composition teardown: leave range so the host lifts anything still inked. */
|
||||
fun reset() {
|
||||
if (inRange || touching) release()
|
||||
sawHover = false
|
||||
}
|
||||
|
||||
/** The ≤100 ms keepalive (80 ms leaves headroom for one lost datagram). Runs until
|
||||
* cancelled; resends the last state-full sample while the pen is in range. */
|
||||
suspend fun heartbeatLoop() {
|
||||
try {
|
||||
while (true) {
|
||||
delay(80)
|
||||
if (inRange || touching) {
|
||||
last[9] = 0f // dt
|
||||
NativeBridge.nativeSendPen(handle, last, 1)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
touching = false
|
||||
inRange = false
|
||||
last[0] = 0f // state: out of range
|
||||
last[4] = 0f // pressure
|
||||
NativeBridge.nativeSendPen(handle, last, 1)
|
||||
}
|
||||
|
||||
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
|
||||
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
|
||||
val history = minOf(me.historySize, MAX_SAMPLES - 1)
|
||||
var count = 0
|
||||
var prevT = if (history > 0) me.getHistoricalEventTime(0) else me.eventTime
|
||||
for (h in (me.historySize - history) until me.historySize) {
|
||||
val t = me.getHistoricalEventTime(h)
|
||||
fill(
|
||||
batch, count * STRIDE, size,
|
||||
x = me.getHistoricalX(idx, h), y = me.getHistoricalY(idx, h),
|
||||
pressure = me.getHistoricalPressure(idx, h),
|
||||
tiltRad = me.getHistoricalAxisValue(MotionEvent.AXIS_TILT, idx, h),
|
||||
orientRad = me.getHistoricalAxisValue(MotionEvent.AXIS_ORIENTATION, idx, h),
|
||||
distance = me.getHistoricalAxisValue(MotionEvent.AXIS_DISTANCE, idx, h),
|
||||
buttons = me.buttonState, tool = me.getToolType(idx),
|
||||
dtUs = ((t - prevT) * 1000).coerceIn(0, 65535).toFloat(),
|
||||
)
|
||||
prevT = t
|
||||
count++
|
||||
}
|
||||
fill(
|
||||
batch, count * STRIDE, size,
|
||||
x = me.getX(idx), y = me.getY(idx), pressure = me.getPressure(idx),
|
||||
tiltRad = me.getAxisValue(MotionEvent.AXIS_TILT, idx),
|
||||
orientRad = me.getAxisValue(MotionEvent.AXIS_ORIENTATION, idx),
|
||||
distance = me.getAxisValue(MotionEvent.AXIS_DISTANCE, idx),
|
||||
buttons = me.buttonState, tool = me.getToolType(idx),
|
||||
dtUs = ((me.eventTime - prevT) * 1000).coerceIn(0, 65535).toFloat(),
|
||||
)
|
||||
count++
|
||||
batch.copyInto(last, 0, (count - 1) * STRIDE, count * STRIDE)
|
||||
NativeBridge.nativeSendPen(handle, batch, count)
|
||||
}
|
||||
|
||||
private fun fill(
|
||||
out: FloatArray,
|
||||
off: Int,
|
||||
size: IntSize,
|
||||
x: Float,
|
||||
y: Float,
|
||||
pressure: Float,
|
||||
tiltRad: Float,
|
||||
orientRad: Float,
|
||||
distance: Float,
|
||||
buttons: Int,
|
||||
tool: Int,
|
||||
dtUs: Float,
|
||||
) {
|
||||
var state = 0f
|
||||
if (inRange || touching) state += PEN_IN_RANGE
|
||||
if (touching) state += PEN_TOUCHING
|
||||
if (buttons and MotionEvent.BUTTON_STYLUS_PRIMARY != 0) state += PEN_BARREL1
|
||||
if (buttons and MotionEvent.BUTTON_STYLUS_SECONDARY != 0) state += PEN_BARREL2
|
||||
out[off + 0] = state
|
||||
out[off + 1] = if (tool == MotionEvent.TOOL_TYPE_ERASER) 1f else 0f
|
||||
out[off + 2] = (x / (size.width - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
out[off + 3] = (y / (size.height - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
out[off + 4] = if (touching) pressure.coerceIn(0f, 1f) else 0f
|
||||
// AXIS_DISTANCE units are device-arbitrary; 0..1 covers real hardware, and 0 while
|
||||
// hovering legitimately means "at the hover floor".
|
||||
out[off + 5] = if (touching) 0f else distance.coerceIn(0f, 1f)
|
||||
out[off + 6] = Math.toDegrees(tiltRad.toDouble()).toFloat().coerceIn(0f, 90f)
|
||||
// AXIS_ORIENTATION: 0 = pointed away from the user (= wire north), clockwise, −π..π.
|
||||
out[off + 7] = ((Math.toDegrees(orientRad.toDouble()) + 360.0) % 360.0).toFloat()
|
||||
out[off + 8] = -1f // no barrel-roll axis on Android
|
||||
out[off + 9] = dtUs
|
||||
}
|
||||
|
||||
private fun idle(out: FloatArray) {
|
||||
out.fill(0f)
|
||||
out[5] = -1f // distance unknown
|
||||
out[6] = -1f // tilt unknown
|
||||
out[7] = -1f // azimuth unknown
|
||||
out[8] = -1f // roll unknown
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
@@ -58,26 +56,7 @@ private const val ACCEL_MAX = 3.0f
|
||||
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
|
||||
* contact is lifted so nothing stays stuck on the host.
|
||||
*/
|
||||
/** Whether this change belongs to the stylus lane (only when a pen-capable host is live). */
|
||||
private fun isStylus(c: PointerInputChange, stylus: StylusStream?): Boolean =
|
||||
stylus != null && (c.type == PointerType.Stylus || c.type == PointerType.Eraser)
|
||||
|
||||
/** [awaitFirstDown] with the stylus lane split out: pen events feed [stylus] and never start a
|
||||
* mouse/touch gesture. Toward a pen-less host ([stylus] == null) a stylus stays a finger. */
|
||||
private suspend fun AwaitPointerEventScope.awaitFirstFingerDown(
|
||||
stylus: StylusStream?,
|
||||
): PointerInputChange {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
stylus?.intercept(ev, size)
|
||||
val down = ev.changes.firstOrNull {
|
||||
it.changedToDownIgnoreConsumed() && !isStylus(it, stylus)
|
||||
}
|
||||
if (down != null) return down
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, stylus: StylusStream?) {
|
||||
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||
val ids = mutableMapOf<PointerId, Int>()
|
||||
fun alloc(p: PointerId): Int {
|
||||
var id = 0
|
||||
@@ -89,12 +68,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
stylus?.intercept(ev, size)
|
||||
val sw = size.width
|
||||
val sh = size.height
|
||||
if (sw <= 0 || sh <= 0) continue
|
||||
for (c in ev.changes) {
|
||||
if (isStylus(c, stylus)) continue // the pen plane owns it
|
||||
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
|
||||
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
|
||||
when {
|
||||
@@ -121,13 +98,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
||||
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
stylus: StylusStream?,
|
||||
trackpad: Boolean,
|
||||
invertScroll: Boolean,
|
||||
onCycleStats: () -> Unit,
|
||||
onKeyboard: (show: Boolean) -> Unit,
|
||||
) {
|
||||
val scrollDir = if (invertScroll) -1 else 1
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
var lastTapY = 0f
|
||||
@@ -144,7 +118,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
)
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstFingerDown(stylus)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val startX = down.position.x
|
||||
val startY = down.position.y
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
@@ -181,8 +155,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
stylus?.intercept(ev, size)
|
||||
val pressed = ev.changes.filter { it.pressed && !isStylus(it, stylus) }
|
||||
val pressed = ev.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) {
|
||||
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||
break
|
||||
@@ -211,12 +184,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||
if (sy != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
|
||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||
prevCy = cy
|
||||
moved = true
|
||||
}
|
||||
if (sx != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
|
||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
|
||||
@@ -9,22 +9,16 @@ 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
|
||||
@@ -38,9 +32,6 @@ 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
|
||||
@@ -57,26 +48,9 @@ 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 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.
|
||||
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
|
||||
*/
|
||||
@Composable
|
||||
fun HostCard(
|
||||
@@ -84,25 +58,11 @@ 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.
|
||||
@@ -129,8 +89,8 @@ fun HostCard(
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
HostAvatar(name, online)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HostAvatar(name)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -138,49 +98,25 @@ fun HostCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
if (onForget != null || onEdit != null || onWake != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -219,16 +155,6 @@ fun HostCard(
|
||||
},
|
||||
)
|
||||
}
|
||||
menuItems.forEach { item ->
|
||||
if (item.startsSection) HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(item.label) },
|
||||
onClick = {
|
||||
menu = false
|
||||
item.onClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,122 +162,59 @@ fun HostCard(
|
||||
}
|
||||
}
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
@Composable
|
||||
fun HostAvatar(name: String) {
|
||||
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileChip(label: String, accent: Color?, prominent: Boolean) {
|
||||
val tint = accent ?: MaterialTheme.colorScheme.primary
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.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))
|
||||
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(
|
||||
label,
|
||||
style = if (prominent) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium
|
||||
},
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
if (online) "Online" else "Offline",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A small colored dot + label for the host's trust state. */
|
||||
@Composable
|
||||
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" },
|
||||
)
|
||||
fun StatusPill(status: HostStatus) {
|
||||
val color = when (status) {
|
||||
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
|
||||
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
|
||||
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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
|
||||
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)
|
||||
}
|
||||
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. */
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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,44 +25,10 @@ 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"),
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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,21 +56,6 @@ 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) }
|
||||
@@ -112,15 +97,6 @@ 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,12 +19,10 @@ 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
|
||||
@@ -33,20 +31,11 @@ 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
|
||||
@@ -59,31 +48,15 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
private data class MockHost(val name: String, val address: String, val status: HostStatus)
|
||||
|
||||
// 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(
|
||||
// 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),
|
||||
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING),
|
||||
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU),
|
||||
)
|
||||
|
||||
/** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */
|
||||
@@ -113,170 +86,42 @@ internal fun HostsScene() {
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(SAVED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(DISCOVERED) { h ->
|
||||
HostCard(
|
||||
h.name, h.address, h.status, online = h.online,
|
||||
enabled = true, onConnect = {}, onForget = null,
|
||||
)
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
/** The real SettingsScreen, fed a representative non-default Settings. */
|
||||
@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 = SHOT_SETTINGS,
|
||||
initial = Settings(
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
hz = 120,
|
||||
bitrateKbps = 50_000,
|
||||
compositor = 1,
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
),
|
||||
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.3.1 · Gradle 9.5.0 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
|
||||
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · 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.3.1" apply false
|
||||
id("com.android.library") version "9.3.1" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("com.android.library") version "9.2.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.5.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
@@ -33,11 +33,7 @@ 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 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")
|
||||
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -106,17 +106,6 @@ object Keymap {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> 0x26
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
|
||||
// TV-remote SELECT = Enter (a gamepad's press routes via SOURCE_GAMEPAD before this).
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> 0x0D
|
||||
|
||||
// Consumer/media keys — forwarded to the host while streaming (volume stays local:
|
||||
// MainActivity's pass-through list wins before the map is consulted).
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> 0xB3 // VK_MEDIA_PLAY_PAUSE
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> 0xB0 // VK_MEDIA_NEXT_TRACK
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> 0xB1 // VK_MEDIA_PREV_TRACK
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> 0xB2 // VK_MEDIA_STOP
|
||||
|
||||
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
|
||||
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
|
||||
|
||||
@@ -57,10 +57,6 @@ 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. */
|
||||
@@ -149,24 +145,6 @@ 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
|
||||
@@ -309,66 +287,6 @@ object NativeBridge {
|
||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||
|
||||
/**
|
||||
* Whether the host advertised full-fidelity stylus injection (`HOST_CAP_PEN`) — the gate
|
||||
* for splitting stylus pointers out of the touch path onto the pen plane. False on `0`.
|
||||
*/
|
||||
external fun nativeHostSupportsPen(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* One stylus batch of STATE-FULL samples (the pen plane; design/pen-tablet-input.md §7):
|
||||
* [count] × 10 floats, oldest first — `[state, tool, x, y, pressure, distance, tilt_deg,
|
||||
* azimuth_deg, roll_deg, dt_us]`. `state` = the wire in-range/touching/barrel bits; `tool`
|
||||
* 0=pen 1=eraser; x/y/pressure/distance normalized 0..1; distance/tilt/azimuth/roll < 0 =
|
||||
* unknown. Send only when [nativeHostSupportsPen]; repeat the last sample ≤100 ms while the
|
||||
* pen is in range (the host force-releases a silent stroke after 200 ms).
|
||||
*/
|
||||
external fun nativeSendPen(handle: Long, samples: FloatArray, count: Int)
|
||||
|
||||
/**
|
||||
* Whether the host advertised committed-text injection (`HOST_CAP_TEXT_INPUT`) — its inject
|
||||
* backend can type Unicode text directly. Picks the real IME `InputConnection` (autocorrect,
|
||||
* gesture typing, non-Latin scripts) over the TYPE_NULL raw-key fallback. False on `0`.
|
||||
*/
|
||||
external fun nativeTextInputSupported(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* Committed IME text → one `TextInput` wire event per Unicode scalar, in order. Control
|
||||
* characters are skipped natively (Enter/Backspace ride [nativeSendKey]). Only meaningful
|
||||
* when [nativeTextInputSupported] returned true — older hosts ignore the events.
|
||||
*/
|
||||
external fun nativeSendText(handle: Long, text: String)
|
||||
|
||||
// ---- Shared clipboard (text v1): Kotlin drives ClipboardManager, Rust the protocol ----
|
||||
// Opt-in per session (nativeClipControl). Local copies are announced as lazy offers; bytes
|
||||
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
|
||||
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
|
||||
|
||||
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
|
||||
external fun nativeClipSupported(handle: Long): Boolean
|
||||
|
||||
/** Session-level clipboard opt-in/out; nothing happens until enabled=true crosses. */
|
||||
external fun nativeClipControl(handle: Long, enabled: Boolean)
|
||||
|
||||
/** Announce "this device's clipboard now holds text". [seq]: monotonic, newest wins. */
|
||||
external fun nativeClipOfferText(handle: Long, seq: Int)
|
||||
|
||||
/** Pull the text of the host's offer [seq] → transfer id echoed on "data:"/"error:", or -1. */
|
||||
external fun nativeClipFetchText(handle: Long, seq: Int): Int
|
||||
|
||||
/** Answer a "fetch:" event with the clipboard's current text (the host is pasting). */
|
||||
external fun nativeClipServeText(handle: Long, reqId: Int, text: String)
|
||||
|
||||
/** Abort a clipboard transfer by id (either direction). */
|
||||
external fun nativeClipCancel(handle: Long, id: Int)
|
||||
|
||||
/**
|
||||
* Block ≤250 ms for the next clipboard event, as a compact string: `state:<0|1>` ·
|
||||
* `offer:<seq>:<hasText>` · `fetch:<reqId>` · `data:<xferId>:<text>` · `cancel:<id>` ·
|
||||
* `error:<id>:<code>` · `closed` (session gone) — null on timeout. Dedicated poll thread.
|
||||
*/
|
||||
external fun nativeNextClip(handle: Long): String?
|
||||
|
||||
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||
|
||||
@@ -18,17 +18,16 @@ 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␟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.
|
||||
* 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.
|
||||
*/
|
||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
val f = record.split(FIELD_SEP)
|
||||
@@ -45,41 +44,9 @@ 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
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
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,19 +1,11 @@
|
||||
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,
|
||||
@@ -26,85 +18,37 @@ 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
|
||||
* [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.
|
||||
* `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.
|
||||
*/
|
||||
class KnownHostStore(context: Context) {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences(PREFS_HOSTS, Context.MODE_PRIVATE)
|
||||
context.applicationContext.getSharedPreferences("punktfunk_hosts", Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
migrateIfNeeded(context)
|
||||
}
|
||||
// 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"
|
||||
|
||||
/** The trusted record for [address]:[port], or `null` if this host has never been trusted. */
|
||||
fun get(address: String, port: Int): KnownHost? =
|
||||
all().firstOrNull { it.address == address && it.port == port }
|
||||
prefs.getString(key(address, port), null)?.let(::parse)
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
/** Pin (or update) a trusted host — upsert by `address:port`. */
|
||||
fun save(host: KnownHost) {
|
||||
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
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,56 +63,30 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(mac = mac))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(address: String, port: Int) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
}
|
||||
|
||||
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(host: KnownHost) {
|
||||
prefs.edit().remove(host.id).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).
|
||||
*/
|
||||
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||
save(updated)
|
||||
}
|
||||
|
||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||
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()
|
||||
}
|
||||
}
|
||||
fun all(): List<KnownHost> =
|
||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||
|
||||
private fun parse(s: String): KnownHost? = runCatching {
|
||||
val j = JSONObject(s)
|
||||
@@ -179,67 +97,10 @@ 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;
|
||||
@@ -255,32 +116,5 @@ 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,39 +33,6 @@ 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.
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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,10 +1,6 @@
|
||||
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. */
|
||||
@@ -34,119 +30,4 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,3 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
|
||||
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, 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,12 +253,6 @@ 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
|
||||
@@ -350,12 +344,6 @@ 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}");
|
||||
}
|
||||
@@ -368,27 +356,7 @@ pub(super) fn run_async(
|
||||
if aus_dropped > 0 {
|
||||
gate.arm(now);
|
||||
}
|
||||
// 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)
|
||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
@@ -436,14 +404,7 @@ fn feeder_loop(
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
if stats.enabled() || measure_decode {
|
||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||
// here would fold the hand-off queue wait into the network latency figure
|
||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
};
|
||||
let received_ns = now_realtime_ns();
|
||||
{
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
|
||||
@@ -128,9 +128,7 @@ 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) {
|
||||
// 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)) };
|
||||
drop(Arc::from_raw(ud));
|
||||
}
|
||||
|
||||
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
|
||||
@@ -148,10 +146,7 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
media_time_us: i64,
|
||||
system_nano: i64,
|
||||
) {
|
||||
// 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) };
|
||||
let t = &*(userdata as *const DisplayTracker);
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
||||
}
|
||||
|
||||
@@ -41,27 +41,6 @@ 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, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
|
||||
use super::{DecodeOptions, IN_FLIGHT_CAP, 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,12 +144,6 @@ 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
|
||||
@@ -227,13 +221,7 @@ pub(super) fn run_sync(
|
||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||
if stats.enabled() || measure_decode {
|
||||
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
|
||||
// async_loop: a pull stamp folds hand-off queue wait into "network".
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
};
|
||||
let received_ns = now_realtime_ns();
|
||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
@@ -319,11 +307,6 @@ 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
|
||||
@@ -366,29 +349,8 @@ 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();
|
||||
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)
|
||||
if gate.poll(client.frames_dropped(), now)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
|
||||
@@ -31,10 +31,9 @@ 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␟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.
|
||||
/// 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.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Host {
|
||||
key: String,
|
||||
@@ -45,9 +44,6 @@ 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 {
|
||||
@@ -60,7 +56,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),
|
||||
@@ -68,7 +64,6 @@ impl Host {
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
clean(&self.mac),
|
||||
clean(&self.os),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -191,7 +186,6 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
mac: val("mac"),
|
||||
os: val("os"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,7 +206,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␟os` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = 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>(
|
||||
@@ -274,11 +268,10 @@ 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(), 8);
|
||||
assert_eq!(fields.len(), 7);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
@@ -286,7 +279,6 @@ 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"
|
||||
@@ -305,13 +297,12 @@ 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(),
|
||||
7,
|
||||
"exactly eight fields"
|
||||
6,
|
||||
"exactly seven fields"
|
||||
);
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
@@ -319,6 +310,5 @@ mod tests {
|
||||
assert_eq!(fields[1], "evilhost");
|
||||
assert_eq!(fields[4], "abcd");
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[7], "linuxevil/arch");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
//! Shared-clipboard plane (text-only v1): Kotlin drives the Android `ClipboardManager`, these
|
||||
//! shims drive [`punktfunk_core::client::NativeClient`]'s clipboard surface.
|
||||
//!
|
||||
//! Model (mirrors the desktop clients): opt-in via `nativeClipControl(true)`; local copies are
|
||||
//! announced lazily as format-list offers (`nativeClipOfferText`) and the bytes cross only when
|
||||
//! the host pastes (a `fetch` event answered by `nativeClipServeText`); a host copy arrives as an
|
||||
//! `offer` event, which the Kotlin side fetches eagerly (Android's clipboard has no lazy provider
|
||||
//! path worth the complexity) and lands in the system clipboard on the `data` event.
|
||||
//!
|
||||
//! Events cross to Kotlin as compact strings from the blocking `nativeNextClip` poll (drained on
|
||||
//! a dedicated thread, same pattern as `nativeNextRumble`):
|
||||
//! `state:<0|1>` · `offer:<seq>:<has_text 0|1>` · `fetch:<req_id>` · `data:<xfer_id>:<text>` ·
|
||||
//! `cancel:<id>` · `error:<id>:<code>` · `closed` — null on a poll timeout. Non-text fetch
|
||||
//! requests are cancelled natively (only text is ever offered, so they shouldn't occur).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::clipboard::ClipEventCore;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
/// The portable wire MIME both ends map to their platform text type.
|
||||
const TEXT_MIME: &str = "text/plain;charset=utf-8";
|
||||
|
||||
/// Deref the opaque handle (`0` → `None`).
|
||||
///
|
||||
/// SAFETY: live handle per the nativeConnect/nativeClose contract; every method used is `&self`
|
||||
/// on the `Sync` connector.
|
||||
fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
||||
if handle == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: see the function docs — the Kotlin side guarantees the handle outlives the call.
|
||||
Some(unsafe { &*(handle as *const SessionHandle) })
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
client(handle).map_or(0, |h| {
|
||||
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
enabled: jboolean,
|
||||
) {
|
||||
if let Some(h) = client(handle) {
|
||||
let _ = h.client.clip_control(enabled != 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
|
||||
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
|
||||
/// counter, newest wins.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
seq: jint,
|
||||
) {
|
||||
if let Some(h) = client(handle) {
|
||||
let _ = h.client.clip_offer(
|
||||
seq as u32,
|
||||
vec![ClipKind {
|
||||
mime: TEXT_MIME.into(),
|
||||
size_hint: 0,
|
||||
}],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
|
||||
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
seq: jint,
|
||||
) -> jint {
|
||||
client(handle)
|
||||
.and_then(|h| {
|
||||
h.client
|
||||
.clip_fetch(seq as u32, TEXT_MIME.into(), CLIP_FILE_INDEX_NONE)
|
||||
.ok()
|
||||
})
|
||||
.map_or(-1, |xfer| xfer as jint)
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
||||
/// clipboard's current text (the host is pasting our offer).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
req_id: jint,
|
||||
text: JString,
|
||||
) {
|
||||
let Some(h) = client(handle) else { return };
|
||||
let Ok(s) = env.get_string(&text) else {
|
||||
let _ = h.client.clip_cancel(req_id as u32);
|
||||
return;
|
||||
};
|
||||
let _ = h
|
||||
.client
|
||||
.clip_serve(req_id as u32, String::from(s).into_bytes(), true);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
id: jint,
|
||||
) {
|
||||
if let Some(h) = client(handle) {
|
||||
let _ = h.client.clip_cancel(id as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextClip(handle)` — block ≤250 ms for the next clipboard event, encoded
|
||||
/// as a compact string (module docs); null on timeout, `"closed"` once the session is gone.
|
||||
/// Call from a dedicated poll thread.
|
||||
///
|
||||
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
|
||||
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
|
||||
/// can never split a UTF-8 sequence.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
let Some(h) = client(handle) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
let msg = match h.client.next_clip(Duration::from_millis(250)) {
|
||||
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
|
||||
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
|
||||
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
|
||||
format!("offer:{seq}:{}", u8::from(has_text))
|
||||
}
|
||||
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
|
||||
if mime.starts_with("text/plain") {
|
||||
format!("fetch:{req_id}")
|
||||
} else {
|
||||
// We only ever offer text; cancel anything else rather than stall the host.
|
||||
let _ = h.client.clip_cancel(req_id);
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
|
||||
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
|
||||
}
|
||||
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
|
||||
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
|
||||
Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(),
|
||||
Err(_) => "closed".into(),
|
||||
};
|
||||
env.new_string(msg)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
@@ -84,11 +84,8 @@ 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,
|
||||
/// deviceName): Long`.
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): 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`
|
||||
@@ -120,7 +117,6 @@ 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(),
|
||||
@@ -139,14 +135,6 @@ 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
|
||||
@@ -213,12 +201,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||
None,
|
||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
||||
0,
|
||||
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
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
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).
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||
|
||||
use jni::objects::{JByteBuffer, JFloatArray, JObject, JString};
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{
|
||||
PenSample, PenTool, RichInput, HID_REPORT_MAX, HOST_CAP_PEN, HOST_CAP_TEXT_INPUT,
|
||||
PEN_ANGLE_UNKNOWN, PEN_BATCH_MAX, PEN_DISTANCE_UNKNOWN, PEN_TILT_UNKNOWN,
|
||||
};
|
||||
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
@@ -148,132 +145,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
|
||||
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
|
||||
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
u8::from(h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0)
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
|
||||
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
|
||||
/// (design/pen-tablet-input.md §7). `0` handle → false.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
u8::from(h.client.host_caps() & HOST_CAP_PEN != 0)
|
||||
}
|
||||
|
||||
/// Floats per sample in the `nativeSendPen` flat array.
|
||||
const PEN_JNI_STRIDE: usize = 10;
|
||||
|
||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
|
||||
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
|
||||
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
|
||||
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
|
||||
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
|
||||
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
|
||||
/// while in range (Kotlin side — see `StylusStream`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
samples: JFloatArray,
|
||||
count: jint,
|
||||
) {
|
||||
if handle == 0 || count <= 0 {
|
||||
return;
|
||||
}
|
||||
let count = (count as usize).min(PEN_BATCH_MAX);
|
||||
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
|
||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
||||
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
||||
return; // short array — a bridge bug, never worth a crash on the input path
|
||||
}
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return; // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_pen(&batch[..count]);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
||||
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
|
||||
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
text: JString,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(s) = env.get_string(&text) else {
|
||||
return;
|
||||
};
|
||||
for ch in String::from(s).chars().filter(|c| !c.is_control()) {
|
||||
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||
|
||||