Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1679275272 | ||
|
|
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 | ||
|
|
6a506a8fa9 | ||
|
|
712ee935d6 | ||
|
|
a2aa0a5f97 | ||
|
|
d6b9862f1e | ||
|
|
f4e39a442b | ||
|
|
a0577cb86e | ||
|
|
2a62fe7857 | ||
|
|
66ba61b12c | ||
|
|
5002849737 | ||
|
|
9a59504ba4 | ||
|
|
e8c306b9c0 | ||
|
|
c3b57438e1 | ||
|
|
e20b614059 | ||
|
|
6eb89b3f34 | ||
|
|
bbd26ea82c | ||
|
|
549fdf238b | ||
|
|
3ea411fa39 | ||
|
|
3b2fcd076d | ||
|
|
d67ab9ede4 | ||
|
|
abec2a1457 | ||
|
|
5f097d530d | ||
|
|
2bfd1cd2d5 | ||
|
|
dfebb9dfbb | ||
|
|
f675b3710e | ||
|
|
23fa03b051 | ||
|
|
6e4638dab5 | ||
|
|
0c2ac333ae |
+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
|
||||
|
||||
+285
-1
@@ -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,10 +14,21 @@
|
||||
# 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.)
|
||||
# * miri → NON-BLOCKING interpretation of the few FFI-free leaf crates, one of them
|
||||
# cross-compiled to MSVC layout. Not a supply-chain scan; it lives here because
|
||||
# audit.yml already has exactly the shape it needs (weekly cron,
|
||||
# workflow_dispatch, the rust-ci container, the same cache pattern) and because
|
||||
# ci.yml runs on every push against a fleet where 37 of 46 jobs contend for
|
||||
# ubuntu-24.04. See the `miri:` job below for what it does and does not buy.
|
||||
# * c-abi-asan → NON-BLOCKING ASAN+LSAN run of the C ABI harness (tests/c/run.sh under
|
||||
# PF_SAN=address): both sides of the abi.rs boundary instrumented at once, and
|
||||
# the only automated check on its Box::into_raw/from_raw leak contract. Same
|
||||
# here-not-ci.yml reasoning as miri — plus -Zbuild-std defeats sccache, so it
|
||||
# must not ride the per-push leg.
|
||||
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
|
||||
# change, and on demand.
|
||||
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
|
||||
@@ -36,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'
|
||||
@@ -44,6 +61,13 @@ on:
|
||||
- 'about.toml'
|
||||
- '.gitea/workflows/audit.yml'
|
||||
workflow_dispatch:
|
||||
# NOTE on the `paths:` list above and the `miri:` job: `crates/pf-driver-proto/**` is deliberately
|
||||
# NOT listed, even though that crate is what the Miri job exists to watch. `paths:` is a
|
||||
# WORKFLOW-level filter — adding it would fire all six jobs (three bun trees, pnpm, cargo-audit,
|
||||
# the license gate) on every driver-proto edit, onto a fleet where 37 of 46 jobs contend for
|
||||
# ubuntu-24.04, to run one 2-minute job. Weekly cron + workflow_dispatch is the day-one cadence;
|
||||
# revisit once the job has a green history, and if you do, prefer moving miri to its own workflow
|
||||
# file over widening this filter.
|
||||
|
||||
jobs:
|
||||
cargo-audit:
|
||||
@@ -65,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:
|
||||
@@ -177,3 +209,255 @@ jobs:
|
||||
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
|
||||
cargo about generate about.hbs --fail -o /dev/null
|
||||
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
|
||||
|
||||
# ── Miri ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# WHAT THIS BUYS, precisely — one thing, and it is worth having:
|
||||
# It interprets `pf-driver-proto` CROSS-COMPILED TO `x86_64-pc-windows-msvc`, on a Linux
|
||||
# runner, with no Windows box anywhere in the loop. That crate is `#![forbid(unsafe_code)]`
|
||||
# and is path-dep'd by BOTH the main workspace and the driver workspace, so it is the layout
|
||||
# oracle for every frame and IOCTL crossing that boundary — and drift there is silent
|
||||
# corruption, not a compile error. Nothing else in CI checks it at MSVC layout.
|
||||
# On the first run ever performed against this repo it found a real defect: a layout test
|
||||
# reading an align-8 struct out of an align-1 stack buffer, which had passed on every machine
|
||||
# and every CI leg since it was written because a stack `[u8; 40]` usually lands 8-aligned.
|
||||
#
|
||||
# WHAT IT DOES NOT BUY — do not let anyone report this as unsafe coverage, and do not publish a
|
||||
# "Miri coverage" percentage; it would be noise. Miri can execute on the order of 2% of the
|
||||
# host's unsafe. It cannot run ash, windows-rs, ffmpeg, CUDA or the WDK, and in those crates
|
||||
# the unsafe *is* the foreign call, so there is nothing for an interpreter to execute. This
|
||||
# job is a targeted instrument for three leaf surfaces, not a safety net.
|
||||
#
|
||||
# NON-BLOCKING, deliberately, and via a step-level `||` — NOT job-level `continue-on-error`,
|
||||
# which act_runner does not reliably honor (same reasoning as docs-site-audit above; a red job
|
||||
# here would take the whole run red). Flip to blocking only after several weeks of green
|
||||
# establish the nightly-drift rate.
|
||||
#
|
||||
# Do NOT add crates here because they merely compile under Miri. Add them because they contain
|
||||
# pure-Rust unsafe or a layout contract worth interpreting. Explicitly excluded:
|
||||
# * pf-bitstream — its compile did not finish in 27 min at 2.1 GB RSS, and it is
|
||||
# `forbid(unsafe_code)`, so there is nothing to find. Do not re-add it.
|
||||
# * pf-update-check — ring; every FFI crate — dies on the first foreign call. Structural.
|
||||
# * punktfunk-core in bulk — `-- fec packet crypto` selects 63 tests and was killed at a
|
||||
# 25-minute cap with not one test reported complete. Only the narrow
|
||||
# `fec::gf8` selection below is affordable, and it was timed before it
|
||||
# was committed. Do not widen this filter without timing the result.
|
||||
#
|
||||
# MEASURED, not estimated — 192.168.1.25 (Ubuntu, 8 cores), on the DATED toolchain this job
|
||||
# actually installs, with a COLD target dir and a COLD sysroot cache (so each step's figure
|
||||
# includes building the Miri sysroot it needs) and a warm cargo registry. Every step below has
|
||||
# been run start to finish; nothing here is extrapolated:
|
||||
# step A 21 + 12 + 4 pass 43 s
|
||||
# step B 21 pass 26 s
|
||||
# step C 2 pass 63 s
|
||||
# TOTAL 132 s cold. Interpretation itself is ~10 s of that; the rest is compiling, plus ~38 s
|
||||
# of one-time sysroot builds (21 s host + 17 s MSVC) that the cache below then carries.
|
||||
# Warm, the three steps are ~6 s / ~3 s / ~10 s. `timeout-minutes: 30` is therefore vast
|
||||
# headroom, kept deliberately so a first fully-uncached run — which additionally downloads a
|
||||
# ~400 MB toolchain and the registry — cannot trip it.
|
||||
# If you add a step, MEASURE IT FIRST. The estimate this job replaced said "under 15 s across
|
||||
# all four steps" and was extrapolated from a partial run; the real punktfunk-core figure was
|
||||
# >25 min. Extrapolation is exactly how that happened.
|
||||
miri:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# A DATED nightly, bumped deliberately — exactly like rust-toolchain.toml, and for the same
|
||||
# reason. The cache keys below carry this value, so bumping it self-invalidates them.
|
||||
# ⚠ `nightly-<date>` names the day rustup PUBLISHED the build, and that build is compiled
|
||||
# from the PREVIOUS day's commit. This pin therefore resolves to
|
||||
# `rustc 1.99.0-nightly (969b803cb 2026-08-09)` [verified by installing it], NOT the
|
||||
# `12c36e253 2026-08-10` that the rust-safety programme doc's §7 table cites — that figure
|
||||
# came from the ROLLING `nightly` channel and was mislabelled as the dated one. Harmless,
|
||||
# but do not "fix" the date to chase that hash: all three steps below were re-run and are
|
||||
# green on the dated toolchain this job actually installs.
|
||||
MIRI_TOOLCHAIN: nightly-2026-08-10
|
||||
# A GUARD, not a fix for a present problem: audit.yml sets no sccache — only ci.yml does, at
|
||||
# workflow level (ci.yml:27). `cargo-miri` REPLACES rustc and cannot be wrapped; it prints
|
||||
# "Ignoring `RUSTC_WRAPPER` environment variable, Miri does not support wrapping" and
|
||||
# carries on [verified]. This keeps a future workflow-level sccache from becoming a puzzle.
|
||||
RUSTC_WRAPPER: ""
|
||||
# -Zmiri-disable-isolation: pf-gpu's tests mkdir, and Miri aborts them without it [verified].
|
||||
# -Zmiri-symbolic-alignment-check: the whole point — it refuses to let an accidentally
|
||||
# favourable stack slot stand in for an alignment guarantee. This is the flag that caught
|
||||
# the pf-driver-proto defect.
|
||||
# NOTE the absence of -Zmiri-ignore-leaks. Miri leak-checks by DEFAULT, and that is the one
|
||||
# leak-detection capability it offers here. None of the crates below leaks, so the job is
|
||||
# green. The tree does contain DELIBERATE leaks (pf-umdf-util/src/section.rs `ViewCell`,
|
||||
# gamepad_raii.rs leak-on-timeout) — when coverage ever reaches them, annotate those two
|
||||
# sites; do not blanket-disable the check.
|
||||
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
|
||||
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
|
||||
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
|
||||
# belongs with the toolchain, not with the lockfile.
|
||||
- name: cache the nightly toolchain + Miri sysroots
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
|
||||
~/.cache/miri
|
||||
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
|
||||
- name: cache the cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: miri-registry-v1-
|
||||
|
||||
# The image needs no change for this: ci/rust-ci.Dockerfile:51-54 installs via rustup and
|
||||
# `chmod -R a+w`s both RUSTUP_HOME and CARGO_HOME, so a job can add a toolchain at runtime.
|
||||
# `rust-src` is required — cargo-miri builds its sysroot from source, per target.
|
||||
#
|
||||
# This does NOT disturb the 1.96.0 pin: `cargo +<toolchain>` overrides rust-toolchain.toml
|
||||
# for that single invocation only, so `cargo fmt` / `clippy` keep resolving 1.96.0 and the
|
||||
# fmt-parity contract in CLAUDE.md is untouched. The two echo lines below keep that claim
|
||||
# honest in the log. They are deliberately NOT `rustup show active-toolchain`: that command
|
||||
# RESOLVES the toolchain file and would install the whole 1.96.0 toolchain just to print a
|
||||
# line, in a job where every cargo call is `+$MIRI_TOOLCHAIN` and 1.96.0 is never needed.
|
||||
# Deliberately NOT `rustup override set` — that writes persistent per-directory state into
|
||||
# the runner's rustup config, which leaks into unrelated later jobs on a self-hosted fleet.
|
||||
# Deliberately NOT a second rust-toolchain.toml in a subdirectory — that would apply to
|
||||
# every cargo invocation under that subtree including fmt, which is the drift the root pin
|
||||
# exists to prevent.
|
||||
- name: install the pinned nightly + miri
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
rustup toolchain install "$MIRI_TOOLCHAIN" \
|
||||
--profile minimal \
|
||||
--component miri,rust-src \
|
||||
--target x86_64-pc-windows-msvc
|
||||
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
|
||||
cargo +"$MIRI_TOOLCHAIN" --version
|
||||
|
||||
# A run that reports `0 passed` is a selection that matched nothing, not a success — that
|
||||
# exact mistake has already cost one round-trip here. So each step below checks a zero exit
|
||||
# AND that at least one target reported a non-zero pass count, which is what catches a
|
||||
# crate rename or a `--` filter that stops matching. (Each step legitimately prints several
|
||||
# `0 passed` lines too — the empty bin/doctest targets — so the check is "at least one
|
||||
# non-zero", not "no zeroes".) Expected counts at the time of writing: 21 + 12 + 4.
|
||||
- name: miri — FFI-free leaf crates (native)
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
cargo +"$MIRI_TOOLCHAIN" miri test \
|
||||
-p pf-driver-proto -p pf-host-config -p pf-gpu 2>&1 | tee /tmp/miri-native.log || ok=0
|
||||
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-native.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::miri (FFI-free leaf crates, native) did not pass — non-blocking; see punktfunk-planning design/rust-safety-programme.md §7"
|
||||
|
||||
# THE step that justifies the job: pf-driver-proto at MSVC layout, on Linux, no Windows box.
|
||||
# Expected: 21 passed. If this one ever goes red, treat it as a layout-contract break
|
||||
# between the host and driver workspaces until proven otherwise.
|
||||
- name: miri — pf-driver-proto at x86_64-pc-windows-msvc layout
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
cargo +"$MIRI_TOOLCHAIN" miri test \
|
||||
-p pf-driver-proto --target x86_64-pc-windows-msvc 2>&1 | tee /tmp/miri-msvc.log || ok=0
|
||||
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-msvc.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::miri (pf-driver-proto @ MSVC layout) did not pass — non-blocking, but this is the layout oracle for every frame and IOCTL; see design/rust-safety-programme.md §7"
|
||||
|
||||
# fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`. Under
|
||||
# Miri that detection reports the COMPILE-TIME target features, so WITHOUT these RUSTFLAGS
|
||||
# the step silently interprets the scalar fallback and is worthless. Verified both ways on
|
||||
# 192.168.1.25: bare, `avx2=false ssse3=false`; with the flags, `avx2=true ssse3=true` and
|
||||
# `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays false either
|
||||
# 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 — 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:
|
||||
RUSTFLAGS: -C target-feature=+avx2,+ssse3
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
cargo +"$MIRI_TOOLCHAIN" miri test \
|
||||
-p punktfunk-core --lib -- fec::gf8 2>&1 | tee /tmp/miri-gf8.log || ok=0
|
||||
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-gf8.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::miri (punktfunk-core fec::gf8, AVX2/SSSE3) did not pass — non-blocking; see design/rust-safety-programme.md §7"
|
||||
|
||||
# ASAN + LSAN over the C ABI harness — §6.1 of design/rust-safety-programme.md, its rank-1
|
||||
# tooling item. crates/punktfunk-core/tests/c/run.sh already proves the staticlib links and
|
||||
# round-trips 4 frames byte-exact from C on every push (ci.yml); PF_SAN=address rebuilds BOTH
|
||||
# sides instrumented — the staticlib on nightly with -Zsanitizer/-Zbuild-std (std itself
|
||||
# included), the harness with clang -fsanitize — so ASAN sees the seam a Rust-only tool cannot,
|
||||
# and LSAN (detect_leaks=1, the script's default) becomes the one automated check on abi.rs's
|
||||
# Box::into_raw/from_raw leak contract.
|
||||
# Proven to fail on 192.168.1.25: deleting a single punktfunk_session_free() from harness.c
|
||||
# makes LSAN report the ~308 Rust-side allocations behind the handle and run.sh exit 1.
|
||||
# What it does NOT see: the invalid-InputKind-discriminant UB at abi.rs (that needs the
|
||||
# validator, tracked in §5 of the programme doc), and nothing GPU/Windows — this is the
|
||||
# default-feature (quic-less, opus-less) core only.
|
||||
c-abi-asan:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# The SAME dated pin as the miri job above, deliberately — one nightly date to bump for
|
||||
# both jobs (they have no toolchain interaction; sharing the date just halves the chores).
|
||||
SAN_TOOLCHAIN: nightly-2026-08-10
|
||||
# Same guard as the miri job: audit.yml sets no sccache today, and -Zbuild-std could not
|
||||
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
|
||||
RUSTC_WRAPPER: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
|
||||
# note there (and so an incomplete save from one job can never starve the other).
|
||||
- name: cache the nightly toolchain
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
|
||||
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
|
||||
- name: cache the cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: san-registry-v1-
|
||||
|
||||
# rust-src is required: -Zbuild-std compiles std from source so it is instrumented too —
|
||||
# without that, LSAN cannot attribute allocations made inside std (Vec, Box, HashMap).
|
||||
- name: install the pinned nightly + rust-src
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
rustup toolchain install "$SAN_TOOLCHAIN" --profile minimal --component rust-src
|
||||
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
|
||||
cargo +"$SAN_TOOLCHAIN" --version
|
||||
|
||||
# The image installs clang but Ubuntu does not always pull the compiler-rt sanitizer
|
||||
# runtime with it (verified absent on a stock 26.04 box). Probe with an actual ASAN link
|
||||
# and self-heal via apt if it fails — container jobs on this fleet run as root (the
|
||||
# bun-audit job's apt-get above relies on the same fact).
|
||||
- name: ensure clang's ASAN runtime
|
||||
run: |
|
||||
if ! echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe 2>/dev/null; then
|
||||
apt-get update && apt-get install -y --no-install-recommends "libclang-rt-$(clang -dumpversion | cut -d. -f1)-dev"
|
||||
echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe
|
||||
fi
|
||||
|
||||
# run.sh handles everything behind PF_SAN (nightly build, target path, clang flags,
|
||||
# ASAN_OPTIONS=detect_leaks=1) and exits non-zero on any report. The grep is the
|
||||
# proved-it-ran guard, same reasoning as the miri steps: a script change that silently
|
||||
# skips the harness must not read as green. run.sh expects bash and PATH cargo — both true
|
||||
# in this container. PF_SAN_TOOLCHAIN pins the script's `cargo +<toolchain>` to the dated
|
||||
# nightly installed above — without it the script would ask for the ROLLING `nightly`
|
||||
# channel, which this job deliberately does not install.
|
||||
- name: C ABI harness under ASAN+LSAN
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
PF_SAN=address PF_SAN_TOOLCHAIN="$SAN_TOOLCHAIN" \
|
||||
bash crates/punktfunk-core/tests/c/run.sh 2>&1 | tee /tmp/asan-harness.log || ok=0
|
||||
grep -q 'PASS: 4 frames round-tripped byte-exact' /tmp/asan-harness.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
|
||||
|
||||
@@ -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: |
|
||||
|
||||
+76
-21
@@ -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,12 +123,28 @@ 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
|
||||
|
||||
# rust-safety WP2c: three textual gates for classes no lint covers — unsafe fn markers
|
||||
# carrying no contract, panic across an extern boundary (an abort since 1.81), and
|
||||
# process-global safe APIs (env::set_var & co, count-ratcheted). Pure grep/awk, no cargo.
|
||||
# Both failure modes were demonstrated before this became blocking (planted instances).
|
||||
- name: Unsafe-hygiene grep gates
|
||||
run: sh scripts/ci/check-unsafe-hygiene.sh
|
||||
|
||||
- name: Clippy (deny warnings)
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
@@ -139,8 +173,8 @@ jobs:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
|
||||
# `cargo build`, where warnings are not errors, so pf-encode's own
|
||||
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
|
||||
# `cargo build`, where warnings are not errors, so the `undocumented_unsafe_blocks` deny
|
||||
# (now hoisted into [workspace.lints]) — pf-encode's stated unsafe-proof gate —
|
||||
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
|
||||
# `default = ["pyrowave"]`, so the steps above already cover it.)
|
||||
#
|
||||
@@ -180,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)
|
||||
@@ -203,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"
|
||||
@@ -225,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: |
|
||||
@@ -242,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.
|
||||
@@ -159,9 +159,10 @@ jobs:
|
||||
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
|
||||
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. wdk-probe is a
|
||||
# toolchain-only probe crate, but it holds real DDI slot-dispatch unsafe (iddcx_rt.rs), so it
|
||||
# runs the same gates.
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay -p wdk-probe --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
|
||||
@@ -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 }}
|
||||
+113
@@ -32,6 +32,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
|
||||
@@ -184,6 +225,78 @@ 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.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
Generated
+818
-1029
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -66,8 +66,8 @@ ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["unom"]
|
||||
repository = "https://git.unom.io/unom/punktfunk"
|
||||
@@ -101,6 +101,17 @@ repository = "https://git.unom.io/unom/punktfunk"
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
|
||||
# The companion lint: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof. Hoisted here
|
||||
# from ~85 per-file `#![deny(...)]` attributes so a NEW crate (or a new module in an old one) is
|
||||
# covered on creation rather than on remembering — the per-file form left pf-vkhdr-layer,
|
||||
# wdk-probe, and half of pf-clipboard uncovered for months. NOTE: this table reaches only crates
|
||||
# with `[lints] workspace = true`; `packaging/windows/drivers` and `packaging/windows/pf-vkhdr-layer`
|
||||
# are SEPARATE workspaces and restate it (any "workspace-wide" claim must be made three times or it
|
||||
# is false). Of the members, only the two vendored snapshots (pf-bitstream/vendor/cros-codecs,
|
||||
# punktfunk-host/vendor/usbip-sim) stay out, deliberately — upstream code stays pristine.
|
||||
[workspace.lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-1
@@ -53,7 +53,7 @@
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair a client",
|
||||
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Removing the last pairing also closes the GameStream ENet control port\n(UDP 47999), which is only bound while at least one pairing exists. Caveat: the nvhttp TLS\nlayer does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
|
||||
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Revocation is complete: a LIVE GameStream session owned by this certificate is\nended (the client gets the standard TERMINATION+disconnect), and removing the last pairing\nalso closes the ENet control port (UDP 47999), which is only bound while at least one pairing\nexists. The nvhttp TLS layer still completes a handshake with any well-formed client cert BY\nDESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client\nthat reconnects is rejected at every post-pair endpoint.",
|
||||
"operationId": "unpairClient",
|
||||
"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."
|
||||
|
||||
@@ -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
@@ -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,15 +1,16 @@
|
||||
//! 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};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||
use super::{hex32, jni_guard, lock_recover, parse_hex32, SessionHandle};
|
||||
|
||||
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
|
||||
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
|
||||
@@ -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 {
|
||||
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
|
||||
match env.new_string(token) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
) -> JString<'local> {
|
||||
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
|
||||
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,
|
||||
|
||||
@@ -45,6 +45,15 @@ pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||
})
|
||||
}
|
||||
|
||||
/// Poison-recovering lock for the JNI entry points that are NOT behind [`jni_guard`]: a
|
||||
/// `.lock().unwrap()` there turns a poisoned mutex into a panic across the `extern "system"`
|
||||
/// boundary — an abort of the whole app on Rust ≥ 1.81 (the panic-in-extern grep gate's class).
|
||||
/// The slots behind these mutexes are plane-thread handles and last-value caches; whatever a
|
||||
/// poisoned writer left is still valid to inspect or replace.
|
||||
pub(crate) fn lock_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
||||
pub(crate) struct SessionHandle {
|
||||
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
//! 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, SessionHandle};
|
||||
use super::{jni_guard, lock_recover, SessionHandle};
|
||||
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||||
@@ -19,9 +17,9 @@ use super::{jni_guard, 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 = h.video.lock().unwrap();
|
||||
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 h.video.lock().unwrap().is_none() {
|
||||
return std::ptr::null_mut(); // not streaming → no stats
|
||||
if lock_recover(&h.video).is_none() {
|
||||
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,
|
||||
@@ -385,11 +374,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.audio.lock().unwrap();
|
||||
let mut guard = lock_recover(&h.audio);
|
||||
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,
|
||||
@@ -434,13 +423,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.mic.lock().unwrap();
|
||||
let mut guard = lock_recover(&h.mic);
|
||||
if let Some(m) = guard.as_ref() {
|
||||
return m.session_id(); // already capturing — same stream, same session
|
||||
}
|
||||
// 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) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
*lock_recover(&h.pad_audio) = Some(p);
|
||||
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(h.mic.lock().unwrap().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>()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -176,7 +176,13 @@ unsafe extern "system" fn wnd_proc(
|
||||
let slice = unsafe { std::slice::from_raw_parts(cds.lpData as *const u16, len) };
|
||||
let url = String::from_utf16_lossy(slice);
|
||||
tracing::debug!(%url, "link from another instance");
|
||||
INBOX.lock().unwrap().push(url);
|
||||
// Poison-recover, never unwrap: a panic out of a window procedure is an abort since
|
||||
// Rust 1.81, and the inbox is a plain Vec that stays valid whatever a poisoned
|
||||
// writer left behind.
|
||||
INBOX
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(url);
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//! (measure the path: probe burst → goodput / loss / recommended bitrate)
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` in this client carries a `// SAFETY:` proof.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
|
||||
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
|
||||
// terminal's console at startup (see main), so their output is still visible when run from a shell.
|
||||
@@ -59,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
|
||||
@@ -83,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") {
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
#![allow(non_snake_case)]
|
||||
// Bindgen output for a C API: u128 layout warnings and the like are upstream's concern.
|
||||
#![allow(improper_ctypes)]
|
||||
// The workspace-wide undocumented_unsafe_blocks deny cannot apply to GENERATED code: bindgen
|
||||
// emits `unsafe {}` in layout tests/accessors and nobody hand-writes proofs into OUT_DIR. This
|
||||
// crate is bindings-only by charter (the safe wrapper lives with the consumer), so the allow is
|
||||
// crate-wide; the hand-written link-sanity test below still carries its proof by convention.
|
||||
#![allow(clippy::undocumented_unsafe_blocks)]
|
||||
// Generated code — clippy findings in it (missing safety docs on generated unsafe fns, style
|
||||
// nits across 14k lines) are bindgen's shape, not ours; the safe wrapper in pf-encode is the
|
||||
// linted surface.
|
||||
@@ -27,6 +32,8 @@ mod tests {
|
||||
/// implementations — that's fine, MFXLoad itself must still succeed).
|
||||
#[test]
|
||||
fn dispatcher_links_and_loads() {
|
||||
// SAFETY: MFXLoad allocates the dispatcher's loader context (documented to work with no
|
||||
// driver present) and MFXUnload frees that same non-null handle; nothing else is touched.
|
||||
unsafe {
|
||||
let loader = MFXLoad();
|
||||
assert!(!loader.is_null(), "MFXLoad returned NULL");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
||||
//! orchestrator).
|
||||
|
||||
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
|
||||
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use anyhow::Result;
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
// The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||
// program). This file itself has none — the FFI lives in the child modules declared at the bottom
|
||||
// (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
//! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this
|
||||
//! module contains no capturer.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
|
||||
|
||||
// The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
|
||||
@@ -67,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;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
|
||||
//! drift is a compile error rather than a "must match" comment.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{
|
||||
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare,
|
||||
VideoConverter, WinCaptureTarget,
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost
|
||||
//! and delivers them as bare handle values over the SYSTEM-only control device.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
|
||||
//! cursor forwarder, the wire, the client renderer) is shared.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use pf_driver_proto::cursor::{
|
||||
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely
|
||||
//! GPU-side on the capture device, before the normal conversion runs from the scratch.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use windows::core::s;
|
||||
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs`
|
||||
//! `spawn_host`), so the poller thread sees the session's cursor directly; no helper process.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the
|
||||
//! live HDR state + active resolution of the virtual target, sampled off the capture loop via CCD.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
//! The session's `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land
|
||||
//! AFTER that stall's report line — the next report (and the metronomic tally) still carries it.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
//! ([`acquire`]), refcounted across parallel capturers; probes sample at 20 Hz or slower and cost
|
||||
//! microseconds each, so the engine is invisible next to a streaming session.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms
|
||||
//! holes in DWM frame delivery that open while the desktop was actively composing.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
||||
@@ -536,14 +533,47 @@ impl StallWatch {
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
|
||||
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
|
||||
// the same way, so this read mirrors what the driver decided — modulo a machine
|
||||
// env edited after either process started, which a restart heals). The RX 9070
|
||||
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
|
||||
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
|
||||
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
|
||||
// say whether either lever is engaged before anyone chases display hardware.
|
||||
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
|
||||
"off (PFVD_NO_RT_GPU)"
|
||||
} else {
|
||||
match std::env::var_os("PFVD_RT_GPU") {
|
||||
None => "off (default)",
|
||||
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
|
||||
Some(_) => "REALTIME (PFVD_RT_GPU)",
|
||||
}
|
||||
};
|
||||
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||
.ok()
|
||||
.as_deref()
|
||||
{
|
||||
Some("off") => "off",
|
||||
Some("normal") => "normal",
|
||||
Some("realtime") => "REALTIME (pinned)",
|
||||
Some("auto") => "auto (gated REALTIME upgrade)",
|
||||
_ => "high (default)",
|
||||
};
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
rt_gpu_driver,
|
||||
rt_gpu_host,
|
||||
verdicts = %verdict_tally,
|
||||
classes = %class_tally,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
|
||||
rt_gpu_host shows a REALTIME opt-in, clear it (unset PFVD_RT_GPU / \
|
||||
set PUNKTFUNK_GPU_PRIORITY_CLASS=high) — a punktfunk process holding \
|
||||
REALTIME GPU priority is the field-proven amplifier of exactly this \
|
||||
signature on AMD. Otherwise: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
// proof of why it is sound. This crate held ~91 unsafe items with NO enforcement while every
|
||||
// other subsystem crate denied it — the decoders' `unsafe impl Send`s had a one-line aside
|
||||
// instead of an argument precisely because nothing required one.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod au_dump;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
|
||||
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; the deny enforcing it sits at
|
||||
// the crate root (lib.rs), covering every backend.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the
|
||||
//! platform split lives entirely behind [`start`].
|
||||
|
||||
// Unsafe-proof program: every `unsafe` block in any backend carries a `// SAFETY:` proof,
|
||||
// enforced workspace-wide by `[workspace.lints]` — a new backend under `host/` is covered on
|
||||
// creation.
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user