Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8216f1d92d | ||
|
|
1677d1c0c2 | ||
|
|
3f738a9989 | ||
|
|
7df321f459 | ||
|
|
c7d0fd2e03 | ||
|
|
9c13335089 | ||
|
|
92db66514b | ||
|
|
86cbbea020 | ||
|
|
3d5d8e2d76 | ||
|
|
8f6eb1494d | ||
|
|
362d532d25 | ||
|
|
b2a9b281f0 | ||
|
|
e568513f74 | ||
|
|
7403450a8d | ||
|
|
141c04cd6b | ||
|
|
0a53457cb7 | ||
|
|
e7af5a5274 | ||
|
|
155cced56b | ||
|
|
8f66fafb90 | ||
|
|
6fe53fff2b | ||
|
|
1679275272 | ||
|
|
a5c9b7b865 | ||
|
|
6237e3d0a3 | ||
|
|
14425716e5 | ||
|
|
4903c9d3b5 | ||
|
|
a4af1ee8bd | ||
|
|
5cd4da4b46 | ||
|
|
59346b46dc | ||
|
|
76b80cffb5 | ||
|
|
9e492bfec3 | ||
|
|
692bfbaa4f | ||
|
|
f9932e0873 | ||
|
|
bd987d373e | ||
|
|
deb83ecc48 | ||
|
|
2718b7d4bd | ||
|
|
99c3a47bbf | ||
|
|
677b8ceb41 | ||
|
|
4358261387 | ||
|
|
8020fb6711 | ||
|
|
49a8f4f1d1 | ||
|
|
72c7c3b17f | ||
|
|
d73bdcdcc1 | ||
|
|
93b4c725a6 | ||
|
|
c814340607 | ||
|
|
1fb081a1f0 | ||
|
|
2b13b6353a | ||
|
|
dfde5080cc | ||
|
|
00245499e0 | ||
|
|
94e3629905 | ||
|
|
2d037aa443 | ||
|
|
c95db8eebc | ||
|
|
6202543b21 | ||
|
|
d0a7b262d2 | ||
|
|
51a005dd43 | ||
|
|
b84d37b5a0 | ||
|
|
9ec8350fc3 | ||
|
|
3ccfd01699 | ||
|
|
79d755cd98 | ||
|
|
5fbf04f56d | ||
|
|
85980b425e | ||
|
|
107fa3472d | ||
|
|
0bfc7fe913 | ||
|
|
3f7fbf1061 | ||
|
|
f1dc6c9f94 | ||
|
|
5d8682d7b7 | ||
|
|
030bc8a1c2 | ||
|
|
346385bad8 | ||
|
|
0026143164 | ||
|
|
ba16237c35 | ||
|
|
981f32b8f6 | ||
|
|
a9a1b923a2 | ||
|
|
124cb66324 | ||
|
|
6774c4e7a2 | ||
|
|
f06b84be63 | ||
|
|
d6dbb391d6 | ||
|
|
907080f92b | ||
|
|
9425c6d40a | ||
|
|
ab8c7ec37c | ||
|
|
64e2af17c5 | ||
|
|
72189b29ec | ||
|
|
339a1d70f9 | ||
|
|
79dba7f95a | ||
|
|
d7430fe2bd | ||
|
|
6f81ec24ba | ||
|
|
539236de91 | ||
|
|
118758ff0b | ||
|
|
dcde856178 | ||
|
|
77918674c3 | ||
|
|
faefbae830 | ||
|
|
44fa12a298 | ||
|
|
55a3d8b919 | ||
|
|
a02014ec19 | ||
|
|
5f55b820bc | ||
|
|
57703fe31c | ||
|
|
90a3304c2b | ||
|
|
017867f211 | ||
|
|
ef0af3b558 | ||
|
|
535e95c4c0 | ||
|
|
b385f0a031 | ||
|
|
7528fd48a9 | ||
|
|
c68e0be688 | ||
|
|
71c1970b93 | ||
|
|
f373dffb5e | ||
|
|
28b6633058 | ||
|
|
c58217e403 | ||
|
|
23f9b1130e | ||
|
|
c2c71f0ac5 | ||
|
|
712ee935d6 | ||
|
|
a2aa0a5f97 | ||
|
|
d6b9862f1e | ||
|
|
f4e39a442b | ||
|
|
a0577cb86e | ||
|
|
2a62fe7857 |
+19
-19
@@ -4,12 +4,21 @@
|
||||
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
|
||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||
#
|
||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
|
||||
# their latest published version with no successor, so there's nothing to bump — left visible on
|
||||
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
|
||||
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
|
||||
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
|
||||
# ⚠ NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities* — `unmaintained` AND
|
||||
# `unsound` advisories are warnings that do NOT fail CI. That is deliberate for the two unmaintained
|
||||
# crates below, but it does mean an unsoundness can sit here unnoticed: RUSTSEC-2026-0221
|
||||
# (event-listener) did exactly that until the 2026-08-13 sweep. Read the job's warnings, not just
|
||||
# its exit code.
|
||||
#
|
||||
# The two unmaintained ones, both transitive with no successor to bump to, left visible on purpose
|
||||
# so we keep getting the maintenance signal:
|
||||
# * audiopus_sys via opus (opus itself IS maintained; only its -sys layer is stuck).
|
||||
# * paste via BOTH utoipa-axum (host) and rav1d (client decode path) — an earlier version of this
|
||||
# note named only utoipa-axum, which would have made dropping utoipa-axum look like it cleared
|
||||
# paste. It would not: every client pulls it through rav1d.
|
||||
# (rustls-pemfile was dropped 2026-06-29 by removing axum-server's unused tls-rustls feature +
|
||||
# moving our own PEM parsing to rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11
|
||||
# bump.)
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
@@ -34,17 +43,8 @@ ignore = [
|
||||
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
|
||||
# this key, or any RSA decryption / key-transport using the private key is added.
|
||||
"RUSTSEC-2023-0071",
|
||||
|
||||
# quick-xml DoS advisories (RUSTSEC-2026-0194 quadratic-time duplicate-attribute check;
|
||||
# RUSTSEC-2026-0195 unbounded namespace-declaration allocation in NsReader). Both are
|
||||
# exploited by feeding attacker-controlled XML to a running parser. In this tree quick-xml is
|
||||
# a BUILD-TIME-ONLY, transitive dependency of `wayland-scanner` (a proc-macro that parses the
|
||||
# TRUSTED wayland protocol XML files shipped with the wayland-rs crates at compile time). It is
|
||||
# never linked into any shipped binary and never parses runtime/attacker-controlled input, so
|
||||
# neither DoS is reachable. There is no fix to bump to: wayland-scanner 0.31.10 (latest) pins
|
||||
# `quick-xml ^0.39`, and the fixes only exist in quick-xml >=0.41. Revisit (drop these) when
|
||||
# wayland-scanner releases against quick-xml >=0.41, or if quick-xml is ever pulled onto a
|
||||
# runtime path that parses untrusted XML.
|
||||
"RUSTSEC-2026-0194",
|
||||
"RUSTSEC-2026-0195",
|
||||
# The quick-xml DoS pair (RUSTSEC-2026-0194/0195) used to be ignored here, with the note
|
||||
# "revisit when wayland-scanner releases against quick-xml >=0.41". It has: wayland-scanner
|
||||
# 0.31.11 moved to `quick-xml ^0.41` and the lock is on 0.41.0 as of 2026-08-13, so both
|
||||
# entries were dropped rather than left as permanent exceptions.
|
||||
]
|
||||
|
||||
+36
-15
@@ -1,18 +1,39 @@
|
||||
# Workspace-wide build flags.
|
||||
#
|
||||
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
|
||||
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
|
||||
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
|
||||
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
|
||||
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
|
||||
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
|
||||
# extensions falls back safely.
|
||||
# THERE ARE DELIBERATELY NONE. This file is kept as a tombstone so the aarch64 AES cfgs are not
|
||||
# reintroduced — read this before adding rustflags here.
|
||||
#
|
||||
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
|
||||
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
|
||||
# `--cfg aes_armv8` themselves.
|
||||
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
|
||||
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
|
||||
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
|
||||
[target.'cfg(target_arch = "aarch64")']
|
||||
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
||||
# Until 2026-08-13 this file carried:
|
||||
#
|
||||
# [target.'cfg(target_arch = "aarch64")']
|
||||
# rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
||||
#
|
||||
# because RustCrypto's `aes` 0.8.x enabled the ARMv8-Crypto hardware AES backend on aarch64 ONLY
|
||||
# behind `--cfg aes_armv8`, and `polyval` 0.6.x gated its PMULL (carry-less multiply) GHASH path
|
||||
# behind `--cfg polyval_armv8`. That was a live footgun, not just boilerplate: a RUSTFLAGS
|
||||
# ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY — it does not merge and does not
|
||||
# append — so every aarch64 lane that set its own RUSTFLAGS silently dropped both and fell back to
|
||||
# SOFTWARE AES on the per-packet decrypt path. cargo-ndk sets RUSTFLAGS internally for its linker
|
||||
# configuration, which means every Android arm64-v8a build was hitting exactly that.
|
||||
#
|
||||
# `aes` 0.9 removed the cfg: on aarch64 it runtime-detects with `cpufeatures::new!(features_aes,
|
||||
# "aes")` (lib.rs), the same way x86_64 AES-NI always did. `polyval` 0.7 likewise selects
|
||||
# `backend/intrinsics/armv8.rs` by `target_arch` alone. Neither cfg exists any more — passing them
|
||||
# is inert.
|
||||
#
|
||||
# Measured here before deleting them, `crypto/open_in_place` from benches/pipeline.rs (one 1408-byte
|
||||
# MTU shard, AES-128-GCM, single core, Mac15,14 M3 Ultra, all four runs back to back under the same
|
||||
# background load):
|
||||
#
|
||||
# aes 0.8 + both cfgs 2.19 GiB/s <- what the cfgs bought
|
||||
# aes 0.8, cfgs stripped 225 MiB/s <- the footgun: ~10x slower, software AES
|
||||
# aes 0.9 + both cfgs 5.28 GiB/s
|
||||
# aes 0.9, cfgs stripped 5.28 GiB/s <- identical to 4 s.f.; the cfgs do nothing
|
||||
#
|
||||
# The ChaCha20-Poly1305 series of the same bench was the control: it moved 0.07% across the cfg
|
||||
# toggle at both versions, confirming the toggle reached only the AES path.
|
||||
#
|
||||
# So 0.9 without the cfgs is not merely as fast as 0.8 with them — it is ~2.4x faster, and ~24x
|
||||
# the software fallback. Do not re-add these flags; if a future aarch64 slowdown is suspected,
|
||||
# re-run `cargo bench -p punktfunk-core --bench pipeline -- in_place` and compare against the
|
||||
# table above rather than reaching for a cfg.
|
||||
|
||||
@@ -63,6 +63,15 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache. The one that pays here is the CMake-built vendored libopus
|
||||
# (audiopus_sys), which kit/build.gradle.kts drives through cargo-ndk once per ABI — three
|
||||
# from-scratch libopus builds per run until now. The per-ABI compilers come from the NDK via
|
||||
# cargo-ndk's own CC_<android-triple> vars, which this does not touch; CC_x86_64_unknown_linux_gnu
|
||||
# covers only the HOST build scripts and proc macros.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -265,7 +274,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
# Generic registry is public for reads — matches windows-client.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
# to the unified Gitea Release.
|
||||
- name: Publish to generic registry + attach to Gitea release
|
||||
|
||||
+579
-41
@@ -1,12 +1,86 @@
|
||||
# Apple client CI — runs on the self-hosted macOS runner (home-mac-mini-1, host mode;
|
||||
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
||||
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
||||
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
||||
# Apple client CI **and** distribution — everything that runs on the self-hosted macOS runner
|
||||
# (home-mac-mini-1, host mode; see scripts/ci/setup-macos-runner.sh), in dependency order:
|
||||
#
|
||||
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
|
||||
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
|
||||
# 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.
|
||||
# swift — build the Rust core into PunktfunkCore.xcframework, then build + test the Swift
|
||||
# package. Network-dependent tests (RemoteFirstLightTests) self-skip without
|
||||
# PUNKTFUNK_REMOTE_HOST. Runs on pushes, tags AND pull requests.
|
||||
# distribute — needs: swift. The signed/notarized artifacts:
|
||||
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached
|
||||
# to the Gitea release on tag pushes
|
||||
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
|
||||
# iOS -> archive + upload to TestFlight, plus an exported .ipa
|
||||
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
|
||||
# nightly -Zbuild-std, in build-xcframework.sh)
|
||||
# screenshots — needs: swift. App Store Connect screenshots of the REAL UI, attached to the run
|
||||
# as a zip artifact. Best-effort, so a capture gap never reds the core signal.
|
||||
#
|
||||
# ⚠ WHY THIS FILE IS ONE FILE. `distribute` used to live in its own workflow called `release.yml` —
|
||||
# a name that described neither what it did (Apple only) nor how releases actually work here (every
|
||||
# platform's packaging workflow attaches to the same Gitea release on a v* tag, and announce.yml is
|
||||
# the manual "go"). The name was the smaller problem. The real one: Gitea has no cross-workflow
|
||||
# `needs`, so nothing sequenced it against apple.yml's tests — a canary main push uploaded iOS,
|
||||
# macOS and tvOS builds to TestFlight even when `swift test` had just failed on the same commit,
|
||||
# and the two files' `paths:` filters had already drifted apart, so it was possible for one to fire
|
||||
# without the other. Merging is what makes `needs: swift` expressible. Do not split them again.
|
||||
#
|
||||
# The trigger list is deliberately NARROW on crates/: everything here is built from
|
||||
# `crates/punktfunk-core` (via scripts/build-xcframework.sh) and nothing else in the workspace.
|
||||
# VERIFY THAT BEFORE WIDENING OR TRUSTING IT — punktfunk-core's only path dependency is its own
|
||||
# vendored fec-rs, under crates/punktfunk-core/vendor/:
|
||||
# sed -n '/^\[dependencies\]/,/^\[/p' crates/punktfunk-core/Cargo.toml | grep path
|
||||
# If punktfunk-core ever gains a path dep on a sibling crate, add that crate here. Cargo.lock is a
|
||||
# partial safety net (it moves when the dep is ADDED) but not a complete one — later edits to that
|
||||
# crate would not fire this workflow. This is the same class of gap flatpak.yml documents.
|
||||
#
|
||||
# ── Signing / distribution notes (all of these belong to `distribute`) ────────────────────────────
|
||||
#
|
||||
# One App Store listing for all platforms (universal purchase): every target shares the
|
||||
# bundle ID io.unom.punktfunk.
|
||||
#
|
||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||
#
|
||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||
# step is continue-on-error until they exist):
|
||||
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
|
||||
# (universal purchase).
|
||||
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
|
||||
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
|
||||
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
|
||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||
# .pkg is installer-signed with it.
|
||||
#
|
||||
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||
# a launchable app.
|
||||
#
|
||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
|
||||
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
|
||||
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
|
||||
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
|
||||
# ~/Library/Keychains/login.keychain-db
|
||||
#
|
||||
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
|
||||
# key — notarization, TestFlight upload, automatic-signing profile fetch).
|
||||
#
|
||||
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
|
||||
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
|
||||
# loud warning.
|
||||
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
|
||||
@@ -19,30 +93,39 @@ concurrency:
|
||||
|
||||
on:
|
||||
push:
|
||||
# Canary: a relevant main push builds + tests, then uploads the iOS + macOS + tvOS builds to
|
||||
# TestFlight (Apple's own canary channel) — no notarized DMG (that's stable-only; see the
|
||||
# per-step gates). Heavy on the shared mac-mini runner, hence the tight paths filter.
|
||||
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/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'clients/apple/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'scripts/ci/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/apple.yml'
|
||||
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
|
||||
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. Tag runs are matched by
|
||||
# `tags:` and are NOT subject to the paths filter above.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'clients/apple/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'scripts/ci/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/apple.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testflight:
|
||||
description: "Upload the iOS/macOS/tvOS builds to TestFlight (true/false)"
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
|
||||
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
|
||||
@@ -61,11 +144,11 @@ env:
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
|
||||
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
|
||||
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
|
||||
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
|
||||
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
|
||||
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
|
||||
# with the release-signing job below (which loads the App Store Connect key). Untrusted PR code could
|
||||
# persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's "require
|
||||
# approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral runners. The
|
||||
# `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and still runs
|
||||
# same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
|
||||
swift:
|
||||
runs-on: macos-arm64
|
||||
if: >-
|
||||
@@ -85,17 +168,10 @@ jobs:
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
|
||||
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
|
||||
# Shared compile cache. The script handles the macOS side (user-prefix install +
|
||||
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
|
||||
- name: sccache (self-healing install)
|
||||
run: |
|
||||
if ! command -v sccache >/dev/null; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
|
||||
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
|
||||
fi
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
|
||||
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
|
||||
@@ -127,6 +203,475 @@ jobs:
|
||||
working-directory: clients/apple
|
||||
run: swift test
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# ── Distribution ────────────────────────────────────────────────────────────────────────────────
|
||||
# `needs: swift` is the entire reason this lives here rather than in its own file: it is what makes
|
||||
# a failed `swift test` stop a TestFlight upload. Never demote it to a parallel job.
|
||||
distribute:
|
||||
needs: swift
|
||||
# Pushes to main (canary), v* tags (stable) and manual dispatch — never pull requests.
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
TEAM_ID: F4H37KF6WC
|
||||
PROJECT: clients/apple/Punktfunk.xcodeproj
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Select release Xcode
|
||||
run: |
|
||||
DEV_DIR=""
|
||||
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
|
||||
case "$app" in *beta*|*Beta*) continue;; esac
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
if [ -z "$DEV_DIR" ]; then
|
||||
for app in /Applications/Xcode*.app; do
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
|
||||
fi
|
||||
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
|
||||
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
|
||||
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
|
||||
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
|
||||
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
|
||||
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
|
||||
|
||||
- name: Version from tag
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
# GITHUB_RUN_NUMBER is REPO-WIDE in Gitea (not per-workflow as on GitHub): consecutive runs
|
||||
# of different workflows get consecutive numbers. That is why folding the old release.yml
|
||||
# into this file could not reset the build number and strand TestFlight, which rejects a
|
||||
# non-increasing CFBundleVersion. It also means this climbs by ~8 per push rather than by 1
|
||||
# — monotonic either way, which is all App Store Connect asks.
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||
|
||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||
run: |
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
|
||||
# nightly + -Zbuild-std, so ensure nightly + rust-src are present.
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
# Shared compile cache. The script handles the macOS side — see scripts/ci/ensure-sccache.sh.
|
||||
- name: sccache (self-healing install)
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- 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.
|
||||
#
|
||||
# The directory is still named `release` after the workflow this job used to live in. Left
|
||||
# alone deliberately: renaming it would orphan a warm ~760 MB tree and buy nothing.
|
||||
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
|
||||
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||
#
|
||||
# This repeats the `swift` job's mac-slice build, and that is the intended trade: the two
|
||||
# jobs share the runner's sccache and DerivedData, so the overlap is cheap, whereas passing
|
||||
# an xcframework between jobs would mean uploading/downloading it through Gitea's artifact
|
||||
# backend (the one that already forces upload-artifact@v3) on every run.
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Stage App Store Connect API key
|
||||
env:
|
||||
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
run: |
|
||||
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
|
||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||
|
||||
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
||||
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
|
||||
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||
# Bundle is a single static binary.
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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
|
||||
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
|
||||
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
|
||||
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
|
||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||
|
||||
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||
PROFILE_SRC=""
|
||||
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||
[ -e "$p" ] || continue
|
||||
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||
done
|
||||
if [ -n "$PROFILE_SRC" ]; then
|
||||
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||
else
|
||||
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$RESOLVED" \
|
||||
--sign "Developer ID Application" "$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
# Notarized DMG.
|
||||
STAGE="$RUNNER_TEMP/dmg-stage"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
|
||||
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
|
||||
--key "$RUNNER_TEMP/asc.p8" \
|
||||
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
||||
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Attach DMG 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)
|
||||
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
|
||||
|
||||
- name: macOS App Store — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect record has the macOS platform + the
|
||||
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
|
||||
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
|
||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
||||
# regenerate the managed *development* profile — needed because the App Groups capability
|
||||
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
||||
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
|
||||
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
|
||||
# step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk macOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-macos-appstore.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>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
||||
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
||||
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
||||
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
||||
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
||||
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
|
||||
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
|
||||
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
|
||||
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
|
||||
# App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
|
||||
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
|
||||
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-appstore.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>upload</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 "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-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 / the screenshots job below). 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.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
||||
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
||||
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
||||
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
|
||||
# DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-tvos.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>upload</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></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-tvos" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||
@@ -155,17 +700,10 @@ jobs:
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
|
||||
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
|
||||
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
|
||||
# Shared compile cache. The script handles the macOS side (user-prefix install +
|
||||
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
|
||||
- name: sccache (self-healing install)
|
||||
run: |
|
||||
if ! command -v sccache >/dev/null; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
|
||||
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
|
||||
fi
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
@@ -183,7 +721,7 @@ jobs:
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pin + prune DerivedData (same disease release.yml already cures)
|
||||
- name: Pin + prune DerivedData (same disease the distribute job already cures)
|
||||
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
|
||||
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
|
||||
# honored by the script) so repeat runs are incremental, and GC anything a week old
|
||||
|
||||
@@ -81,6 +81,14 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache (aws-lc-sys, vendored libopus, openh264's C++). Safe at workflow
|
||||
# level — no cross-compiling job here (see ci.yml's `rust` job for that trap). NOTE these ALSO
|
||||
# have to be named in the makepkg step's `sudo -u builder env …` list: sudo's env_reset drops
|
||||
# everything not listed, which is why the sccache vars are already spelled out there.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
@@ -220,6 +228,39 @@ jobs:
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
echo "pacman $V-$R -> repo '$REPO'"
|
||||
|
||||
# ── The web console, built once per (web+sdk content, bun) instead of once per job ─────────
|
||||
# Shares deb.yml's key family — see the fuller note there. Unlike the RPM leg this needs no
|
||||
# hand-off macro: makepkg builds with PF_SRCDIR pointing at this workspace, so a restored
|
||||
# web/.output is already exactly where PKGBUILD's build-if-missing guard looks for it.
|
||||
#
|
||||
# Built here as root, BEFORE the makepkg step's `chown -R builder:` sweeps the tree, so the
|
||||
# bundle ends up owned like everything else the builder user is handed.
|
||||
- name: Web console cache key
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
|
||||
- name: Build the web console (cache miss only)
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
run: |
|
||||
if [ ! -f web/.output/server/index.mjs ]; then
|
||||
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
|
||||
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
|
||||
echo "web console present: $(du -sh web/.output | cut -f1)"
|
||||
|
||||
- name: Build packages (makepkg)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
@@ -252,8 +293,19 @@ jobs:
|
||||
SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
|
||||
SCCACHE_REGION="$SCCACHE_REGION" \
|
||||
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
|
||||
CMAKE_C_COMPILER_LAUNCHER="$CMAKE_C_COMPILER_LAUNCHER" \
|
||||
CMAKE_CXX_COMPILER_LAUNCHER="$CMAKE_CXX_COMPILER_LAUNCHER" \
|
||||
CC_x86_64_unknown_linux_gnu="$CC_x86_64_unknown_linux_gnu" \
|
||||
CXX_x86_64_unknown_linux_gnu="$CXX_x86_64_unknown_linux_gnu" \
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
# Visibility only. The stats have to be read as the SAME user that ran the compiles —
|
||||
# sccache keeps its stats in a per-user server process, so a root `--show-stats` here
|
||||
# would report an idle server and zero everything.
|
||||
sudo -u builder env SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
|
||||
SCCACHE_REGION="$SCCACHE_REGION" \
|
||||
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
|
||||
sccache --show-stats || true
|
||||
|
||||
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
|
||||
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
|
||||
# it lands).
|
||||
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
|
||||
# ⚠ ALL FIVE Rust lockfiles, each named with its own `--file`: a bare `cargo audit`
|
||||
# reads only the root one, which is how the drivers lock went unscanned for so
|
||||
# long despite already being in this job's `paths:` filter.
|
||||
# * 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).
|
||||
@@ -11,7 +14,7 @@
|
||||
# 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`);
|
||||
# * cargo-about → license-allowlist gate over the host + driver 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.)
|
||||
@@ -47,6 +50,9 @@ on:
|
||||
paths:
|
||||
- 'Cargo.lock'
|
||||
- 'packaging/windows/drivers/Cargo.lock'
|
||||
- 'packaging/windows/pf-vkhdr-layer/Cargo.lock'
|
||||
- 'tools/win-input-matrix/Cargo.lock'
|
||||
- 'tools/hid-descriptor-dump/Cargo.lock'
|
||||
- 'web/bun.lock'
|
||||
- 'docs-site/bun.lock'
|
||||
- 'sdk/bun.lock'
|
||||
@@ -83,7 +89,15 @@ jobs:
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
|
||||
# Bare `cargo audit` scans ONLY the root Cargo.lock. The other three Rust workspaces are
|
||||
# separate locks and were silently never scanned — the drivers one despite already being
|
||||
# in this job's `paths:` filter, so edits to it triggered a run that then ignored it.
|
||||
# Each needs its own `--file`. `pf-vkhdr-layer` had no lockfile at all until 2026-08-13.
|
||||
cargo audit
|
||||
cargo audit --file packaging/windows/drivers/Cargo.lock
|
||||
cargo audit --file packaging/windows/pf-vkhdr-layer/Cargo.lock
|
||||
cargo audit --file tools/win-input-matrix/Cargo.lock
|
||||
cargo audit --file tools/hid-descriptor-dump/Cargo.lock
|
||||
|
||||
bun-audit:
|
||||
strategy:
|
||||
@@ -355,9 +369,10 @@ jobs:
|
||||
# way — Miri does not implement it — so the gfni branch is simply not covered here.
|
||||
#
|
||||
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
|
||||
# ENTIRELY (.cargo/config.toml:11-13 says so), and that config carries `--cfg aes_armv8` /
|
||||
# `--cfg polyval_armv8` for aarch64 — worth a measured ~3x decrypt-throughput cliff if
|
||||
# dropped. Harmless here because this job pins ubuntu-24.04/x86_64; fatal on mac-mini-1.
|
||||
# ENTIRELY — it does not merge. That used to cost the aarch64 `--cfg aes_armv8` /
|
||||
# `--cfg polyval_armv8` decrypt flags; the aes 0.9 / polyval 0.7 bump retired those cfgs
|
||||
# (see the tombstone in .cargo/config.toml), so there is nothing left for an override to
|
||||
# drop here. Keep the pin anyway: these target-features are meaningless off x86_64.
|
||||
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
|
||||
- name: miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
|
||||
env:
|
||||
|
||||
@@ -38,14 +38,9 @@ jobs:
|
||||
- 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.
|
||||
# images; this heals the job 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Prep
|
||||
run: |
|
||||
|
||||
+67
-19
@@ -23,6 +23,12 @@ on:
|
||||
# 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.
|
||||
#
|
||||
# RUSTC_WRAPPER covers RUST compilations and nothing else. The C/C++ half of this workspace —
|
||||
# aws-lc-sys, openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys, pyrowave —
|
||||
# was paid in full on every run until the CMAKE_*_COMPILER_LAUNCHER / CC_* wiring below existed.
|
||||
# Linking is the third phase and is cacheable by nothing: that one is addressed in the builder
|
||||
# images with mold (ci/cargo-config-mold.toml).
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
@@ -30,6 +36,11 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# Route CMake-driven C/C++ through the same cache. Safe at workflow level: it names no
|
||||
# triple, and cmake-rs overrides it per-invocation with a `-D` flag when cc-rs reports a
|
||||
# wrapper, so the two can never double-wrap into `sccache sccache cc`.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -40,18 +51,25 @@ jobs:
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# cc-rs recognises `sccache` as a compiler wrapper when it leads CC/CXX, and cmake-rs then
|
||||
# forwards it as -DCMAKE_C_COMPILER_LAUNCHER, so this covers both build-script styles.
|
||||
#
|
||||
# ⚠ JOB-scoped, NOT workflow-scoped, and it must stay that way: the `rust-arm64` job below
|
||||
# runs in the cross image, which sets CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc
|
||||
# (ci/rust-ci-arm64cross.Dockerfile) — a wrapper that strips arm64 include dirs off
|
||||
# HOST-targeted compiles so ffmpeg-sys-next's probe resolves against the amd64 headers.
|
||||
# Setting this at workflow level would silently overwrite that wrapper and break the cross
|
||||
# build in a way that looks like a header mismatch, not a CI config error.
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
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.
|
||||
# images; this heals the job 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# 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
|
||||
@@ -105,8 +123,17 @@ jobs:
|
||||
# out of disk mid-build and actions/cache saved a truncated target/ (a dep's .rmeta
|
||||
# went missing -> E0463 "can't find crate"). A suffix bump wouldn't help — restore-keys
|
||||
# would fall back to the poisoned prefix — so the prefix itself is versioned.
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
#
|
||||
# `-debug-`: THIS JOB BUILDS DEBUG. deb.yml builds RELEASE and used to share this exact
|
||||
# key, with a comment claiming the release build "reuses ci.yml's clean artifacts" — it
|
||||
# never could. actions/cache is first-saver-wins on an exact key and this job is the
|
||||
# faster of the two, so what landed under the shared key was always a debug-only target/
|
||||
# (target/debug, no target/release). deb.yml restored a tree containing nothing it could
|
||||
# use and, because the key was already taken, never got to save its own — so every
|
||||
# release build re-linked from scratch, forever. Splitting the profiles into separate key
|
||||
# families is the fix; do not merge them again, however tempting the dedupe looks.
|
||||
key: cargo-target-debug-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-debug-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
@@ -187,7 +214,17 @@ jobs:
|
||||
|
||||
- name: Verify generated header is committed & up to date
|
||||
run: |
|
||||
cargo build -p punktfunk-core --locked
|
||||
cargo build -p punktfunk-core --locked >/tmp/core-build.log 2>&1 \
|
||||
|| { cat /tmp/core-build.log; exit 1; }
|
||||
cat /tmp/core-build.log
|
||||
# build.rs demotes a cbindgen failure to a warning and then writes NOTHING — the
|
||||
# checked-in header stays untouched and the drift check below stays green while the
|
||||
# header is silently stale. So first assert the regeneration actually happened.
|
||||
# (cargo replays build-script warnings from cache, so this holds on cached builds too.)
|
||||
grep -q "punktfunk-core: wrote" /tmp/core-build.log
|
||||
if grep -q "cbindgen failed" /tmp/core-build.log; then
|
||||
echo "cbindgen failed to parse the ABI surface — header NOT regenerated" && exit 1
|
||||
fi
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git diff --exit-code include/punktfunk_core.h \
|
||||
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
||||
@@ -210,14 +247,9 @@ jobs:
|
||||
- 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.
|
||||
# images; this heals the job 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
@@ -232,8 +264,16 @@ jobs:
|
||||
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 }}-
|
||||
#
|
||||
# `-debug-` in the name is load-bearing. This job builds DEBUG (clippy + a
|
||||
# `cargo build`), while deb.yml's arm64 leg builds RELEASE into the same
|
||||
# target/aarch64-unknown-linux-gnu tree. They used to share this exact key, and
|
||||
# actions/cache is first-saver-wins on an exact key: this job finishes in ~1.5 min and
|
||||
# saved a debug-only tree, so the .deb leg's release artifacts were NEVER persisted and
|
||||
# it re-linked everything from sccache on every run. Same disease as the amd64 pair —
|
||||
# see the note on deb.yml's `cargo-target-release-v1-` key.
|
||||
key: cargo-target-arm64-debug-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-debug-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Clippy for aarch64 (deny warnings)
|
||||
run: |
|
||||
@@ -249,6 +289,14 @@ jobs:
|
||||
cargo build --release --target aarch64-unknown-linux-gnu --locked \
|
||||
-p punktfunk-client-session --no-default-features
|
||||
|
||||
# Visibility only — but the ONLY way to tell a warm cache from a cold one after the fact.
|
||||
# Every Rust job in this repo ends with this line for that reason; a hit rate that quietly
|
||||
# collapses (a toolchain bump, a flag change, an S3 outage) is otherwise invisible and just
|
||||
# looks like "CI got slower".
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
|
||||
+288
-131
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
# apt distribution/component:
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Debian and
|
||||
# Ubuntu boxes get new builds via `apt update && apt upgrade`. Five jobs; the four builders all
|
||||
# publish to the same apt distribution/component, and the fifth checks the result:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
@@ -11,8 +11,17 @@
|
||||
# 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
|
||||
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
||||
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
||||
# on Ubuntu 24.04 LTS through 26.04 — and, for free, on Debian 13.
|
||||
# (A 26.04-built host .deb is uninstallable on 24.04 — the reason this job
|
||||
# exists; see packaging/debian/README.md.)
|
||||
# build-publish-gamescope
|
||||
# — the patched `punktfunk-gamescope`, on DEBIAN 13. It lived in the host job
|
||||
# until 2026-08 and never once succeeded there: noble's wayland is 1.22.0
|
||||
# and the vendored wlroots floors it at 1.23.1, so v0.26.0 and v0.27.0 both
|
||||
# shipped without the package while the docs told apt users to install it.
|
||||
# smoke-install — installs what was just published, from the registry, in pristine
|
||||
# ubuntu:24.04 / ubuntu:26.04 / debian:trixie images. Nothing here used to
|
||||
# install a package it built, which is how both of the above survived.
|
||||
#
|
||||
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
||||
# host and client packages always share a version line. The release-attach helpers are race-safe
|
||||
@@ -70,6 +79,11 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# CMake-driven C/C++ through the same cache (aws-lc-sys, the vendored libopus). Workflow level
|
||||
# is safe — it names no triple; the CC_*/CXX_* half is per-job below, because the arm64 leg's
|
||||
# image needs its own CC_x86_64 wrapper. See ci.yml's `rust` job for the full note.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -80,18 +94,18 @@ jobs:
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# ⚠ Job-scoped, never workflow-scoped: build-publish-client-arm64 runs in the cross image,
|
||||
# whose own CC_x86_64_unknown_linux_gnu=pf-host-cc must survive. See ci.yml's `rust` job.
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
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.
|
||||
# images; this heals the job 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
@@ -129,7 +143,9 @@ jobs:
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
# The cargo-home (registry/git) cache IS shared with ci.yml — those are pure downloads,
|
||||
# profile-independent, and deduping them across the fleet is the whole point. The target
|
||||
# cache is NOT; see below.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -142,10 +158,20 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml). Shares the
|
||||
# key with ci.yml so the release build reuses its clean artifacts.
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml).
|
||||
#
|
||||
# `-release-`, and NOT ci.yml's key. This step used to read
|
||||
# `cargo-target-v3-<rustc>-<lock>` — byte-identical to ci.yml's — under a comment saying
|
||||
# the release build "reuses its clean artifacts". It never did, and could not: ci.yml
|
||||
# builds DEBUG, this job builds RELEASE, and actions/cache is first-saver-wins on an
|
||||
# exact key. ci.yml's `rust` job finishes in ~6 min against this job's ~7-8, so ci.yml
|
||||
# always won the save, this job always restored a target/ with an empty target/release,
|
||||
# and — the expensive half — its own release artifacts were then never persisted,
|
||||
# because the key it would have saved under was already taken. Every canary .deb has
|
||||
# therefore been a from-scratch release build (sccache-assisted, but every link and
|
||||
# every build script re-run) for as long as both keys have existed.
|
||||
key: cargo-target-release-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-release-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build release clients
|
||||
env:
|
||||
@@ -163,7 +189,31 @@ jobs:
|
||||
cargo build --release --locked \
|
||||
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
|
||||
|
||||
# ── The web console, built ONCE per (web+sdk content, bun) rather than once per job ────────
|
||||
# This bundle was being rebuilt six times on every push — ci.yml, here, both RPM legs, arch,
|
||||
# and the docker app image — at ~2.5 min each, for output that is a pure function of web/ and
|
||||
# sdk/. windows-host.yml has cached it this way for a while; this is the same arrangement for
|
||||
# the Linux packaging legs, sharing one key family so a hit in one warms the others.
|
||||
#
|
||||
# The bun version is IN THE KEY. Each builder image installs its own bun (rust-ci, fedora-rpm
|
||||
# and arch-ci each run the bun.sh installer at image-build time), so without it a bundle built
|
||||
# by one image's bun could be shipped by a job running a different one. They are usually the
|
||||
# same version and do share; when they diverge, they simply stop sharing instead of silently
|
||||
# mixing.
|
||||
- name: Web console cache key
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Skipped when the cache already holds this exact (web+sdk, bun) bundle — the assertion step
|
||||
# below is what makes that safe. The bundle in the cache was smoke-booted by the run that
|
||||
# produced it, and ci.yml's `web` job still builds and tests the console on every push.
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
# (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
|
||||
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
|
||||
@@ -176,7 +226,12 @@ jobs:
|
||||
}
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
# --ignore-scripts, like every other web install in CI (ci.yml, web-screenshots.yml,
|
||||
# windows-host.yml, the SDK installs). This was the ONE site still running lifecycle
|
||||
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
|
||||
# neither consumes nor commits, whose only effect here is to make the install depend on
|
||||
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
@@ -188,6 +243,22 @@ jobs:
|
||||
echo "web console smoke: /login -> $code"
|
||||
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
|
||||
|
||||
# ⚠ NOT optional, and it must run on BOTH paths (cache hit and fresh build). The packaging
|
||||
# scripts treat a missing web/.output as "build it yourself", which is right for a local run
|
||||
# and wrong here: it would silently turn a broken cache restore into a slow, quiet rebuild, or
|
||||
# — with the build step skipped and the restore empty — into a package with no console at all.
|
||||
# windows-host.yml shipped exactly that in 0.22.1/0.22.2 (an unset WEB_OUTPUT_DIR behind a
|
||||
# single Write-Host), which is why its equivalent step throws too. Fail loudly instead.
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
run: |
|
||||
if [ ! -f web/.output/server/index.mjs ]; then
|
||||
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
|
||||
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
|
||||
echo "web console present: $(du -sh web/.output | cut -f1)"
|
||||
|
||||
- name: Build .debs
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
@@ -198,6 +269,13 @@ jobs:
|
||||
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
|
||||
|
||||
# Visibility only. With the target cache now actually saving release artifacts (see the
|
||||
# cache key note above), this is how a regression in that arrangement becomes visible:
|
||||
# a run that suddenly reports thousands of misses is a cache that stopped restoring.
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -242,18 +320,17 @@ jobs:
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# ⚠ Job-scoped — see build-publish above and ci.yml's `rust` job.
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
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.
|
||||
# images; this heals the job 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Version + channel
|
||||
run: |
|
||||
@@ -289,9 +366,12 @@ jobs:
|
||||
with:
|
||||
path: target
|
||||
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
||||
# ci.yml's 26.04 target cache (mixing would poison both).
|
||||
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
|
||||
# ci.yml's 26.04 target cache (mixing would poison both). It is also the only job using
|
||||
# this prefix, so — unlike the amd64/arm64 pairs above — it has always been able to save
|
||||
# and restore its own release artifacts. `-release-` is spelled out anyway so the whole
|
||||
# file reads consistently and nobody "unifies" it back into a shared key later.
|
||||
key: cargo-target-noble-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-noble-release-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host
|
||||
env:
|
||||
@@ -326,6 +406,12 @@ jobs:
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
# Visibility only — placed here, right after the last compile, rather than at the end of the
|
||||
# job: the gamescope gate below must stay the final step (see its own note).
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
|
||||
# capability metadata — the postinst applies them — so this reads the postinst that will
|
||||
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
|
||||
@@ -337,82 +423,6 @@ jobs:
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
#
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||
# a half-built result impossible to ship.
|
||||
run: |
|
||||
set -x
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||
apt-get build-dep -y gamescope || true
|
||||
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
|
||||
# gamescope — where noble has one at all — so it misses what the master tree needs, and
|
||||
# wayland-protocols is the gap that actually stops the build: meson dies in
|
||||
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
|
||||
# file was found", because the tree has no wrap fallback for it. That is what happened on
|
||||
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
|
||||
# shipped with no gamescope .deb while the notes said it had one.
|
||||
apt-get install -y --no-install-recommends wayland-protocols
|
||||
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
|
||||
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
|
||||
# releases should not fail the job. (No libstdc++ static package is needed here — g++
|
||||
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
|
||||
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
|
||||
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
|
||||
# purpose: a single transaction aborts wholesale on one unknown package, which would
|
||||
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
|
||||
# the missing one named; the end-of-job gate below is what actually decides.
|
||||
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
|
||||
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
|
||||
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
|
||||
cmake xwayland; do
|
||||
apt-get install -y --no-install-recommends "$p" \
|
||||
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
|
||||
done
|
||||
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
|
||||
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
|
||||
# skips the host .deb's own publish + release-attach steps below, which is how the
|
||||
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
|
||||
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
|
||||
# meant to protect the release. Never let a missing EXTRA stop a good artifact
|
||||
# shipping; go red afterwards instead.
|
||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
- name: Build punktfunk-gamescope .deb
|
||||
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -440,25 +450,109 @@ jobs:
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
|
||||
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
|
||||
# because every failure on this path was a `::warning::` that returned 0.
|
||||
#
|
||||
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
|
||||
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
|
||||
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
|
||||
# gate protecting the release withheld the fix for it. Everything good ships first; the job
|
||||
# goes red afterwards.
|
||||
- name: A stable tag must ship the gamescope .deb
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# `punktfunk-gamescope` for apt — the patched build that gives a gamescope session HDR, a cursor,
|
||||
# and the client's real refresh rate instead of a hardcoded 60 Hz. Same reasoning as the RPM leg
|
||||
# in rpm.yml: without a package, a Debian/Ubuntu box has no route to it except compiling by hand.
|
||||
#
|
||||
# ⚠ THIS IS ITS OWN JOB, ON DEBIAN 13, BECAUSE THE NOBLE BUILD COULD NEVER HAVE WORKED.
|
||||
# It used to be a few best-effort steps inside build-publish-host (Ubuntu 24.04), where it failed
|
||||
# every single time:
|
||||
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
# Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 and
|
||||
# always will. v0.26.0 AND v0.27.0 both shipped with no gamescope .deb — while the release notes
|
||||
# and docs-site told apt users to install it — because every rung of that path was a `::warning::`
|
||||
# that returned 0, and the one hard gate ran last by design (so good artifacts still publish) and
|
||||
# was simply never acted on. Moving it to trixie makes the build possible; making it a job of its
|
||||
# own makes its failure visible instead of a footnote in someone else's log.
|
||||
#
|
||||
# Debian 13 is the OLDEST apt distro the tree configures on (wayland 1.23.1, libxcb-errors,
|
||||
# libdisplay-info 0.2). The binary it produces needs GLIBC_2.38 and links no libstdc++, so what
|
||||
# actually bounds it is wayland: Debian 13 and Ubuntu 26.04 YES, Ubuntu 24.04 NO — and 24.04
|
||||
# could not run this binary however it was built, so nothing was lost by leaving noble.
|
||||
build-publish-gamescope:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
|
||||
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
|
||||
# version + our patch level, which build-gamescope-deb.sh derives itself; it deliberately
|
||||
# does NOT follow the punktfunk version line, because this package moves on its own cadence.
|
||||
- name: Channel
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
built=(dist/punktfunk-gamescope_*.deb)
|
||||
if [ ${#built[@]} -eq 0 ]; then
|
||||
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||
exit 1
|
||||
fi
|
||||
echo "gamescope .deb present: ${built[*]}"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) DIST=stable ;;
|
||||
*) DIST=canary ;;
|
||||
esac
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "gamescope -> apt distribution '$DIST'"
|
||||
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-trixie-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
# NOT best-effort, unlike the noble version of this step. Every dependency now comes from the
|
||||
# image (which asserts the wayland floor at build time), so a failure here is a real
|
||||
# regression in the tree or the pin — exactly the thing the previous arrangement hid.
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# `--extra-fallback libdisplay-info` is what makes ONE .deb serve both Debian 13 and
|
||||
# Ubuntu 26.04. Built against the distro's copy, the package picks up
|
||||
# `Depends: libdisplay-info2 (>= 0.2.0)` on trixie — and Ubuntu 26.04 carries
|
||||
# libdisplay-info **3** (0.3.0), so apt refuses it there ("Depends libdisplay-info2 …" —
|
||||
# measured, not predicted). gamescope vendors the library as a submodule, so linking the
|
||||
# vendored copy drops the dependency entirely. Same reasoning the build script already
|
||||
# applies to wlroots: a binary we SHIP must not follow the build host's shared libraries.
|
||||
run: |
|
||||
bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
|
||||
--extra-fallback libdisplay-info
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
|
||||
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
|
||||
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
|
||||
# actually produced a working compositor.
|
||||
- name: Build the .deb
|
||||
run: |
|
||||
gs-cache/punktfunk-gamescope --version
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/punktfunk-gamescope_*.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 gamescope to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the gamescope .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/punktfunk-gamescope_*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
@@ -476,14 +570,14 @@ jobs:
|
||||
- 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.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
#
|
||||
# NOTE this job deliberately sets no CC_x86_64_unknown_linux_gnu: the cross image already
|
||||
# points it at /usr/local/bin/pf-host-cc, which is what keeps ffmpeg-sys-next's HOST probe
|
||||
# from picking up arm64 include dirs. The target-side compiles go through
|
||||
# CC_aarch64_unknown_linux_gnu (also set by the image) and are not sccache-wrapped.
|
||||
- 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# 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.
|
||||
@@ -520,8 +614,13 @@ jobs:
|
||||
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 }}-
|
||||
#
|
||||
# `-release-`: this key was previously identical to ci.yml's `rust-arm64` key, which
|
||||
# builds DEBUG (clippy) and finishes in ~1.5 min against this job's ~5. Exactly the
|
||||
# amd64 collision described on the release key above — ci.yml won every save, this job
|
||||
# restored a tree with no release artifacts and could never persist its own.
|
||||
key: cargo-target-arm64-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-release-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build the arm64 client .deb
|
||||
env:
|
||||
@@ -534,6 +633,10 @@ jobs:
|
||||
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
|
||||
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -560,3 +663,57 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Does the thing we just published actually INSTALL? Nothing in this repo asked that before, and
|
||||
# the cost of not asking was two independent, long-lived facts nobody knew:
|
||||
# * `punktfunk-host` has installed cleanly on Debian 13 for a long time — while docs-site said
|
||||
# "Debian isn't a supported target … nobody has verified it".
|
||||
# * `punktfunk-gamescope` was missing from apt entirely across two releases.
|
||||
# Both are exactly what a five-minute install check catches, so it is now a job.
|
||||
#
|
||||
# It runs on the RUNNER (no `container:`) and drives docker directly — the same access
|
||||
# docker.yml's image builds use — because the check must happen in a pristine distro image, not
|
||||
# in a builder image with the deps already present.
|
||||
#
|
||||
# It installs FROM THE REGISTRY, after the publish jobs, rather than from a local .deb: that
|
||||
# exercises the real path a user takes (repo key, apt distribution, dependency resolution against
|
||||
# the distro's own package set), and it matches this workflow's established order — publish the
|
||||
# good artifacts first, go red afterwards, never let a gate withhold a shipping fix.
|
||||
smoke-install:
|
||||
needs: [build-publish, build-publish-host, build-publish-gamescope]
|
||||
# `needs` for ORDER only — this must still run when a builder went red, or the failure that
|
||||
# matters most (a package that did not publish) is exactly the one that skips its own check and
|
||||
# leaves the run looking merely "partly red" instead of saying what a user would hit.
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
|
||||
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
|
||||
# below can insist the registry is serving THIS run's build. Without that, a smoke job that
|
||||
# beats the index regeneration installs the previous build, passes, and proves nothing.
|
||||
- name: Channel + expected version
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
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 "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "EXPECT_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "expecting $V in apt distribution '$DIST'"
|
||||
|
||||
# The support matrix, asserted rather than asserted-in-prose. Each row names the packages
|
||||
# that MUST install on that distro; anything absent from the row is expected not to and is
|
||||
# not checked here (the client's glibc 2.43 floor keeps it off 24.04 and Debian 13 —
|
||||
# see docs-site/content/docs/debian.md).
|
||||
- name: Install from the apt registry on every supported distro
|
||||
run: bash scripts/ci/deb-install-smoke.sh
|
||||
env:
|
||||
PF_APT_DISTRIBUTION: ${{ env.DISTRIBUTION }}
|
||||
PF_EXPECT_VERSION: ${{ env.EXPECT_VERSION }}
|
||||
|
||||
@@ -96,6 +96,12 @@ jobs:
|
||||
# (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
|
||||
# Debian 13 gamescope builder. The gamescope .deb used to be built in the noble image
|
||||
# and NEVER once succeeded there — noble's wayland is 1.22.0 and the vendored wlroots
|
||||
# 0.19.3 floors it at 1.23.1, so two releases shipped without the package. trixie is the
|
||||
# oldest apt distro the tree configures on. Consumed by deb.yml's build-publish-gamescope.
|
||||
- image: punktfunk-gamescope-trixie
|
||||
dockerfile: ci/gamescope-trixie.Dockerfile
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# Gitea has NO flatpak/ostree registry, so the bundle lives in the generic registry:
|
||||
# PUT https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
|
||||
# GET https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
|
||||
# On tags the bundle is ALSO attached to the Gitea release (mirrors release.yml's DMG).
|
||||
# On tags the bundle is ALSO attached to the Gitea release (mirrors apple.yml's DMG).
|
||||
#
|
||||
# PRIVILEGED-BUILD CONSTRAINT: flatpak-builder runs bubblewrap, which needs user namespaces.
|
||||
# In a Gitea/act_runner Docker executor that means the job container must be --privileged
|
||||
@@ -37,7 +37,7 @@ on:
|
||||
# binary's dependency closure must be listed here — including the native decode rungs, or a
|
||||
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
|
||||
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
|
||||
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
|
||||
# and never enters the Linux closure (windows-client.yml carries it instead).
|
||||
paths:
|
||||
- 'clients/linux/**'
|
||||
- 'clients/session/**'
|
||||
|
||||
@@ -29,6 +29,12 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache — same wiring as ci.yml/deb.yml. Safe at workflow level: no
|
||||
# cross-compiling job here.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -45,14 +51,9 @@ jobs:
|
||||
- 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.
|
||||
# images; this heals the job 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# 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,
|
||||
@@ -67,7 +68,8 @@ jobs:
|
||||
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||
|
||||
# Reuse the workspace cargo caches (same keys as ci.yml/deb.yml).
|
||||
# Reuse the workspace cargo caches. The cargo-home (download) cache is shared verbatim —
|
||||
# it is profile-independent.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -80,8 +82,20 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
# This job builds RELEASE (see the build step) in the same image and target layout as
|
||||
# deb.yml's `build-publish`, so it wants THAT tree — it used to name ci.yml's key, which
|
||||
# holds a debug build and gave it nothing. (Third participant in the collision documented
|
||||
# on ci.yml's `cargo-target-debug-v3-` key.)
|
||||
#
|
||||
# Its OWN exact key with deb's prefix as a FALLBACK restore-key, deliberately: both
|
||||
# workflows run on a v* tag, and an exact-key match would make them race for the single
|
||||
# save slot — this job builds one crate, so if it won that race it would replace deb's
|
||||
# full release tree with a nearly empty one for the rest of the lockfile's life. This way
|
||||
# it always READS the warm tree and never blocks the job that fills it.
|
||||
key: cargo-target-shots-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-target-shots-release-v1-${{ env.rustc }}-
|
||||
cargo-target-release-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build client
|
||||
run: cargo build --release -p punktfunk-client-linux --locked
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
# Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1).
|
||||
#
|
||||
# Tag v* (or workflow_dispatch):
|
||||
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached to a
|
||||
# Gitea release on tag pushes
|
||||
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
|
||||
# iOS -> archive + upload straight to TestFlight (App Store Connect)
|
||||
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
|
||||
# nightly -Zbuild-std, in build-xcframework.sh)
|
||||
#
|
||||
# One App Store listing for all platforms (universal purchase): every target shares the
|
||||
# bundle ID io.unom.punktfunk.
|
||||
#
|
||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||
#
|
||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||
# step is continue-on-error until they exist):
|
||||
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
|
||||
# (universal purchase).
|
||||
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
|
||||
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
|
||||
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
|
||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||
# .pkg is installer-signed with it.
|
||||
#
|
||||
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||
# a launchable app.
|
||||
#
|
||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
|
||||
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
|
||||
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
|
||||
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
|
||||
# ~/Library/Keychains/login.keychain-db
|
||||
#
|
||||
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
|
||||
# key — notarization, TestFlight upload, automatic-signing profile fetch).
|
||||
#
|
||||
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
|
||||
# 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:
|
||||
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
|
||||
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
|
||||
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
|
||||
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/apple/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/release.yml'
|
||||
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
|
||||
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testflight:
|
||||
description: "Upload the iOS build to TestFlight (true/false)"
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
|
||||
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
|
||||
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
|
||||
# side stays on DerivedData (sccache doesn't cache swiftc).
|
||||
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; the shared cache makes the
|
||||
# runner's persistent target/ disposable instead of precious.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
apple:
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
TEAM_ID: F4H37KF6WC
|
||||
PROJECT: clients/apple/Punktfunk.xcodeproj
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Select release Xcode
|
||||
run: |
|
||||
DEV_DIR=""
|
||||
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
|
||||
case "$app" in *beta*|*Beta*) continue;; esac
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
if [ -z "$DEV_DIR" ]; then
|
||||
for app in /Applications/Xcode*.app; do
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
|
||||
fi
|
||||
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
|
||||
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
|
||||
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
|
||||
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
|
||||
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
|
||||
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
|
||||
|
||||
- name: Version from tag
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||
|
||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||
run: |
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
|
||||
# nightly + -Zbuild-std, so ensure nightly + rust-src are present.
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
|
||||
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
|
||||
- name: sccache (self-healing install)
|
||||
run: |
|
||||
if ! command -v sccache >/dev/null; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
|
||||
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
|
||||
fi
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
sccache --version
|
||||
|
||||
- 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
|
||||
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Stage App Store Connect API key
|
||||
env:
|
||||
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
run: |
|
||||
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
|
||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||
|
||||
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
||||
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
|
||||
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||
# Bundle is a single static binary.
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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
|
||||
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
|
||||
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
|
||||
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
|
||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||
|
||||
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||
PROFILE_SRC=""
|
||||
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||
[ -e "$p" ] || continue
|
||||
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||
done
|
||||
if [ -n "$PROFILE_SRC" ]; then
|
||||
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||
else
|
||||
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$RESOLVED" \
|
||||
--sign "Developer ID Application" "$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
# Notarized DMG.
|
||||
STAGE="$RUNNER_TEMP/dmg-stage"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
|
||||
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
|
||||
--key "$RUNNER_TEMP/asc.p8" \
|
||||
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
||||
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Attach DMG 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)
|
||||
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
|
||||
|
||||
- name: macOS App Store — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect record has the macOS platform + the
|
||||
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
|
||||
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
|
||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
||||
# regenerate the managed *development* profile — needed because the App Groups capability
|
||||
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
||||
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
|
||||
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
|
||||
# step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk macOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-macos-appstore.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>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
||||
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
||||
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
||||
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
||||
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
||||
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
|
||||
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
|
||||
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
|
||||
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
|
||||
# App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
|
||||
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
|
||||
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-appstore.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>upload</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 "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-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.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
||||
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
||||
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
||||
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
|
||||
# DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-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" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-tvos.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>upload</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></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-tvos" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
@@ -51,6 +51,14 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache (aws-lc-sys, the vendored libopus, openh264's C++). Safe at
|
||||
# workflow level here: unlike ci.yml/deb.yml this workflow has no cross-compiling job whose
|
||||
# image sets its own CC_x86_64_unknown_linux_gnu. See ci.yml's `rust` job for that trap.
|
||||
# This matters twice per push — the f43 and f44 legs are the two longest jobs in the fleet.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -80,14 +88,9 @@ jobs:
|
||||
- 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.
|
||||
# images; this heals the job 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# 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
|
||||
@@ -145,11 +148,63 @@ jobs:
|
||||
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
|
||||
echo "rpm $V-$R -> group '$GROUP'"
|
||||
|
||||
# ── The web console, built once per (web+sdk content, bun) instead of once per leg ─────────
|
||||
# Two legs run here (f43 + f44) and each built its own identical copy of a bundle that is a
|
||||
# pure function of web/ and sdk/ — see the fuller note in deb.yml, whose key family this
|
||||
# shares, so whichever job builds it first warms the rest of the fleet.
|
||||
#
|
||||
# ⚠ The build has to happen HERE, in the workspace, rather than being left to the spec. Two
|
||||
# reasons, and both are load-bearing:
|
||||
# * build-rpm.sh packages a `git archive` tarball and web/.output is gitignored, so a
|
||||
# bundle sitting in the workspace is invisible to rpmbuild — it must be handed over by
|
||||
# absolute path (PF_PREBUILT_WEB_OUTPUT -> the spec's pf_prebuilt_web macro).
|
||||
# * the reverse direction is worse: the spec builds into rpmbuild's %{_topdir}, which
|
||||
# build-rpm.sh creates with mktemp and removes on EXIT. A console built in there is gone
|
||||
# before actions/cache's post step runs, so the cache would never populate and every run
|
||||
# would be a miss that quietly rebuilt — the cache would look present and do nothing.
|
||||
- name: Web console cache key
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
|
||||
- name: Build the web console (cache miss only)
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
|
||||
# become a quietly console-less RPM. The spec re-checks the marker on whatever it packages.
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
run: |
|
||||
if [ ! -f web/.output/server/index.mjs ]; then
|
||||
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
|
||||
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
|
||||
echo "web console present: $(du -sh web/.output | cut -f1)"
|
||||
|
||||
- name: Build RPM
|
||||
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
|
||||
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
|
||||
# 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
|
||||
run: |
|
||||
PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" \
|
||||
PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
|
||||
PF_PREBUILT_WEB_OUTPUT="$GITHUB_WORKSPACE/web/.output" \
|
||||
bash packaging/rpm/build-rpm.sh
|
||||
|
||||
# Visibility only — the two RPM legs are the longest jobs in the fleet, so a cache
|
||||
# regression here is the most expensive one to leave undetected.
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
. 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).
|
||||
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see apple.yml).
|
||||
- name: Upload artifact (non-tag runs)
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# Windows CLIENT — build, lint, test and package, on a self-hosted windows-amd64 runner (host mode;
|
||||
# the generic runner + MSVC/WinUI toolchain come from unom/infra's windows-runner/, punktfunk's own
|
||||
# extras — WDK, Inno Setup, the ARM64 rustup target — self-provision via the "Ensure Windows
|
||||
# toolchain" step, a fast no-op once present, so any runner with that label works).
|
||||
#
|
||||
# Covers BOTH client binaries: the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the
|
||||
# punktfunk-session Vulkan client (pf-presenter/pf-client-core/pf-console-ui — every stream runs in
|
||||
# it, spawned by the shell), plus punktfunk-cli, whose `punktfunk.exe` alias the MSIX manifest
|
||||
# references.
|
||||
#
|
||||
# ⚠ WHY THIS IS ONE FILE. This was `windows.yml` (build+lint+test, DEBUG, x64 + arm64) and
|
||||
# `windows-msix.yml` (build+package, RELEASE, x64 + arm64) — four full compiles of the same crates
|
||||
# per client push, on ONE runner, from three copies of the same `paths:` list that had already
|
||||
# started to drift. windows-host.yml learned the hard way that debug trees on this machine are pure
|
||||
# liability: a second dep tree tips it into `cabac_decoder.cpp: fatal error C1069` building
|
||||
# openh264-sys2's vendored C++, which is disk/temp exhaustion, not a source error. So there is now
|
||||
# ONE release build per arch, and clippy/fmt/test run against it. Do not reintroduce a debug leg.
|
||||
#
|
||||
# Renamed from windows-msix.yml deliberately, and safely: `github.run_number` is REPO-WIDE in Gitea
|
||||
# (consecutive runs of DIFFERENT workflows get consecutive numbers), so the canary MSIX version
|
||||
# `<minor>.<run>.0` keeps climbing across the rename — on GitHub, where run_number is per-workflow,
|
||||
# this same rename would have reset it to 1 and made every canary sort below the published ones.
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and aarch64-pc-windows-msvc
|
||||
# by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust target
|
||||
# with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 compiler from
|
||||
# the target triple (SDL3 + libopus build-from-source cross-compile fine). The one thing the aarch64
|
||||
# build can't do is *run* on the x64 host, so fmt + test run only for x64.
|
||||
#
|
||||
# ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the session builds
|
||||
# --no-default-features there (no Skia console UI; streaming is unaffected) — flip when
|
||||
# skia-binaries adds the target.
|
||||
#
|
||||
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
|
||||
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
|
||||
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
|
||||
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
|
||||
# `amf-qsv` leg link-imports them.
|
||||
#
|
||||
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, 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_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
|
||||
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
|
||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
||||
# root keeps every nested path well under the limit (per-arch so the two
|
||||
# matrix legs don't share a target dir).
|
||||
#
|
||||
# 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.
|
||||
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
#
|
||||
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# 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.
|
||||
name: windows-client
|
||||
# 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]
|
||||
# ONE list now, not three. The old windows.yml + windows-msix.yml pair carried this same set
|
||||
# three times (push, pull_request, and the second file), which is exactly how a crate goes
|
||||
# missing from one copy — windows-host.yml documents the "Cargo.lock luck" gap that produced.
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-client.yml'
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-client.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.
|
||||
#
|
||||
# NOTE the C/C++ launcher wiring the Linux workflows carry (CMAKE_*_COMPILER_LAUNCHER, CC_*) is
|
||||
# deliberately NOT set here. This runner's failure mode under extra compiler processes is the
|
||||
# C1069 disk/temp exhaustion documented in windows-host.yml, so sccache-for-MSVC is its own
|
||||
# change, to be made with a measurement rather than folded into a reorganisation.
|
||||
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:
|
||||
# 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 steps below and
|
||||
# windows-host.yml (which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
|
||||
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
|
||||
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
|
||||
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
|
||||
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
|
||||
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
|
||||
# never blocks internal PR CI.
|
||||
client:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
td: C:\t
|
||||
session_flags: ''
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
td: C:\t-a64
|
||||
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||
# rust-skia adds the target.
|
||||
session_flags: '--no-default-features'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
|
||||
# crates (see this file's header). No FFMPEG_DIR: nothing in this package links libav*
|
||||
# (M10), and pack-msix.ps1 no longer copies runtime DLLs from one.
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }} target-dir ${{ matrix.td }}"
|
||||
|
||||
# All three client binaries, ONCE, in release. The shell spawns punktfunk-session.exe (a
|
||||
# package sibling) for every stream, and punktfunk-cli builds the `punktfunk.exe` the manifest
|
||||
# aliases and pack-msix.ps1 requires (bf981027 added the requirement without the build — the
|
||||
# same gap 90c84ef4 closed for deb). --no-default-features on ARM64 is a no-op for the shell.
|
||||
#
|
||||
# Release, not debug, even for the lint/test legs below: a debug build here would compile the
|
||||
# whole dep tree into a SECOND target dir and re-run openh264-sys2's vendored C++ through
|
||||
# cc-rs's cl.exe fan-out, which is what tips this runner into C1069 (see the header).
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
|
||||
# dependency but only LINTS the packages it is given, so a decode crate that starts the
|
||||
# run but is missing from this list would be gated by nothing.
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy --release @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
if ($LASTEXITCODE) { throw "clippy" }
|
||||
|
||||
- name: Rustfmt check
|
||||
if: matrix.arch == 'x64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
|
||||
if ($LASTEXITCODE) { throw "rustfmt" }
|
||||
|
||||
- name: Test
|
||||
# x64 only: the aarch64 binaries cross-compile here but cannot RUN on this host.
|
||||
if: matrix.arch == 'x64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
cargo test --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
|
||||
if ($LASTEXITCODE) { throw "tests" }
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: sccache --show-stats
|
||||
|
||||
# ── Packaging: pushes, tags and dispatch only. A PR gets the build/lint/test signal above and
|
||||
# stops there — packing would sign with a throwaway cert and publish nothing.
|
||||
- name: Pack + sign MSIX
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||
# status code instead of aborting on it.
|
||||
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||
Write-Output "published ($code): $url"
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
$name = Split-Path $f -Leaf
|
||||
# 1) immutable, versioned path
|
||||
Put $f "$base/$($env:MSIX_VERSION)/$name"
|
||||
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
|
||||
$an = $aliasNames["$f"]
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
# only live NVENC encode does, which defers to the RTX box.
|
||||
#
|
||||
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
|
||||
# first GITHUB_ENV line — see windows.yml).
|
||||
# first GITHUB_ENV line — see windows-client.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
|
||||
@@ -39,7 +39,7 @@ on:
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
|
||||
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
|
||||
# the release-signing jobs (windows-host.yml / windows-client.yml). See windows-client.yml for the full
|
||||
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
|
||||
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
|
||||
probe-and-proto:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Build + test pf-driver-proto (MSVC)
|
||||
run: |
|
||||
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
|
||||
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows-client.yml).
|
||||
$env:CARGO_TARGET_DIR = "C:\t\drv"
|
||||
cargo build -p pf-driver-proto
|
||||
cargo test -p pf-driver-proto
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
# Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
|
||||
# Shared self-provision step (also used by windows-client.yml/windows-host.yml) so
|
||||
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
|
||||
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
|
||||
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
|
||||
# windows-amd64 runner
|
||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows-client.yml — generic from unom/infra's
|
||||
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||
#
|
||||
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
|
||||
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
|
||||
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
|
||||
# it links no libav* at all (windows-client.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
|
||||
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
||||
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
||||
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
|
||||
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
|
||||
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
|
||||
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
|
||||
#
|
||||
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
|
||||
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
|
||||
# BOM/MAX_PATH runner gotchas.
|
||||
#
|
||||
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
|
||||
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
|
||||
# (windows-host.yml) is unchanged.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
#
|
||||
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# 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.
|
||||
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:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-msix.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
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:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
td: C:\t
|
||||
session_flags: ''
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
td: C:\t-a64
|
||||
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||
# rust-skia adds the target.
|
||||
session_flags: '--no-default-features'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- 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). No FFMPEG_DIR: nothing in this package links libav* (M10),
|
||||
# and pack-msix.ps1 no longer copies runtime DLLs from one.
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||
|
||||
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
||||
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
# punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
|
||||
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
|
||||
# closed for deb).
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
- name: Pack + sign MSIX
|
||||
shell: pwsh
|
||||
env:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
shell: pwsh
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||
# status code instead of aborting on it.
|
||||
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||
Write-Output "published ($code): $url"
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
$name = Split-Path $f -Leaf
|
||||
# 1) immutable, versioned path
|
||||
Put $f "$base/$($env:MSIX_VERSION)/$name"
|
||||
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
|
||||
$an = $aliasNames["$f"]
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
|
||||
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
|
||||
# no-op once already present, so any runner with that label works with no manual dispatch step
|
||||
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
|
||||
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
|
||||
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
|
||||
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
|
||||
# flip when skia-binaries adds the target.
|
||||
#
|
||||
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
|
||||
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
|
||||
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
|
||||
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
|
||||
# `amf-qsv` leg link-imports them.
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
|
||||
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
|
||||
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
|
||||
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
|
||||
#
|
||||
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, 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_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
|
||||
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
|
||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
||||
# root keeps every nested path well under the limit (per-arch so the two
|
||||
# matrix legs don't share a target dir).
|
||||
#
|
||||
# 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.
|
||||
# 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:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.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 /
|
||||
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
|
||||
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
|
||||
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
|
||||
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
|
||||
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
|
||||
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
|
||||
# never blocks internal PR CI.
|
||||
build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Configure + toolchain versions
|
||||
shell: pwsh
|
||||
run: |
|
||||
# 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
|
||||
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
|
||||
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
|
||||
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td"
|
||||
|
||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||
# for the shell, which has no features).
|
||||
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
|
||||
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
|
||||
# RUN the binary (help contract), as the session's contract_smoke runs the session —
|
||||
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
|
||||
- name: Build
|
||||
shell: pwsh
|
||||
run: |
|
||||
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
|
||||
# dependency but only LINTS the packages it is given, so a decode crate that starts the
|
||||
# run but is missing from this list would be gated by nothing.
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
|
||||
+681
-2
@@ -12,7 +12,109 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.27.1 — in development
|
||||
## v0.28.0
|
||||
|
||||
180 commits since v0.27.0.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.27.0 | v0.28.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 18 | **19** | `punktfunk_connection_note_frame_index_ex` + `punktfunk_reanchor_gate_arm_expecting_drops` **added**; nothing removed, nothing widened |
|
||||
| Rust edition | 2021 | **2024** | the whole tree bar four vendored crates |
|
||||
| MSRV (`rust-version`) | 1.82 | **1.85** | the *declared floor* only — the pinned toolchain is unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 members; two `tools/` crates still deliberately *excluded*) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.25.0 | **0.27.0** | the management API **did** change this release (below); the file was regenerated mid-cycle, so it carries the then-current stamp, not `0.28.0` |
|
||||
| gamescope patch level (`+pfhdrN`) | 5 | **6** | 7 patches → 8 (`GAMESCOPE_NO_FOCUS`); no new capability |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||
| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.1** | publishes the `icon` field |
|
||||
|
||||
⚠ **`crates/pf-driver-proto` changed again**, as it did in v0.27.0 — but *not* in its contract. The
|
||||
wire bytes, `PROTOCOL_VERSION` (6) and `MIN_DRIVER_PROTOCOL_VERSION` (3) are all untouched; what
|
||||
moved is the manifest (`edition`/`rust-version` now inherit from the workspace) and one test that
|
||||
was reading a `[u8; 40]` through `bytemuck::from_bytes` — an alignment assumption a favourable
|
||||
stack slot had been hiding, and the kind of thing Miri exists to catch (below). If you ship the
|
||||
driver or the gamepad channel, this release needs no re-integration.
|
||||
|
||||
⚠ **`api/openapi.json` is still not gated by CI** — nothing regenerates or diffs it in a workflow.
|
||||
A unit test (`openapi_document_is_complete_and_checked_in`) does compare the checked-in copy against
|
||||
the served document, with `info.version` normalized on both sides, so the *surface* is protected
|
||||
even though the stamp drifts. The docs-site copy is a plain file copy and was **not** protected:
|
||||
see the note under **Documentation** below.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None on the wire, and none that break an embedder at runtime.** Wire protocol 2 is unchanged, so
|
||||
existing pairings and every shipped client keep working; the C ABI moves by addition only. What
|
||||
follows changes what the **host itself does**, how you **build**, and what a **stock package does by
|
||||
default**.
|
||||
|
||||
- 🛑 **The host no longer scans any launcher itself — the six built-in library scanners are
|
||||
deleted and replaced by plugins.** This is the only change here that can leave a working install
|
||||
visibly emptier: **a host with no library plugins installed has an empty grid.** Full detail and
|
||||
the (deliberately absent) migration below.
|
||||
- **Rust edition 2024, MSRV floor 1.85.** If you vendor or patch any workspace crate, your toolchain
|
||||
must be ≥ 1.85. Our pinned toolchain did not move — only the declared floor.
|
||||
- **Building from source now needs a working C compiler**, because `aws-lc-sys` compiles AWS-LC.
|
||||
No CMake, Go or NASM for the default (non-FIPS) build. Detail under the TLS section below.
|
||||
- **GameStream is opt-in on every route.** A packaged host that served Moonlight by default becomes
|
||||
native-only until the operator sets `PUNKTFUNK_GAMESTREAM=1`. Full detail below.
|
||||
- **No punktfunk process holds REALTIME GPU priority any more.** Both levers (the driver's
|
||||
`IddCxSetRealtimeGPUPriority` raise and the host's `HIGH → REALTIME` auto-upgrade) default OFF;
|
||||
the ladders that re-enable them are new opt-ins. This is a field-convicted stall fix, below.
|
||||
- **The shipped Bazzite `host.env` template no longer pins `PUNKTFUNK_GAMESCOPE_ATTACH=1`.** If you
|
||||
copied it verbatim — which the docs told you to — Game Mode was mirroring the box's screen. Below.
|
||||
|
||||
### The six built-in library scanners are gone — every game source is a plugin (⚠ operator-visible)
|
||||
|
||||
The host no longer scans any launcher itself. `library/{steam,epic,gog,heroic,lutris,xbox}.rs` and
|
||||
the `scanner_defs()` table are deleted; `GET /library/scanners` now lists exactly what the operator
|
||||
has installed, and every row reports `origin: "plugin"`. This is M6/WP6.4, the end of the migration
|
||||
whose bridge half shipped in v0.26.0 — the plugins have been published and index-pinned since
|
||||
2026-08-08.
|
||||
|
||||
**A host with no library plugins installed has an empty grid.** That is the upgrade note: the
|
||||
console's Library page offers one-click install per source (the D9 nudge, still there and still
|
||||
never auto-installing), and nothing about a title changes when its plugin takes over.
|
||||
|
||||
Why that last part is true, and why this was safe to do as a deletion rather than a rewrite: a
|
||||
plugin **claims** its store (D2), and a claimed entry surfaces under the deterministic
|
||||
`<store>:<external_id>` id the scanner used to produce. Entry ids, GameStream FNV-1a app ids,
|
||||
client-side art caches, Moonlight pins, the operator's per-source toggles and their per-entry hides
|
||||
are all keyed on that id and none of them move. `library-scanners.json` keeps its name, its shape
|
||||
and its contents — an operator who had `steam` switched off still has it switched off, with no
|
||||
migration step.
|
||||
|
||||
What survives the scanners, deliberately:
|
||||
|
||||
- **`launch.rs` in full.** Launch is host-owned by design D1 — a plugin publishes a validated
|
||||
*value* and the host builds the command — so every typed kind (`steam_appid`, `steam_ui`,
|
||||
`launcher_ui`, `epic`, `gog`, `aumid`, `xbox`, `lutris_id`, `playnite`) stays exactly as it was.
|
||||
`xbox_pfn()` moved here from the deleted `xbox.rs`: resolving a package Identity to its
|
||||
PackageFamilyName needs `AppRepository` enumeration, which is readable by the host (LocalSystem)
|
||||
and denied to the plugin runner (LocalService), and that measured asymmetry is the entire reason
|
||||
the `xbox` launch kind exists.
|
||||
- **`SourceOrigin::Builtin`.** No host build emits it, but the web console ships as its own package
|
||||
and is expected to drive an N-1 host that still does, so the variant stays in the schema and the
|
||||
console keeps its `builtin` handling.
|
||||
- **The store-label table.** Six ids keep their display names (`steam` → "Steam", …) so a source row
|
||||
does not rename itself to a bare id the day its plugin takes over.
|
||||
|
||||
Removed with them: the background cover-art warmer and its on-disk cache (they existed only for the
|
||||
GOG and Xbox scanners, the two sources that had to ask a network catalog what a cover was — a
|
||||
plugin resolves art while it scans), the legacy `steam:` branch of the art proxy, and the
|
||||
`GameMeta::pc()` helper. **The host now makes no outbound HTTP request to build a library at all.**
|
||||
|
||||
⚠ **Dependency drop (packager-visible):** `rusqlite` (with its bundled, `cc`-compiled SQLite) and
|
||||
`roxmltree` leave the host's dependency graph — they had no other users. `winreg` stays: `launch.rs`,
|
||||
`procscan/windows.rs` and the two `audio/windows/` modules still need it. `base64`/`ureq` stay, as
|
||||
the M6 plan predicted.
|
||||
|
||||
A stale `library-art-cache.json` from an older host is ignored, not migrated.
|
||||
|
||||
### GameStream is now opt-in on EVERY route (⚠ packager-visible default change)
|
||||
|
||||
@@ -32,6 +134,47 @@ pairing + the legacy GCM path, security-review #5/#9) are enabled only by an exp
|
||||
the old flag is still accepted as explicit-off).
|
||||
- Windows was already opt-in (unchecked installer task) and is unchanged.
|
||||
|
||||
### TLS moved to aws-lc-rs, with post-quantum key exchange (⚠ build-visible for packagers/embedders)
|
||||
|
||||
The rustls backend across the whole workspace — host, tray, clients and `punktfunk-core` — is now
|
||||
**aws-lc-rs** instead of `ring`, which enables rustls's `prefer-post-quantum`: every TLS 1.3
|
||||
handshake (management API, the native `punktfunk/1` control plane, QUIC) now offers the
|
||||
**X25519MLKEM768** hybrid key exchange first. Ring has no ML-KEM, which is why the backend had to
|
||||
move. This is negotiation-only and additive — the classical curves stay in the list, so any client
|
||||
that does not implement ML-KEM connects exactly as before, and no wire format, ABI or pairing
|
||||
record changes. The session AEAD (AES-128-GCM / ChaCha20-Poly1305) is a separate mechanism and is
|
||||
untouched.
|
||||
|
||||
⚠ **Building from source now needs a working C compiler**, because `aws-lc-sys` compiles AWS-LC.
|
||||
No CMake, Go, or NASM is required for the default (non-FIPS) build — on Windows x86_64 rustls turns
|
||||
on `aws-lc-rs/prebuilt-nasm`, so no NASM has to be installed. If you add a crate that depends on
|
||||
`aws-lc-rs` *directly*, name `features = ["prebuilt-nasm"]` on it: a package selection that pulls
|
||||
`aws-lc-rs` without also enabling rustls's `aws_lc_rs` feature otherwise fails on Windows.
|
||||
|
||||
`punktfunk-core` gains an off-by-default **`ureq-tls`** feature (`tls::ureq_agent`) that builds a
|
||||
blocking HTTP agent around a caller-supplied `rustls::ClientConfig` — the only way to install the
|
||||
fingerprint-pinning verifier, since ureq's own `TlsConfig` has no hook for one. The desktop client
|
||||
and the tray enable it; the Apple/Android cdylib embedders do not, and pull no HTTP stack.
|
||||
|
||||
**`ring` is gone from the tree entirely** — aws-lc-rs is now the only crypto backend on every
|
||||
target we ship. Getting there needed the `ureq 2 → 3` upgrade in the same change, because ureq 2
|
||||
named `rustls/ring` inside its own dependency declaration where no dependent could switch it off.
|
||||
ureq 3 declares rustls with `default-features = false` and picks no backend, so the choice is
|
||||
finally ours. ⚠ Spell that dependency `features = ["rustls-no-provider", "rustls-webpki-roots"]`:
|
||||
ureq 3's convenience `rustls` feature pulls `_ring` and would quietly restore the second backend.
|
||||
|
||||
The ureq upgrade is otherwise internal, but two behaviours are worth knowing. Response size caps
|
||||
are now enforced by the body reader, so an over-cap response is an **error** instead of ureq 2's
|
||||
silent truncation (which used to surface as a confusing signature failure). And a fingerprint
|
||||
mismatch is now matched on ureq 3's typed `Error::Rustls(..)` rather than by sniffing a substring
|
||||
out of a transport error message — the old test could also fire on unrelated certificate errors.
|
||||
Conditional requests are unchanged: ureq 3 still returns 304 as `Ok`, only 4xx/5xx become `Err`.
|
||||
|
||||
**Embedders of `punktfunk-core` that build their own rustls configs** should still call
|
||||
`punktfunk_core::tls::install_default_provider()` at startup, or use `builder_with_provider`. With
|
||||
one backend present rustls can infer it, so this is now insurance rather than a requirement — but
|
||||
it is what stops a future second backend from turning config construction into a panic.
|
||||
|
||||
### The ENet control port now exists only while a pairing does (rust-safety WP0)
|
||||
|
||||
`rusty_enet` — a c2rust-style transpile of C ENet, and the host's only pre-auth-reachable unsafe
|
||||
@@ -79,7 +222,8 @@ The checked-in `api/openapi.json` remains the default-features document.
|
||||
One RSA-2048 identity historically served every plane, because Moonlight mandates RSA and the
|
||||
planes grew out of the GameStream host. The native punktfunk/1 QUIC plane and the management API
|
||||
now share a separate **ECDSA P-256** identity (`native-cert.pem`/`native-key.pem`): generated by
|
||||
ring via rcgen, browser-compatible (Ed25519 server certs are not), carrying real SANs
|
||||
rcgen on the workspace's aws-lc-rs backend, browser-compatible (Ed25519 server certs are not),
|
||||
carrying real SANs
|
||||
(localhost, loopback, the machine hostname — the legacy cert had none), and free of the accepted
|
||||
`rsa`-crate Marvin advisory. The GameStream plane keeps the RSA identity untouched.
|
||||
|
||||
@@ -96,6 +240,34 @@ grant covers both. ⚠ A plugin bundling an **older** `@punktfunk/host` SDK on a
|
||||
(P-256) host trusts the wrong cert — set `PUNKTFUNK_MGMT_CA=<config>/native-cert.pem` in its
|
||||
environment or rebuild against the current SDK.
|
||||
|
||||
⚠ **It is ECDSA P-256, not Ed25519 — deliberately.** rcgen can generate either, and Ed25519 would
|
||||
be the obvious modern pick, but **no mainstream browser accepts an Ed25519 server certificate** and
|
||||
an operator opens `/api/docs` in one. P-256 is the strongest curve that keeps the management API
|
||||
reachable from a browser.
|
||||
|
||||
#### 🗓 Deprecation: the legacy-identity fallback goes away on **1 October 2026**
|
||||
|
||||
The fallback in `load_or_adopt` — "an upgraded host with live native pairings keeps presenting the
|
||||
legacy RSA cert those clients pinned" — is a **migration aid, not a permanent branch**. From
|
||||
**2026-10-01** the host stops taking it: a host that still holds only `cert.pem`/`key.pem` will mint
|
||||
the P-256 identity and its native clients will have to re-pair once.
|
||||
|
||||
**Scope, precisely** — this affects the **native punktfunk/1 plane and the management API only**:
|
||||
|
||||
- **The GameStream/Moonlight plane is NOT deprecated and keeps its RSA identity permanently.**
|
||||
Moonlight mandates RSA and its pairing hashes bind the cert's X.509 signature bytes, so that
|
||||
identity cannot move without breaking every Moonlight client. Nothing about that changes on any
|
||||
date.
|
||||
- Operators who want the split **today** need no new release: unpair all native clients, restart the
|
||||
host, re-pair. The host already logs exactly this.
|
||||
- Fresh installs since v0.28.0 are already on P-256 and are unaffected.
|
||||
|
||||
⚠ **This date is a published commitment**, tracked as
|
||||
[#201](https://git.unom.io/unom/punktfunk/issues/201) (due 2026-10-01), which carries the arm to
|
||||
delete, the three identity-following consumers to re-check, and the test that has to invert. Without
|
||||
it the notes would have promised something that silently never happens — the same shape as the
|
||||
v0.22.3 notes describing a feature that release never contained.
|
||||
|
||||
### Memory-safety, compiler-enforced (embedder-visible lint tightening)
|
||||
|
||||
`punktfunk-core` now carries `#![deny(unsafe_code)]` crate-wide: everything that parses network
|
||||
@@ -184,6 +356,513 @@ but latches nothing; only the full-length attempts that follow hand down negotia
|
||||
classification is a pure function with tests
|
||||
(`pf_capture::linux::first_frame_timeout_tests`).
|
||||
|
||||
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
|
||||
|
||||
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
|
||||
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
|
||||
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
|
||||
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
|
||||
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
|
||||
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
|
||||
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
|
||||
within one device period — below the jitter buffer's prime depth, so nothing is audible.
|
||||
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
### Windows host — audio no longer costs local-game frame time
|
||||
|
||||
🛑 **The host could tank a locally-played game's frame lows** (field-reported 2026-08-12:
|
||||
Helldivers 2 at 1% lows of 2–5 FPS, cured by uninstalling). Two mechanisms, both fixed:
|
||||
|
||||
- **The minted-endpoint retry storm.** The virtual-mic resolve ran a FULL provisioning pass on
|
||||
every reopen with no cooldown, no in-flight guard, and no give-up — and the pass reached
|
||||
`UpdateDriverForPlugAndPlayDevicesW` even over an already-existing devnode. On a box where
|
||||
minting cannot converge, the pump's reopen backoff (capped 60 s) turned that into a SetupAPI
|
||||
sweep + PnP driver re-bind + default-device writes roughly once a minute, forever — each
|
||||
raising the system-wide device-change broadcast games service by rebuilding their audio
|
||||
graphs. Provisioning now short-circuits to a no-PnP fast path while the minted devices are
|
||||
healthy, waits on an in-flight pass instead of racing a second one, honours the 60 s retry
|
||||
cooldown from the blocking path too, and stops for the host lifetime after five unlatched
|
||||
passes (a service restart re-arms minting).
|
||||
- **Session tuning never reverted.** The first streaming session put the whole host process at
|
||||
HIGH priority class with a 1 ms global timer (`timeBeginPeriod`) and DWM MMCSS, documented as
|
||||
"reverts at process exit" — but the host is a 24/7 service, so after one stream it competed
|
||||
at HIGH priority against whatever the user played locally, forever. The process-wide tuning
|
||||
is now refcounted across the hot stream threads and reverts when the last one exits
|
||||
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
|
||||
|
||||
### Debian 13 is a supported target, and `punktfunk-gamescope` reaches apt for the first time
|
||||
|
||||
🛑 **The `punktfunk-gamescope` .deb had never been published — not once, in any release.** It was
|
||||
built inside the host job's Ubuntu 24.04 image, where it cannot build: our pin vendors wlroots
|
||||
0.19.3, which floors `wayland-server` at 1.23.1, and noble ships 1.22.0 (it also has no
|
||||
`libxcb-errors-dev` and only libdisplay-info 0.1.1). Every rung of that path was a `::warning::`
|
||||
returning 0, and the one hard gate ran last by design so good artifacts still shipped — so
|
||||
**v0.26.0 and v0.27.0 both released with the package missing** while the release notes and
|
||||
docs-site told Debian/Ubuntu users to `apt install` it. The same tag shipped it fine for Arch,
|
||||
Fedora 44 and Bazzite; apt was the only platform affected.
|
||||
|
||||
It now has its own job on **Debian 13** (`ci/gamescope-trixie.Dockerfile`), the oldest apt base the
|
||||
tree configures on. One package serves Debian 13 **and** Ubuntu 26.04 — verified by installing and
|
||||
running it on both — because the build additionally vendors libdisplay-info
|
||||
(`build-punktfunk-gamescope.sh --extra-fallback libdisplay-info`, opt-in so the Arch/Fedora/nix
|
||||
outputs are unchanged): linked against the distro copy it would demand `libdisplay-info2` on trixie,
|
||||
which Ubuntu 26.04 does not have (it carries `libdisplay-info3`). **Ubuntu 24.04 gets no gamescope
|
||||
package** — its wayland is too old to run one, however it is built.
|
||||
|
||||
⭐ **Debian 13 is now a documented, CI-tested host target** ([docs](https://docs.punktfunk.unom.io/docs/debian)).
|
||||
It required no packaging change: the host .deb's glibc-2.39 floor and bundled FFmpeg already made
|
||||
it installable, and it had been working for a long time while docs-site said Debian was unsupported
|
||||
and unverified. The desktop **client** remains Ubuntu-26.04-only (built there, floors at
|
||||
`libc6 >= 2.43`; Debian 13 has 2.41).
|
||||
|
||||
⚠ **Cinnamon (Linux Mint, LMDE) cannot host a virtual display**, and compositor detection now says
|
||||
so instead of advising a `PUNKTFUNK_COMPOSITOR` value that cannot help. Muffin forked from Mutter
|
||||
3.36: `org.cinnamon.Muffin.ScreenCast` has only `RecordMonitor`/`RecordWindow`, never
|
||||
`RecordVirtual`, and `xdg-desktop-portal-xapp` implements no ScreenCast at all. The error names the
|
||||
route that does work on those boxes — a headless gamescope, which needs no desktop compositor.
|
||||
|
||||
New CI job **`smoke-install`** installs every published package from the registry in pristine
|
||||
`ubuntu:24.04`, `ubuntu:26.04` and `debian:trixie` images and asserts the version served is the one
|
||||
the run just built. Nothing in `deb.yml` had ever installed a package it produced, which is how
|
||||
both facts above survived for so long.
|
||||
|
||||
### 🛑 The six built-in library scanners become plugins (M6/WP6.4 — breaking)
|
||||
|
||||
The host no longer scans any launcher. `library/{steam,epic,gog,heroic,lutris,xbox}.rs` and the
|
||||
`scanner_defs()` table are **gone**; `GET /library/scanners` now lists exactly what the operator
|
||||
installed, every row `origin: "plugin"`. This ends the migration whose bridge half shipped in
|
||||
v0.26.0 — the plugins have been published and index-pinned since 2026-08-08, so the replacement has
|
||||
been in the field for the whole bridge window.
|
||||
|
||||
⚠ **The upgrade note is the whole of it: a host with no library plugins installed has an empty
|
||||
grid.** The console's one-click install per source is unchanged and still never auto-installs.
|
||||
|
||||
⭐ **There is no migration, by construction, and that is why this could be a deletion rather than a
|
||||
rewrite.** A plugin *claims* its store (D2), and a claimed entry surfaces under the same
|
||||
deterministic `<store>:<external_id>` id the scanner used to produce. Entry ids, GameStream FNV-1a
|
||||
app ids, client art caches, Moonlight pins, the per-source toggles and the per-entry hides all key
|
||||
on that id and **none of them move**. `library-scanners.json` keeps its name, shape and contents —
|
||||
an operator who had `steam` off still has it off.
|
||||
|
||||
Kept deliberately:
|
||||
|
||||
- **`launch.rs` in full.** Launch is host-owned by design (D1): a plugin publishes a validated
|
||||
value, the host builds the command, so every typed kind survives. `xbox_pfn()` **moved here** out
|
||||
of the deleted `xbox.rs` — resolving a package Identity to its PackageFamilyName needs
|
||||
`AppRepository` enumeration, readable by the host (LocalSystem) and **denied to the plugin runner**
|
||||
(LocalService). That measured asymmetry is the entire reason the `xbox` launch kind exists, so the
|
||||
resolver is launch vocabulary, not scanner vocabulary.
|
||||
- **`SourceOrigin::Builtin`.** No host build emits it any more, but the console ships as its own
|
||||
package and drives an N-1 host that still does, so the variant stays in the schema.
|
||||
- **A store-label table**, so a source row does not rename itself from "Steam" to `steam` the day
|
||||
its plugin takes over.
|
||||
|
||||
Removed with the scanners: the background cover-art warmer and its on-disk cache (they existed only
|
||||
for GOG and Xbox, the two sources that had to ask a network catalog what a cover was — a plugin
|
||||
resolves art while it scans), the legacy `steam:` branch of the art proxy, and `GameMeta::pc()`.
|
||||
|
||||
### Mutter monitor rebuilds are serialized end to end — the two-client chain no longer kills GNOME
|
||||
|
||||
🛑 **Chaining two clients through a kept (keep-alive) Mutter display segfaulted gnome-shell in
|
||||
`meta_monitor_manager_rebuild` (libmutter-18) and took the whole desktop down**; every later session
|
||||
then failed `RemoteDesktop.CreateSession: ServiceUnknown` until GDM restarted, so the client just sat
|
||||
black. ⭐ **A/B'd on .21 during this release's validation: byte-identical on the released 0.27.0 and
|
||||
on the 0.28.0 RC — it was never a regression, the trigger had been there all along.**
|
||||
|
||||
`TOPOLOGY_LOCK` already serialized every topology-mutating D-Bus call, but two gaps let Mutter's
|
||||
*rebuilds* overlap:
|
||||
|
||||
- **Teardown was fire-and-forget.** `StopGuard::drop` set a flag and returned; the session thread
|
||||
only noticed on its ≤200 ms park tick. The dead-reuse path (reused kept display dead on first
|
||||
frame → `mark_failed` → re-create) therefore issued its fresh `RecordVirtual` with the doomed
|
||||
monitor's removal still pending — the fresh session could even win the lock *before* the old
|
||||
thread had woken to take it, adding a monitor while the dead one still stood. The drop now waits
|
||||
(bounded, 20 s) for the session thread to finish.
|
||||
- **The lock was released while the shell was still rebuilding.** `Stop` / `RecordVirtual` /
|
||||
`ApplyMonitorsConfig` all return mid-rebuild, and an `APPLY_TEMPORARY` config auto-reverts
|
||||
asynchronously on top. Every locked mutation now ends with `settle_topology()` — poll
|
||||
`GetCurrentState` until a removed connector is actually gone and the config serial holds still
|
||||
across two consecutive reads — before the guard drops. Bounded at 4 s and best-effort (a read
|
||||
error means the shell is gone; a hotplug storm must not park sessions), degrading to exactly the
|
||||
old behaviour.
|
||||
|
||||
Cost when Mutter is already quiet: one confirming read plus one 150 ms recheck per setup/teardown.
|
||||
|
||||
### KWin ≤60 Hz — the virtual output's real size is finally read back
|
||||
|
||||
🛑 **A 4K60 GameStream session captured 1920×1080.** `create()` asked KWin for 3840×2160, KWin built
|
||||
something else, and nothing compared the two: only the >60 Hz arm read anything back, and it gets
|
||||
that for free because it installs a custom mode. The ≤60 Hz arm installs nothing, which is exactly
|
||||
why it never noticed.
|
||||
|
||||
⚠ **The line that should have caught it was the one that hid it.** `spawn_vout` returns a node id,
|
||||
never a size, so `tracing::info!(node_id, width, height, "KWin virtual output ready")` was echoing
|
||||
the **request** — the field log stated 3840×2160 while the output was 1080p, and the first pass at
|
||||
diagnosing this was done against that number. It now logs `requested_w`/`requested_h` with the
|
||||
readback beneath it.
|
||||
|
||||
### Apple/Android audio — the de-prime fuse counted callbacks, not time
|
||||
|
||||
🛑 **An iPad gave up on its audio ring three times sooner than a Mac**, which is the residual Apple
|
||||
jitter that survived both the PLC fix (#82) and the jitter-policy fix (#111).
|
||||
`JitterTuning::deprime_after` counted **callbacks**, and a callback is not a unit of time: the same
|
||||
`4` was ~44 ms of starvation slack on a Mac's ~11 ms quantum and **20 ms on iOS**, whose session asks
|
||||
for a short IO buffer — the shortest fuse of any client, on the one with the burstiest transport. A
|
||||
100 ms Wi-Fi delivery stall therefore de-primed the Apple ring on every bunching cycle while the
|
||||
identical policy rode it out everywhere else. It is now **`deprime_ms`**, measured in starved audio,
|
||||
with a `MIN_DEPRIME_CALLBACKS` floor so a large-quantum device keeps real hysteresis instead of
|
||||
de-priming on the first short read. ⚠ **Android was latently exposed too** — AAudio's low-latency
|
||||
burst is ~4–5 ms, so its `5` was also ~20 ms.
|
||||
|
||||
Measured by driving the real policy through a simulated link (100 ms stall / 5 s, −30 ppm, 10 min)
|
||||
at a 5 ms quantum: **120 audible gaps and 690 ms of dead air before, 2 gaps and 60 ms after.**
|
||||
|
||||
### Console — "Update all" on the plugins screen
|
||||
|
||||
The Installed tab could only update one plugin at a time, one dialog and one watched job each. The
|
||||
bulk action now sits beside the list it acts on, plus a count badge on the Installed tab trigger
|
||||
(Browse is the tab the page opens on, and a control nobody passes is a control nobody finds).
|
||||
⚠ **The host takes ONE package operation at a time** — 409 otherwise, because bun operations share a
|
||||
lockfile and a `node_modules` tree — so this is a queue the console works through job by job, driven
|
||||
by each job settling rather than by a timer, carrying its own copy of what is left.
|
||||
|
||||
### Android — the in-stream mic control leaves the stream overlay
|
||||
|
||||
The mic element sat in the top-right of every stream that opened a capture (a standing button on
|
||||
touch, a Muted badge on TV). It is gone for now; the on-screen overlay UI being built will carry
|
||||
mute as one of its controls. **Mute itself is untouched** — `micRunning`/`micMuted`/`setMicMuted`
|
||||
still back the Select + Y chord, which is now the whole of the control, and `MicChordHint` is its
|
||||
only on-screen feedback.
|
||||
|
||||
### ⚠ Flatpak — the currency wave's one loose end
|
||||
|
||||
🛑 **Every flatpak leg died after #193.** The dependency currency wave took skia-safe/skia-bindings
|
||||
0.87.0 → 0.99.0 in `crates/pf-console-ui/Cargo.toml`, but `packaging/flatpak/io.unom.Punktfunk.yml`
|
||||
still pinned the **0.87.0** prebuilt archive, so the build failed with
|
||||
`no variant … named 'Default' found for enum 'SkPathFillType'` inside
|
||||
`skia-bindings-0.99.0/src/defaults.rs`. Nothing in that message points at the manifest, so it reads
|
||||
like a crate bug — it is not: `SKIA_BINARIES_URL: file://…` makes skia-bindings unpack the pinned
|
||||
tarball verbatim, **including its `bindings.rs`**. Archive pinned to 0.99.0.
|
||||
⇒ **If you bump `skia-safe`, bump the flatpak archive in the same commit.**
|
||||
|
||||
### Rust edition 2024 across the tree (MSRV floor 1.85)
|
||||
|
||||
The whole main workspace and `pf-vkhdr-layer` move to **edition 2024**; `[workspace.package]`
|
||||
declares `edition = "2024"` and `rust-version = "1.85"`. The pinned toolchain did not move — only
|
||||
the declared floor — but if you vendor or patch a workspace crate, 1.85 is now the minimum.
|
||||
|
||||
This is the safety half of the rust-safety programme's §8.4, not a tidy-up: in edition 2024
|
||||
`std::env::set_var`/`remove_var` are **`unsafe fn`**, which converts an entire bug class from
|
||||
invisible to counted. The environ data race the programme found the hard way lived in a file
|
||||
containing zero occurrences of the word `unsafe`; every one of the 20 files that mutate the
|
||||
environment now carries an `unsafe` block with a SAFETY comment naming the actual serialization
|
||||
argument (a named lock, or a `--test-threads=1` contract, or single-threaded startup).
|
||||
|
||||
What a downstream integrator sees:
|
||||
|
||||
- The 13 crates that pinned `edition = "2021"` **literally** now inherit from the workspace. A root
|
||||
bump alone would have reached only the `edition.workspace = true` crates and left `pf-encode`,
|
||||
`pf-capture`, `pf-inject` and friends on 2021 while reading as complete.
|
||||
- 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 of them in `abi.rs`), and 12 bare `extern` blocks
|
||||
→ `unsafe extern`. Done textually across **all** `cfg` branches, because 44% of the host's unsafe
|
||||
is Windows-only and a one-platform `cargo fix` silently misses it.
|
||||
- `gen` is a reserved keyword in 2024, so `pf-vdisplay`'s generation stamps and the WinUI shell's
|
||||
animation counters rename `gen` → `generation`. **Internal identifiers only — no serde field, no
|
||||
wire name and no API surface changed.**
|
||||
- The four **vendored** crates (`fec-rs`, `cros-codecs`, `usbip-sim`, the patched `ndk`) stay on
|
||||
2021 deliberately: upstream code stays pristine.
|
||||
|
||||
### No punktfunk process holds REALTIME GPU priority by default (⚠ default change)
|
||||
|
||||
🛑 **Both of our REALTIME GPU-scheduling levers were convicted of *generating* the metronomic
|
||||
capture-stall class the stall program has chased for weeks** — compose-silence holes of 150–800 ms
|
||||
in which ETW shows no process presenting while the GPU stays responsive. From the RX 9070 XT field
|
||||
A/B: the virtual-display driver's `IddCxSetRealtimeGPUPriority` raise beat at ~1.75–1.78 s, and the
|
||||
host's `HIGH → REALTIME` auto-upgrade beat at ~3.58 s in the sessions where it promoted. Disabling
|
||||
each removed its own metronome; pinning both left the stall rate at the clean-run baseline.
|
||||
|
||||
Neither period matches **any** punktfunk clock — the full periodic-actor census (driver drain,
|
||||
16 ms `E_PENDING` wait, 33 ms cursor poll, 3 s watchdog; host descriptor poll, VRAM gate, exclusive
|
||||
re-assert, pinger, stats, phase-lock, LTR marks) has nothing in the 1.69–2.29 s band, and the period
|
||||
even differs by *which* of our processes holds REALTIME. The periodicity is emergent from holding an
|
||||
unreachable-priority queue against the WDDM scheduler on this AMD family. There is therefore no
|
||||
punktfunk cadence to fix; the fix is to stop holding REALTIME, which is also canonical parity — no
|
||||
shipping IDD raises it, and HIGH is the class that delivered the original encode win.
|
||||
|
||||
- **Driver:** the old `PFVD_NO_RT_GPU` opt-**out** (default ON) becomes the **`PFVD_RT_GPU` ladder,
|
||||
default OFF on every vendor**. Unset = no raise = canonical IDD behaviour.
|
||||
- **Host:** the `pf-frame` auto-gate no longer upgrades to REALTIME. `PUNKTFUNK_GPU_PRIORITY_CLASS`
|
||||
still pins a class explicitly.
|
||||
|
||||
### The reanchor gate learns gap WIDTH — two new C ABI exports (ABI 19)
|
||||
|
||||
🛑 **Every unrecoverable loss armed the client's freeze gate twice**, and on AMD hosts the second arm
|
||||
re-froze a stream that had already healed. The two signals are the frame-index gap (instant, and what
|
||||
fires the RFI) and the reassembler ageing the lost frame into `frames_dropped` (~120 ms later, which
|
||||
re-armed unconditionally). An LTR-RFI recovery anchor lands in ~60 ms — *between* them — so the stale
|
||||
climb re-froze a bit-exact-healed picture, the host swallowed the re-ask as an RFI echo, and the
|
||||
stream stayed frozen until the overdue backstop extracted a full IDR. This is the field
|
||||
"H.265 freezes on every loss, AV1 fine" signature: AMF is the only LTR-RFI backend, and the slower
|
||||
IDR path usually lands after the climb and dodged the race.
|
||||
|
||||
The gap-arm now **pre-credits** the climb it knows is coming (`ReanchorGate::arm_expecting_drops`;
|
||||
the credit expires after `DROP_CREDIT_WINDOW` so a straggler-filled gap cannot mask a later real
|
||||
loss), and `poll()` consumes credited climbs instead of re-arming. Plumbed through every embedder:
|
||||
`pf-client-core`'s session pump, Android's sync and async loops (`note_frame_index` now returns the
|
||||
gap width), and the Swift client via the two new exports —
|
||||
**`punktfunk_connection_note_frame_index_ex`** and **`punktfunk_reanchor_gate_arm_expecting_drops`**.
|
||||
Both originals keep their signatures and their behaviour, so an embedder that adopts neither is
|
||||
unchanged; it simply keeps the race. Nothing new goes on the wire.
|
||||
|
||||
### ⚠ `punktfunk_send_input` now rejects an unrecognized event kind
|
||||
|
||||
`punktfunk_send_input` and `punktfunk_connection_send_input` **validate `ev->kind` before forming a
|
||||
reference** and return `InvalidArg` for a value that is not a recognized `InputKind`. Previously the
|
||||
byte was transmuted into an enum, which is UB for an out-of-range discriminant — a caller passing an
|
||||
uninitialized or garbage `kind` had undefined behaviour rather than an error return. The safety
|
||||
contract in the header relaxes correspondingly: `ev` need only point to *a readable
|
||||
`InputEvent`-sized allocation*, not to an already-valid `InputEvent`. **If you build an event by
|
||||
zeroing a struct and setting fields, nothing changes.** If you relied on an unknown kind being
|
||||
silently forwarded, it is now an error.
|
||||
|
||||
### Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)
|
||||
|
||||
The 0xD1 per-gamepad audio plane (DualSense haptics + speaker) was **Windows-host-only**:
|
||||
`host_cap()` answered false everywhere else and `spawn()` was a stub, so a tier-A Android client
|
||||
against a Linux host negotiated the capability off and fell back to wire rumble. The downstream
|
||||
machinery — framer, silence gate, lanes, 0xD1 send — was already capture-agnostic; only the capturer
|
||||
was WASAPI.
|
||||
|
||||
Linux hosts now mint **one PipeWire Audio/Sink node per DualSense-family pad**, carrying the identity
|
||||
the game-side matchers read (ALSA-style `node.name` with the pad's pairing MAC, description
|
||||
"Wireless Controller", bus/vendor/product/form-factor proplist, per-pad serial), 4-channel F32
|
||||
48 kHz FL/FR/RL/RR, claiming no default sink, `priority.session 50`. The `process()` callback *is*
|
||||
the capture. `host_cap()` on Linux = client asked **and** `PUNKTFUNK_PAD_AUDIO` **and** a reachable
|
||||
PipeWire socket; the sink is minted lazily in the streamer thread. `PUNKTFUNK_PAD_SINK_NAME` /
|
||||
`_DESC` override the strings for field debugging (`{pad}`/`{mac}` expand).
|
||||
`PUNKTFUNK_PAD_AUDIO{,_SLOTS}` are no longer documented as Windows-only. Verified on a Bazzite 44
|
||||
host: identity served through `pipewire-pulse`, rear-pair voice-coil tone captured bit-exact over
|
||||
both the native and Pulse legs. The Linux sink speaks GE-Proton's AUX0–3 channel shape.
|
||||
|
||||
### Wake-on-LAN now works over Wi-Fi (WoWLAN)
|
||||
|
||||
The host's arming check asked **`ethtool`** about every NIC, which is the wrong question for
|
||||
wireless: the magic-packet trigger lives in nl80211's WoWLAN state, and most Wi-Fi drivers print
|
||||
`Wake-on: d` whether or not it is armed. An armed Wi-Fi host was therefore reported as *not* armed
|
||||
and handed an `ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy
|
||||
(`/sys/class/net/<i>/phy80211`) is now asked `iw phy <phy> wowlan show`, and the warning carries
|
||||
WoWLAN-correct guidance (`iw … wowlan enable magic-packet`, plus the NetworkManager
|
||||
`802-11-wireless.wake-on-wlan magic` that survives a reconnect). Two fallbacks for when `iw` cannot
|
||||
answer: a **positive** ethtool reading counts (brcmfmac and friends do report there), a negative one
|
||||
never does, and sysfs `device/power/wakeup` reading `disabled` is conclusive in the negative.
|
||||
|
||||
The **client sender** now emits from a socket bound to each non-loopback interface's own address
|
||||
instead of leaving the choice to the routing table. A station in WoWLAN sleep stays associated and
|
||||
its AP buffers broadcast frames until the next DTIM beacon — but only if the datagram reaches the
|
||||
wireless segment at all, and with a VPN or mesh interface holding the default route
|
||||
`255.255.255.255` never did. A failed bind falls back to the routed socket, so no segment is lost.
|
||||
|
||||
### Zero-copy capture withholds buffers until the encoder has finished reading
|
||||
|
||||
🛑 **Gamescope streams could tear pink at 120 fps.** The raw-dmabuf passthrough handed the SPA buffer
|
||||
back to gamescope at `.process` return while the encode thread had not yet imported — let alone read
|
||||
— its dmabuf, and nothing ordered the producer's writes against the consumer's read (there is no
|
||||
explicit sync, and the implicit-fence wait measures `NoFence` on every compositor × vendor pairing we
|
||||
have). On the direct-VCN arms (native NV12, RGB-direct EFC) the captured buffer *is* the encode
|
||||
source for the whole 2-deep encode ring plus the phase-lock hold, so at 120 fps gamescope cycles back
|
||||
into the buffer mid-encode: luma/chroma desync (the magenta tint) plus block corruption propagating
|
||||
through the P-chain until the next intra. KDE sessions were clean because `cursor_blend` routes them
|
||||
to the compute-CSC copy arm, whose read window is microseconds.
|
||||
|
||||
A published passthrough frame now carries a **`FrameHold`**, and the buffer rejoins the producer's
|
||||
pool only when the last clone drops. The Vulkan encoder clones the hold into the ring slot at submit
|
||||
and releases it when that slot's fence retires, extending "the producer must not rewrite this" across
|
||||
exactly the GPU read. The host loop's repeat path is fixed by the same mechanism.
|
||||
|
||||
### Bazzite Game Mode no longer mirrors the box's screen (⚠ shipped-template default)
|
||||
|
||||
🛑 **Our own template caused it.** `packaging/bazzite/host.env` set
|
||||
`PUNKTFUNK_GAMESCOPE_ATTACH=1`, and every install path — rpm, deb, Arch, nix — ships that file as
|
||||
`/usr/share/punktfunk/host.env.bazzite` with the docs telling people to copy it verbatim. So the
|
||||
*recommended* Bazzite setup turned the attach override on for everyone.
|
||||
|
||||
That override is **rung 2** of `pick_gamescope_mode`, above `dedicated_launch` at rung 3. The rung
|
||||
comment calls the operator overrides a debug/CI escape hatch — correct, but we were shipping one as
|
||||
a distro default, so on a Bazzite box the managed takeover and the dedicated game session were both
|
||||
unreachable, and a game launched from a client's library could not get a session of its own. With a
|
||||
physical display connected, attach then takes the `physical_display_connected()` arm and streams the
|
||||
box's own head at the box's own mode: the mirror the field report described.
|
||||
|
||||
The template now forces nothing and lets per-connect detection answer, which on a box with
|
||||
`gamescope-session-plus` is MANAGED. Attach stays available, documented as the opt-in it is, with the
|
||||
mirror and the dedicated-session cost stated.
|
||||
|
||||
### `edid_lock` — pin AMD connector EDID emulation while streaming (EXPERIMENTAL)
|
||||
|
||||
A new display-policy axis beside `ddc_power_off` / `pnp_disable_monitors`, orthogonal to presets and
|
||||
**off by default**. At the first Exclusive isolate the host pins each occupied AMD connector's live
|
||||
EDID plus `ADL_EMUL_MODE_ALWAYS` — the software equivalent of an HPD-holding dummy plug — **before**
|
||||
the physicals deactivate, so an awake sink answers its own live-EDID read; last-member teardown
|
||||
unlocks. It targets the standby-sink stall class at its source: with emulation pinned the kernel-mode
|
||||
driver stops servicing the sleeping sink's HPD/DDC/link.
|
||||
|
||||
Pinned emulation outlives the process, so a crash journal (`edid-lock-active.json`) unlocks on the
|
||||
next host start, mirroring the `pnp_disable_monitors` recovery. Inert without an AMD driver
|
||||
(`atiadlxx.dll` absent) and on non-Windows. The ADL FFI lives once in `pf_win_display::adl_emul`, so
|
||||
the new **`display-disturb adl-emul`** probe and the host exercise byte-identical driver calls. The
|
||||
console shows the toggle **only** when the GPU inventory lists an AMD adapter — a toggle that can
|
||||
never act is exactly the "saved, then did nothing" trap the enforced-axes list exists to prevent.
|
||||
|
||||
### An over-declared stream level no longer demotes native Vulkan decode
|
||||
|
||||
A HEVC stream whose declared level exceeds what the device advertises is now treated as a **clamp**
|
||||
rather than a refusal, so native Vulkan decode survives an encoder that over-declares. The Windows
|
||||
client legs also build again: the edition-2024 `clients/session` binary could not compile on Windows,
|
||||
and `pf-presenter` now spells `MAKEINTRESOURCE(1)` as `ptr::without_provenance` — clippy 1.96's
|
||||
`manual_dangling_ptr` reads the integer-ordinal cast as a dangling pointer and fails the Windows
|
||||
`-D warnings` gate, which was masked on main by the client bins failing to build first.
|
||||
|
||||
### Library, launcher marks and plugin-kit 0.4.1
|
||||
|
||||
- **Launcher tiles carry their launcher's mark.** A brand **token** goes on the wire (`steam`,
|
||||
`heroic` — never bytes, never a URL) and each client draws the vector it already ships. `icon`
|
||||
joins `GameEntry` and `CustomEntry` in the management API, and is hand-settable for the same
|
||||
reason `role` is: an operator's own "Steam" tile should be able to look like one.
|
||||
- **`@punktfunk/plugin-kit` 0.4.1 publishes the `icon` field.** The kit had shipped the field
|
||||
without a version bump, so no plugin could name its mark.
|
||||
- **Every pinned card gets a library, and it launches with that card's profile.**
|
||||
|
||||
### Decky: one library shortcut, not one per boot
|
||||
|
||||
🛑 A boot race minted a **new** Steam library shortcut on every plugin load, so the library
|
||||
accumulated duplicates indefinitely. Fixed, and the plugin no longer toasts on every launch and every
|
||||
failed panel refresh.
|
||||
|
||||
### A stats tier picked between streams now applies without a restart
|
||||
|
||||
The console latched the stats tier at stream start, so a tier chosen between two streams reached
|
||||
nothing until the app was restarted.
|
||||
|
||||
### Miri, sanitizers, and the lint ratchets
|
||||
|
||||
- **Miri** now interprets the FFI-free leaf crates, one of them at **MSVC layout**. It immediately
|
||||
earned its place: `pf-driver-proto`'s legacy-`AddRequest` test read a `[u8; 40]` (align 1) through
|
||||
`bytemuck::from_bytes`, which takes a *reference into* the buffer and panics unless that buffer
|
||||
happens to be 8-aligned — as a stack array usually is. Now `pod_read_unaligned`.
|
||||
- **ASAN + LSAN over the C ABI boundary**: a `c-abi-asan` job in `audit.yml` runs the harness under
|
||||
both, weekly and on demand, behind a `PF_SAN` sanitizer gate.
|
||||
- **Two soundness fixes**: `InputKind` is validated before a `&InputEvent` is formed (above), and the
|
||||
Windows `TOKEN_USER` buffer is properly aligned with `EqualSid` made to fail closed.
|
||||
- **WP4**: `AvFrame`/`AvSwsContext` are RAII across all three libav backends in `pf-encode`.
|
||||
- **The lint ratchets (WP2b + WP2c)**: crate-level gaps closed, the unsafe lints hoisted into the
|
||||
workspace tables across all three workspaces, and three blocking unsafe-hygiene grep gates in
|
||||
`ci.yml`. The two bindings-only `sys` crates are explicitly exempted from the hoisted deny.
|
||||
|
||||
### Dependencies, audit and licences
|
||||
|
||||
The 2026-08-13 dependency sweep, acted on in full:
|
||||
|
||||
- **Security:** `event-listener` 5.4.1 → 5.4.2 (RUSTSEC-2026-0221, unsound `Send`/`Sync`);
|
||||
`spin` 0.9.8 → 0.9.9 (0.9.8 is **yanked** and was genuinely compiled); `wayland-scanner`
|
||||
0.31.10 → 0.31.11, which moves `quick-xml` 0.39 → 0.41 and lets **both** RUSTSEC-2026-0194/0195
|
||||
ignores be deleted rather than left as permanent exceptions. Only RUSTSEC-2023-0071 (`rsa` Marvin,
|
||||
still unfixed upstream) remains.
|
||||
- ⚠ **Two CI gates that scanned nothing.** `cargo audit` only ever reads the **root** `Cargo.lock`,
|
||||
so the drivers lock was in the job's `paths:` filter while being ignored; all four secondary
|
||||
workspaces now get an explicit `--file`. And `packaging/windows/pf-vkhdr-layer` had **no lockfile
|
||||
at all** while shipping as a DLL in the host installer, so neither cargo-audit nor cargo-about had
|
||||
ever seen it — lockfile generated, committed, and added to `paths:`. `audit.toml` now also says out
|
||||
loud that `cargo audit` reports unsoundness as a *warning* and the job fails only on
|
||||
vulnerabilities, which is why the `event-listener` advisory sat unnoticed.
|
||||
- **13 unused dependencies removed from `punktfunk-host`** (the Wayland stack, xkbcommon, reis,
|
||||
khronos-egl, ash, usbip-sim, parking_lot, bytemuck) — the code moved to `pf-inject`/`pf-zerocopy`
|
||||
in the subsystem extraction and those crates declare them; only the manifest entries and their
|
||||
now-false comments stayed. Plus unused `bytes`, `anyhow`, `tracing`, `serde` in five other crates,
|
||||
and the high-level `wdk` crate from all five driver crates.
|
||||
- **Latent breakage fixed** — crates that compiled only through feature unification now declare what
|
||||
they use: `pf-inject` (`tokio` `macros`), `pf-capture` (`tokio` `sync`), `pf-client-core` (two
|
||||
windows-rs headers). `pf-console-ui` took `pf-client-core` **without** `default-features = false`,
|
||||
unlike every other consumer; that default compiles the vendored PyroWave C++, which is fatal on
|
||||
Windows ARM64 and only safe today because that leg passes `--no-default-features`.
|
||||
- **Licences:** `ring`'s `OpenSSL` exception and its per-crate acceptance are retired now that ring
|
||||
is gone. THIRD-PARTY-NOTICES regenerated — 601 → 580 → 582 crates across the sweep.
|
||||
|
||||
### The dependency currency wave — thirteen majors, and a silently-disabled AES path
|
||||
|
||||
The currency half the sweep above deferred, landed as one wave. Most of it is version hygiene, but
|
||||
one item is a real defect and one changes a build flag you may be carrying.
|
||||
|
||||
🛑 **Hardware AES was silently off on every Android build.** `aes` 0.8 enabled the ARMv8 AES
|
||||
instructions on aarch64 only behind `--cfg aes_armv8`, and `polyval` 0.6 gated its PMULL GHASH path
|
||||
behind `--cfg polyval_armv8` — both set in `.cargo/config.toml`. A `RUSTFLAGS` environment variable
|
||||
**overrides config rustflags entirely**, and `cargo-ndk` sets its own for every Android build, so
|
||||
those two cfgs vanished and the per-packet decrypt path fell back to **software AES**. `aes` 0.9
|
||||
runtime-detects through `cpufeatures` and `polyval` 0.7 selects its armv8 backend by `target_arch`,
|
||||
so neither cfg exists any more and the flags are **deleted** from `.cargo/config.toml`. If you carry
|
||||
a fork of that file, drop them: they are dead, and keeping them costs nothing but confusion.
|
||||
|
||||
- **The RustCrypto family moves as ONE change** — `aes` 0.9, `aes-gcm` 0.11, `sha2` 0.11, `hmac` 0.13,
|
||||
`cbc` 0.2, `chacha20poly1305` 0.11. They share the `crypto-common`/`digest` traits, so a partial
|
||||
bump strands crates on trait generations that cannot interoperate. The API generation forces
|
||||
`AeadInPlace` → `AeadInOut` (`{encrypt,decrypt}_inout_detached` over `InOutBuf`), `generic-array` →
|
||||
`hybrid-array`, `Mac::new_from_slice` → `KeyInit::new_from_slice`, and the `BlockCipher*`/
|
||||
`BlockMode*` renames. ⚠ **The GameStream wire formats are untouched** — AES-128-ECB no-padding, the
|
||||
CBC audio path and the GCM control-stream seal all keep their exact byte behaviour; only type
|
||||
plumbing moved.
|
||||
- ⚠ **`rsa` 0.9 cannot come along**: it is built on `digest` 0.10, whose 0.11 line is release-candidate
|
||||
only — not something the Moonlight pairing ceremony should ride. The three sites where a digest is
|
||||
an `rsa` *type parameter* now name `rsa::sha2::Sha256` explicitly; everything else is on sha2 0.11.
|
||||
- **`skia-safe` 0.87 → 0.99** in `pf-console-ui` — twelve releases carrying Skia milestones 140–150.
|
||||
Only three reach us: m143 **deleted `SkPath`'s mutating API** (geometry is built through
|
||||
`PathBuilder` and frozen with `snapshot()`/`detach()`; 34 errors over eight call sites), 0.93
|
||||
deprecated `gradient_shader` for `gradient` (a warning, but the gate runs `-D warnings`), and the
|
||||
Vulkan surface path came through untouched.
|
||||
- **`wasapi` 0.23 → 0.24.** ⭐ 0.24 fixes upstream the dangling-`PCWSTR` bug this tree routes around
|
||||
in five places — `DeviceEnumerator::get_device` built its argument as
|
||||
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`, dropping the `HSTRING` at the end of that statement
|
||||
so `GetDevice` read freed memory. The five comments asserting that bug in the present tense are
|
||||
corrected. ⚠ **The workarounds stay** — `open_wasapi_device` is still the one resolution path whose
|
||||
errors name the endpoint id, and `device_by_id` additionally filters to ACTIVE endpoints, which the
|
||||
crate's `get_device` does not. Removing them would be a behaviour change, not currency.
|
||||
- **Ten more**: `jni` 0.21 → 0.22 (the Android bridge), `rcgen` 0.13 → 0.14, `rand` 0.8 → 0.9 (the
|
||||
host was the last crate on the old major), `base64` 0.22 → 0.23, `x509-parser` 0.16 → 0.18 — which
|
||||
takes `thiserror` 1.0 out of the host graph entirely — `libloading` 0.8 → 0.9 across the five crates
|
||||
that `dlopen`, `mdns-sd` 0.20 → 0.21 with `if-addrs` 0.13 → 0.15 (together, they share types),
|
||||
`x11rb` 0.13 → 0.14, `xkbcommon` 0.8 → 0.9, `reis` 0.6.1 → 0.7.1, `windows-service` 0.7 → 0.8
|
||||
(removing the last `windows-sys` 0.52 in the tree), `android_logger` 0.14 → 0.15, and `criterion`
|
||||
0.5 → 0.8 (dev-only, benches).
|
||||
- **New test coverage**: the TLS 1.2 Moonlight handshake, and the post-quantum group is pinned by a
|
||||
test so a backend change cannot silently drop it.
|
||||
- THIRD-PARTY-NOTICES regenerated across every client and the host for the wave.
|
||||
|
||||
### Documentation and the docs site
|
||||
|
||||
⚠ **`docs-site/public/openapi.json` had drifted far behind `api/openapi.json`** — it was stamped
|
||||
`0.21.0` against the checked-in spec's `0.27.0`, and was missing five endpoints (`/library/hidden/{id}`, `/plugins/logs`, and all
|
||||
three `/update/*` routes), so the published API reference described a host nobody was running. The
|
||||
copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`) that nothing
|
||||
in CI enforces, and it had simply been skipped. Re-synced for this release; the two files are now
|
||||
byte-identical.
|
||||
|
||||
⚠ **It drifted again within the same release cycle** — the scanner-removal regen updated
|
||||
`api/openapi.json` and not the docs-site copy, which is the failure mode repeating in miniature.
|
||||
Re-synced a second time. **Until something gates it, treat `cp api/openapi.json
|
||||
docs-site/public/openapi.json` as part of regenerating the spec, not a follow-up.**
|
||||
|
||||
### CI
|
||||
|
||||
- The C/C++ half of the build is cached and links with **mold**; the debug/release target caches no
|
||||
longer collide.
|
||||
- `release.yml` folds into `apple.yml`, and the two Windows-client workflows consolidate into one.
|
||||
- The web console builds **once per push** instead of once per packaging job.
|
||||
- The `smoke-install` job (see the Debian section) installs every published package from the registry
|
||||
in pristine `ubuntu:24.04`, `ubuntu:26.04` and `debian:trixie` images and asserts the served
|
||||
version is the one the run just built.
|
||||
- ⚠ Gate C counted **comments**: a comment that named the env mutators verbatim satisfied the gate it
|
||||
was documenting.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
Generated
+848
-1151
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -65,9 +65,9 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
version = "0.28.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["unom"]
|
||||
repository = "https://git.unom.io/unom/punktfunk"
|
||||
|
||||
@@ -100,7 +100,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||
|
||||
+1835
-1304
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -40,7 +40,6 @@ accepted = [
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"WTFPL",
|
||||
"OpenSSL",
|
||||
]
|
||||
|
||||
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
|
||||
@@ -57,7 +56,8 @@ ignore-dev-dependencies = true
|
||||
# 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"]
|
||||
# There is deliberately NO per-crate entry here any more. `ring` used to need one (its licence is an
|
||||
# AND that includes the OpenSSL licence, which was accepted for that crate alone), but the crypto
|
||||
# backend moved to aws-lc-rs and the ureq 2 → 3 upgrade removed ring from every target we build.
|
||||
# aws-lc-sys 0.44's SPDX is an AND of ISC / Apache-2.0 / MIT / BSD-3-Clause / MIT-0 — all globally
|
||||
# accepted above — and carries no OpenSSL clause, so `OpenSSL` left the global list with ring.
|
||||
|
||||
+15
-11
@@ -997,7 +997,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"description": "Every title this host knows about, sorted by title: the entries each installed library plugin\nhas synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's\nown custom entries. Artwork fields are URLs the client fetches directly, except local files on\nthe host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a\ngiven external provider owns; `?platform=` to one platform (case-insensitive — whatever the\nsource authored, conventionally `PC` for desktop stores).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file; anything else 404s and\nthe client falls through to its next art candidate.\n\nThe host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the\nclient directly — this proxy exists for the *local* files a plugin finds on the host's own disk\n(a launcher's cover cache), which a client has no way to read.",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1380,7 +1380,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches, and is why\nremoving those scanners changed nothing downstream. One provider per store; a second claimant\ngets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately\nhave zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1538,8 +1538,8 @@
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "List the library scanners",
|
||||
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
|
||||
"summary": "List the library sources",
|
||||
"description": "Every game source on this host with its enable state — one row per installed library plugin\n(Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what\nthe operator has actually installed rather than what this build happens to support. Sources\ndefault to enabled; disabling one hides its titles from every library surface from the next\nread. The user-curated custom store is not a source and is always on.\n\nOlder hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with\n`origin: \"builtin\"`. Those are gone; every row now reports `origin: \"plugin\"`.",
|
||||
"operationId": "listLibraryScanners",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -1573,8 +1573,8 @@
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Enable or disable a library scanner",
|
||||
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
|
||||
"summary": "Enable or disable a library source",
|
||||
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a source\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling\nwhile its source is off, and those entries simply aren't surfaced. Emits `library.changed` with\nthe source id as `source` when the state changed.",
|
||||
"operationId": "setLibraryScanner",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4753,6 +4753,10 @@
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
},
|
||||
"edid_lock": {
|
||||
"type": "boolean",
|
||||
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
|
||||
},
|
||||
"game_session": {
|
||||
"$ref": "#/components/schemas/GameSession",
|
||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||
@@ -5393,7 +5397,7 @@
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
"description": "The external provider owning this entry (entries synced by a provider plugin, RFC §8) —\n`None` only for the manual entries the operator typed in. The console uses it for\nattribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
@@ -7139,7 +7143,7 @@
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
"description": "Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
@@ -7159,7 +7163,7 @@
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the scanner should run on this host."
|
||||
"description": "Whether this source should contribute titles on this host."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7954,7 +7958,7 @@
|
||||
},
|
||||
{
|
||||
"name": "library",
|
||||
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
|
||||
"description": "Game library: the titles each installed library plugin syncs, plus user-curated custom entries"
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
|
||||
@@ -48,6 +48,11 @@ RUN pacman -Syu --noconfirm --needed \
|
||||
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
|
||||
xcb-util-errors xcb-util-wm xorg-xwayland \
|
||||
meson glm wayland-protocols benchmark libxcursor \
|
||||
# mold: link-phase accelerator (sccache cannot cache linking). makepkg links the release
|
||||
# host, client, worker and tray on every arch.yml run. Wired via cargo-config-mold.toml
|
||||
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
|
||||
# and its `-static-libstdc++` link is untouched.
|
||||
mold \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
|
||||
@@ -64,3 +69,16 @@ 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
|
||||
|
||||
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
|
||||
# level (and so `cargo` finds the config below when the image is used by hand). The workflow still
|
||||
# passes CARGO_HOME explicitly across the `sudo -u builder env …` boundary, which strips ambient
|
||||
# env — that is why the C/C++ sccache wiring has to be re-exported there by name while THIS file,
|
||||
# being a file, crosses the boundary for free.
|
||||
ENV CARGO_HOME=/usr/local/cargo
|
||||
RUN mkdir -p /usr/local/cargo && chmod -R a+w /usr/local/cargo
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Installed as $CARGO_HOME/config.toml in every Linux CI builder image (ci/*.Dockerfile).
|
||||
#
|
||||
# WHAT: link the x86_64 Linux targets with mold instead of GNU ld. Linking is the one phase of a
|
||||
# Rust build that sccache CANNOT cache — every job relinks punktfunk-host, punktfunk-client-linux,
|
||||
# punktfunk-client-session, punktfunk-cli, pf-update and punktfunk-encode-worker from scratch on
|
||||
# every run, and the packaging legs (deb/rpm/arch) do it for release binaries with full debug info.
|
||||
# mold is the only lever that touches that phase.
|
||||
#
|
||||
# ⚠ THE TRAP THIS FILE HAS TO STAY CLEAR OF — read before editing, and before adding rustflags
|
||||
# anywhere else in this repo:
|
||||
#
|
||||
# 1. A `RUSTFLAGS` ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY. It does not merge
|
||||
# and it does not append. Any job that sets RUSTFLAGS silently loses mold here — it still
|
||||
# builds, just with the default linker. Never "simplify" this file into a RUSTFLAGS export.
|
||||
# (This trap used to be far worse: the workspace .cargo/config.toml carried aarch64
|
||||
# `--cfg aes_armv8` / `--cfg polyval_armv8`, worth ~10x on the decrypt path, and an override
|
||||
# dropped those too. The RustCrypto aes 0.9 / polyval 0.7 bump retired both cfgs — see the
|
||||
# tombstone in .cargo/config.toml — so today only mold is at stake here.)
|
||||
#
|
||||
# 2. CONFIG FILES MERGE PER KEY, HIGHEST-PRECEDENCE FILE WINS — they do not concatenate. The
|
||||
# workspace's .cargo/config.toml outranks this one ($CARGO_HOME is the LOWEST precedence).
|
||||
# Today that is harmless because the workspace file defines NO rustflags at all and this one
|
||||
# defines only `target.x86_64-unknown-linux-gnu.rustflags`. But the moment someone adds an
|
||||
# x86_64 rustflags entry to the workspace .cargo/config.toml, IT WINS and mold silently stops
|
||||
# being used here. If that ever happens, move the link-arg into that file instead of
|
||||
# duplicating it.
|
||||
#
|
||||
# 3. aarch64 IS DELIBERATELY NOT WIRED. The cross image links with aarch64-linux-gnu-gcc against a
|
||||
# multiarch sysroot (ci/rust-ci-arm64cross.Dockerfile); pointing that driver at mold is a
|
||||
# separate thing to prove, and those legs are already the fast ones (~1.5 min of clippy, ~5 min
|
||||
# for the arm64 .deb). Add it only with a measurement, and in a commit of its own.
|
||||
#
|
||||
# Requires GCC >= 12.1 (or clang) for `-fuse-ld=mold`; every base here ships far newer. mold itself
|
||||
# is installed in the same Dockerfile layer that copies this file, so an image can never carry the
|
||||
# flag without the linker — see the `mold --version` assertion there.
|
||||
#
|
||||
# NOTE this affects the HOST-targeted compiles of build scripts and proc macros too (they are
|
||||
# x86_64-unknown-linux-gnu), which is exactly what we want: those link constantly and are pure
|
||||
# overhead.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
@@ -22,6 +22,12 @@ RUN dnf -y install \
|
||||
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||
# build toolchain + bindgen
|
||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
|
||||
# host, client, worker and tray on every rpm.yml run, TWICE per push (f43 + f44). Wired via
|
||||
# cargo-config-mold.toml below. Note the linker DRIVER is unchanged — still gcc, so Fedora's
|
||||
# default `-Wl,--build-id` still reaches the link and rpmbuild's debuginfo extraction (which
|
||||
# hard-requires a build-id) behaves exactly as before; mold implements --build-id natively.
|
||||
mold \
|
||||
# ffmpeg (NVENC), capture/audio/display link deps
|
||||
ffmpeg-devel pipewire-devel wayland-devel libxkbcommon-devel opus-devel \
|
||||
mesa-libGL-devel mesa-libgbm-devel \
|
||||
@@ -76,3 +82,8 @@ 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
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Builder for the `punktfunk-gamescope` .deb — Debian 13 (trixie).
|
||||
#
|
||||
# WHY THIS EXISTS, AND WHY IT IS NOT THE NOBLE IMAGE:
|
||||
# The gamescope .deb was built in the host job's Ubuntu 24.04 (noble) image, and it has NEVER once
|
||||
# succeeded there — v0.26.0 and v0.27.0 both shipped with no gamescope package while the release
|
||||
# notes and docs-site said it was apt-installable. The failure is structural, not a flaky dep:
|
||||
#
|
||||
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
# subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
|
||||
#
|
||||
# Our gamescope pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1. Noble ships
|
||||
# 1.22.0 and will never ship more — so no amount of `apt-get install` in that image can fix it.
|
||||
# Noble also has no `libxcb-errors-dev` at all and only libdisplay-info 0.1.1 (the tree wants 0.2).
|
||||
#
|
||||
# Debian 13 ships wayland 1.23.1 exactly, libxcb-errors 1.0.1 and libdisplay-info 0.2.0 — the
|
||||
# oldest apt distro the tree actually builds on. Building HERE rather than on Ubuntu 26.04
|
||||
# (wayland 1.24, libdisplay-info 0.3) is deliberate twice over: it keeps the glibc floor low, and
|
||||
# it stays on the libdisplay-info 0.2 line the pin was developed against.
|
||||
#
|
||||
# WHAT THE RESULTING BINARY RUNS ON — verified by building it and reading the ELF:
|
||||
# * glibc floor GLIBC_2.38 (the C++ runtime is linked statically by
|
||||
# build-punktfunk-gamescope.sh, so libstdc++ never enters the NEEDED list)
|
||||
# * NEEDED libwayland-server.so.0 / libwayland-client.so.0 — wlroots 0.19 calls symbols
|
||||
# added in 1.23.1, so THAT, not glibc, is the real floor.
|
||||
# ⇒ Debian 13 (1.23.1) and Ubuntu 26.04 (1.24.0) YES; Ubuntu 24.04 (1.22.0) NO — and 24.04
|
||||
# could not run this binary however it was built, so nothing is lost by moving off noble.
|
||||
#
|
||||
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-gamescope-trixie); consumed by
|
||||
# the `build-publish-gamescope` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci-noble, the
|
||||
# first deb.yml run after this image is added needs the image to already exist — seed it once by
|
||||
# hand (docker build -f ci/gamescope-trixie.Dockerfile -t <registry>/punktfunk-gamescope-trixie:latest ci
|
||||
# && docker push …) before that job can run.
|
||||
FROM debian:trixie
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# nodejs is not optional: the Gitea runner executes the JS actions (checkout/cache) INSIDE this
|
||||
# container, so an image without it fails before the first `run:` step ever starts.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake meson ninja-build git curl ca-certificates nodejs \
|
||||
# .deb assembly (dpkg-shlibdeps computes the runtime Depends from the built binary)
|
||||
dpkg-dev \
|
||||
# shader compilers gamescope's meson looks for
|
||||
glslc glslang-tools \
|
||||
# wayland + protocols. libwayland-dev 1.23.1 is the whole reason this image is Debian.
|
||||
libwayland-dev wayland-protocols \
|
||||
# gamescope's own dependency set. `apt-get build-dep gamescope` is useless here — Debian has
|
||||
# no gamescope package to derive it from — so the tree's needs are named outright, exactly as
|
||||
# the noble job had to. Kept as ONE transaction on purpose: in an image build a missing name
|
||||
# SHOULD fail loudly at build time, unlike the workflow's per-package best-effort loop where a
|
||||
# rename would have silently dropped a dep into a warning nobody reads.
|
||||
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
|
||||
libxcb-ewmh-dev libxcb-icccm4-dev libxcb-errors-dev libxcb-shape0-dev \
|
||||
libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev xwayland \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Assert the ONE version that decides whether this image can do its job, so a future Debian base
|
||||
# bump that regressed it fails HERE (loudly, at image build) instead of in a deb.yml run whose
|
||||
# gamescope failure has historically been a `::warning::` nobody saw.
|
||||
RUN set -eux; \
|
||||
have="$(pkg-config --modversion wayland-server)"; \
|
||||
pkg-config --atleast-version=1.23.1 wayland-server \
|
||||
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
|
||||
echo "wayland-server $have — OK"
|
||||
@@ -26,6 +26,9 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
|
||||
# host + encode worker on every deb.yml run. Wired via cargo-config-mold.toml below.
|
||||
mold \
|
||||
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
|
||||
dpkg-dev patchelf \
|
||||
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
|
||||
@@ -99,3 +102,10 @@ 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
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
|
||||
# ⚠ This does NOT touch the from-source FFmpeg built above: that is a plain ./configure && make in
|
||||
# an earlier layer, linked by GNU ld exactly as before. Only cargo's links move to mold.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
@@ -13,6 +13,9 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
|
||||
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
|
||||
mold \
|
||||
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
|
||||
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
|
||||
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
|
||||
@@ -61,3 +64,12 @@ 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
|
||||
|
||||
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
|
||||
#
|
||||
# The assertion is the point: an image carrying the flag but NOT the linker would fail every cargo
|
||||
# invocation in every consuming job, which is a catastrophic way to find out that a base image
|
||||
# renamed the package. `mold --version` fails the docker build instead, so nothing is pushed and
|
||||
# `:latest` keeps pointing at the previous working image — consumers never see it.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,20 +32,12 @@ import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -57,7 +49,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -130,12 +121,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// it, and survives the same recreate because the composition outlives the surface.
|
||||
var micMuted by remember(handle) { mutableStateOf(false) }
|
||||
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
|
||||
// rather than one that lies about a mic being heard.
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute chord and
|
||||
// no chord line in the start banner, rather than an offer to mute a mic nobody is hearing.
|
||||
var micRunning by remember(handle) { mutableStateOf(false) }
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
|
||||
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
|
||||
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). With no standing mic
|
||||
// element on screen, this is mute's only feedback: a chord has no on-screen state of its own,
|
||||
// and "did that register?" is exactly the doubt to answer.
|
||||
var micHint by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(micHint) {
|
||||
if (micHint != null) {
|
||||
@@ -413,9 +404,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
// Select + Y toggles the mic — with no on-screen mute element, this chord is the whole of
|
||||
// the control. Ignored when no capture is running (there is nothing to mute, and a hint
|
||||
// saying "Microphone muted" over a mic nobody opened would be a lie).
|
||||
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
|
||||
// moment it is claimed, on the main thread.
|
||||
router.onMotionUnreachable = { motionHint = true }
|
||||
@@ -981,19 +972,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
},
|
||||
)
|
||||
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
|
||||
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
|
||||
// costs the stream that small corner of touch area, which is why it exists only while a
|
||||
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
|
||||
// control there, and a focusable button would fight the game for the D-pad.
|
||||
if (micRunning && (micMuted || !isTv)) {
|
||||
MicMuteControl(
|
||||
muted = micMuted,
|
||||
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
// No standing mic element here: the in-stream mute control is deliberately absent until the
|
||||
// on-screen overlay UI lands and can carry it as one of its controls. Mute itself is intact
|
||||
// — the Select + Y chord toggles it, and the hint below is what confirms the toggle.
|
||||
// Chord confirmation (gamepad/TV) — mute has no standing indicator, so this is the whole
|
||||
// of its feedback: a toggle that showed nothing at all would be indistinguishable from one
|
||||
// that never registered.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
|
||||
// notice landing on top of one of those would cost the user both.
|
||||
@@ -1030,47 +1014,8 @@ private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
|
||||
* uplink is live, a red **Muted** badge while it isn't — so the state that matters is the loud one,
|
||||
* readable at couch distance and impossible to mistake for the stream's own picture.
|
||||
*
|
||||
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
|
||||
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
|
||||
* caller's decision — it means a capture is genuinely running.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
|
||||
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
// Spoken state first, then the action — a talkback user needs to know they are muted
|
||||
// before they need to know how to stop being muted.
|
||||
contentDescription = if (muted) {
|
||||
"Microphone muted. Activate to unmute."
|
||||
} else {
|
||||
"Microphone live. Activate to mute."
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
if (muted) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Muted", color = Color.White, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
|
||||
* *muted*, but nothing on screen says *un*muted — and "did that press do anything?" is the whole
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. Nothing else on screen says
|
||||
* *muted* or *un*muted, so this pill carries both — "did that press do anything?" is the whole
|
||||
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
|
||||
* in-stream cues; the caller clears it after a beat.
|
||||
*/
|
||||
|
||||
@@ -15,22 +15,41 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
|
||||
# the punktfunk/1 control plane (now ring-only — no aws-lc, see punktfunk-core/Cargo.toml).
|
||||
# the punktfunk/1 control plane, whose TLS runs on aws-lc-rs (see punktfunk-core/Cargo.toml) —
|
||||
# aws-lc-sys cross-compiles for all three ABIs with the NDK clang cargo-ndk already exports.
|
||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||
jni = "0.21"
|
||||
# 0.22, NOT 0.21 — and the version is load-bearing beyond currency: `rustls-platform-verifier`
|
||||
# (via quinn-proto, for Android cert verification) already depends on jni 0.22, so pinning 0.21
|
||||
# here compiled TWO jni copies into the one .so. Matching it collapses them and, with them, the
|
||||
# whole windows-rs 0.42 generation jni 0.21 dragged in behind `cfg(windows)` (windows-sys 0.45 —
|
||||
# the oldest crate in the tree — plus windows-targets 0.42.2 and its seven per-arch import libs)
|
||||
# and jni 0.21's `cesu8`: 11 crates, for a dependency that never even builds on Android.
|
||||
#
|
||||
# NOTE for whoever next tries to retire thiserror 1.0 or the jni-sys 0.3/0.4 split: jni is no
|
||||
# longer why they are here. Both now come solely from `vendor/ndk` 0.9.0 (thiserror 1.0.23,
|
||||
# jni-sys 0.3) and the crates.io `ndk-sys` 0.6 (jni-sys 0.3). jni-sys 0.3.1 is itself a facade
|
||||
# over 0.4.1, so the "split" cannot close until ndk + ndk-sys move — and ndk is vendored for one
|
||||
# visibility patch, so bumping its deps would mean rewriting the vendor rather than a version edit.
|
||||
jni = "0.22"
|
||||
log = "0.4"
|
||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||
# Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), replacing Android's per-OEM
|
||||
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||
mdns-sd = "0.20"
|
||||
mdns-sd = "0.21"
|
||||
|
||||
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
||||
# compiles this crate (as a host cdylib) — the Android-framework glue (logging, AMediaCodec + AAudio
|
||||
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.14"
|
||||
# Default features only, DELIBERATELY: 0.15 added an opt-in `android-api-30` feature that routes
|
||||
# level filtering through `__android_log_is_loggable_len` so logcat's system-wide/process-wide
|
||||
# level overrides (`setprop log.tag.*`) are honoured. That symbol is API 30 and the feature
|
||||
# HARD-LINKS it — on our minSdk-28 floor (Android 9/10) `System.loadLibrary` would fail outright,
|
||||
# the same way ndk 0.9.0 hard-linking `AMediaCodec_setOnFrameRenderedCallback` broke every
|
||||
# pre-Android-13 device (see the `ndk-sys` note below). Do not enable it while minSdk is 28.
|
||||
android_logger = "0.15"
|
||||
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
|
||||
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
|
||||
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
|
||||
|
||||
@@ -43,10 +43,12 @@ struct OutputReady {
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
|
||||
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
|
||||
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
|
||||
/// request).
|
||||
Au(Frame, u32),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
@@ -603,7 +605,11 @@ fn feeder_loop(
|
||||
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||
// counting AUs.
|
||||
let au_first = frame.part.is_none_or(|p| p.first);
|
||||
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||
let gap = if au_first {
|
||||
client.note_frame_index(frame.frame_index)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
@@ -691,9 +697,12 @@ fn dispatch_event(
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
|
||||
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
|
||||
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
|
||||
// race — see `ReanchorGate::arm_expecting_drops`).
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||
if f.complete {
|
||||
|
||||
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
//! wrong, and 1 Hz is plenty for a host picker.
|
||||
|
||||
use crate::session::jni_guard;
|
||||
use jni::objects::JObject;
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::jlong;
|
||||
use jni::JNIEnv;
|
||||
use jni::EnvUnowned;
|
||||
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -200,9 +201,9 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||
///
|
||||
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
) -> jlong {
|
||||
jni_guard(0, || match Discovery::start() {
|
||||
@@ -214,13 +215,16 @@ 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 /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jni::sys::jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
) -> JString<'local> {
|
||||
// `with_env` subsumes the `jni_guard` this used to carry: it catches panics at the boundary and
|
||||
// `LogErrorAndDefault` logs then yields `JString::default()` — the null reference the old
|
||||
// `std::ptr::null_mut()` default returned. Kotlin still sees a null String on failure.
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
let out = if handle == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -229,11 +233,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
|
||||
let d = unsafe { &*(handle as *const Discovery) };
|
||||
d.snapshot()
|
||||
};
|
||||
match env.new_string(out) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
env.new_string(out)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
|
||||
@@ -245,9 +247,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
|
||||
///
|
||||
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
||||
|
||||
use crate::session::{jni_guard, SessionHandle};
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use jni::EnvUnowned;
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -60,9 +61,9 @@ const TAG_HID_RAW: u8 = 0x05;
|
||||
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
||||
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
|
||||
/// poll thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jlong {
|
||||
@@ -99,96 +100,105 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
env: JNIEnv,
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
buf: JByteBuffer,
|
||||
) -> jint {
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
//
|
||||
// Deliberately `with_env_no_catch` INSIDE `jni_guard`, not the usual `with_env`: every error
|
||||
// policy resolves a failure to `T::default()`, and `jint::default()` is 0 — a *valid* byte
|
||||
// count — whereas this method's contract says -1. Letting the panic travel out to `jni_guard`
|
||||
// keeps the -1 sentinel exact. Every non-panic failure path below likewise returns `Ok(-1)`
|
||||
// rather than `Err`, so the policy's default is unreachable by construction.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return -1, // timeout or closed — Kotlin loops
|
||||
};
|
||||
env.with_env_no_catch(|env| -> jni::errors::Result<jint> {
|
||||
if handle == 0 {
|
||||
return Ok(-1);
|
||||
}
|
||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return Ok(-1), // timeout or closed — Kotlin loops
|
||||
};
|
||||
|
||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return -1,
|
||||
};
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(-1),
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return Ok(-1),
|
||||
};
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
|
||||
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
|
||||
let n = match ev {
|
||||
HidOutput::Led { pad, r, g, b } => {
|
||||
if cap < 5 {
|
||||
return -1;
|
||||
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
|
||||
let n = match ev {
|
||||
HidOutput::Led { pad, r, g, b } => {
|
||||
if cap < 5 {
|
||||
return Ok(-1);
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_LED;
|
||||
out[2] = r;
|
||||
out[3] = g;
|
||||
out[4] = b;
|
||||
5
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_LED;
|
||||
out[2] = r;
|
||||
out[3] = g;
|
||||
out[4] = b;
|
||||
5
|
||||
}
|
||||
HidOutput::PlayerLeds { pad, bits } => {
|
||||
if cap < 3 {
|
||||
return -1;
|
||||
HidOutput::PlayerLeds { pad, bits } => {
|
||||
if cap < 3 {
|
||||
return Ok(-1);
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_PLAYER_LEDS;
|
||||
out[2] = bits;
|
||||
3
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_PLAYER_LEDS;
|
||||
out[2] = bits;
|
||||
3
|
||||
}
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
let n = 3 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
let n = 3 + effect.len();
|
||||
if cap < n {
|
||||
return Ok(-1); // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_TRIGGER;
|
||||
out[2] = which;
|
||||
out[3..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_TRIGGER;
|
||||
out[2] = which;
|
||||
out[3..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
HidOutput::TrackpadHaptic { .. } => {
|
||||
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||
// rumble already rides the universal 0xCA plane).
|
||||
return -1;
|
||||
}
|
||||
HidOutput::HidRaw { pad, kind, data } => {
|
||||
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
|
||||
// the virtual pad; Kotlin replays it verbatim on the physical controller.
|
||||
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
|
||||
let n = 3 + data.len();
|
||||
if cap < n {
|
||||
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
|
||||
HidOutput::TrackpadHaptic { .. } => {
|
||||
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||
// rumble already rides the universal 0xCA plane).
|
||||
return Ok(-1);
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_HID_RAW;
|
||||
out[2] = kind;
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
HidOutput::HidRaw { pad, kind, data } => {
|
||||
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
|
||||
// the virtual pad; Kotlin replays it verbatim on the physical controller.
|
||||
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
|
||||
let n = 3 + data.len();
|
||||
if cap < n {
|
||||
return Ok(-1); // reports are ≤ 64 bytes; Kotlin allocates 128
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_HID_RAW;
|
||||
out[2] = kind;
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return Ok(-1);
|
||||
}
|
||||
};
|
||||
Ok(n as jint)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
//! surface, the per-plane pumps (video → AMediaCodec, audio ↔ AAudio, mic uplink), input, and
|
||||
//! rumble/HID feedback ([`feedback`]). Mode renegotiation is still TODO (see [`session`]).
|
||||
|
||||
use jni::objects::JObject;
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::jint;
|
||||
use jni::JNIEnv;
|
||||
use jni::EnvUnowned;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod adpf;
|
||||
@@ -54,7 +55,7 @@ mod probe;
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
_vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut std::ffi::c_void,
|
||||
@@ -74,22 +75,20 @@ pub extern "system" fn JNI_OnLoad(
|
||||
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
|
||||
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
|
||||
/// linked `punktfunk-core` is the one we expect.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
) -> jint {
|
||||
punktfunk_core::ABI_VERSION as jint
|
||||
}
|
||||
|
||||
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
match env.new_string(env!("CARGO_PKG_VERSION")) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| env.new_string(env!("CARGO_PKG_VERSION")))
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
@@ -5,32 +5,29 @@
|
||||
//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion
|
||||
//! to the dial-first connect fix.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint};
|
||||
use jni::JNIEnv;
|
||||
use jni::EnvUnowned;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
|
||||
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
|
||||
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
host: JString<'local>,
|
||||
port: jint,
|
||||
timeout_ms: jint,
|
||||
) -> jboolean {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let port = port.clamp(0, u16::MAX as jint) as u16;
|
||||
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
|
||||
if NativeClient::probe(&host, port, timeout) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
env.with_env(|env| -> jni::errors::Result<bool> {
|
||||
let host: String = host.try_to_string(env)?;
|
||||
let port = port.clamp(0, u16::MAX as jint) as u16;
|
||||
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
|
||||
Ok(NativeClient::probe(&host, port, timeout))
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use jni::JNIEnv;
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::EnvUnowned;
|
||||
use punktfunk_core::clipboard::ClipEventCore;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
|
||||
@@ -40,37 +41,35 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
client(handle).map_or(0, |h| {
|
||||
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
|
||||
})
|
||||
client(handle).is_some_and(|h| 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]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
enabled: jboolean,
|
||||
) {
|
||||
if let Some(h) = client(handle) {
|
||||
let _ = h.client.clip_control(enabled != 0, 0);
|
||||
let _ = h.client.clip_control(enabled, 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]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
seq: jint,
|
||||
@@ -88,9 +87,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
|
||||
|
||||
/// `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]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
seq: jint,
|
||||
@@ -106,28 +105,34 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
|
||||
|
||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
||||
/// clipboard's current text (the host is pasting our offer).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
||||
mut env: JNIEnv,
|
||||
mut env: EnvUnowned,
|
||||
_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);
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
let Some(h) = client(handle) else {
|
||||
return Ok(());
|
||||
};
|
||||
// An unreadable payload still has to answer the host's `fetch:` — leaving it unanswered
|
||||
// stalls the paste — so cancel the transfer rather than propagating the error.
|
||||
let Ok(s) = text.try_to_string(env) else {
|
||||
let _ = h.client.clip_cancel(req_id as u32);
|
||||
return Ok(());
|
||||
};
|
||||
let _ = h.client.clip_serve(req_id as u32, s.into_bytes(), true);
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
id: jint,
|
||||
@@ -144,39 +149,42 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
/// 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,
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
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();
|
||||
) -> JString<'local> {
|
||||
// `JString::default()` is the null reference the old `std::ptr::null_mut()` returned, so the
|
||||
// "null on timeout" contract in the doc comment above is unchanged.
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
let Some(h) = client(handle) else {
|
||||
return Ok(JString::default());
|
||||
};
|
||||
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::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())
|
||||
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 Ok(JString::default());
|
||||
}
|
||||
}
|
||||
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 Ok(JString::default()),
|
||||
Err(_) => "closed".into(),
|
||||
};
|
||||
env.new_string(msg)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Connect lifecycle + the trust surface: identity mint, connect (TOFU / pinned), close,
|
||||
//! host-fingerprint read, and the SPAKE2 PIN pairing ceremony.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use jni::EnvUnowned;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -36,26 +37,24 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
||||
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
||||
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
||||
/// handle / `""` fingerprint.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
) -> JString<'local> {
|
||||
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
|
||||
match env.new_string(token) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
env.with_env(|env| env.new_string(token))
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
) -> JString<'local> {
|
||||
let out = match punktfunk_core::quic::endpoint::generate_identity() {
|
||||
Ok((cert, key)) => format!("{cert}\n-----PUNKTFUNK-KEY-----\n{key}"),
|
||||
Err(e) => {
|
||||
@@ -63,10 +62,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
match env.new_string(out) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
env.with_env(|env| env.new_string(out))
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetLowLatencyMode(enabled)` — apply the user's "Low-latency mode
|
||||
@@ -74,13 +71,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
|
||||
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
|
||||
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
enabled: jboolean,
|
||||
) {
|
||||
punktfunk_core::transport::set_dscp_default(enabled != 0);
|
||||
punktfunk_core::transport::set_dscp_default(enabled);
|
||||
}
|
||||
|
||||
/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the
|
||||
@@ -120,10 +117,10 @@ fn force_parts_sysprop() -> bool {
|
||||
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
|
||||
/// host's approval-park window) so a slow operator approval lands on this same parked connection
|
||||
/// rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
host: JString<'local>,
|
||||
port: jint,
|
||||
@@ -147,31 +144,44 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return 0,
|
||||
// Every JNI string this method needs, read up front in the one `Env` scope jni 0.22 grants a
|
||||
// native method; everything below is pure Rust over owned `String`s. `None` = the mandatory
|
||||
// `host` could not be read, which is the old `Err(_) => return 0` arm.
|
||||
type ConnectStrings = Option<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>;
|
||||
let strings: ConnectStrings = env
|
||||
.with_env(|env| -> jni::errors::Result<ConnectStrings> {
|
||||
let Ok(host) = host.try_to_string(env) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let cert: String = cert_pem.try_to_string(env).unwrap_or_default();
|
||||
let key: String = key_pem.try_to_string(env).unwrap_or_default();
|
||||
let pin_hex: String = pin_hex.try_to_string(env).unwrap_or_default();
|
||||
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a
|
||||
// game; null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
|
||||
let launch: Option<String> = launch
|
||||
.try_to_string(env)
|
||||
.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> = device_name
|
||||
.try_to_string(env)
|
||||
.ok()
|
||||
.map(|s: String| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
Ok(Some((host, cert, key, pin_hex, launch, device_name)))
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>();
|
||||
let Some((host, cert, key, pin_hex, launch, device_name)) = strings else {
|
||||
return 0;
|
||||
};
|
||||
let cert: String = env
|
||||
.get_string(&cert_pem)
|
||||
.map(Into::into)
|
||||
.unwrap_or_default();
|
||||
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
|
||||
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
|
||||
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
|
||||
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
|
||||
let launch: Option<String> = env
|
||||
.get_string(&launch)
|
||||
.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
|
||||
@@ -184,16 +194,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec
|
||||
// that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.)
|
||||
let force_parts = force_parts_sysprop();
|
||||
let frame_parts = frame_parts_ok != 0 || force_parts;
|
||||
let frame_parts = frame_parts_ok || force_parts;
|
||||
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is
|
||||
// inert client-side unless BOTH probes pass — this line is the one place that says which.
|
||||
log::info!(
|
||||
target: "pf.caps",
|
||||
"decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}",
|
||||
multi_slice_ok != 0,
|
||||
frame_parts_ok != 0,
|
||||
multi_slice_ok,
|
||||
frame_parts_ok,
|
||||
if force_parts { " (FORCED by sysprop)" } else { "" },
|
||||
hdr_enabled != 0,
|
||||
hdr_enabled,
|
||||
video_codecs,
|
||||
);
|
||||
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
|
||||
@@ -229,11 +239,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
|
||||
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
|
||||
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
|
||||
(if hdr_enabled != 0 {
|
||||
(if hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
}) | (if multi_slice_ok != 0 {
|
||||
}) | (if multi_slice_ok {
|
||||
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||
} else {
|
||||
0
|
||||
@@ -274,7 +284,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
| if pad_audio_ok {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
@@ -322,9 +332,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
@@ -344,9 +354,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
@@ -363,12 +373,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||
/// connect. `""` on a `0` handle.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jni::sys::jstring {
|
||||
) -> JString<'local> {
|
||||
let out = if handle == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -376,10 +386,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
hex32(&h.client.host_fingerprint)
|
||||
};
|
||||
match env.new_string(out) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
env.with_env(|env| env.new_string(out))
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended?
|
||||
@@ -388,19 +396,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
||||
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
|
||||
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
|
||||
/// handle. Cheap (one atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
jni_guard(false, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
jboolean::from(h.client.is_session_ended())
|
||||
h.client.is_session_ended()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -413,9 +421,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
|
||||
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
|
||||
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
|
||||
/// atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jint {
|
||||
@@ -433,10 +441,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
host: JString<'local>,
|
||||
port: jint,
|
||||
@@ -444,40 +452,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
||||
key_pem: JString<'local>,
|
||||
pin: JString<'local>,
|
||||
name: JString<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
let g = |e: &mut JNIEnv<'local>, j: &JString<'local>| -> String {
|
||||
e.get_string(j).map(Into::into).unwrap_or_default()
|
||||
};
|
||||
let host = g(&mut env, &host);
|
||||
let cert = g(&mut env, &cert_pem);
|
||||
let key = g(&mut env, &key_pem);
|
||||
let pin = g(&mut env, &pin);
|
||||
let name = g(&mut env, &name);
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
let g = |e: &jni::Env<'local>, j: &JString<'local>| -> String {
|
||||
j.try_to_string(e).unwrap_or_default()
|
||||
};
|
||||
let host = g(env, &host);
|
||||
let cert = g(env, &cert_pem);
|
||||
let key = g(env, &key_pem);
|
||||
let pin = g(env, &pin);
|
||||
let name = g(env, &name);
|
||||
|
||||
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
|
||||
log::error!("nativePair: missing host/identity");
|
||||
String::new()
|
||||
} else {
|
||||
match NativeClient::pair(
|
||||
&host,
|
||||
port as u16,
|
||||
(&cert, &key), // borrowed identity
|
||||
&pin,
|
||||
&name,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
Ok(host_fp) => hex32(&host_fp),
|
||||
Err(e) => {
|
||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
||||
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
|
||||
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||
note_error(&e);
|
||||
String::new()
|
||||
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
|
||||
log::error!("nativePair: missing host/identity");
|
||||
String::new()
|
||||
} else {
|
||||
match NativeClient::pair(
|
||||
&host,
|
||||
port as u16,
|
||||
(&cert, &key), // borrowed identity
|
||||
&pin,
|
||||
&name,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
Ok(host_fp) => hex32(&host_fp),
|
||||
Err(e) => {
|
||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
||||
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
|
||||
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||
note_error(&e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
match env.new_string(out) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
};
|
||||
env.new_string(out)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
//! 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::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JByteBuffer, JFloatArray, JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use jni::EnvUnowned;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{
|
||||
PenSample, PenTool, RichInput, HID_REPORT_MAX, HOST_CAP_PEN, HOST_CAP_TEXT_INPUT,
|
||||
@@ -35,9 +36,9 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
dx: jint,
|
||||
@@ -51,9 +52,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
x: jint,
|
||||
@@ -68,15 +69,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
button: jint,
|
||||
down: jboolean,
|
||||
) {
|
||||
let kind = if down != 0 {
|
||||
let kind = if down {
|
||||
InputKind::MouseButtonDown
|
||||
} else {
|
||||
InputKind::MouseButtonUp
|
||||
@@ -86,9 +87,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
|
||||
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
axis: jint,
|
||||
@@ -103,9 +104,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
|
||||
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
|
||||
/// (libei touchscreen / wlroots / SendInput).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
id: jint,
|
||||
@@ -128,9 +129,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
vk: jint,
|
||||
@@ -140,7 +141,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
if vk == 0 {
|
||||
return;
|
||||
}
|
||||
let kind = if down != 0 {
|
||||
let kind = if down {
|
||||
InputKind::KeyDown
|
||||
} else {
|
||||
InputKind::KeyUp
|
||||
@@ -151,35 +152,35 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
/// `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]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
// 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)
|
||||
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]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
// 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)
|
||||
h.client.host_caps() & HOST_CAP_PEN != 0
|
||||
}
|
||||
|
||||
/// Floats per sample in the `nativeSendPen` flat array.
|
||||
@@ -197,89 +198,97 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
|
||||
/// 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]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
env: JNIEnv,
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
samples: JFloatArray,
|
||||
count: jint,
|
||||
) {
|
||||
if handle == 0 || count <= 0 {
|
||||
return;
|
||||
}
|
||||
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
|
||||
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
|
||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
||||
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
||||
return; // short array — a bridge bug, never worth a crash on the input path
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
|
||||
let n = run.len() / PEN_JNI_STRIDE;
|
||||
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return; // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if handle == 0 || count <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = h.client.send_pen(&batch[..n]);
|
||||
}
|
||||
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
|
||||
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
|
||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
||||
if samples.get_region(env, 0, flat).is_err() {
|
||||
return Ok(()); // short array — a bridge bug, never worth a crash on the input path
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
|
||||
let n = run.len() / PEN_JNI_STRIDE;
|
||||
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return Ok(()); // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
}
|
||||
let _ = h.client.send_pen(&batch[..n]);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `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]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
mut env: JNIEnv,
|
||||
mut env: EnvUnowned,
|
||||
_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);
|
||||
}
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if handle == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(s) = text.try_to_string(env) else {
|
||||
return Ok(());
|
||||
};
|
||||
for ch in s.chars().filter(|c| !c.is_control()) {
|
||||
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||
@@ -296,9 +305,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
bit: jint,
|
||||
@@ -309,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
handle,
|
||||
InputKind::GamepadButton,
|
||||
bit as u32,
|
||||
i32::from(down != 0),
|
||||
i32::from(down),
|
||||
0,
|
||||
pad as u32,
|
||||
);
|
||||
@@ -318,9 +327,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
axis_id: jint,
|
||||
@@ -343,9 +352,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pref: jint,
|
||||
@@ -373,35 +382,35 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
///
|
||||
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
|
||||
/// the `Auto` rule inside the predicate itself.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
declared_pref: jint,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 1;
|
||||
return true;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy
|
||||
// values read behind `&self`.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let declared =
|
||||
punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8);
|
||||
u8::from(punktfunk_core::config::pad_motion_reaches(
|
||||
punktfunk_core::config::pad_motion_reaches(
|
||||
declared,
|
||||
h.client.requested_gamepad,
|
||||
h.client.resolved_gamepad,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
@@ -415,38 +424,42 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
||||
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
||||
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
||||
env: JNIEnv,
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
buf: JByteBuffer,
|
||||
len: jint,
|
||||
) {
|
||||
if handle == 0 || len <= 0 {
|
||||
return;
|
||||
}
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return,
|
||||
};
|
||||
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
|
||||
let mut data = [0u8; HID_REPORT_MAX];
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
|
||||
// `n` is bounded by both the buffer capacity and the fixed wire body.
|
||||
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::HidReport {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
len: n as u8,
|
||||
data,
|
||||
});
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if handle == 0 || len <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
|
||||
let mut data = [0u8; HID_REPORT_MAX];
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
|
||||
// `n` is bounded by both the buffer capacity and the fixed wire body.
|
||||
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::HidReport {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
len: n as u8,
|
||||
data,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
|
||||
@@ -455,9 +468,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
@@ -474,7 +487,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
|
||||
let _ = h.client.send_rich_input(RichInput::Touchpad {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
finger: (finger as u32 & 0x1) as u8,
|
||||
active: active != 0,
|
||||
active,
|
||||
x: (x as i64).clamp(0, 65535) as u16,
|
||||
y: (y as i64).clamp(0, 65535) as u16,
|
||||
});
|
||||
@@ -485,10 +498,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Plane start/stop: video (HEVC decode → Surface), host→client audio, mic uplink — plus the
|
||||
//! ~1 Hz decode-stats drain for the HUD.
|
||||
|
||||
use jni::objects::JObject;
|
||||
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
|
||||
#[cfg(target_os = "android")]
|
||||
use jni::objects::JString;
|
||||
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
|
||||
use jni::JNIEnv;
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JDoubleArray, JIntArray, JObject, JString};
|
||||
use jni::sys::{jboolean, jlong};
|
||||
use jni::EnvUnowned;
|
||||
|
||||
use super::{jni_guard, lock_recover, SessionHandle};
|
||||
|
||||
@@ -19,9 +17,9 @@ use super::{jni_guard, lock_recover, SessionHandle};
|
||||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
mut env: JNIEnv,
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
surface: JObject,
|
||||
@@ -37,53 +35,58 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
|
||||
let decoder = env
|
||||
.get_string(&decoder_name)
|
||||
.ok()
|
||||
.map(String::from)
|
||||
.filter(|s| !s.is_empty());
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = lock_recover(&h.video);
|
||||
if guard.is_some() {
|
||||
return; // already streaming
|
||||
}
|
||||
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
|
||||
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
|
||||
let window = match unsafe {
|
||||
ndk::native_window::NativeWindow::from_surface(
|
||||
env.get_native_interface() as *mut _,
|
||||
surface.as_raw() as *mut _,
|
||||
)
|
||||
} {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
log::error!("nativeStartVideo: no ANativeWindow from Surface");
|
||||
return;
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if handle == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature: ll_feature != 0,
|
||||
low_latency_mode: low_latency_mode != 0,
|
||||
is_tv: is_tv != 0,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
.spawn(move || crate::decode::run(client, window, sd, st, opts))
|
||||
.ok();
|
||||
*guard = Some(VideoThread { shutdown, join });
|
||||
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
|
||||
let decoder = decoder_name
|
||||
.try_to_string(env)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = lock_recover(&h.video);
|
||||
if guard.is_some() {
|
||||
return Ok(()); // already streaming
|
||||
}
|
||||
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
|
||||
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers)
|
||||
// — a real skew here, not a hypothetical one: `jni` is on jni-sys 0.4 while the vendored
|
||||
// `ndk` is still on 0.3.
|
||||
let window = match unsafe {
|
||||
ndk::native_window::NativeWindow::from_surface(
|
||||
env.get_raw() as *mut _,
|
||||
surface.as_raw() as *mut _,
|
||||
)
|
||||
} {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
log::error!("nativeStartVideo: no ANativeWindow from Surface");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
.spawn(move || crate::decode::run(client, window, sd, st, opts))
|
||||
.ok();
|
||||
*guard = Some(VideoThread { shutdown, join });
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
@@ -91,23 +94,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
return Ok(JString::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
env.new_string(crate::decode::codec_mime(h.client.codec))
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
|
||||
@@ -116,23 +117,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
return Ok(JString::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(crate::decode::codec_label(h.client.codec)) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
env.new_string(crate::decode::codec_label(h.client.codec))
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||||
@@ -140,30 +139,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
|
||||
/// device).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
return Ok(JString::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(h.stats.decoder_label()) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
env.new_string(h.stats.decoder_label())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||
/// session). No-op on `0`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
@@ -210,20 +207,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jdoubleArray {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
) -> JDoubleArray<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JDoubleArray<'local>> {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
return Ok(JDoubleArray::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
if lock_recover(&h.video).is_none() {
|
||||
return std::ptr::null_mut(); // not streaming → no stats
|
||||
return Ok(JDoubleArray::default()); // not streaming → no stats
|
||||
}
|
||||
let snap = h
|
||||
.stats
|
||||
@@ -294,15 +291,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
h.client.audio_buffer_ms() as f64,
|
||||
h.client.audio_av_offset_ms() as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
arr.into_raw()
|
||||
let arr = env.new_double_array(buf.len())?;
|
||||
arr.set_region(env, 0, &buf)?;
|
||||
Ok(arr)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||||
@@ -312,15 +305,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||
/// build too. Cheap; safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jintArray {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
) -> JIntArray<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JIntArray<'local>> {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
return Ok(JIntArray::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
@@ -330,15 +323,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
mode.height as i32,
|
||||
mode.refresh_hz as i32,
|
||||
];
|
||||
let arr = match env.new_int_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
if env.set_int_array_region(&arr, 0, &buf).is_err() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
arr.into_raw()
|
||||
let arr = env.new_int_array(buf.len())?;
|
||||
arr.set_region(env, 0, &buf)?;
|
||||
Ok(arr)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
|
||||
@@ -346,9 +335,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||||
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
||||
/// pure `jni` + an atomic store, so it links on the host build too.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
enabled: jboolean,
|
||||
@@ -360,7 +349,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
// The current cumulative counters seed the window baselines, so the first snapshot's
|
||||
// `lost`/`FEC` cover only time the HUD was actually up.
|
||||
h.stats.set_enabled(
|
||||
enabled != 0,
|
||||
enabled,
|
||||
h.client.frames_dropped(),
|
||||
h.client.fec_recovered_shards(),
|
||||
);
|
||||
@@ -373,9 +362,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||
/// streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
low_latency_mode: jboolean,
|
||||
@@ -389,7 +378,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
if guard.is_some() {
|
||||
return; // already playing
|
||||
}
|
||||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) {
|
||||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode) {
|
||||
Some(p) => *guard = Some(p),
|
||||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||||
}
|
||||
@@ -398,9 +387,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
||||
/// closing the session). No-op on `0`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
@@ -422,9 +411,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||||
/// the rest of the session streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
echo_cancel: jboolean,
|
||||
@@ -440,7 +429,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
}
|
||||
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
|
||||
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
|
||||
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
|
||||
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel, h.mic_muted.clone()) {
|
||||
Some(m) => {
|
||||
let session_id = m.session_id();
|
||||
*guard = Some(m);
|
||||
@@ -457,9 +446,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
|
||||
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
@@ -484,10 +473,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
@@ -495,9 +484,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
jni_guard(false, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
@@ -512,14 +501,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
haptics,
|
||||
speaker,
|
||||
) {
|
||||
Some(p) => {
|
||||
*lock_recover(&h.pad_audio) = Some(p);
|
||||
1
|
||||
true
|
||||
}
|
||||
None => 0,
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -530,10 +519,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
@@ -553,10 +542,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
@@ -594,9 +583,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
|
||||
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
|
||||
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
|
||||
/// no captured audio leaves the process.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
muted: jboolean,
|
||||
@@ -606,7 +595,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.mic_muted
|
||||
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
|
||||
.store(muted, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -617,18 +606,18 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
|
||||
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
|
||||
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
jni_guard(false, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
jboolean::from(lock_recover(&h.mic).is_some())
|
||||
lock_recover(&h.mic).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
//! coroutine on the main thread the way it polls the stats HUD.
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
use jni::objects::JObject;
|
||||
use jni::sys::{jboolean, jdoubleArray, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JDoubleArray, JObject};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::EnvUnowned;
|
||||
|
||||
/// The `DoubleArray` [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] returns. Kept in
|
||||
/// one place because Kotlin indexes it positionally; see the Kotlin doc for the field order.
|
||||
@@ -23,27 +24,27 @@ const PROBE_RESULT_LEN: usize = 6;
|
||||
/// **briefly pausing video**. Non-blocking: poll
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
|
||||
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
target_kbps: jint,
|
||||
duration_ms: jint,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
jni_guard(false, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let target = target_kbps.clamp(0, i32::MAX) as u32;
|
||||
let duration = duration_ms.clamp(0, i32::MAX) as u32;
|
||||
match h.client.request_probe(target, duration) {
|
||||
Ok(()) => 1,
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("speed test: could not ask the host to probe: {e:?}");
|
||||
0
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -54,15 +55,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
///
|
||||
/// Layout (doubles so one array carries both the counts and the percentages):
|
||||
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jdoubleArray {
|
||||
jni_guard(JObject::null().into_raw(), || {
|
||||
) -> JDoubleArray<'local> {
|
||||
// `JDoubleArray::default()` is the null reference the old `JObject::null().into_raw()` returned,
|
||||
// so Kotlin still reads `null` on every failure path.
|
||||
env.with_env(|env| -> jni::errors::Result<JDoubleArray<'local>> {
|
||||
if handle == 0 {
|
||||
return JObject::null().into_raw();
|
||||
return Ok(JDoubleArray::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
@@ -75,14 +78,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult
|
||||
f64::from(r.elapsed_ms),
|
||||
r.recv_bytes as f64,
|
||||
];
|
||||
match env.new_double_array(PROBE_RESULT_LEN as i32) {
|
||||
Ok(arr) => {
|
||||
if env.set_double_array_region(&arr, 0, &values).is_err() {
|
||||
return JObject::null().into_raw();
|
||||
}
|
||||
arr.into_raw()
|
||||
}
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
let arr = env.new_double_array(PROBE_RESULT_LEN)?;
|
||||
arr.set_region(env, 0, &values)?;
|
||||
Ok(arr)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
@@ -3,38 +3,35 @@
|
||||
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
|
||||
//! just before connecting to an offline saved host.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::JNIEnv;
|
||||
use jni::EnvUnowned;
|
||||
|
||||
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
|
||||
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||
/// Returns true if at least one datagram went out.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
macs_csv: JString<'local>,
|
||||
last_ip: JString<'local>,
|
||||
) -> jni::sys::jboolean {
|
||||
let macs_csv: String = match env.get_string(&macs_csv) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let last_ip: String = env
|
||||
.get_string(&last_ip)
|
||||
.map(Into::<String>::into)
|
||||
.unwrap_or_default();
|
||||
let macs: Vec<[u8; 6]> = macs_csv
|
||||
.split(',')
|
||||
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
|
||||
.collect();
|
||||
if macs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
|
||||
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
|
||||
Ok(()) => 1,
|
||||
Err(_) => 0,
|
||||
}
|
||||
env.with_env(|env| -> jni::errors::Result<bool> {
|
||||
let macs_csv: String = macs_csv.try_to_string(env)?;
|
||||
// Unlike `macs_csv`, an unreadable `lastIp` is not fatal: the core falls back to the
|
||||
// subnet broadcast when it has no address, so keep the old lenient default.
|
||||
let last_ip: String = last_ip.try_to_string(env).unwrap_or_default();
|
||||
let macs: Vec<[u8; 6]> = macs_csv
|
||||
.split(',')
|
||||
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
|
||||
.collect();
|
||||
if macs.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
|
||||
Ok(punktfunk_core::wol::send_magic_packet(&macs, ip).is_ok())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
@@ -39,7 +39,18 @@ final class AudioRing: @unchecked Sendable {
|
||||
private static let maxTargetMS = 70
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// How long the ring may run short before it goes back to priming, in MILLISECONDS of
|
||||
/// starvation — not a count of callbacks. As a count (it was 4) the hysteresis meant a
|
||||
/// different span of time on every device, because a callback is not a unit of time: 4 of them
|
||||
/// is ~44 ms on a Mac's ~11 ms quantum and **20 ms on iOS**, whose session asks for a short IO
|
||||
/// buffer. A Wi-Fi delivery stall therefore de-primed this ring on every bunching cycle where
|
||||
/// the same policy rode it out elsewhere — measured on the shared Rust policy at 120 audible
|
||||
/// gaps per 10 minutes at a 5 ms quantum, against 3 at 8 ms and 1 at 16 ms on an identical
|
||||
/// link. Mirrors `JitterTuning::COREAUDIO.deprime_ms`.
|
||||
private static let deprimeMS = 60
|
||||
/// Floor in callbacks under `deprimeMS`, so a large-quantum device keeps real hysteresis
|
||||
/// instead of de-priming on the first short read. Mirrors `MIN_DEPRIME_CALLBACKS`.
|
||||
private static let minDeprimeCallbacks = 2
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
@@ -93,7 +104,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
/// Consecutive short reads, and the audio they starved for in interleaved samples. BOTH gate
|
||||
/// the de-prime (see `deprimeMS`): the run must be at least that long AND at least
|
||||
/// `minDeprimeCallbacks` callbacks, so the fuse is the same span of time whatever the device's
|
||||
/// quantum without collapsing to a hair trigger on a large-quantum device.
|
||||
private var emptyReads = 0
|
||||
private var emptyRun = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// The live target in interleaved samples — `targetMS` grown by underrun pressure
|
||||
@@ -240,8 +256,10 @@ final class AudioRing: @unchecked Sendable {
|
||||
min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS),
|
||||
target + renderQuantum)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
// Crossfaded, like the smooth shed — see `dropFront`. This is the correction a
|
||||
// bunching link actually pays, so it is the one that most needs not to click.
|
||||
dropFront(writeIdx - readIdx - cap)
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
@@ -262,6 +280,7 @@ final class AudioRing: @unchecked Sendable {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
// The refill just banked this much: seed the average with it rather than letting
|
||||
// it climb from wherever the drought left it — a freshly-primed ring would
|
||||
// otherwise read as hollow for the EWMA's whole settling time, and the FIRST
|
||||
@@ -348,15 +367,23 @@ final class AudioRing: @unchecked Sendable {
|
||||
if ranShort {
|
||||
quietRun = 0
|
||||
emptyReads += 1
|
||||
emptyRun += count
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter || hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// Starved for `deprimeMS` of audio, over at least `minDeprimeCallbacks` callbacks.
|
||||
// Both, because either alone is wrong at one end of the quantum range: time alone is a
|
||||
// hair trigger on a device whose single quantum already exceeds the window, and a
|
||||
// callback count alone is the device-dependent fuse this replaced.
|
||||
let starved = emptyRun >= Self.deprimeMS * perMS
|
||||
&& emptyReads >= Self.minDeprimeCallbacks
|
||||
if starved || hollow {
|
||||
// The starvation hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
primed = false
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
}
|
||||
if !restored {
|
||||
underrunsInWindow += 1
|
||||
@@ -375,12 +402,14 @@ final class AudioRing: @unchecked Sendable {
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
quietRun = 0
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
if !nearMissGrown, !restored {
|
||||
nearMissGrown = true
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else {
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
quietRun += count
|
||||
// Without a sync request, time is the only evidence that hard-won slack is no longer
|
||||
// needed, so a grown target waits out the long window. A request for less IS evidence,
|
||||
@@ -402,13 +431,21 @@ final class AudioRing: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
/// Drop one protocol frame from the front — the smooth drift correction.
|
||||
private func shedOneFrame() { dropFront(Self.frameMS * perMS) }
|
||||
|
||||
/// Drop `drop` interleaved samples from the front, linearly crossfading the seam so the
|
||||
/// correction is inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`;
|
||||
/// caller holds the lock.
|
||||
///
|
||||
/// Used by BOTH corrections. The hard-cap trim in `write` used to splice raw, on the reasoning
|
||||
/// that a ring which blew its ceiling is already a discontinuity — but that describes the
|
||||
/// ARRIVALS, not the samples either side of the seam, which are ordinary continuous audio. It
|
||||
/// is also the drop that actually fires here: a bunching Wi-Fi link trims far more often than
|
||||
/// drift sheds, so the one path left unfaded was the audible one.
|
||||
private func dropFront(_ drop: Int) {
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
guard drop > 0, available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
|
||||
@@ -234,11 +234,23 @@ public final class SessionAudio {
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .mixWithOthers])
|
||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||
// Uplink latency: ask for 10 ms IO quanta at the wire rate (the default ~23 ms
|
||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||
// is actually granted, the capture tap handles the buffers it gets.
|
||||
try? session.setPreferredIOBufferDuration(0.005)
|
||||
//
|
||||
// 10 ms, NOT the 5 ms this used to ask for. The IO buffer duration is a property
|
||||
// of the whole IO unit, so a shorter quantum is not free to the PLAYBACK side —
|
||||
// and it bought the uplink nothing, because the encoder frames at 10 ms
|
||||
// (`installMicTap` installs with `bufferSize: 480` and `OpusEncoder` consumes
|
||||
// whole `framesPerPacket` chunks): at a 5 ms quantum the tap simply fired twice
|
||||
// per packet, for the same packet latency. What it did buy was a halved deadline
|
||||
// for the render callback and — because the de-prime fuse used to be a callback
|
||||
// COUNT — half the starvation hysteresis in the jitter ring, on the one platform
|
||||
// whose transport bunches hardest. Both ends of that are fixed now (`AudioRing`
|
||||
// measures the fuse in ms), but there is still no reason to ask for a quantum
|
||||
// finer than the packets we send.
|
||||
try? session.setPreferredIOBufferDuration(0.010)
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
@@ -247,6 +259,16 @@ public final class SessionAudio {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
// What we were actually GRANTED, not what we asked for. Both are best-effort, and the
|
||||
// ring's behaviour depends on the quantum it really gets — without this, a report of
|
||||
// audio jitter arrives with no way to tell a 10 ms session from a 5 ms or a 23 ms one,
|
||||
// which is exactly the gap that made the last round of this take a simulation to close.
|
||||
log.info("""
|
||||
AVAudioSession active: io_buffer_ms=\
|
||||
\(session.ioBufferDuration * 1000, format: .fixed(precision: 2)) \
|
||||
sample_rate=\(Int(session.sampleRate)) \
|
||||
route=\(session.currentRoute.outputs.first?.portType.rawValue ?? "none")
|
||||
""")
|
||||
#if os(iOS)
|
||||
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
||||
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
||||
|
||||
@@ -774,12 +774,22 @@ public final class PunktfunkConnection {
|
||||
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||
/// after close.
|
||||
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||
noteFrameIndexGapWidth(frameIndex) > 0
|
||||
}
|
||||
|
||||
/// Like `noteFrameIndexGap`, but reports the gap's WIDTH — how many frames this arrival revealed
|
||||
/// as missing (0 = none). The post-loss re-anchor gate arms with the width
|
||||
/// (`ReanchorGate.arm(expectingDrops:)`) so the reassembler's later `framesDropped` climb for
|
||||
/// the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm race).
|
||||
/// Same core side effect as `noteFrameIndex` (the throttled RFI request); call it for every
|
||||
/// received AU. Returns 0 after close.
|
||||
public func noteFrameIndexGapWidth(_ frameIndex: UInt32) -> UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var gap = false
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||
return gap
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var width: UInt32 = 0
|
||||
_ = punktfunk_connection_note_frame_index_ex(h, frameIndex, &width)
|
||||
return width
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,16 @@ final class ReanchorGate: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `arm()` for a loss detected as a frame-index gap of a known width
|
||||
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
|
||||
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
|
||||
/// RFI anchor already healed (the double-arm race — the Rust gate's docs tell the story).
|
||||
func arm(expectingDrops: UInt64) {
|
||||
lock.lock()
|
||||
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||
|
||||
@@ -57,6 +57,8 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||
/// Pump-side events (loss recovery, format seeding) — the stage-2 sibling of StreamPump's log.
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
|
||||
|
||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||
@@ -921,7 +923,11 @@ public final class Stage2Pipeline {
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||
// the following concealed frames are withheld until a clean re-anchor.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { reanchorGate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
@@ -932,6 +938,21 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the guard below drops
|
||||
// every AU silently, forever — the field "black stream, zero recovery
|
||||
// requests" state (2026-08-12): the host streams perfectly, the client
|
||||
// shows nothing and says nothing. awaitingIDR routes through the same
|
||||
// 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
guard let f = format, !token.isStopped else { return true }
|
||||
if decoder.decode(au: au, format: f) {
|
||||
decodeFailRun = 0
|
||||
|
||||
@@ -100,7 +100,11 @@ final class StreamPump {
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { gate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
@@ -116,6 +120,21 @@ final class StreamPump {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the format guard below
|
||||
// drops every AU silently, forever — the field "black stream, zero
|
||||
// recovery requests" state (2026-08-12). awaitingIDR routes through the
|
||||
// same 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
awaitingSince = Date()
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
let failed = layer.status == .failed
|
||||
if failed {
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
|
||||
@@ -106,6 +106,77 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
|
||||
/// THE regression that made an iPad crackle where a Mac did not: the de-prime fuse must be the
|
||||
/// same SPAN OF TIME whatever the device's IO quantum. It used to be a callback COUNT (4), and
|
||||
/// a callback is not a unit of time — the same 4 was ~44 ms on a Mac's ~11 ms quantum and 20 ms
|
||||
/// on iOS, whose session asked for a 5 ms IO buffer. A Wi-Fi delivery stall therefore de-primed
|
||||
/// this ring on every bunching cycle where the identical policy rode it out elsewhere (measured
|
||||
/// on the shared Rust policy: 120 audible gaps per 10 min at a 5 ms quantum against 3 at 8 ms).
|
||||
/// Plant the defect by restoring a fixed count and the quanta below stop agreeing.
|
||||
///
|
||||
/// Mirrors `deprime_fuse_is_a_duration_not_a_callback_count` in `punktfunk_core::audio`.
|
||||
func testDeprimeFuseIsADurationNotACallbackCount() {
|
||||
let deprimeMS = 60 // AudioRing.deprimeMS / JitterTuning::COREAUDIO.deprime_ms
|
||||
let quanta = [5, 8, 10, 16, 21]
|
||||
var deprimedAt: [Int: Int] = [:]
|
||||
for quantumMS in quanta {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = quantumMS * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Prime DEEP: the depth average is seeded with the refill, so `hollow` stays false for
|
||||
// the EWMA's whole settling second and the starvation fuse — not the hollow shortcut —
|
||||
// is what this measures.
|
||||
let big = [Float](repeating: 0.5, count: 80 * perMS)
|
||||
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(
|
||||
scratch.contains { $0 != 0 }, "q=\(quantumMS)ms: must play after priming")
|
||||
|
||||
// Starve on a trickle far under what the device takes: every read runs short but still
|
||||
// carries audio, so an all-zero read can only mean the ring gave up and re-primed.
|
||||
let trickle = [Float](repeating: 0.5, count: max(perMS, want / 4))
|
||||
var starvedMS = 0
|
||||
var deprimedAfterMS: Int?
|
||||
for _ in 0..<2_000 {
|
||||
trickle.withUnsafeBufferPointer {
|
||||
ring.write($0.baseAddress!, count: trickle.count)
|
||||
}
|
||||
let short = ring.bufferedSamples < want
|
||||
scratch.withUnsafeMutableBufferPointer {
|
||||
ring.read(into: $0.baseAddress!, count: want)
|
||||
}
|
||||
if scratch.allSatisfy({ $0 == 0 }) {
|
||||
deprimedAfterMS = starvedMS
|
||||
break
|
||||
}
|
||||
if short { starvedMS += quantumMS }
|
||||
}
|
||||
guard let deprimedAfterMS else {
|
||||
return XCTFail("q=\(quantumMS)ms: never de-primed at all")
|
||||
}
|
||||
deprimedAt[quantumMS] = deprimedAfterMS
|
||||
}
|
||||
|
||||
// Each quantum must give up somewhere around the fuse. The band is wide on purpose: at a
|
||||
// short quantum the HOLLOW shortcut legitimately fires a little before the fuse does (the
|
||||
// target has grown, the depth was never re-banked, so the click is taken early and spent
|
||||
// on a full refill — see `deprimeDebtMS`), and that is the policy working, not drift.
|
||||
for (q, ms) in deprimedAt.sorted(by: { $0.key < $1.key }) {
|
||||
XCTAssertTrue(
|
||||
(deprimeMS - 20...deprimeMS + 25).contains(ms),
|
||||
"q=\(q)ms de-primed after \(ms) ms, nowhere near the \(deprimeMS) ms fuse — "
|
||||
+ "\(deprimedAt.sorted { $0.key < $1.key })")
|
||||
}
|
||||
// ...and THE property: the fuse must not SCALE with the quantum. As a callback count these
|
||||
// same devices de-primed after 20/32/40/64/84 ms — a 4.2x spread, which is exactly why an
|
||||
// iPad crackled where a Mac did not. Measured in time the spread collapses to ~1.3x.
|
||||
let spread = Double(deprimedAt.values.max()!) / Double(deprimedAt.values.min()!)
|
||||
XCTAssertLessThan(
|
||||
spread, 1.6,
|
||||
"de-prime time still scales with the IO quantum (\(String(format: "%.2f", spread))x "
|
||||
+ "across \(deprimedAt.sorted { $0.key < $1.key })) — the fuse is a count again")
|
||||
}
|
||||
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for
|
||||
/// near-miss growth: the drain's LAST full read (less than a frame left over) already grows
|
||||
/// the floor before anything was audible, clustered genuine underruns raise it further, and
|
||||
|
||||
@@ -21,7 +21,6 @@ path = "src/main.rs"
|
||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1389,6 +1389,7 @@ from the config directory for a true factory reset."
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn main() -> std::process::ExitCode {
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
// Logs to stderr; stdout is the machine interface (TSV/JSON), exactly like the session
|
||||
// binary's contract.
|
||||
tracing_subscriber::fmt()
|
||||
|
||||
@@ -215,9 +215,10 @@ export function useHosts() {
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// any of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
// "list-failed" — the refresh itself blew up (backend down, call threw)
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -236,7 +237,11 @@ export function useHosts() {
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
// Inline, not a toast: the panel remounts (and refreshes) on every QAM open, so while
|
||||
// the backend is unhappy a toast here nagged on each open. The panel row also sits next
|
||||
// to the Refresh button that retries it, which is where the eyes already are.
|
||||
console.warn("punktfunk: host list refresh failed", e);
|
||||
setProblem("list-failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -454,9 +459,12 @@ export async function startStream(
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(v.ref, opts);
|
||||
// No success toast: the user just pressed the button that names this host/card, the QAM
|
||||
// closes, and Steam's own launch UI takes over — a toast here fired on EVERY launch and
|
||||
// then sat on top of the starting stream. Failure still toasts (the QAM may already be
|
||||
// closed, so inline error state would go unseen).
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed${label ? ` (${label})` : ""}: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,23 @@ import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut
|
||||
// and sweeps duplicate entries (the piles a boot race used to mint, one per Steam start).
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
||||
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
||||
async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
const appId = await recreateShortcuts();
|
||||
const { appId, removedDuplicates } = await recreateShortcuts();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
||||
body:
|
||||
appId == null
|
||||
? "Couldn't create the shortcut"
|
||||
: removedDuplicates > 0
|
||||
? `Shortcut restored — removed ${removedDuplicates} duplicate ${
|
||||
removedDuplicates === 1 ? "entry" : "entries"
|
||||
}`
|
||||
: "Shortcut restored to your library",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,12 +230,16 @@ const QamPanel: FC = () => {
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
: problem === "list-failed"
|
||||
? "Couldn’t scan for hosts"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
: problem === "list-failed"
|
||||
? "Something went wrong while scanning — Refresh tries again."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
@@ -313,7 +325,7 @@ const QamPanel: FC = () => {
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Missing the Punktfunk entry in your library? This puts it back."
|
||||
description="Missing the Punktfunk entry in your library, or seeing several? This puts one back and removes the rest."
|
||||
onClick={() => void recreatePunktfunkShortcut()}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
|
||||
+220
-39
@@ -44,6 +44,7 @@ declare const SteamClient: {
|
||||
): Promise<unknown>;
|
||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||
TerminateApp(gameId: string, _b: boolean): void;
|
||||
RemoveShortcut(appId: number): void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,29 +63,114 @@ declare const collectionStore:
|
||||
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||
// entry never comes back.
|
||||
declare const appStore:
|
||||
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||
| {
|
||||
GetAppOverviewByAppID?: (appId: number) => unknown | null;
|
||||
allApps?: SteamAppOverviewLike[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
// The overview surface we read when scanning the library — Steam internals, so everything is
|
||||
// optional and accessed defensively.
|
||||
interface SteamAppOverviewLike {
|
||||
appid?: number;
|
||||
display_name?: string;
|
||||
BIsShortcut?: () => boolean;
|
||||
}
|
||||
|
||||
// Steam-injected global whose WaitForServicesInitialized resolves once the client's app
|
||||
// services are up (the MoonDeck-verified readiness signal). Services-init alone doesn't
|
||||
// guarantee the overview map is populated, so it's paired with the hydration witness below.
|
||||
declare const App:
|
||||
| { WaitForServicesInitialized?: () => Promise<boolean> }
|
||||
| undefined;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let servicesInitialized: Promise<void> | undefined;
|
||||
function waitForServicesInitialized(): Promise<void> {
|
||||
servicesInitialized ??= (async () => {
|
||||
try {
|
||||
if (typeof App !== "undefined" && App?.WaitForServicesInitialized) {
|
||||
await App.WaitForServicesInitialized();
|
||||
}
|
||||
} catch {
|
||||
/* no signal — the hydration witness still gates the verdict */
|
||||
}
|
||||
})();
|
||||
return servicesInitialized;
|
||||
}
|
||||
|
||||
/** Has appStore demonstrably finished its initial load? An empty `allApps` means "not yet":
|
||||
* any account that ever had our shortcut has at least one app, so a populated map is the
|
||||
* witness that a null overview lookup is an ANSWER rather than a not-loaded-yet. null =
|
||||
* can't tell (missing global, API drift). */
|
||||
function appStoreHydrated(): boolean | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !appStore) {
|
||||
return null;
|
||||
}
|
||||
const apps = appStore.allApps;
|
||||
return Array.isArray(apps) ? apps.length > 0 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One overview lookup: true = live, false = absent, null = can't tell. */
|
||||
function queryShortcutAlive(appId: number): boolean | null {
|
||||
try {
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
// "can't tell". `typeof` first: `appStore` is a Steam-injected global, and a bare
|
||||
// reference to a missing one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
return null;
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// How long to wait for the app store before conceding liveness can't be verified. A Deck boot
|
||||
// hydrates the store within a few seconds of plugin mount; 30 s is comfortably past any real
|
||||
// boot, and the wait only burns on the absent/unverifiable paths — a live overview answers on
|
||||
// the first query. Overview registration can trail the bulk hydration by a beat, so a
|
||||
// "hydrated but absent" verdict gets one grace recheck before it counts as deleted.
|
||||
const STORE_WAIT_MS = 30_000;
|
||||
const STORE_POLL_MS = 1_000;
|
||||
const STORE_GRACE_MS = 2_000;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut.
|
||||
*
|
||||
* The dangerous verdict is FALSE — it sends the caller to AddShortcut, so a wrong "deleted"
|
||||
* mints a duplicate library entry. And a bare null-overview check gets it wrong on EVERY
|
||||
* boot: the plugin mounts while Steam is still starting up, before appStore has registered
|
||||
* its overviews, so the remembered (perfectly live) appId looks up as null and each boot
|
||||
* added another visible "Punktfunk" — the field-reported duplicate pile. Absent is therefore
|
||||
* only believed once the store is demonstrably hydrated; if that can't be established within
|
||||
* budget the answer is true, because a false "alive" merely no-ops Set-calls until the next
|
||||
* ask (and the recreate button re-asks when the store IS ready) while a false "dead"
|
||||
* duplicates forever. */
|
||||
async function shortcutStillExists(appId: number): Promise<boolean> {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
// Race the init signal against the same budget the poll loop gets: a signal that never
|
||||
// resolves must not wedge the guard (the single-flight ensure would stay occupied forever).
|
||||
await Promise.race([waitForServicesInitialized(), sleep(STORE_WAIT_MS)]);
|
||||
for (let waited = 0; waited < STORE_WAIT_MS; waited += STORE_POLL_MS) {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
if (appStoreHydrated() === true) {
|
||||
await sleep(STORE_GRACE_MS);
|
||||
return queryShortcutAlive(appId) !== false; // null = unverifiable → reuse
|
||||
}
|
||||
await sleep(STORE_POLL_MS);
|
||||
}
|
||||
return true; // store never became inspectable — reusing beats duplicating
|
||||
}
|
||||
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
@@ -156,6 +242,67 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
/** Find an existing "Punktfunk" shortcut to ADOPT instead of minting a new library entry — the
|
||||
* healing path for a lost/wiped appId, and for the duplicate piles the boot race left behind
|
||||
* in the field: rebind one of the existing entries to the role rather than adding an N+1th.
|
||||
* (The caller rewrites exe/dir/opts/visibility anyway, so any of them serves.) Only overviews
|
||||
* Steam itself says are shortcuts qualify, and the other role's remembered id is excluded so
|
||||
* the two roles never collapse onto one shortcut. */
|
||||
function findAdoptableShortcut(excludeAppId: number | null): number | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return null;
|
||||
}
|
||||
for (const app of appStore.allApps) {
|
||||
if (
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
app.appid !== excludeAppId &&
|
||||
app.BIsShortcut?.() === true
|
||||
) {
|
||||
return app.appid;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Steam internals drifted — AddShortcut is the fallback */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove every "Punktfunk" shortcut beyond the two remembered role ids — the cleanup for
|
||||
* piles already minted by the boot race. Deliberately reachable ONLY from the user-pressed
|
||||
* recreate button, never from mount: automatic library deletion at boot is a bigger hazard
|
||||
* than the mess it would tidy. Returns how many entries were removed. */
|
||||
function removeDuplicateShortcuts(): number {
|
||||
let removed = 0;
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return 0;
|
||||
}
|
||||
const keep = [recall(STORAGE_KEY_STREAM), recall(STORAGE_KEY_UI)];
|
||||
// Snapshot before removing — RemoveShortcut mutates the store's list under the iteration.
|
||||
const surplus = appStore.allApps.filter(
|
||||
(app) =>
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
!keep.includes(app.appid) &&
|
||||
app.BIsShortcut?.() === true,
|
||||
);
|
||||
for (const app of surplus) {
|
||||
SteamClient.Apps.RemoveShortcut(app.appid as number);
|
||||
try {
|
||||
localStorage.removeItem(artKey(app.appid as number));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
removed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: duplicate-shortcut sweep incomplete", e);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
@@ -223,7 +370,7 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
async function doEnsureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
@@ -232,25 +379,38 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||
// outlived it across a reinstall) must fall through, not be silently repointed. On a lost id,
|
||||
// ADOPT an existing same-named shortcut before AddShortcut so a wiped key never duplicates.
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null && shortcutStillExists(remembered)) {
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
let appId =
|
||||
remembered != null && (await shortcutStillExists(remembered)) ? remembered : null;
|
||||
if (appId == null) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_UI)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
setShortcutHidden(appId, true);
|
||||
setShortcutHidden(appId, true); // also migrates pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
}
|
||||
|
||||
// Concurrent ensure calls share one run per role — two ensures racing past the liveness check
|
||||
// would each AddShortcut, which is exactly the duplicate class this file exists to prevent (and
|
||||
// the store-readiness wait makes the window real: mount's fire-and-forget ensure can be mid-wait
|
||||
// when a QAM press arrives). Sequential calls still re-run, so per-launch repointing is kept.
|
||||
let streamEnsureInFlight: Promise<{ appId: number; runner: string; clientBin: string }> | null =
|
||||
null;
|
||||
function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
streamEnsureInFlight ??= doEnsureStreamShortcut().finally(() => {
|
||||
streamEnsureInFlight = null;
|
||||
});
|
||||
return streamEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||
@@ -258,7 +418,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||
*/
|
||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
async function doEnsureGamepadUiShortcut(): Promise<number | null> {
|
||||
try {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
@@ -275,18 +435,20 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||
// library entry actually comes back instead of repointing a dead id.
|
||||
// localStorage key survived a plugin reinstall) falls through so the visible library entry
|
||||
// actually comes back instead of repointing a dead id. On a lost id, ADOPT an existing
|
||||
// same-named shortcut (a boot-race duplicate, or the entry whose key was wiped) before
|
||||
// AddShortcut — creation is the last resort, never the response to a mere lookup miss.
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null && shortcutStillExists(appId)) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
} else {
|
||||
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
if (appId == null || !(await shortcutStillExists(appId))) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_STREAM)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_UI, appId);
|
||||
}
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||
setShortcutHidden(appId, false); // the visible library entry
|
||||
void applyArtwork(appId);
|
||||
@@ -297,18 +459,32 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Same single-flight rule as the stream role (see ensureStreamShortcut).
|
||||
let uiEnsureInFlight: Promise<number | null> | null = null;
|
||||
export function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
uiEnsureInFlight ??= doEnsureGamepadUiShortcut().finally(() => {
|
||||
uiEnsureInFlight = null;
|
||||
});
|
||||
return uiEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||
* Returns the (new or existing) visible appId, or null on failure.
|
||||
* Also sweeps surplus "Punktfunk" entries (the piles the boot race minted before the store-
|
||||
* readiness gate existed) — the button is where that cleanup lives, never mount. Returns the
|
||||
* (new or existing) visible appId (null on failure) plus how many duplicates were removed.
|
||||
*/
|
||||
export async function recreateShortcuts(): Promise<number | null> {
|
||||
export async function recreateShortcuts(): Promise<{
|
||||
appId: number | null;
|
||||
removedDuplicates: number;
|
||||
}> {
|
||||
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||
const id = recall(key);
|
||||
if (id != null && !shortcutStillExists(id)) {
|
||||
if (id != null && !(await shortcutStillExists(id))) {
|
||||
try {
|
||||
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||
localStorage.removeItem(key);
|
||||
@@ -317,8 +493,13 @@ export async function recreateShortcuts(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||
return ensureGamepadUiShortcut();
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next
|
||||
// launch. Sweep AFTER the ensure so the remembered ids are fresh — and only when the ensure
|
||||
// succeeded: on a failed ensure the "keep" list can't be trusted, and deleting candidates a
|
||||
// later ensure would adopt could leave the library with no entry at all.
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
const removedDuplicates = appId != null ? removeDuplicateShortcuts() : 0;
|
||||
return { appId, removedDuplicates };
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
|
||||
+1989
-1163
File diff suppressed because it is too large
Load Diff
@@ -870,6 +870,23 @@ fn deliver_deep_link(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate's one runtime env mutation, isolated so `main.rs`'s `deny(unsafe_code)` covers
|
||||
/// everything else and the exemption is a named function rather than a whole call site.
|
||||
#[allow(unsafe_code)]
|
||||
fn clear_steam_sdl_device_filter() {
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
// SAFETY: called at the top of `run()`, before GTK init or any other thread
|
||||
// exists in this process — nothing reads the environment concurrently.
|
||||
unsafe { std::env::remove_var(var) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -879,15 +896,7 @@ pub fn run() -> glib::ExitCode {
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||
// real devices (same rationale as the session binary).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
clear_steam_sdl_device_filter();
|
||||
// Headless paths (no GTK window).
|
||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||
return crate::cli::headless_pair(&pin);
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`, since edition 2024: clearing Steam's SDL device filter and the spawn
|
||||
// test's `HOME` scoping mutate the process env, which is now an unsafe call. Both carry a named
|
||||
// `#[allow(unsafe_code)]` with the proof at the site; everything else stays compiler-refused.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
@@ -30,6 +33,7 @@ mod ui_trust;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
app::run()
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@ mod tests {
|
||||
/// that is merely capped. One test, one `HOME` — the stores are read from it, so this
|
||||
/// deliberately does not split into several that would race over the same env var.
|
||||
#[test]
|
||||
// The crate's one test env mutation (the `HOME` scoping below) — see main.rs's deny note.
|
||||
#[allow(unsafe_code)]
|
||||
fn the_plan_carries_resolved_settings_not_defaults() {
|
||||
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
|
||||
use pf_client_core::trust::{KnownHost, KnownHosts, Settings};
|
||||
@@ -135,7 +137,10 @@ mod tests {
|
||||
let home = std::env::temp_dir().join(format!("pf-spawn-test-{}", std::process::id()));
|
||||
let cfg = home.join(".config/punktfunk");
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
// SAFETY: the only env-mutating test in this binary (see the doc above — one test, one
|
||||
// `HOME`, deliberately not split). Parallel tests may `getenv` concurrently; glibc keeps
|
||||
// replaced environ storage alive, and every reader tolerates either value.
|
||||
unsafe { std::env::set_var("HOME", &home) };
|
||||
|
||||
// A device whose owner has set a bitrate, and a host bound to a profile that raises it
|
||||
// further — the two layers the spec has to carry.
|
||||
|
||||
@@ -10,14 +10,21 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
quinn = "0.11"
|
||||
# Backend features mirror punktfunk-core's quinn exactly (see its Cargo.toml).
|
||||
quinn = { version = "0.11", default-features = false, features = [
|
||||
"log",
|
||||
"platform-verifier",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
"bloom",
|
||||
] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host
|
||||
# advertises (same crate/version the host advertises with).
|
||||
mdns-sd = "0.20"
|
||||
mdns-sd = "0.21"
|
||||
# Opus: multistream DECODE of the host's audio plane (the surround validator) + `--mic-test`'s
|
||||
# encoder. libopus is already in the graph via `punktfunk-core`'s quic feature; this exposes the
|
||||
# name directly. Cross-platform (cmake-vendored), so the probe builds + validates everywhere.
|
||||
|
||||
@@ -37,7 +37,6 @@ pf-client-core = { path = "../../crates/pf-client-core", default-features = fals
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||
serde_json = { version = "1", optional = true }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! (portrait paths starting with `/` load from disk), the GPU-only dev path.
|
||||
|
||||
use crate::session_main::{
|
||||
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
|
||||
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, stats_tier, window_pos,
|
||||
};
|
||||
use pf_client_core::gamepad::is_steam_deck;
|
||||
use pf_client_core::{discovery, library, trust, wol};
|
||||
@@ -141,11 +141,18 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
let json_status = arg_flag("--json-status");
|
||||
let settings_at_start = trust::Settings::load();
|
||||
// The console's window and its input models are built ONCE, from the global defaults, and
|
||||
// live across every launch — so the presentation-tier fields below (stats tier, touch and
|
||||
// mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host
|
||||
// profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec,
|
||||
// audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means
|
||||
// rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
||||
// live across every launch — so the presentation-tier fields below (touch and mouse model,
|
||||
// shortcut inhibit, match-window, render scale) are latched here and a per-host profile
|
||||
// cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio,
|
||||
// pad) is re-resolved per launch and does honor the binding. Closing the rest of that gap
|
||||
// means rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
||||
//
|
||||
// ⚠ The STATS TIER used to be latched here too, and that was a bug people hit: the console's
|
||||
// own settings screen writes the tier to the file and redraws its row, so the choice looked
|
||||
// taken while every stream kept the tier the process started on — "no matter what I select
|
||||
// the overlay is stuck on Detailed", cured only by restarting the app. It now rides
|
||||
// `SessionParams` per launch (`stats_verbosity`), so the value below only seeds the loop
|
||||
// until the first stream. Anything else moved off this snapshot has to travel the same way.
|
||||
let latched_mouse = settings_at_start.mouse_mode();
|
||||
|
||||
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||
@@ -162,11 +169,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
),
|
||||
fullscreen: fullscreen_mode(),
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible without demoting a richer chosen tier.
|
||||
stats_verbosity: match settings_at_start.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
// Seeds the loop only — every launch carries its own freshly resolved tier.
|
||||
stats_verbosity: stats_tier(&settings_at_start),
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
|
||||
+88
-11
@@ -13,7 +13,13 @@
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`: edition 2024 makes the std process-environment mutators unsafe
|
||||
// (WP20 — the env-mutation class made visible; named-API mentions here would count against
|
||||
// the unsafe-hygiene gate C baseline, which tracks this file's real call sites), and this
|
||||
// bin's three single-threaded-startup env writes carry documented SAFETY comments under
|
||||
// localized `#[allow(unsafe_code)]` (the pf-update idiom). A `forbid` cannot be overridden
|
||||
// at those sites and refuses the file.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
@@ -116,6 +122,28 @@ mod session_main {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// The stats-overlay tier a session starts on: the resolved setting, except that
|
||||
/// `--stats` (tooling/debug runs) forces the overlay VISIBLE without demoting an
|
||||
/// explicitly chosen richer tier.
|
||||
///
|
||||
/// One helper because three callers need the identical rule — both run modes' presenter
|
||||
/// options and the per-launch [`session_params`] — and a fourth reading of it would be
|
||||
/// the bug this is here to prevent.
|
||||
pub(crate) fn stats_tier(settings: &trust::Settings) -> trust::StatsVerbosity {
|
||||
stats_tier_with(settings.stats_verbosity(), arg_flag("--stats"))
|
||||
}
|
||||
|
||||
/// [`stats_tier`]'s rule, with argv lifted out so it is testable.
|
||||
pub(crate) fn stats_tier_with(
|
||||
chosen: trust::StatsVerbosity,
|
||||
stats_flag: bool,
|
||||
) -> trust::StatsVerbosity {
|
||||
match chosen {
|
||||
trust::StatsVerbosity::Off if stats_flag => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
|
||||
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
|
||||
/// "auto" policy keys off this.
|
||||
@@ -420,6 +448,12 @@ mod session_main {
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
// Presentation-tier, carried per launch rather than read once by the run loop:
|
||||
// the console streams many sessions through ONE loop, so this is the only way a
|
||||
// tier the user picked between streams (or one a host's profile carries) reaches
|
||||
// the overlay before the app is restarted. Single mode passes the same value its
|
||||
// presenter options already hold, so it changes nothing there.
|
||||
stats_verbosity: stats_tier(settings),
|
||||
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
|
||||
// advertised only when the presenter has real on-glass latch stamps
|
||||
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
|
||||
@@ -505,12 +539,18 @@ mod session_main {
|
||||
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||
/// that cannot decode while the streaming path decodes on it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||
// SAFETY: called at the very top of `run()`, before this process creates any
|
||||
// thread — the Vulkan loader, SDL, and the session runtime all start later.
|
||||
Ok(v) if !v.is_empty() => unsafe {
|
||||
std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}"))
|
||||
},
|
||||
// SAFETY: as above — single-threaded startup.
|
||||
_ => unsafe { std::env::set_var("RADV_PERFTEST", TOKEN) },
|
||||
}
|
||||
tracing::info!(
|
||||
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||
@@ -804,7 +844,13 @@ mod session_main {
|
||||
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
||||
] {
|
||||
if std::env::var_os(var).is_none() && !value.is_empty() {
|
||||
std::env::set_var(var, value);
|
||||
// SAFETY: still the single-threaded startup stretch of `run()` — the
|
||||
// early-exit probes above return out of the process, and everything that
|
||||
// spawns threads (the session, the console, SDL) only starts below.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::set_var(var, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -818,7 +864,12 @@ mod session_main {
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
// SAFETY: as the settings block above — single-threaded startup, before SDL
|
||||
// (the reader of these variables) or any other thread exists.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::remove_var(var)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,12 +977,7 @@ mod session_main {
|
||||
window_title: format!("Punktfunk · {title}"),
|
||||
fullscreen,
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||
// demoting an explicitly chosen richer tier.
|
||||
stats_verbosity: match settings.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
stats_verbosity: stats_tier(&settings),
|
||||
touch_mode: settings.touch_mode(),
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
@@ -1000,6 +1046,37 @@ mod session_main {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trust::StatsVerbosity as V;
|
||||
|
||||
/// `--stats` is a floor, never a ceiling: it lifts Off to Normal and leaves every
|
||||
/// richer chosen tier alone. Both run modes' presenter options AND the per-launch
|
||||
/// params read this one rule, which is the point of having it.
|
||||
#[test]
|
||||
fn the_stats_flag_lifts_off_and_demotes_nothing() {
|
||||
assert_eq!(stats_tier_with(V::Off, true), V::Normal);
|
||||
assert_eq!(stats_tier_with(V::Off, false), V::Off);
|
||||
for chosen in [V::Compact, V::Normal, V::Detailed] {
|
||||
assert_eq!(stats_tier_with(chosen, true), chosen);
|
||||
assert_eq!(stats_tier_with(chosen, false), chosen);
|
||||
}
|
||||
}
|
||||
|
||||
/// The console reads the file ONCE for its window, so a tier changed between streams
|
||||
/// can only reach the overlay by riding the launch. Guards the wiring the field exists
|
||||
/// for: whatever settings a launch resolved is what the params carry.
|
||||
#[test]
|
||||
fn a_launch_carries_the_tier_its_settings_resolved() {
|
||||
let mut s = trust::Settings::default();
|
||||
for chosen in [V::Off, V::Compact, V::Normal, V::Detailed] {
|
||||
s.set_stats_verbosity(chosen);
|
||||
assert_eq!(stats_tier_with(s.stats_verbosity(), false), chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "punktfunk-client-windows"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
# Not workspace-inherited (1.82): windows-reactor at the pinned rev declares rust-version 1.95+
|
||||
# Not workspace-inherited (1.85): windows-reactor at the pinned rev declares rust-version 1.95+
|
||||
# and edition 2024. rust-toolchain.toml pins 1.96, so this records reality rather than raising it.
|
||||
rust-version = "1.96"
|
||||
license.workspace = true
|
||||
@@ -47,8 +47,15 @@ pf-client-core = { path = "../../crates/pf-client-core", default-features = fals
|
||||
# Unpublished (version 0.0.0) and fast-moving, so pinned to a verified commit. Pin bumped
|
||||
# 2026-07-29 (from the 2026-07-01 rev) for: reconciler keyed-child-order fix (#4728), widget
|
||||
# validation (#4727), DPI collision fix (#4751), icon elements (#4736), multi-window (#4730),
|
||||
# scroll virtualization (#4710). All three windows-rs deps here MUST share this rev, and it
|
||||
# must match pf-client-core's `windows` pin, so the workspace builds ONE windows-rs.
|
||||
# scroll virtualization (#4710). All three windows-rs deps here MUST share this rev, and it must
|
||||
# match pf-client-core's `windows` pin — that is what makes the `IDXGISwapChain1` handed to reactor
|
||||
# satisfy reactor's own `windows_core::Interface`.
|
||||
# ⚠ This is NOT "the workspace builds ONE windows-rs", which an earlier version of this note
|
||||
# claimed. `wasapi` (via pf-client-core) pulls the crates.io `windows 0.62.2` alongside this git
|
||||
# copy, so both are in the lock and both compile. That costs build time and binary size, not
|
||||
# correctness. ⛔ Do NOT try to collapse it with a blanket `[patch.crates-io] windows`: this rev
|
||||
# uses header-named features (`dxgi`, `combaseapi`) while a dozen other manifests still use the
|
||||
# old `Win32_*` namespace features, and the patch would break every one of them.
|
||||
windows-reactor = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a7441033d9312b16842af02eb0c2b403dc" }
|
||||
# Win32 / DXGI for the GPU picker and the shell's window plumbing. Pulled from the SAME
|
||||
# windows-rs commit as windows-reactor so their `windows-core` unifies — the `IDXGISwapChain1`
|
||||
@@ -82,9 +89,8 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
|
||||
# itself (built from source via the bundled CMake on Windows) is pulled transitively by
|
||||
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
|
||||
mdns-sd = "0.20"
|
||||
mdns-sd = "0.21"
|
||||
async-channel = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
+1968
-1142
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's
|
||||
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
@@ -14,7 +14,7 @@ package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged
|
||||
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
|
||||
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
|
||||
stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
[`windows.yml`](../../../.gitea/workflows/windows.yml) for the cross-build rationale.
|
||||
[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale.
|
||||
|
||||
## What's in the package
|
||||
|
||||
|
||||
@@ -318,10 +318,10 @@ fn edit_editor(
|
||||
if !addr.is_empty() {
|
||||
h.addr = addr;
|
||||
}
|
||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
|
||||
if p != 0 {
|
||||
h.port = p;
|
||||
}
|
||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>()
|
||||
&& p != 0
|
||||
{
|
||||
h.port = p;
|
||||
}
|
||||
let mac = mac_draft.borrow().trim().to_string();
|
||||
h.mac = if mac.is_empty() {
|
||||
@@ -1094,12 +1094,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.close_button_text("Cancel")
|
||||
.is_open(pending.is_some())
|
||||
.on_closed(move |r: ContentDialogResult| {
|
||||
if r == ContentDialogResult::Primary {
|
||||
if let Some((fp, _)) = &pending {
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(fp);
|
||||
let _ = known.save();
|
||||
}
|
||||
if r == ContentDialogResult::Primary
|
||||
&& let Some((fp, _)) = &pending
|
||||
{
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(fp);
|
||||
let _ = known.save();
|
||||
}
|
||||
sf.call(None); // re-renders the page; the row is gone on the next load
|
||||
})
|
||||
|
||||
@@ -515,35 +515,37 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
move || {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-probe".into())
|
||||
.spawn(move || loop {
|
||||
// A spawned session/browse child is running: the shell is hidden
|
||||
// (nobody sees the pips) and one of these hosts is mid-stream —
|
||||
// probing it is pure noise. Sleep through and sweep after it ends.
|
||||
if shared.session.lock().unwrap().is_running() {
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
continue;
|
||||
}
|
||||
let handles: Vec<_> = KnownHosts::load()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.filter(|h| !h.addr.is_empty())
|
||||
.map(|h| {
|
||||
std::thread::spawn(move || {
|
||||
(
|
||||
h.fp_hex,
|
||||
NativeClient::probe(
|
||||
&h.addr,
|
||||
h.port,
|
||||
Duration::from_millis(2500),
|
||||
),
|
||||
)
|
||||
.spawn(move || {
|
||||
loop {
|
||||
// A spawned session/browse child is running: the shell is hidden
|
||||
// (nobody sees the pips) and one of these hosts is mid-stream —
|
||||
// probing it is pure noise. Sleep through and sweep after it ends.
|
||||
if shared.session.lock().unwrap().is_running() {
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
continue;
|
||||
}
|
||||
let handles: Vec<_> = KnownHosts::load()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.filter(|h| !h.addr.is_empty())
|
||||
.map(|h| {
|
||||
std::thread::spawn(move || {
|
||||
(
|
||||
h.fp_hex,
|
||||
NativeClient::probe(
|
||||
&h.addr,
|
||||
h.port,
|
||||
Duration::from_millis(2500),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let map: HashMap<String, bool> =
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect();
|
||||
set_probed.call(map);
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
.collect();
|
||||
let map: HashMap<String, bool> =
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect();
|
||||
set_probed.call(map);
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -560,14 +562,15 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let anim_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (anim, set_anim) = cx.use_async_state((Option::<Screen>::None, 1.0f64));
|
||||
cx.use_effect(screen.clone(), {
|
||||
let (s, set_anim, gen) = (screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
|
||||
let (s, set_anim, generation) =
|
||||
(screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 14;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // a newer navigation superseded this tween
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
@@ -593,18 +596,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let nav_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (nav_anim, set_nav_anim) = cx.use_async_state((String::new(), 1.0f64));
|
||||
cx.use_effect(settings_nav.clone(), {
|
||||
let (s, set_nav_anim, gen) = (
|
||||
let (s, set_nav_anim, generation) = (
|
||||
settings_nav.clone(),
|
||||
set_nav_anim.clone(),
|
||||
nav_gen.borrow().clone(),
|
||||
);
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 14;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // a newer section switch superseded this tween
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
@@ -628,10 +631,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let add_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (add_anim, set_add_anim) = cx.use_async_state(0.0f64);
|
||||
cx.use_effect(show_add, {
|
||||
let (set_add_anim, gen) = (set_add_anim.clone(), add_gen.borrow().clone());
|
||||
let (set_add_anim, generation) = (set_add_anim.clone(), add_gen.borrow().clone());
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
if !show_add {
|
||||
set_add_anim.call(0.0);
|
||||
return;
|
||||
@@ -639,7 +642,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 12;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // reopened/closed mid-tween — a newer run owns the value
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
|
||||
@@ -58,6 +58,7 @@ fn main() {
|
||||
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
}
|
||||
set_app_user_model_id();
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
|
||||
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
|
||||
// a GUI/MSIX launch has no console, so without the file the client side of any field report
|
||||
@@ -82,10 +83,11 @@ fn main() {
|
||||
// where the user's hosts already are. A hand-off that finds nobody falls through and this
|
||||
// process becomes the shell that opens it, so the link is never simply lost.
|
||||
let link = deeplink::positional_url(&args);
|
||||
if let Some(url) = &link {
|
||||
if !deeplink::claim_primary() && deeplink::forward_to_primary(url) {
|
||||
return;
|
||||
}
|
||||
if let Some(url) = &link
|
||||
&& !deeplink::claim_primary()
|
||||
&& deeplink::forward_to_primary(url)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if flag("--discover") {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[package]
|
||||
name = "pf-capture"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host frame capture: Linux PipeWire portal + Windows IDD direct-push capturers behind one Capturer trait."
|
||||
@@ -29,13 +29,15 @@ ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
|
||||
pipewire = "0.9"
|
||||
libc = "0.2"
|
||||
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
# `sync` is for the `tokio::sync::oneshot` quit channels in the portal/linux capture paths. It used
|
||||
# to be absent and compile anyway, borrowed from ashpd→zbus via feature unification.
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "sync"] }
|
||||
# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no
|
||||
# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/
|
||||
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
|
||||
# `RustConnection` is the pure-Rust default (no libxcb link → no new C dependency on the host); the
|
||||
# `xfixes` feature (auto-pulls `render` + `shape`) is what exposes GetCursorImage/SelectCursorInput.
|
||||
x11rb = { version = "0.13", default-features = false, features = ["xfixes"] }
|
||||
x11rb = { version = "0.14", default-features = false, features = ["xfixes"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
|
||||
|
||||
@@ -96,6 +96,10 @@ struct UserData {
|
||||
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
|
||||
/// within a frame or two).
|
||||
gate_since: Option<std::time::Instant>,
|
||||
/// Deferred requeue of raw-passthrough buffers (see [`DeferredRequeue`]): the encode thread
|
||||
/// reads the dmabuf long after `.process` returns, so the buffer must not rejoin the
|
||||
/// producer's pool until the frame's [`BufferHold`] drops.
|
||||
defer: std::sync::Arc<DeferredRequeue>,
|
||||
}
|
||||
|
||||
impl UserData {
|
||||
@@ -113,6 +117,46 @@ impl UserData {
|
||||
}
|
||||
let _ = self.wake.try_send(());
|
||||
}
|
||||
|
||||
/// Withhold the raw-passthrough buffer from the producer's pool until the returned hold
|
||||
/// drops — the deferred requeue that closes the rewrite-while-the-encoder-reads race.
|
||||
/// `None` (pool too shallow, or `PUNKTFUNK_ZEROCOPY_HOLD=0`) falls back to the immediate
|
||||
/// `.process`-epilogue requeue, i.e. the old racy contract; said once per session.
|
||||
fn try_defer(&mut self, pw_buf: *mut pw::sys::pw_buffer) -> Option<pf_frame::FrameHold> {
|
||||
if !zerocopy_hold_enabled() {
|
||||
return None;
|
||||
}
|
||||
let buf = pw_buf as usize;
|
||||
let pool_live = self.pool.live;
|
||||
let generation = self.defer.book.lock().ok()?.try_hold(buf, pool_live);
|
||||
let Some(generation) = generation else {
|
||||
if !self.defer.logged_shallow.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
pool_depth = pool_live,
|
||||
reserve = HOLD_POOL_RESERVE,
|
||||
"zero-copy: the producer's buffer pool cannot spare a buffer to hold across \
|
||||
the encode — falling back to the immediate requeue, which the producer may \
|
||||
rewrite mid-encode (torn/discolored frames under load); PUNKTFUNK_FORCE_SHM=1 \
|
||||
trades CPU for a race-free capture if artifacts appear"
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if !self.defer.logged_active.swap(true, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
pool_depth = pool_live,
|
||||
reserve = HOLD_POOL_RESERVE,
|
||||
"zero-copy: withholding each published buffer from the producer until the \
|
||||
encoder releases it (deferred requeue — the producer can no longer rewrite a \
|
||||
frame mid-encode); PUNKTFUNK_ZEROCOPY_HOLD=0 restores the immediate requeue"
|
||||
);
|
||||
}
|
||||
Some(std::sync::Arc::new(BufferHold {
|
||||
defer: self.defer.clone(),
|
||||
buf,
|
||||
generation,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the zero-copy negotiation decision depends on, gathered at ONE point in time.
|
||||
@@ -510,11 +554,12 @@ impl FenceWaitStats {
|
||||
|
||||
/// PW5 stage 1: how many buffers the producer actually allocated for this stream.
|
||||
///
|
||||
/// **Nothing in this codebase had ever counted them.** The zero-copy path dups the dmabuf fd and
|
||||
/// publishes the frame while the SPA buffer is handed straight back to the producer at `.process`
|
||||
/// return — so the only thing keeping capture untorn is that the producer round-robins a pool
|
||||
/// deeper than our import+encode window. That depth was an unmeasured assumption; this makes it a
|
||||
/// logged number, on every producer, before anything is built on it.
|
||||
/// **Nothing in this codebase had ever counted them.** The zero-copy path used to hand the SPA
|
||||
/// buffer straight back to the producer at `.process` return, leaving pool depth as the only
|
||||
/// thing keeping capture untorn. The deferred requeue ([`DeferredRequeue`]) now withholds
|
||||
/// published buffers until the consumer is done, but the depth still matters twice over: it is
|
||||
/// the budget `HoldBook::try_hold` spends (a pool of ≤ [`HOLD_POOL_RESERVE`] cannot defer at
|
||||
/// all and runs the old race), and for un-deferred frames it remains the race window.
|
||||
///
|
||||
/// `live` is maintained by the `add_buffer`/`remove_buffer` stream callbacks, which PipeWire fires
|
||||
/// on the loop thread as the pool is allocated (and again, remove-then-add, on a renegotiation that
|
||||
@@ -586,6 +631,104 @@ impl PassthroughFallbacks {
|
||||
/// short streak of dropped frames the capturer fails loudly and the session renegotiates.
|
||||
const IMPORT_FAIL_POISON: u32 = 3;
|
||||
|
||||
/// Buffers the deferred requeue always leaves in the producer's pool. One for the frame the
|
||||
/// producer is rendering right now, one in transit — withholding past that would make the
|
||||
/// producer skip frames whenever our holds are at their worst (host frame + up to two encoder
|
||||
/// ring slots), which is a pacing hiccup, not corruption, but there is no reason to court it.
|
||||
const HOLD_POOL_RESERVE: u32 = 2;
|
||||
|
||||
/// `PUNKTFUNK_ZEROCOPY_HOLD=0` restores the immediate `.process`-return requeue (the racy
|
||||
/// pre-hold behavior) — a field bisect lever, not a tuning knob. `env_on` grammar like every
|
||||
/// other capture knob (a bare `== "0"` compare is the trap `PUNKTFUNK_FORCE_SHM` already fell in).
|
||||
fn zerocopy_hold_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| pf_host_config::env_on("PUNKTFUNK_ZEROCOPY_HOLD").unwrap_or(true))
|
||||
}
|
||||
|
||||
/// Pure bookkeeping for the deferred requeue: which buffers are currently withheld from the
|
||||
/// producer, each under a per-hold generation so a pointer-value reuse across a pool
|
||||
/// renegotiation can never satisfy a stale hold's release (see `complete`).
|
||||
///
|
||||
/// Threading contract (what makes the single-requeue invariant hold with no atomics): entries are
|
||||
/// INSERTED (`try_hold`) and REMOVED (`complete` via the requeue channel's callback, `purge` via
|
||||
/// `remove_buffer`) only on the PipeWire loop thread; a dropping [`BufferHold`] on any other
|
||||
/// thread only *sends* the release message. So between a hold's creation and the loop servicing
|
||||
/// its release, `contains` is stable — which is exactly what the `.process` epilogue relies on to
|
||||
/// decide "requeue now" vs "the hold owns the requeue".
|
||||
#[derive(Default)]
|
||||
struct HoldBook {
|
||||
/// Withheld buffers: `*mut pw_buffer` as usize → the generation of the hold that owns it.
|
||||
out: std::collections::HashMap<usize, u64>,
|
||||
/// Last issued hold generation (monotonic per stream).
|
||||
last_gen: u64,
|
||||
}
|
||||
|
||||
impl HoldBook {
|
||||
/// Withhold `buf` if the pool can spare it: at most `pool_live - HOLD_POOL_RESERVE` buffers
|
||||
/// out at once. Returns the generation to release with, or `None` (pool too shallow / buffer
|
||||
/// somehow already out — the caller falls back to the immediate requeue).
|
||||
fn try_hold(&mut self, buf: usize, pool_live: u32) -> Option<u64> {
|
||||
let cap = pool_live.saturating_sub(HOLD_POOL_RESERVE) as usize;
|
||||
if self.out.len() >= cap || self.out.contains_key(&buf) {
|
||||
return None;
|
||||
}
|
||||
self.last_gen += 1;
|
||||
self.out.insert(buf, self.last_gen);
|
||||
Some(self.last_gen)
|
||||
}
|
||||
|
||||
/// A hold released: take `buf` out of the book iff this generation still owns it. `true` ⇒
|
||||
/// the caller must requeue the buffer; `false` ⇒ the entry was purged (pool renegotiated —
|
||||
/// the pointer may even be a NEW buffer under a reused address) and the buffer must NOT be
|
||||
/// touched.
|
||||
fn complete(&mut self, buf: usize, generation: u64) -> bool {
|
||||
match self.out.get(&buf) {
|
||||
Some(&g) if g == generation => {
|
||||
self.out.remove(&buf);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `remove_buffer`: the buffer is being freed under us (renegotiation/teardown) — forget it.
|
||||
/// Its hold's later release finds the generation gone and becomes a no-op.
|
||||
fn purge(&mut self, buf: usize) {
|
||||
self.out.remove(&buf);
|
||||
}
|
||||
|
||||
fn contains(&self, buf: usize) -> bool {
|
||||
self.out.contains_key(&buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared between the loop thread ([`HoldBook`] ops) and the [`BufferHold`] guards riding
|
||||
/// published frames to the encode thread.
|
||||
struct DeferredRequeue {
|
||||
book: std::sync::Mutex<HoldBook>,
|
||||
/// Wakes the loop to requeue `(buffer, generation)`. Send failure = the loop (and with it
|
||||
/// the stream and every buffer) is gone — nothing to release.
|
||||
tx: pw::channel::Sender<(usize, u64)>,
|
||||
/// One-per-session lines: the first successful defer, and the shallow-pool fallback.
|
||||
logged_active: std::sync::atomic::AtomicBool,
|
||||
logged_shallow: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// The concrete [`pf_frame::FrameHold`]: releases its buffer back to the producer when the last
|
||||
/// clone drops. Send-only from the dropping thread — the actual `pw_stream_queue_buffer` runs in
|
||||
/// the requeue channel's loop-thread callback.
|
||||
struct BufferHold {
|
||||
defer: std::sync::Arc<DeferredRequeue>,
|
||||
buf: usize,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Drop for BufferHold {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.defer.tx.send((self.buf, self.generation));
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
||||
/// pipeline must say why without flooding).
|
||||
fn warn_once(msg: &'static str) {
|
||||
@@ -644,7 +787,14 @@ impl Drop for DmabufMap {
|
||||
/// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced
|
||||
/// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data`
|
||||
/// accessors (`.type_()`, `.chunk()`, `.data()`, `.fd()`, `.as_raw()`) keep working.
|
||||
fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
///
|
||||
/// `pw_buf` is the buffer's `pw_buffer` handle (`spa_buf`'s owner), used only as the identity a
|
||||
/// raw-passthrough publish withholds via [`UserData::try_defer`] — never dereferenced here.
|
||||
fn consume_frame(
|
||||
ud: &mut UserData,
|
||||
spa_buf: *mut spa::sys::spa_buffer,
|
||||
pw_buf: *mut pw::sys::pw_buffer,
|
||||
) {
|
||||
// No active stream: release the buffer without the (expensive at 5K) de-pad.
|
||||
if !ud.signals.active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
@@ -822,8 +972,11 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
None
|
||||
};
|
||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||
// imports it. Content stability across the brief import/encode window relies
|
||||
// on the compositor's buffer-pool depth, like any zero-copy capture.
|
||||
// imports it. Content stability across the read window comes from the deferred
|
||||
// requeue below (`try_defer` — the producer does not get this buffer back until
|
||||
// the frame's hold drops); with no hold (shallow pool / PUNKTFUNK_ZEROCOPY_HOLD=0)
|
||||
// it falls back to the compositor's pool depth outrunning the encode, the old
|
||||
// racy contract.
|
||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||
@@ -836,6 +989,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let hold = ud.try_defer(pw_buf);
|
||||
ud.publish(CapturedFrame {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
@@ -852,6 +1006,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
offset,
|
||||
stride,
|
||||
plane1,
|
||||
hold,
|
||||
}),
|
||||
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
|
||||
// embeds its pointer in the produced pixels, so native NV12 has none.
|
||||
@@ -1434,6 +1589,18 @@ pub fn pipewire_thread(
|
||||
);
|
||||
}
|
||||
|
||||
// Deferred requeue (the rewrite-while-encoding fix): holds riding published frames release
|
||||
// their buffers through this channel from whatever thread drops them last; the receiver —
|
||||
// attached to the loop below, after the stream exists — is the single place a withheld
|
||||
// buffer rejoins the producer's pool.
|
||||
let (requeue_tx, requeue_rx) = pw::channel::channel::<(usize, u64)>();
|
||||
let defer = std::sync::Arc::new(DeferredRequeue {
|
||||
book: std::sync::Mutex::new(HoldBook::default()),
|
||||
tx: requeue_tx,
|
||||
logged_active: std::sync::atomic::AtomicBool::new(false),
|
||||
logged_shallow: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
|
||||
let data = UserData {
|
||||
info: VideoInfoRaw::default(),
|
||||
format: None,
|
||||
@@ -1459,6 +1626,7 @@ pub fn pipewire_thread(
|
||||
},
|
||||
gate_skips: 0,
|
||||
gate_since: None,
|
||||
defer: defer.clone(),
|
||||
};
|
||||
|
||||
let stream = pw::stream::StreamBox::new(
|
||||
@@ -1562,10 +1730,18 @@ pub fn pipewire_thread(
|
||||
}
|
||||
})
|
||||
// PW5 stage 1 — the pool census. PipeWire fires these on the loop thread as it allocates
|
||||
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. Counting only:
|
||||
// the buffer pointer is not touched, so no lifetime question arises here.
|
||||
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. The census only
|
||||
// counts; `remove_buffer` additionally purges the buffer from the deferred-requeue book —
|
||||
// the buffer is being freed under any hold still riding a frame, so that hold's later
|
||||
// release must become a no-op (the generation check in `HoldBook::complete` also covers
|
||||
// the freed address being reused by a new pool's buffer).
|
||||
.add_buffer(|_stream, ud, _buf| ud.pool.add())
|
||||
.remove_buffer(|_stream, ud, _buf| ud.pool.remove())
|
||||
.remove_buffer(|_stream, ud, buf| {
|
||||
ud.pool.remove();
|
||||
if let Ok(mut book) = ud.defer.book.lock() {
|
||||
book.purge(buf as usize);
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
|
||||
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
|
||||
@@ -1598,19 +1774,19 @@ pub fn pipewire_thread(
|
||||
// value. MEASURED, not requested: `build_dmabuf_buffers` asks for a range and the
|
||||
// producer picks — this line is the only place the picked number is visible.
|
||||
//
|
||||
// Why it matters beyond curiosity: `stream.queue_raw_buffer(newest)` at the end of this
|
||||
// callback hands the buffer back while the encode thread may still be importing and
|
||||
// reading its dmabuf, so content stability rests entirely on the producer not cycling
|
||||
// back to this buffer before we are done with it. That window is `pool_depth` buffer
|
||||
// periods wide. A pool of 2 has essentially none.
|
||||
// Why it matters beyond curiosity: the depth is the budget the deferred requeue
|
||||
// (`HoldBook::try_hold`) spends withholding published buffers from the producer
|
||||
// while the encoder reads them. A pool of ≤ HOLD_POOL_RESERVE cannot defer at all —
|
||||
// those sessions run the old contract, where a requeued buffer may be rewritten
|
||||
// mid-encode and only pool depth keeps frames untorn.
|
||||
if let Some(depth) = ud.pool.note_frame() {
|
||||
tracing::info!(
|
||||
pool_depth = depth,
|
||||
high_water = ud.pool.high_water,
|
||||
drained,
|
||||
"pipewire buffer pool negotiated — this is the producer's ACTUAL count \
|
||||
(add_buffer/remove_buffer), the window in which a buffer we handed back may \
|
||||
be rewritten while the encoder still reads it"
|
||||
"pipewire buffer pool negotiated — the producer's ACTUAL count \
|
||||
(add_buffer/remove_buffer): the deferred-requeue budget, and the rewrite \
|
||||
window for any frame published without a hold"
|
||||
);
|
||||
}
|
||||
// Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the
|
||||
@@ -1766,14 +1942,30 @@ pub fn pipewire_thread(
|
||||
return;
|
||||
}
|
||||
|
||||
consume_frame(ud, spa_buf);
|
||||
consume_frame(ud, spa_buf, newest);
|
||||
}));
|
||||
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
|
||||
// or a caught panic in the closure above. This single requeue is what keeps the fixed
|
||||
// buffer pool from draining.
|
||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
|
||||
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
// or a caught panic in the closure above — UNLESS a raw-passthrough publish withheld it
|
||||
// (`try_defer` put it in the hold book): then the requeue duty belongs to the frame's
|
||||
// `BufferHold`, and requeueing here too would hand the producer the same buffer twice.
|
||||
// The book is stable across this check: only this thread removes entries (the requeue
|
||||
// channel's callback / `remove_buffer`), and neither can run inside `.process` — a
|
||||
// consumer racing the frame to its drop merely queues the release message. A panic
|
||||
// AFTER the publish leaves the hold live on the published frame, so skipping the
|
||||
// immediate requeue remains correct on that path too.
|
||||
let withheld = ud
|
||||
.defer
|
||||
.book
|
||||
.lock()
|
||||
.map(|b| b.contains(newest as usize))
|
||||
.unwrap_or(false);
|
||||
if !withheld {
|
||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame)
|
||||
// completed inside the closure above; `newest` was dequeued from this stream,
|
||||
// not yet requeued, and — per the `withheld` check — carries no hold that would
|
||||
// requeue it a second time.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
}
|
||||
if outcome.is_err() {
|
||||
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
||||
// format) would fire this every frame, so power-of-two throttle it — enough to
|
||||
@@ -1789,6 +1981,34 @@ pub fn pipewire_thread(
|
||||
.register()
|
||||
.context("register stream listener")?;
|
||||
|
||||
// The deferred-requeue service. A `BufferHold` dropping on any thread only *sends*
|
||||
// `(buffer, generation)`; this callback — on the loop thread, like every other stream op —
|
||||
// is where a withheld buffer actually rejoins the producer's pool. `HoldBook::complete`
|
||||
// makes a release for a renegotiated-away buffer (or a freed address reused by a new
|
||||
// pool's buffer) a no-op, so a stale hold can never queue somebody else's buffer.
|
||||
let defer_cb = defer.clone();
|
||||
let stream_ptr = stream.as_raw_ptr() as usize;
|
||||
let _requeue_attach = requeue_rx.attach(mainloop.loop_(), move |(buf, generation)| {
|
||||
let requeue = defer_cb
|
||||
.book
|
||||
.lock()
|
||||
.map(|mut b| b.complete(buf, generation))
|
||||
.unwrap_or(false);
|
||||
if requeue {
|
||||
// SAFETY: `complete` returned true ⇒ this buffer was withheld by exactly this hold
|
||||
// and no `remove_buffer` has freed it since (that purges the book), so the pointer
|
||||
// is a live buffer of this stream that we own (dequeued, never requeued). The
|
||||
// stream outlives this attached receiver (declared after it, dropped before it),
|
||||
// and the loop stops dispatching once `run()` returns.
|
||||
let _ = unsafe {
|
||||
pw::sys::pw_stream_queue_buffer(
|
||||
stream_ptr as *mut pw::sys::pw_stream,
|
||||
buf as *mut pw::sys::pw_buffer,
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Debug knob: offer a single fixed format (PUNKTFUNK_PW_FIXED_POD="WxH") to bisect
|
||||
// negotiation failures against a producer's exact EnumFormat (e.g. gamescope).
|
||||
let fixed_pod: Option<(u32, u32)> = std::env::var("PUNKTFUNK_PW_FIXED_POD")
|
||||
@@ -2479,4 +2699,77 @@ mod tests {
|
||||
assert_eq!(p.note_frame(), Some(0));
|
||||
assert_eq!(p.high_water, 0);
|
||||
}
|
||||
|
||||
use super::{HoldBook, HOLD_POOL_RESERVE};
|
||||
|
||||
/// The book must always leave [`HOLD_POOL_RESERVE`] buffers with the producer: an 8-pool
|
||||
/// spares 6, and the pools at or below the reserve spare NOTHING — those sessions must fall
|
||||
/// back to the immediate requeue rather than starve the compositor of render targets.
|
||||
#[test]
|
||||
fn hold_book_spends_at_most_pool_minus_reserve() {
|
||||
let mut b = HoldBook::default();
|
||||
for i in 0..6 {
|
||||
assert!(
|
||||
b.try_hold(0x1000 + i, 8).is_some(),
|
||||
"hold {i} within budget"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
b.try_hold(0x2000, 8).is_none(),
|
||||
"7th of 8 exceeds the budget"
|
||||
);
|
||||
assert!(
|
||||
HoldBook::default()
|
||||
.try_hold(0x1000, HOLD_POOL_RESERVE)
|
||||
.is_none(),
|
||||
"a pool of exactly the reserve cannot spare a buffer"
|
||||
);
|
||||
assert!(
|
||||
HoldBook::default()
|
||||
.try_hold(0x1000, HOLD_POOL_RESERVE + 1)
|
||||
.is_some(),
|
||||
"one past the reserve spares exactly one"
|
||||
);
|
||||
}
|
||||
|
||||
/// One hold ⇒ one requeue: the first `complete` releases, a duplicate release (a bug shape,
|
||||
/// but also the benign stale-message case) must NOT requeue a second time — handing the
|
||||
/// producer the same buffer twice corrupts its pool.
|
||||
#[test]
|
||||
fn hold_book_releases_exactly_once() {
|
||||
let mut b = HoldBook::default();
|
||||
let g = b.try_hold(0x1000, 8).unwrap();
|
||||
assert!(b.complete(0x1000, g), "first release requeues");
|
||||
assert!(!b.complete(0x1000, g), "second release is a no-op");
|
||||
assert!(!b.contains(0x1000));
|
||||
}
|
||||
|
||||
/// The renegotiation hazard the generation exists for: the pool is replaced (`remove_buffer`
|
||||
/// purges), a NEW buffer lands on the SAME address and is withheld, and only then does the
|
||||
/// OLD hold's release arrive. Matching by pointer alone would requeue the new tenant while
|
||||
/// its own hold is still out — the mid-encode rewrite race, reintroduced by the fix itself.
|
||||
#[test]
|
||||
fn hold_book_generation_outlives_an_address_reuse() {
|
||||
let mut b = HoldBook::default();
|
||||
let old = b.try_hold(0x1000, 8).unwrap();
|
||||
b.purge(0x1000); // remove_buffer: pool renegotiated away under the hold
|
||||
assert!(!b.complete(0x1000, old), "purged hold releases nothing");
|
||||
let new = b.try_hold(0x1000, 8).unwrap(); // new pool's buffer, same address
|
||||
assert!(
|
||||
!b.complete(0x1000, old),
|
||||
"the OLD hold cannot release the NEW tenant"
|
||||
);
|
||||
assert!(b.contains(0x1000), "new tenant still withheld");
|
||||
assert!(b.complete(0x1000, new), "its own hold releases it");
|
||||
}
|
||||
|
||||
/// A buffer already out cannot be withheld again (one requeue duty per buffer): `.process`
|
||||
/// can only re-see an address after its requeue, so a duplicate try_hold means state
|
||||
/// confusion — refuse it and let the epilogue requeue immediately.
|
||||
#[test]
|
||||
fn hold_book_refuses_a_buffer_already_out() {
|
||||
let mut b = HoldBook::default();
|
||||
b.try_hold(0x1000, 8).unwrap();
|
||||
assert!(b.try_hold(0x1000, 8).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,12 +322,14 @@ pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||
|
||||
/// PW5 stage 2: the buffer-pool depth we ASK for on the zero-copy path, as a Choice range.
|
||||
///
|
||||
/// The zero-copy path hands the SPA buffer back to the producer at `.process` return, while the
|
||||
/// encode thread still holds a dup of its dmabuf fd and has not yet imported, let alone read, the
|
||||
/// contents. Nothing bounds that window — see the `queue_raw_buffer` comment in `pipewire.rs` — so
|
||||
/// the only thing that keeps capture untorn is the producer round-robining a pool deeper than our
|
||||
/// import+encode latency. Until PW5 stage 1 nobody had ever counted what that pool was; we never
|
||||
/// even asked for a size (`build_dmabuf_buffers` set `dataType` and nothing else).
|
||||
/// The raw-passthrough arm now WITHHOLDS each published buffer from the producer until the
|
||||
/// consumer's hold drops (`DeferredRequeue` in `pipewire.rs` — the fix for the producer
|
||||
/// rewriting a buffer mid-encode), spending up to `pool - HOLD_POOL_RESERVE` buffers of this
|
||||
/// depth. A pool at the old floor of 2 has nothing to spend and falls back to the racy
|
||||
/// immediate requeue, where only the producer round-robining a pool deeper than our
|
||||
/// import+encode latency keeps capture untorn. Until PW5 stage 1 nobody had ever counted what
|
||||
/// that pool was; we never even asked for a size (`build_dmabuf_buffers` set `dataType` and
|
||||
/// nothing else).
|
||||
///
|
||||
/// A **range**, deliberately, not a fixed count: SPA intersects the consumer's and producer's
|
||||
/// Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection
|
||||
|
||||
@@ -64,7 +64,7 @@ pub(crate) fn hybrid_hook_hits() -> u64 {
|
||||
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
|
||||
// different core), so the "same-thread, no flush needed" assumption was wrong.
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn FlushInstructionCache(h: *mut c_void, base: *const c_void, size: usize) -> i32;
|
||||
fn GetCurrentProcess() -> *mut c_void;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ repository.workspace = true
|
||||
# the old main.rs. Audio is the one per-OS swap: PipeWire on Linux, WASAPI on Windows
|
||||
# (same public surface — see lib.rs).
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic", "ureq-tls"] }
|
||||
# Native Vulkan Video decode (WP-C of the native-decode program, HEVC added by M3
|
||||
# WP-2, AV1 by M7): auto's TOP rung on both desktop OSes since M9 — for every codec it
|
||||
# speaks, AV1 included — also pinnable via `PUNKTFUNK_DECODER=native-vulkan` —
|
||||
@@ -89,7 +89,7 @@ libc = "0.2"
|
||||
# with libavcodec (`pf-encode`); nothing in this crate does.
|
||||
opus = "0.3"
|
||||
|
||||
mdns-sd = "0.20"
|
||||
mdns-sd = "0.21"
|
||||
|
||||
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
|
||||
# §4.5) — pure Vulkan compute on the presenter's shared device, so it builds wherever the
|
||||
@@ -101,11 +101,19 @@ ash = { version = "0.38", optional = true }
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||
ureq = "2"
|
||||
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature: that one pulls `_ring`, which would
|
||||
# put the ring backend back into a tree that has moved to aws-lc-rs. Same spelling everywhere.
|
||||
ureq = { version = "3", default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"rustls-webpki-roots",
|
||||
"gzip",
|
||||
] }
|
||||
# Signed update-manifest fetch/verify + the install-kind ladder, shared with the host so one
|
||||
# trust rule serves both (crates/pf-update-check).
|
||||
pf-update-check = { path = "../pf-update-check" }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
# aws-lc-rs backend + PQ hybrid key exchange, matching punktfunk-core (see its Cargo.toml for
|
||||
# why every crate that names a rustls backend has to name the same one).
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "logging", "std", "tls12"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
@@ -133,16 +141,16 @@ pf-vaadec = { path = "../pf-vaadec" }
|
||||
# libva itself is dlopen'd, never linked (see `video_vaapi_native`'s module docs): the
|
||||
# container can then compile and clippy the whole rung without `libva-dev`, and a machine
|
||||
# without a VAAPI runtime gets a clean refusal instead of a packaging dependency.
|
||||
libloading = "0.8"
|
||||
libloading = "0.9"
|
||||
# The gamescope overlay watcher (`overlay_focus`): read two CARDINAL properties off a
|
||||
# gamescope root window and block on PropertyNotify. `default-features = false` keeps the
|
||||
# pure-Rust `RustConnection` — no libxcb link, so no new C dependency on any client package
|
||||
# — the same stance pf-capture and pf-vdisplay already take on this crate. No extension
|
||||
# features: root-window properties and an event mask are core X11.
|
||||
x11rb = { version = "0.13", default-features = false }
|
||||
x11rb = { version = "0.14", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
wasapi = "0.24"
|
||||
# Native D3D11VA decode (M5 of the native-decode program): the hand-declared DXVA buffer
|
||||
# layouts and the AuPlan → picparams/qmatrix/slice-control conversion that video_d3d11_native
|
||||
# submits. Windows-only because the rung is; the crate itself is cross-platform CPU code so
|
||||
@@ -166,6 +174,11 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
"handleapi",
|
||||
# RECT/HMONITOR for DXGI_OUTPUT_DESC1 (the display-HDR volume query).
|
||||
"windef",
|
||||
# HGLOBAL (clipboard.rs) + HINSTANCE (video_d3d11.rs), and the NT `HANDLE` the shared-surface
|
||||
# hand-off uses. Both headers were used without being declared — they resolved only because
|
||||
# clients/windows enables them on the same pinned rev, so this crate did not build standalone.
|
||||
"minwindef",
|
||||
"winnt",
|
||||
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES.
|
||||
"minwinbase",
|
||||
# The GlobalAlloc block the clipboard takes ownership of (clipboard.rs).
|
||||
@@ -185,7 +198,7 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
# for `video_d3d11_native::parity`, now `cfg(linux)` as well for
|
||||
# `video_vaapi_native::parity`. A DEV dependency, so no shipped binary gains anything —
|
||||
# which is also part of why the VAAPI readback cannot reach the production video path.
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
|
||||
[features]
|
||||
# PyroWave client decode ships in every default build (flatpak included; pyrowave-sys is a
|
||||
|
||||
@@ -100,12 +100,14 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||
///
|
||||
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||
/// Through `wasapi 0.23` that helper built its argument as
|
||||
/// `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the `HSTRING` was a temporary, dropped at the
|
||||
/// end of that statement, so `GetDevice` read freed memory and missed ids that are perfectly valid.
|
||||
/// `wasapi 0.24` fixed that upstream. Scanning the active collection touches only safe crate APIs,
|
||||
/// so it cannot regress the same way, and it additionally filters to ACTIVE endpoints — which is
|
||||
/// why it stays. (`punktfunk-host` routes around the same bug with raw COM instead; this crate
|
||||
/// cannot, because it pins a different `windows` revision than `wasapi` does, making the two
|
||||
/// `IMMDevice` types incompatible.)
|
||||
pub(crate) fn device_by_id(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -171,9 +170,9 @@ pub fn agent(
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let bad =
|
||||
|what: &str, e: &dyn std::fmt::Display| LibraryError::Unreachable(format!("{what}: {e}"));
|
||||
// The ring provider, explicitly — the same one core's QUIC endpoints install, so the
|
||||
// The aws-lc-rs provider, explicitly — the same one core's QUIC endpoints install, so the
|
||||
// process never mixes rustls crypto providers.
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let builder = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| bad("tls config", &e))?
|
||||
@@ -186,11 +185,15 @@ pub fn agent(
|
||||
let cfg = builder
|
||||
.with_client_auth_cert(vec![cert], key)
|
||||
.map_err(|e| bad("client auth", &e))?;
|
||||
Ok(ureq::AgentBuilder::new()
|
||||
.tls_config(Arc::new(cfg))
|
||||
.timeout_connect(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build())
|
||||
// ureq's own `TlsConfig` has no hook for a custom verifier, so the agent is built around this
|
||||
// `ClientConfig` verbatim (punktfunk-core owns that glue — see `tls::ureq_agent`).
|
||||
Ok(punktfunk_core::tls::ureq_agent::agent(
|
||||
Arc::new(cfg),
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_global(Some(Duration::from_secs(10)))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch the host's unified library. Errors are pre-classified for the UI (401/403 →
|
||||
@@ -204,8 +207,9 @@ pub fn fetch_games(
|
||||
let agent = agent(identity, pin)?;
|
||||
let url = format!("{}/api/v1/library", base_url(addr, mgmt_port));
|
||||
let body = match agent.get(&url).call() {
|
||||
Ok(resp) => resp
|
||||
.into_string()
|
||||
Ok(mut resp) => resp
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?,
|
||||
Err(e) => return Err(classify(e)),
|
||||
};
|
||||
@@ -221,18 +225,26 @@ const ART_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// a public CDN URL on a custom entry — uses ureq's default agent with normal webpki
|
||||
/// trust and no client cert (Apple's `LibraryTLSDelegate` does the same split).
|
||||
pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>, LibraryError> {
|
||||
let resp = if url.starts_with(base) {
|
||||
let mut resp = if url.starts_with(base) {
|
||||
pinned.get(url).call()
|
||||
} else {
|
||||
ureq::get(url).timeout(Duration::from_secs(10)).call()
|
||||
// ureq's default agent builds its own rustls config from the process-default provider.
|
||||
// Installed here rather than trusting the binary, since several link this crate.
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
ureq::get(url)
|
||||
.config()
|
||||
.timeout_global(Some(Duration::from_secs(10)))
|
||||
.build()
|
||||
.call()
|
||||
}
|
||||
.map_err(classify)?;
|
||||
let mut bytes = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(ART_MAX_BYTES)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))?;
|
||||
Ok(bytes)
|
||||
// `limit` replaces the old `take()` — ureq 3 caps body reads itself, and its default cap is
|
||||
// lower than the largest legitimate hero asset.
|
||||
resp.body_mut()
|
||||
.with_config()
|
||||
.limit(ART_MAX_BYTES)
|
||||
.read_to_vec()
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))
|
||||
}
|
||||
|
||||
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
|
||||
@@ -288,19 +300,15 @@ pub fn spawn_art_fetch(
|
||||
|
||||
fn classify(e: ureq::Error) -> LibraryError {
|
||||
match e {
|
||||
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
|
||||
ureq::Error::Status(code, _) => LibraryError::Http(code),
|
||||
ureq::Error::Transport(t) => {
|
||||
// A pin rejection surfaces as a TLS alert wrapped in a transport error; the
|
||||
// verifier's error kind survives in the message.
|
||||
let msg = t.to_string();
|
||||
if msg.contains("ApplicationVerificationFailure") || msg.contains("InvalidCertificate")
|
||||
{
|
||||
LibraryError::PinMismatch
|
||||
} else {
|
||||
LibraryError::Unreachable(msg)
|
||||
}
|
||||
}
|
||||
ureq::Error::StatusCode(401 | 403) => LibraryError::NotPaired,
|
||||
ureq::Error::StatusCode(code) => LibraryError::Http(code),
|
||||
// Exactly the rejection `PinVerify` raises on a fingerprint mismatch. ureq 3 carries the
|
||||
// typed `rustls::Error`, so this is a real match instead of the substring sniff the 2.x
|
||||
// `Transport(t)` string forced — which would also have fired on unrelated cert errors.
|
||||
ureq::Error::Rustls(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
)) => LibraryError::PinMismatch,
|
||||
other => LibraryError::Unreachable(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,10 +164,10 @@ fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
|
||||
.ok()?
|
||||
.reply()
|
||||
.ok()?;
|
||||
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
|
||||
// expression its temporary would outlive it.
|
||||
let id = reply.value32()?.next();
|
||||
id
|
||||
// Inline is sound since edition 2024: tail-expression temporaries now drop BEFORE the
|
||||
// block's locals, so the iterator borrowing `reply` no longer outlives it (the 2021 rule
|
||||
// forced a `let` binding here).
|
||||
reply.value32()?.next()
|
||||
}
|
||||
|
||||
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
|
||||
|
||||
@@ -920,9 +920,10 @@ fn pad_render_thread(
|
||||
let res = (|| -> anyhow::Result<()> {
|
||||
const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved
|
||||
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
// Not `get_device`: that helper resolves through a freed string — see
|
||||
// [`crate::audio::device_by_id`] (audio_wasapi.rs, mounted as `crate::audio` on
|
||||
// Windows by lib.rs's `#[path]` swap — there is no `audio_wasapi` module name).
|
||||
// Not `get_device`: that helper resolved through a freed string through wasapi 0.23, and
|
||||
// this path additionally wants the ACTIVE-only filter — see [`crate::audio::device_by_id`]
|
||||
// (audio_wasapi.rs, mounted as `crate::audio` on Windows by lib.rs's `#[path]` swap —
|
||||
// there is no `audio_wasapi` module name).
|
||||
let device = crate::audio::device_by_id(&enumerator, &Direction::Render, endpoint_id)
|
||||
.map_err(|e| anyhow!("correlated endpoint not found: {e:#}"))?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
|
||||
@@ -104,6 +104,19 @@ pub struct SessionParams {
|
||||
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
|
||||
/// re-reading any store (design/client-settings-profiles.md §5.2).
|
||||
pub profile: Option<String>,
|
||||
/// The stats-overlay tier THIS launch resolved to — the globals, or the profile bound to
|
||||
/// this host. Presentation-tier, like [`profile`](Self::profile): the session controller
|
||||
/// never reads it, it rides along so the presenter can adopt it when a browse-mode launch
|
||||
/// starts.
|
||||
///
|
||||
/// That adoption is the whole point. The console (Gaming Mode / Decky) builds its window
|
||||
/// and its run loop ONCE and streams many sessions through them, so a tier taken only from
|
||||
/// the loop's start-of-process options could never change again — a user picking a tier in
|
||||
/// the console's settings screen saw the row move, the file updated, and every stream keep
|
||||
/// the old overlay until the app was restarted. Carrying it per launch is what lets the
|
||||
/// choice land on the next stream, and it makes a profile's `stats_verbosity` reach the
|
||||
/// console too. The in-stream cycle chord still wins for the rest of the stream it moved.
|
||||
pub stats_verbosity: crate::trust::StatsVerbosity,
|
||||
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
|
||||
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
|
||||
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
|
||||
@@ -885,7 +898,12 @@ fn pump(
|
||||
Some(exp) => {
|
||||
if let Some(gap) = index_gap(exp, frame.frame_index) {
|
||||
let now = Instant::now();
|
||||
gate.arm(now);
|
||||
// Credited arm: the reassembler books these same lost frames into
|
||||
// `frames_dropped` up to ~120 ms from now; the credit keeps that
|
||||
// delayed climb from re-freezing a stream the RFI anchor healed in
|
||||
// between (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
gate.arm_expecting_drops(now, u64::from(gap));
|
||||
next_expected_index = Some(frame.frame_index.wrapping_add(1));
|
||||
// The gap carries the PRECISE lost range — [first missing, newest
|
||||
// received - 1] — so this is the one recovery signal that can drive true
|
||||
|
||||
@@ -2241,7 +2241,7 @@ mod parity {
|
||||
// `desc.Height` rows at `RowPitch` and the chroma plane follows at byte
|
||||
// offset `RowPitch * desc.Height`, so `total` below is exactly the mapped
|
||||
// extent and every sub-slice read is inside it. `Unmap` pairs the `Map`.
|
||||
let out = unsafe {
|
||||
unsafe {
|
||||
let src: ID3D11Resource = pool.cast().expect("pool -> resource");
|
||||
let dst: ID3D11Resource = staging.cast().expect("staging -> resource");
|
||||
self.ctx
|
||||
@@ -2268,8 +2268,7 @@ mod parity {
|
||||
}
|
||||
self.ctx.Unmap(&staging, 0);
|
||||
out
|
||||
};
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[package]
|
||||
name = "pf-clipboard"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator."
|
||||
@@ -18,7 +18,15 @@ publish = false
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
quinn = "0.11"
|
||||
# Backend features mirror punktfunk-core's quinn exactly — quinn's default `rustls-ring` would
|
||||
# drag a second crypto stack into every build that links this crate.
|
||||
quinn = { version = "0.11", default-features = false, features = [
|
||||
"log",
|
||||
"platform-verifier",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
"bloom",
|
||||
] }
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
|
||||
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
|
||||
|
||||
@@ -12,13 +12,34 @@ repository.workspace = true
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
pf-presenter = { path = "../pf-presenter" }
|
||||
# MenuEvent/MenuPulse (the gamepad service's menu mode drives the library).
|
||||
pf-client-core = { path = "../pf-client-core" }
|
||||
# `default-features = false` like every other consumer (pf-presenter, cli, session, clients/windows):
|
||||
# pf-client-core's default is `pyrowave`, which compiles the vendored PyroWave C++ — fatal on
|
||||
# Windows ARM64. Whether that backend is on is the session binary's call (it forwards a `pyrowave`
|
||||
# feature); this crate needs none of it, and taking defaults here quietly turned it on.
|
||||
pf-client-core = { path = "../pf-client-core", default-features = false }
|
||||
|
||||
# Skia on the presenter's VkDevice (`vulkan`); `textlayout` = skparagraph/harfbuzz for
|
||||
# the typography the console library needs (~15 MB stripped, prebuilt binaries exist for
|
||||
# this feature set on x86_64-unknown-linux-gnu AND x86_64-pc-windows-msvc — a source
|
||||
# build is never triggered on either).
|
||||
skia-safe = { version = "0.87", features = ["vulkan", "textlayout"] }
|
||||
#
|
||||
# The prebuilt-binary claim is the whole reason this dep is affordable, so re-verify it on
|
||||
# EVERY bump: the build log must say `DOWNLOAD AND INSTALL SUCCEEDED`. skia-bindings does not
|
||||
# fail when no matching asset exists — it silently falls back to a gn/ninja build of Skia from
|
||||
# source, which turns a 2-minute CI leg into a multi-hour one. Verified at 0.99.0, both targets:
|
||||
# skia-binaries-a25a0fdb7d90429aa2d1-<target>-jpegd-jpege-pdf-textlayout-vulkan.tar.gz
|
||||
# ⚠ The asset name CHANGED across this bump — at 0.87 it was `<target>-pdf-textlayout-vulkan`,
|
||||
# because `jpeg` was not yet in skia-safe's DEFAULT feature set (0.87: binary-cache, embed-icudtl,
|
||||
# pdf; 0.99: + jpeg). We take defaults, so the JPEG codecs came along with the bump. That is a
|
||||
# feature here rather than bloat: `screens/library.rs` feeds host poster art straight to
|
||||
# `Image::from_encoded`, which silently returned `None` for JPEG posters before.
|
||||
#
|
||||
# 🛑 BUMPING THIS LINE IS ONLY HALF THE BUMP. packaging/flatpak/io.unom.Punktfunk.yml pins the
|
||||
# archive above by URL + sha256 BY HAND (the offline sandbox can't fetch it) and nothing derives
|
||||
# that pin from this file. Leave it stale and the flatpak leg unpacks the OLD archive's
|
||||
# pre-generated bindings.rs under the NEW crate, failing with `no variant, associated function,
|
||||
# or constant named 'Default' found for enum SkPathFillType`. That is exactly how #193 shipped.
|
||||
skia-safe = { version = "0.99", features = ["vulkan", "textlayout"] }
|
||||
ash = { version = "0.38", features = ["loaded"] }
|
||||
|
||||
anyhow = "1"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect};
|
||||
use skia_safe::{Canvas, Paint, PathBuilder, Point, RRect, Rect};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GlyphStyle {
|
||||
@@ -294,12 +294,12 @@ fn draw_glyph(
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let (cx, cyf) = ((x + r) as f32, cy as f32);
|
||||
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
|
||||
let mut up = Path::new();
|
||||
let mut up = PathBuilder::new();
|
||||
up.move_to((cx, cyf - th));
|
||||
up.line_to((cx - tw, cyf + th));
|
||||
up.line_to((cx + tw, cyf + th));
|
||||
up.close();
|
||||
canvas.draw_path(&up, &Paint::new(fg(0.85), None));
|
||||
canvas.draw_path(&up.detach(), &Paint::new(fg(0.85), None));
|
||||
}
|
||||
Resolved::Adjust => {
|
||||
// ◀ ▶ — two small solid triangles.
|
||||
@@ -308,18 +308,18 @@ fn draw_glyph(
|
||||
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
|
||||
let gap = (2.6 * k) as f32;
|
||||
let paint = Paint::new(fg(0.85), None);
|
||||
let mut left = Path::new();
|
||||
let mut left = PathBuilder::new();
|
||||
left.move_to((cx - gap, cyf - th));
|
||||
left.line_to((cx - gap - tw, cyf));
|
||||
left.line_to((cx - gap, cyf + th));
|
||||
left.close();
|
||||
canvas.draw_path(&left, &paint);
|
||||
let mut right = Path::new();
|
||||
canvas.draw_path(&left.detach(), &paint);
|
||||
let mut right = PathBuilder::new();
|
||||
right.move_to((cx + gap, cyf - th));
|
||||
right.line_to((cx + gap + tw, cyf));
|
||||
right.line_to((cx + gap, cyf + th));
|
||||
right.close();
|
||||
canvas.draw_path(&right, &paint);
|
||||
canvas.draw_path(&right.detach(), &paint);
|
||||
}
|
||||
Resolved::Key(text) => {
|
||||
let w = keycap_w(fonts, text, k);
|
||||
@@ -377,12 +377,12 @@ fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32
|
||||
}
|
||||
Face::Y => {
|
||||
// △
|
||||
let mut tri = Path::new();
|
||||
let mut tri = PathBuilder::new();
|
||||
tri.move_to((cx, cy - r * 1.2));
|
||||
tri.line_to((cx + r * 1.15, cy + r * 0.85));
|
||||
tri.line_to((cx - r * 1.15, cy + r * 0.85));
|
||||
tri.close();
|
||||
canvas.draw_path(&tri, &p);
|
||||
canvas.draw_path(&tri.detach(), &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect};
|
||||
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, PathBuilder, Point, RRect, Rect};
|
||||
|
||||
const TILE_W: f64 = 340.0;
|
||||
const TILE_H: f64 = 224.0;
|
||||
@@ -526,18 +526,20 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
|
||||
let rr = RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32);
|
||||
if filled {
|
||||
let mut p = Paint::default();
|
||||
p.set_shader(skia_safe::gradient_shader::linear(
|
||||
let colors = [accent(1.0), accent(0.68)];
|
||||
p.set_shader(skia_safe::gradient::shaders::linear_gradient(
|
||||
(
|
||||
Point::new(badge.left, badge.top),
|
||||
Point::new(badge.left, badge.bottom),
|
||||
),
|
||||
skia_safe::gradient_shader::GradientShaderColors::Colors(&[
|
||||
accent(1.0).to_color(),
|
||||
accent(0.68).to_color(),
|
||||
]),
|
||||
None,
|
||||
skia_safe::TileMode::Clamp,
|
||||
None,
|
||||
&skia_safe::gradient::Gradient::new(
|
||||
skia_safe::gradient::Colors::new_evenly_spaced(
|
||||
&colors,
|
||||
skia_safe::TileMode::Clamp,
|
||||
None,
|
||||
),
|
||||
skia_safe::gradient::Interpolation::default(),
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(rr, &p);
|
||||
@@ -586,7 +588,7 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.6 * k) as f32);
|
||||
p.set_anti_alias(true);
|
||||
let mut shackle = Path::new();
|
||||
let mut shackle = PathBuilder::new();
|
||||
let (cx, r) = (x + body_w / 2.0, 3.2 * k);
|
||||
shackle.move_to(((cx - r) as f32, body_top as f32));
|
||||
shackle.arc_to(
|
||||
@@ -600,7 +602,7 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
|
||||
180.0,
|
||||
false,
|
||||
);
|
||||
canvas.draw_path(&shackle, &p);
|
||||
canvas.draw_path(&shackle.detach(), &p);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -382,13 +382,13 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
return match msg {
|
||||
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1054,12 +1054,16 @@ mod tests {
|
||||
fn fake_home() {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
let dir = HOME.get_or_init(|| {
|
||||
HOME.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("pf-settings-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
|
||||
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
|
||||
// set after the closure ran on EVERY call, so two parallel tests could race the
|
||||
// write; setting once under the OnceLock is what makes this sound.)
|
||||
unsafe { std::env::set_var("HOME", &dir) };
|
||||
dir
|
||||
});
|
||||
std::env::set_var("HOME", dir);
|
||||
}
|
||||
|
||||
/// Render the screen once so its strip and list carry real geometry, then hand back a
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::anim::{approach, ease_out_cubic};
|
||||
use crate::glyphs::{hint_bar, Hint, HintKey};
|
||||
use crate::theme::{fg, Fonts, PanelStroke, W};
|
||||
use skia_safe::{gradient_shader, Canvas, Paint, Point, Rect, TileMode};
|
||||
use skia_safe::{gradient, Canvas, Paint, Point, Rect, TileMode};
|
||||
|
||||
use super::{Shell, BOTTOM_BAND};
|
||||
|
||||
@@ -170,16 +170,16 @@ impl Shell {
|
||||
// A soft pool of shade under the centre seats the text against a bright field —
|
||||
// dark on a dark palette, light on a pale one, so it always separates.
|
||||
let mut vignette = Paint::default();
|
||||
vignette.set_shader(gradient_shader::radial(
|
||||
Point::new(cx as f32, (h / 2.0) as f32),
|
||||
(w.max(h) * 0.42) as f32,
|
||||
gradient_shader::GradientShaderColors::Colors(&[
|
||||
crate::theme::shade(0.5).to_color(),
|
||||
crate::theme::shade(0.0).to_color(),
|
||||
]),
|
||||
None,
|
||||
TileMode::Clamp,
|
||||
None,
|
||||
let shades = [crate::theme::shade(0.5), crate::theme::shade(0.0)];
|
||||
vignette.set_shader(gradient::shaders::radial_gradient(
|
||||
(
|
||||
Point::new(cx as f32, (h / 2.0) as f32),
|
||||
(w.max(h) * 0.42) as f32,
|
||||
),
|
||||
&gradient::Gradient::new(
|
||||
gradient::Colors::new_evenly_spaced(&shades, TileMode::Clamp, None),
|
||||
gradient::Interpolation::default(),
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette);
|
||||
|
||||
@@ -49,13 +49,16 @@ fn motion_matches_the_shared_vectors() {
|
||||
fn fake_home() {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
let dir = HOME.get_or_init(|| {
|
||||
HOME.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("HOME", &dir);
|
||||
dir.clone()
|
||||
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
|
||||
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
|
||||
// re-set after the closure ran on EVERY call, so two parallel tests could race the
|
||||
// write; setting once under the OnceLock is what makes this sound.)
|
||||
unsafe { std::env::set_var("HOME", &dir) };
|
||||
dir
|
||||
});
|
||||
std::env::set_var("HOME", dir);
|
||||
}
|
||||
|
||||
fn hosts() -> Vec<HostRow> {
|
||||
|
||||
@@ -236,21 +236,25 @@ impl Overlay for SkiaOverlay {
|
||||
}
|
||||
}
|
||||
};
|
||||
let backend_builder = skvk::BackendContext::new_builder(
|
||||
shared.instance.handle().as_raw() as _,
|
||||
shared.physical_device.as_raw() as _,
|
||||
shared.device.handle().as_raw() as _,
|
||||
(
|
||||
shared.queue.as_raw() as _,
|
||||
shared.queue_family_index as usize,
|
||||
),
|
||||
&get_proc,
|
||||
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, so it caps entry-point
|
||||
// validation at whatever `vkEnumerateInstanceVersion()` reports — byte-for-byte what
|
||||
// the (now removed) `BackendContext::new` did. The presenter owns the instance and its
|
||||
// `VkApplicationInfo`, so pinning a version here would just duplicate its choice.
|
||||
None,
|
||||
);
|
||||
// SAFETY: the instance/physical-device/device handles come from `shared`, which owns them
|
||||
// and outlives this backend context, and `get_proc` above resolves through those same
|
||||
// handles. Skia stores them but does not take ownership — teardown stays ours.
|
||||
let backend = unsafe {
|
||||
skvk::BackendContext::new(
|
||||
shared.instance.handle().as_raw() as _,
|
||||
shared.physical_device.as_raw() as _,
|
||||
shared.device.handle().as_raw() as _,
|
||||
(
|
||||
shared.queue.as_raw() as _,
|
||||
shared.queue_family_index as usize,
|
||||
),
|
||||
&get_proc,
|
||||
)
|
||||
};
|
||||
let backend = unsafe { backend_builder.build() };
|
||||
let mut context = gpu::direct_contexts::make_vulkan(&backend, None)
|
||||
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
|
||||
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
|
||||
|
||||
@@ -10,8 +10,8 @@ use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::{
|
||||
gradient_shader, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect,
|
||||
Point, RRect, Rect, TileMode, Typeface,
|
||||
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
|
||||
RRect, Rect, TileMode, Typeface,
|
||||
};
|
||||
|
||||
// --- Ink ----------------------------------------------------------------------------------
|
||||
@@ -166,18 +166,16 @@ pub(crate) fn panel(
|
||||
sp.set_color4f(accent(alpha), None);
|
||||
}
|
||||
PanelStroke::Gradient | PanelStroke::GradientDashed => {
|
||||
sp.set_shader(gradient_shader::linear(
|
||||
let colors = [fg(0.22), fg(0.04)];
|
||||
sp.set_shader(gradient::shaders::linear_gradient(
|
||||
(
|
||||
Point::new(rect.left, rect.top),
|
||||
Point::new(rect.left, rect.bottom),
|
||||
),
|
||||
gradient_shader::GradientShaderColors::Colors(&[
|
||||
fg(0.22).to_color(),
|
||||
fg(0.04).to_color(),
|
||||
]),
|
||||
None,
|
||||
TileMode::Clamp,
|
||||
None,
|
||||
&gradient::Gradient::new(
|
||||
gradient::Colors::new_evenly_spaced(&colors, TileMode::Clamp, None),
|
||||
gradient::Interpolation::default(),
|
||||
),
|
||||
None,
|
||||
));
|
||||
if matches!(stroke, PanelStroke::GradientDashed) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::library::{BUMP_C, BUMP_K};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Paint, Path, RRect, Rect};
|
||||
use skia_safe::{Canvas, Paint, PathBuilder, RRect, Rect};
|
||||
|
||||
// --- Menu list -----------------------------------------------------------------------------
|
||||
|
||||
@@ -479,11 +479,11 @@ fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
|
||||
p.set_stroke_width((1.8 * r / 4.0) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_anti_alias(true);
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
|
||||
path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
|
||||
path.line_to(((x - dir * r / 2.0) as f32, (cy + r) as f32));
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
}
|
||||
|
||||
// --- On-screen keyboard ----------------------------------------------------------------------
|
||||
@@ -785,12 +785,12 @@ fn draw_space_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Co
|
||||
// ⎵ — an underline bracket.
|
||||
let (w, h) = (16.0 * k, 5.0 * k);
|
||||
let p = stroke_paint(ink, (1.6 * k) as f32);
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((cx - w / 2.0) as f32, (cy - h / 2.0) as f32));
|
||||
path.line_to(((cx - w / 2.0) as f32, (cy + h / 2.0) as f32));
|
||||
path.line_to(((cx + w / 2.0) as f32, (cy + h / 2.0) as f32));
|
||||
path.line_to(((cx + w / 2.0) as f32, (cy - h / 2.0) as f32));
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
}
|
||||
|
||||
fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
|
||||
@@ -799,14 +799,14 @@ fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe
|
||||
let nose = 6.0 * k;
|
||||
let p = stroke_paint(ink, (1.6 * k) as f32);
|
||||
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((l + nose) as f32, t as f32));
|
||||
path.line_to((r as f32, t as f32));
|
||||
path.line_to((r as f32, b as f32));
|
||||
path.line_to(((l + nose) as f32, b as f32));
|
||||
path.line_to((l as f32, cy as f32));
|
||||
path.close();
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
let (xc, xr) = (cx + nose / 2.0, 2.6 * k);
|
||||
canvas.draw_line(
|
||||
((xc - xr) as f32, (cy - xr) as f32),
|
||||
@@ -823,11 +823,11 @@ fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe
|
||||
fn draw_check(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
|
||||
let p = stroke_paint(ink, (1.8 * k) as f32);
|
||||
let r = 5.0 * k;
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((cx - r) as f32, cy as f32));
|
||||
path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32));
|
||||
path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32));
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
[package]
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
|
||||
publish = false
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[package]
|
||||
name = "pf-encode"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host video encode: NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264 backends behind one Encoder trait."
|
||||
@@ -54,7 +54,7 @@ libc = "0.2"
|
||||
# the dep stays unconditional to mirror the host's Linux target — unused-but-declared is harmless).
|
||||
ash = "0.38"
|
||||
# `libnvidia-encode.so.1` is dlopen'd at runtime for the direct-SDK NVENC/CUDA backend.
|
||||
libloading = "0.8"
|
||||
libloading = "0.9"
|
||||
# Direct-SDK NVENC (raw `sys::nvEncodeAPI` types; entry points resolved at runtime). `ci-check` =
|
||||
# vendored bindings, no CUDA toolkit at build.
|
||||
nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true }
|
||||
@@ -67,7 +67,7 @@ nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional =
|
||||
# AMD (AMF) + Intel (QSV) hardware encode via libavcodec (behind `amf-qsv`; link-imports FFmpeg).
|
||||
ffmpeg-next = { version = "9", optional = true }
|
||||
# `libnvidia-encode`/`nvEncodeAPI64.dll` resolved at runtime; the NVENC status→cause table dlopen.
|
||||
libloading = "0.8"
|
||||
libloading = "0.9"
|
||||
# Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`.
|
||||
libvpl-sys = { path = "../libvpl-sys", optional = true }
|
||||
# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only under
|
||||
@@ -84,9 +84,9 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
# D3DKMTSetProcessSchedulingPriorityClass — raise the host's WDDM GPU scheduling priority
|
||||
# above a running game so PyroWave's compute-shader encode isn't starved (enc/windows/pyrowave.rs).
|
||||
"Wdk_Graphics_Direct3D",
|
||||
# ("Wdk_Graphics_Direct3D" used to be here for D3DKMTSetProcessSchedulingPriorityClass. That
|
||||
# call lives in pf-frame's dxgi.rs and is resolved via GetProcAddress on gdi32 because
|
||||
# windows-rs has no stable binding for it — so nothing in this crate ever used the feature.)
|
||||
] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -1652,7 +1652,7 @@ impl NvencCudaEncoder {
|
||||
return Err(nvenc_status::call_err(
|
||||
"register_resource (CUDADEVICEPTR)",
|
||||
e,
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
self.ring.push(RingSlot {
|
||||
@@ -2779,6 +2779,20 @@ mod tests {
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use pf_zerocopy::cuda::DeviceBuffer;
|
||||
|
||||
/// Env knob for the `#[ignore]`d hardware spikes, which every caller's doc says to run ALONE
|
||||
/// with `--test-threads=1` (they mutate process env and own the GPU).
|
||||
fn set_env(key: &str, val: impl AsRef<std::ffi::OsStr>) {
|
||||
// SAFETY: only reached from the manually-run `--test-threads=1` hardware tests, so no
|
||||
// other thread exists in this process to read or write the environment concurrently.
|
||||
unsafe { std::env::set_var(key, val) };
|
||||
}
|
||||
|
||||
/// [`set_env`]'s companion; the same single-threaded-run contract.
|
||||
fn remove_env(key: &str) {
|
||||
// SAFETY: as `set_env` — single-threaded manual test run, no concurrent env access.
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
|
||||
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
|
||||
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
|
||||
@@ -3315,8 +3329,8 @@ mod tests {
|
||||
|
||||
// Isolate the split variable: sub-frame off, and open explicitly split-DISABLED so the
|
||||
// switch below is a real change rather than a no-op.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -3428,8 +3442,8 @@ mod tests {
|
||||
}
|
||||
|
||||
enc.flush().ok();
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **spike S1b**, the other half of S1: an in-place `splitEncodeMode` change that
|
||||
@@ -3468,7 +3482,7 @@ mod tests {
|
||||
const SETTLE: u32 = 16;
|
||||
let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
|
||||
// Separate buffers rotated per frame, so identical content can't let the encoder
|
||||
// skip-code everything and erase the difference we are trying to measure.
|
||||
@@ -3482,7 +3496,7 @@ mod tests {
|
||||
|
||||
// Returns (early-half p50 µs, late-half p50 µs, median bytes/AU).
|
||||
let run_leg = |open_split: &str, switch_to: Option<u32>| -> (u128, u128, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", open_split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", open_split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -3554,9 +3568,15 @@ mod tests {
|
||||
|
||||
println!("S1b @ {W}x{H}@60 HEVC 8-bit, {} Mbps CBR:", BPS / 1_000_000);
|
||||
println!(" (early = first half of the measured window, late = second half)");
|
||||
println!(" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU");
|
||||
println!(" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU");
|
||||
println!(" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU");
|
||||
println!(
|
||||
" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU"
|
||||
);
|
||||
println!(
|
||||
" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU"
|
||||
);
|
||||
println!(
|
||||
" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU"
|
||||
);
|
||||
if c_early > c_late + c_late / 8 {
|
||||
println!(
|
||||
" ⇒ leg C SETTLES ({c_early} → {c_late} us): the in-place switch is not \
|
||||
@@ -3583,8 +3603,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
let _ = (a_bytes, b_bytes, c_bytes);
|
||||
}
|
||||
|
||||
@@ -3618,8 +3638,8 @@ mod tests {
|
||||
|
||||
// Open split-DISABLED, and leave sub-frame at its Linux default (ON where the GPU
|
||||
// advertises SUBFRAME_READBACK) — that is the fleet shape the arbitration starts from.
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -3664,7 +3684,7 @@ mod tests {
|
||||
"S1c SKIPPED: sub-frame is off at open on this GPU/driver, so there is no pair to \
|
||||
flip — the arbitration reduces to S1a's plain split switch here."
|
||||
);
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3715,7 +3735,7 @@ mod tests {
|
||||
}
|
||||
|
||||
enc.flush().ok();
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **the D5 confirm** (design §2 defect D5), the one claim in that list that was
|
||||
@@ -3755,12 +3775,12 @@ mod tests {
|
||||
// produced a spurious "D5 REFUTED" on the first run of this test.
|
||||
let run = |split: Option<&str>, subframe: Option<&str>| -> (u128, bool) {
|
||||
match split {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"),
|
||||
Some(v) => set_env("PUNKTFUNK_SPLIT_ENCODE", v),
|
||||
None => remove_env("PUNKTFUNK_SPLIT_ENCODE"),
|
||||
}
|
||||
match subframe {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"),
|
||||
Some(v) => set_env("PUNKTFUNK_NVENC_SUBFRAME", v),
|
||||
None => remove_env("PUNKTFUNK_NVENC_SUBFRAME"),
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
@@ -3841,8 +3861,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **what is the real split ceiling on this GPU?** Feeds WP1.1: we want to use
|
||||
@@ -3870,13 +3890,13 @@ mod tests {
|
||||
const WARMUP: u32 = 8;
|
||||
const MEASURED: u32 = 24;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
|
||||
|
||||
// → (requested mode, mode actually opened, p50 µs, engines the driver reports)
|
||||
let run = |split: &str| -> (u32, u128, i32) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -3940,11 +3960,7 @@ mod tests {
|
||||
};
|
||||
println!(
|
||||
" req {label} → opened_mode={opened:<2} {} {us:>6} us/frame{vs} [engines={engines}]",
|
||||
if honoured {
|
||||
"HONOURED"
|
||||
} else {
|
||||
"FELL BACK"
|
||||
}
|
||||
if honoured { "HONOURED" } else { "FELL BACK" }
|
||||
);
|
||||
}
|
||||
println!(
|
||||
@@ -3952,8 +3968,8 @@ mod tests {
|
||||
HONOURED but no faster than DISABLE was accepted and did nothing."
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — the live split arbitration end to end (WP3). Opens a 4K session that the
|
||||
@@ -3975,9 +3991,9 @@ mod tests {
|
||||
const H: u32 = 2160;
|
||||
let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
set_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
|
||||
@@ -4045,8 +4061,8 @@ mod tests {
|
||||
max_forced_split_mode(enc_engines)
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
// The verdict cache is process-global: leaving this session's result in it would steer
|
||||
// every later test that opens the same config with the split env unset (the D5 legs do
|
||||
// exactly that).
|
||||
@@ -4086,14 +4102,14 @@ mod tests {
|
||||
})
|
||||
.unwrap_or((3840, 2160, 60));
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// 10-bit input: the packed 2:10:10:10 PQ path is how a Main10 session is actually fed here
|
||||
// (`bit_depth`/`hdr` are DERIVED from the input format, never trusted from the args).
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| rgb10_frame(w, h, i)).collect();
|
||||
|
||||
let run = |split: &str| -> (u128, u8, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::X2Rgb10,
|
||||
@@ -4157,8 +4173,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **THE BITS/FRAME CURVE**, the measurement this whole programme has been blind
|
||||
@@ -4190,7 +4206,7 @@ mod tests {
|
||||
})
|
||||
.unwrap_or((3840, 2160, 60));
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// Sweep CONTENT DETAIL, not nominal bitrate. Pure noise is incompressible, so a low
|
||||
// bitrate target simply overshoots (measured: 719 KB/AU against a 104 KB quota) and every
|
||||
@@ -4207,7 +4223,7 @@ mod tests {
|
||||
let frames: Vec<CapturedFrame> =
|
||||
(0..4).map(|i| noise_nv12_frame(w, h, i, block)).collect();
|
||||
let run = |split: &str| -> (u128, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -4250,8 +4266,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR).
|
||||
@@ -4658,12 +4674,12 @@ mod tests {
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||
set_env("PUNKTFUNK_NVENC_SLICES", "4");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||
let _guard = EnvGuard;
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
@@ -4770,8 +4786,8 @@ mod tests {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// Defaults under test — make sure another test's knobs aren't leaking in.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -4866,8 +4882,8 @@ mod tests {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// The ceiling under test is the negotiated one, not the operator override.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
@@ -4911,16 +4927,16 @@ mod tests {
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
let _guard = EnvGuard;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
|
||||
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
set_env("PUNKTFUNK_NVENC_SLICES", "1");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
@@ -4943,8 +4959,8 @@ mod tests {
|
||||
|
||||
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
|
||||
// disarms and the plain poll path carries the session.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
|
||||
@@ -2803,6 +2803,7 @@ mod tests {
|
||||
plane1: None,
|
||||
offset: 0,
|
||||
stride: 64 * 4,
|
||||
hold: None,
|
||||
}
|
||||
};
|
||||
let fd_count = || std::fs::read_dir("/proc/self/fd").expect("procfs").count();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user