Compare commits

..
Author SHA1 Message Date
enricobuehler c95db8eebc Merge pull request 'Move TLS to aws-lc-rs with post-quantum key exchange, drop ring via ureq 3, and fix the dependency defects behind it' (#192) from worktree-aws-lc-rs-migration into main
apple / swift (push) Successful in 2m18s
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 14s
audit / docs-site-audit (push) Successful in 16s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 2m44s
audit / bun-audit (sdk) (push) Successful in 1m33s
audit / license-gate (push) Successful in 5m13s
ci / rust (push) Failing after 1m48s
ci / web (push) Successful in 1m45s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m36s
audit / miri (push) Successful in 10m5s
ci / docs-site (push) Successful in 1m29s
audit / c-abi-asan (push) Successful in 10m24s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 43s
ci / bun-nix (push) Successful in 1m6s
apple / distribute (push) Successful in 14m0s
deb / build-publish-gamescope (push) Failing after 56s
windows-drivers / driver-build (push) Successful in 1m59s
android / android (push) Successful in 19m12s
arch / build-publish (push) Successful in 19m57s
windows-drivers / probe-and-proto (push) Successful in 25s
ci / rust-arm64 (push) Successful in 13m8s
apple / screenshots (push) Successful in 7m34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 2m14s
deb / build-publish (push) Successful in 10m28s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7m38s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 2m52s
deb / build-publish-client-arm64 (push) Successful in 12m9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7m56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m12s
deb / build-publish-host (push) Successful in 19m29s
docker / deploy-docs (push) Failing after 1m58s
docker / builders-arm64cross (push) Successful in 6m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m36s
deb / smoke-install (push) Failing after 4m16s
flatpak / build-publish (push) Successful in 12m18s
nix / flake (push) Failing after 20m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m36s
windows-host / canary-manifest (push) Blocked by required conditions
windows-host / winget-source (push) Blocked by required conditions
windows-host / package (push) In progress
Reviewed-on: #192
2026-08-13 10:43:33 +00:00
enricobuehler 6202543b21 Merge pull request 'ci: cache the C/C++ half, link with mold, fix the debug/release cache collision, consolidate the Apple and Windows-client workflows' (#191) from worktree-ci-optimization into main
android / android (push) Canceled after 34s
apple / swift (push) Canceled after 1m49s
apple / distribute (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 55s
ci / web (push) Canceled after 49s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 42s
deb / build-publish (push) Canceled after 15s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
decky / build-publish (push) Successful in 1m23s
Reviewed-on: #191
2026-08-13 10:41:54 +00:00
enricobuehler d0a7b262d2 chore(deps): regenerate notices + make audit.yml's header match what it now scans
windows-drivers / driver-build (pull_request) Successful in 1m57s
windows-drivers / probe-and-proto (pull_request) Successful in 35s
apple / swift (pull_request) Successful in 2m29s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 52s
ci / rust-arm64 (pull_request) Failing after 3m22s
ci / web (pull_request) Successful in 3m47s
ci / docs-site (pull_request) Successful in 5m10s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 7m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 24s
ci / rust (pull_request) Failing after 11m30s
nix / flake (pull_request) Failing after 30m26s
android / android (pull_request) Successful in 13m2s
THIRD-PARTY-NOTICES regenerated after the dependency changes (582 crates).

audit.yml's header claimed to cover "EVERY dependency tree the project ships"; it now
actually does, so the note spells out that each Rust lockfile needs its own `--file` —
a bare `cargo audit` reads only the root one, which is how the drivers lock stayed
unscanned while already sitting in this job's `paths:` filter. Also corrected "BOTH
Rust workspaces" for the licence gate, which covers the host + driver workspaces.

Both cargo-about legs re-run after the dependency removals: RC=0.
2026-08-13 12:41:47 +02:00
enricobuehler 51a005dd43 fix(deps): close the audit gaps, drop unused declarations, declare what is used
Acting on the 2026-08-13 dependency sweep. Every claim below was re-verified against
the tree before acting on it (greps carry a positive control; the advisories were
re-checked with cargo audit 0.22.2).

SECURITY
- event-listener 5.4.1 -> 5.4.2 (RUSTSEC-2026-0221, unsound Send/Sync on StackSlot;
  reaches the tray via zbus and the host via ashpd). This sat unnoticed because
  `cargo audit` reports unsoundness as a WARNING and the job fails only on
  vulnerabilities — audit.toml now says so out loud.
- spin 0.9.8 -> 0.9.9. 0.9.8 is YANKED and was genuinely compiled (flume via mdns-sd
  and relm4, plus lazy_static).
- wayland-scanner 0.31.10 -> 0.31.11, which moves quick-xml 0.39 -> 0.41. That is the
  exact trigger audit.toml documented for RUSTSEC-2026-0194/0195, so both ignores are
  deleted rather than left as permanent exceptions. Only RUSTSEC-2023-0071 (rsa
  Marvin, still unfixed upstream) remains.
- Corrected audit.toml's claim that `paste` arrives "via utoipa-axum": rav1d pulls it
  too, so every client has it through the decode path and dropping utoipa-axum would
  not have cleared it.

TWO CI GATES THAT SCANNED NOTHING
- `cargo audit` only ever reads the ROOT Cargo.lock. The drivers lock was already in
  this job's `paths:` filter, so edits to it triggered a run that then ignored them.
  All four secondary workspaces now get an explicit `--file` (verified: clean, bar the
  known `paste` warning in drivers).
- packaging/windows/pf-vkhdr-layer had NO lockfile at all while shipping as a DLL in
  the host installer, so every build resolved fresh and neither cargo-audit nor
  cargo-about ever saw it. Lockfile generated and committed, and added to `paths:`.

UNUSED / DUPLICATE DECLARATIONS
- punktfunk-host: removed 13 dependencies it never references — the Wayland stack
  (client, protocols{,-wlr,-misc}, scanner, backend), xkbcommon, reis, khronos-egl,
  ash, usbip-sim, parking_lot, bytemuck. The code moved to pf-inject and pf-zerocopy
  in the subsystem extraction and those crates declare them; only the manifest entries
  and their now-false comments stayed. Also dropped four redundant re-declarations
  (tokio/serde_json/futures-util in the Linux block, tower in dev-deps).
- Removed genuinely unused: bytes (punktfunk-core), anyhow (pf-win-display),
  tracing (clients/cli), anyhow (clients/session), serde (clients/windows).
- Removed the high-level `wdk` crate from all five driver crates and the drivers
  workspace: none of them ever referenced `wdk::` (62 `wdk_sys::` uses; pf-umdf-util
  is a full WDF crate that never declared it). `tracing`/`tracing-subscriber` remain
  in that lock afterwards but ONLY as wdk-sys build-dependencies, not in the DLLs.
- pf-win-display took punktfunk-core with `quic` for one type (`Mode`) that lives in
  the ungated `config` module; now `default-features = false`, which keeps
  quinn/tokio/rcgen/opus out of a leaf crate's declared closure.
- pf-encode declared the windows-rs feature `Wdk_Graphics_Direct3D` for a call that
  lives in pf-frame and is resolved via GetProcAddress on gdi32.

LATENT BREAKAGE (compiled only by feature unification)
- pf-inject uses `tokio::select!` without declaring `macros` (borrowed from
  punktfunk-core's quic feature); pf-capture uses `tokio::sync::oneshot` without
  declaring `sync` (borrowed from ashpd->zbus); pf-client-core uses the `minwindef`
  and `winnt` windows-rs headers without declaring them (borrowed from
  clients/windows). Each now declares what it uses, so an unrelated crate changing its
  features cannot break them.
- pf-console-ui took pf-client-core WITHOUT `default-features = false`, unlike every
  other consumer. That default is `pyrowave`, which compiles the vendored PyroWave C++
  — "fatal on Windows ARM64". Only safe today because the ARM64 leg passes
  --no-default-features (which also drops `ui`).

CORRECTED A FALSE INVARIANT
- clients/windows claimed "the workspace builds ONE windows-rs". It does not: wasapi
  pulls the crates.io windows 0.62.2 beside the git-rev copy. The invariant that DOES
  hold is narrower (reactor and that crate share one rev, which is what makes the
  IDXGISwapChain1 hand-off type-check). Comment rewritten, with a warning against
  "fixing" it via a blanket [patch.crates-io] — this rev uses header-named features
  while a dozen other manifests use the old Win32_* namespace ones.

Plus the safe in-compat `cargo update` sweep (no manifest edits).

Verified on macOS: punktfunk-core 385, pf-update-check 32, c_abi 1 (with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib), cargo audit clean bar the two known
unmaintained warnings. Linux and Windows legs follow.
2026-08-13 12:41:47 +02:00
enricobuehler b84d37b5a0 test(host): make two socket stubs survive ureq 3 / Windows
Both failures found running the store + plugin-launch tests on the Windows runner
after the ureq 3 port. Neither is a production defect — the request/response round
trip and the 304 semantics both hold — but both tests were resting on assumptions
that ureq 2 happened to tolerate.

catalog::ureq_returns_304_as_ok: the stub answered without ever reading the request.
Closing a socket that still holds unread received data makes Windows send an RST
rather than a FIN, which discards the response already written, so the client saw a
transport error (os error 10053) instead of the 304 the test exists to pin. The stub
now drains the request first. The pinned behaviour is unchanged and still true:
ureq 3 turns only `is_client_error() || is_server_error()` into Err, so 304 arrives
as Ok exactly as before.

plugin_launch::asks_the_registered_plugin_and_takes_its_answer: hardcoded a cwd of
`/opt/emu`, which has no drive letter and is therefore NOT `Path::is_absolute` on
Windows, so `validate_reply` refused the recipe. This test could never have passed
on Windows, with either ureq version — its sibling
`a_working_directory_must_be_absolute` already had the `cfg!(windows)` split and this
one was simply missed. Confirmed by diagnostic before touching it: the body came back
over ureq 3 byte-perfect, so everything up to validation was working.
2026-08-13 12:41:47 +02:00
enricobuehler 9ec8350fc3 chore(licenses): retire ring's licence exception now that ring is gone
`about.toml` carried `OpenSSL` in the global accepted list and a `[ring]` per-crate
acceptance, both there solely because ring's licence is an AND that includes the
OpenSSL terms. The ureq 2 -> 3 upgrade removed ring from every target we build, and
aws-lc-sys 0.44's SPDX (ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND
BSD-3-Clause AND ... MIT-0) carries no OpenSSL clause, so neither entry has anything
left to permit.

Note about.toml sets no `targets`, so cargo-about still walks quinn-proto's wasm-only
ring edge — removing the exception is safe not because ring is invisible but because
ring 0.17.14 declares `Apache-2.0 AND ISC`, and both are globally accepted already.

Verified by running the gate itself, both legs of what audit.yml runs:
  cargo about generate about.hbs --fail                                    -> 0
  cargo about generate -m packaging/windows/drivers/Cargo.toml -c ...      -> 0
and proved non-vacuous with a negative control: dropping "ISC" from the accepted
list makes the first leg exit 1.

THIRD-PARTY-NOTICES regenerated: 601 -> 580 crates (ureq 3 pulls a smaller tree than
ureq 2 + ring), now listing ureq 3.4.0.
2026-08-13 12:41:47 +02:00
enricobuehler 3ccfd01699 feat(deps): upgrade ureq 2 -> 3, removing ring from the tree entirely
The aws-lc-rs move left `ring` compiled in for one reason: ureq 2 names
`features = ["ring", ...]` inside its own `[dependencies.rustls]` block, and cargo
features are additive, so no dependent could switch it off. ureq 3 declares rustls
with `default-features = false` and selects no backend, which finally makes the
choice ours.

`cargo tree -i ring` is now empty for macOS, Windows and Linux. The one remaining
hit under `--target all` is quinn-proto's wasm-only dependency
(`cfg(all(target_family = "wasm", target_os = "unknown"))`), a target we never build.

⚠ The dependency must be spelled `features = ["rustls-no-provider",
"rustls-webpki-roots"]`. ureq 3's convenience `rustls` feature expands to include
`_ring` — the same shape of trap as rustls's own `features = ["ring"]`, and the
reason punktfunk-webos still carries both backends today.

Ported 9 files. The two pinning call sites (the desktop client's library fetch and
the tray's status poll) needed a custom ureq Connector, because ureq 3's `TlsConfig`
exposes roots/client-cert/off-switch but no hook for a custom ServerCertVerifier.
That glue lives once in punktfunk-core behind a new off-by-default `ureq-tls`
feature rather than being hand-rolled twice on a trust boundary; the Apple/Android
cdylib embedders do not enable it and pull no HTTP stack. The connector is modelled
on ureq's own crate-private RustlsConnector and is transport glue only.

Two behaviour changes worth noting, both improvements:
- Body caps are enforced by the reader, so an over-cap response is now an Err rather
  than ureq 2's silent truncation — which used to surface as a signature failure
  pointing at the wrong thing.
- A pin mismatch matches ureq 3's typed `Error::Rustls(..)` instead of sniffing a
  substring out of a transport message, which could also fire on unrelated cert
  errors.
Conditional requests are unchanged: 304 still arrives as Ok (only 4xx/5xx are Err),
which the existing `ureq_returns_304_as_ok` socket test still pins.

Also removed four now-dead `std::io::Read` imports. One of them (plugin_launch) is
still needed by its test module, so it moved there rather than being deleted: host
CI lints without `--all-targets`, so a top-level import used only under cfg(test)
fails the shipping build.

Verified on macOS: punktfunk-core (quic + ureq-tls) checks clean, pf-update-check
32/32, cargo fmt clean.
2026-08-13 12:41:47 +02:00
enricobuehler 79d755cd98 feat(crypto): move the rustls backend from ring to aws-lc-rs, with PQ key exchange
The workspace pinned `ring` everywhere because aws-lc-sys 0.41.0 failed to C-compile
on the Windows CI runner. Re-tested on that runner (.133) with aws-lc-sys 0.44.0: the
`params.c` cl.exe failure does not reproduce under MSVC 14.44, and rustls's `aws_lc_rs`
feature turns on `aws-lc-rs/prebuilt-nasm`, so no NASM is needed on the box either.

That unblocks post-quantum TLS: `prefer-post-quantum` offers X25519MLKEM768 first on
every TLS 1.3 handshake (mgmt API, native control plane, QUIC), which ring cannot do —
it has no ML-KEM. Classical curves stay in the list, so older clients still connect.

rustls, quinn, rcgen and tokio-rustls each select a backend independently, so all four
had to move together; a single dissenter pulls a second crypto stack in via feature
unification. The direct `ring` users (ed25519 in pf-update-check, SHA-256 in the Windows
updater) moved to aws-lc-rs, whose API is ring-compatible.

`ring` does NOT leave the tree: ureq 2 names `features = ["ring"]` in its own rustls
dependency line and cargo features are additive, so no dependent can switch it off. Two
backends compiled in means rustls refuses to infer one, and anything built via
`ClientConfig::builder()` panics instead of picking — which is what ureq's default agent
does on its first HTTPS request. `tls::install_default_provider()` makes the choice
explicit; it runs at each binary's entry point and defensively in pf-client-core, which
several binaries link. Dropping ring entirely needs the ureq 2 -> 3 upgrade (36 call
sites), deliberately left out of this change.

Verified on macOS: pf-update-check 32, punktfunk-core 385, c_abi 1 (the last with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib) — aws-lc-sys links into the C ABI harness, so
the Swift/Kotlin embedders keep working. cargo fmt --all --check clean.
2026-08-13 12:41:47 +02:00
enricobuehler 5fbf04f56d Ship punktfunk-gamescope on apt, support Debian 13, and state the real host floor (#190)
ci / docs-site (push) Successful in 1m19s
windows-host / package (push) Failing after 55s
ci / rust-arm64 (push) Successful in 1m40s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / bun-nix (push) Successful in 20s
apple / swift (push) Successful in 1m39s
deb / build-publish-gamescope (push) Failing after 2s
android / android (push) Successful in 6m22s
deb / build-publish-host (push) Successful in 4m38s
deb / build-publish (push) Successful in 5m31s
ci / web (push) Successful in 7m54s
apple / screenshots (push) Successful in 5m57s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m30s
deb / build-publish-client-arm64 (push) Successful in 8m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6m4s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5m41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m19s
decky / build-publish (push) Successful in 51s
arch / build-publish (push) Successful in 10m45s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10m24s
ci / rust (push) Canceled after 6m35s
deb / smoke-install (push) Canceled after 2m50s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2m34s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1m2s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m22s
2026-08-13 10:21:52 +00:00
enricobuehler 85980b425e docs: state the real host floor — Ubuntu 26.04+ / Debian 13+ — and name Debian everywhere
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m56s
android / android (pull_request) Successful in 9m55s
ci / rust (pull_request) Successful in 15m59s
The floor that matters is the DESKTOP, not the package: 24.04 installs
punktfunk-host and then has no compositor over the version floors and no
gamescope, which reads as a bug rather than an unsupported base. The
requirements page now leads with a per-release table separating 'package
installs' from 'can actually host', and install.md carries the same
caveat next to the apt row.

Debian was already a supported target after the previous commits but was
still invisible at the entry points — README's install table, the docs
index cards, and the 'what you need' list all said Ubuntu only. All three
now name Debian and carry the version floor.
2026-08-13 12:20:33 +02:00
enricobuehler 107fa3472d docs: Linux Mint 22.x cannot host, and 24.04 gives the host no compositor
Measured on a real linuxmintd/mint22-amd64 image and on Ubuntu 24.04. The
package installs on both, which is exactly what makes this easy to miss —
nothing on the box can then produce a stream:

  * Cinnamon cannot host a virtual display (Muffin has no RecordVirtual).
  * gamescope is absent from 24.04 and cannot be built for it: the tree needs
    wayland >= 1.23.1 (has 1.22.0), libinput >= 1.26 (1.25), libavif >= 1.2.1
    (1.0.4), pixman >= 0.44 (0.42), plus libdisplay-info2 and libxcb-errors0,
    neither of which 24.04 packages at all.
  * Switching desktop does not rescue it — 24.04 has KWin 5.27 (floor 6.5.6)
    and GNOME Shell 46 (floor 48). Only sway 1.9 is even a candidate.

So the gamescope route documented for Cinnamon holds for LMDE 7 (Debian 13,
verified end to end) but NOT for Linux Mint 22.x — which is every mainstream
Mint until Mint 23 lands on a 26.04 base in December 2026. Both the Debian and
Ubuntu pages now say so, and the Debian page carries a per-edition table.

Also states what Debian 13 itself can drive: GNOME 48.7 and sway 1.10 are above
the floors; its KWin 6.3.6 is below.
2026-08-13 12:12:52 +02:00
enricobuehler 0bfc7fe913 ci: fold release.yml into apple.yml and the two Windows client workflows into one
apple / swift (pull_request) Successful in 1m43s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-drivers / probe-and-proto (pull_request) Successful in 30s
windows-drivers / driver-build (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 31s
ci / web (pull_request) Successful in 3m50s
ci / rust-arm64 (pull_request) Successful in 8m32s
android / android (pull_request) Successful in 7m22s
ci / rust (pull_request) Successful in 14m7s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m7s
Two merges, both of which exist to express an ordering Gitea cannot express across
files, and both of which delete a duplicated build.

release.yml -> apple.yml (as the `distribute` job)
  The name described neither what it did (Apple only — every other platform's release
  is its own packaging workflow attaching to the same Gitea release on a v* tag, with
  announce.yml as the manual "go") nor anything a reader would guess. The name was the
  smaller problem. 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 that same commit. It is now
  `needs: swift`, which is only expressible in one file.

  The two files' paths: filters had also drifted — apple.yml watched crates/**,
  release.yml watched crates/punktfunk-core/**. The merged filter takes the NARROW one,
  because that is the correct one: everything on this runner is built from
  punktfunk-core via build-xcframework.sh, and punktfunk-core's only path dependency is
  its own vendored fec-rs. That is checkable in one command, and the header says so, and
  says to widen it if that ever stops being true. Net effect on the shared mac mini:
  pushes that touch host-side crates no longer build or upload anything Apple.

windows.yml + windows-msix.yml -> windows-client.yml
  The pair built the same three crates FOUR times per client push on ONE runner: debug
  x64 + arm64 for lint/test, release x64 + arm64 for packaging. windows-host.yml already
  records why a second (debug) dep tree on this machine is a liability rather than a
  cost — it re-runs openh264-sys2's vendored C++ through cc-rs's cl.exe fan-out and tips
  the runner into C1069, which is disk exhaustion wearing a compiler error's clothes. So
  there is one release build per arch now and clippy/fmt/test run against it, exactly as
  windows-host.yml does. The paths list went from three copies to one; PRs get the
  build/lint/test signal and stop before packaging.

The rename is safe, and this is worth recording because the GitHub instinct is wrong
here: `github.run_number` is REPO-WIDE in Gitea, not per-workflow — consecutive runs of
DIFFERENT workflows get consecutive numbers (verified against the API: android 13226,
apple 13227, arch 13228, ci 13229, deb 13230). The canary MSIX version <minor>.<run>.0
and Apple's CURRENT_PROJECT_VERSION therefore keep climbing across a rename. On GitHub
the same rename would reset both to 1, sorting every new canary below the published ones
and getting the TestFlight uploads rejected outright.

25 workflows, down from 27, and every `name:` now matches its filename. Cross-references
in windows-host.yml, windows-drivers.yml, android.yml, flatpak.yml, sbom.yml, the
provisioning scripts, gitea-release.sh and clients/windows/packaging/README.md updated.
2026-08-13 12:10:36 +02:00
enricobuehler 3f7fbf1061 ci: build the web console once per push instead of once per packaging job
The Nitro console bundle is a pure function of web/ and sdk/, and it was being built
six times on every push: ci.yml, deb, both RPM legs (f43 + f44), arch, and the docker
app image, at roughly 2.5 min each. windows-host.yml has cached it on exactly this
shape for a while — this extends the same arrangement to the Linux packaging legs,
sharing one key family so whichever job builds it first warms the others.

The bun version is part of the key. Each builder image runs the bun.sh installer at
image-build time, so rust-ci, fedora-rpm and arch-ci can drift apart; keying on it
means they share while they agree and simply stop sharing when they do not, rather
than one image's bun silently producing the bundle another image ships.

Each packaging path needed a different hand-off:

  * deb  — build-web-deb.sh already builds only if web/.output is missing, so the
           restore alone is enough; the workflow's build+smoke step is now gated on
           the miss.
  * arch — makepkg builds with PF_SRCDIR pointing at the workspace, so a restored
           bundle is already where it needs to be. PKGBUILD gains the same
           build-if-missing guard the deb script has.
  * rpm  — neither direction works by default. build-rpm.sh packages a `git archive`
           tarball and web/.output is gitignored, so a bundle in the workspace is
           invisible to rpmbuild; and the spec's own build lands in rpmbuild's
           %{_topdir}, which build-rpm.sh mktemps and removes on EXIT, so a console
           built there is gone before the cache's post step and the cache would never
           populate — every run a miss that quietly rebuilt. So the workflow builds it,
           and hands it over by absolute path through a new optional `pf_prebuilt_web`
           macro. Undefined (plain rpmbuild, COPR) takes the original build path.

Every path asserts the bundle exists and carries the Bun.serve marker, on cache hits
too. A cache is one more place a wrong artifact can come from, and the packaging
scripts' build-if-missing behaviour — correct for a local build — would otherwise turn
a broken restore into either a silent rebuild or, with the build step skipped, a
package with no console in it. That is not hypothetical: windows-host.yml shipped
0.22.1 and 0.22.2 with no console because an unset path variable was handled by a
single Write-Host, which is why its equivalent step throws.
2026-08-13 12:01:09 +02:00
enricobuehler f1dc6c9f94 ci: cache the C/C++ half, link with mold, and split the debug/release target caches
Three independent reasons Rust CI stayed slow despite sccache, fixed together because
they share the same measurement.

1. sccache only ever covered RUSTC. Every C/C++ dependency in the tree — aws-lc-sys,
   openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys — was
   compiled from scratch on every job of every workflow. CMAKE_{C,CXX}_COMPILER_LAUNCHER
   plus CC_/CXX_x86_64_unknown_linux_gnu route both build-script styles (cc-rs and
   cmake-rs) through the same shared cache.

   The CC_* vars are JOB-scoped in ci.yml and deb.yml, never workflow-scoped: the
   arm64 cross image sets its own CC_x86_64_unknown_linux_gnu=pf-host-cc, the wrapper
   that keeps ffmpeg-sys-next's host probe off the arm64 include dirs. Overwriting it
   would surface as a header mismatch rather than as a CI config error.

2. Linking is cacheable by nothing, and these jobs relink the host, client, session,
   cli, worker and tray on every run — twice per push for rpm (f43 + f44). The four
   Linux builder images now install mold and carry a $CARGO_HOME/config.toml that uses
   it for x86_64. aarch64 is deliberately left alone (cross driver, already-fast legs).
   Each image asserts `mold --version` in its build, so an image can never ship the
   flag without the linker: docker.yml goes red and :latest stays on the last good one.

3. THE EXPENSIVE ONE. ci.yml (debug) and deb.yml (release) named a byte-identical
   target-cache key, under a comment claiming the release build reused ci.yml's
   artifacts. It never could. actions/cache is first-saver-wins on an exact key and
   ci.yml is the faster job, so the shared key always held a debug-only target/ — and,
   worse, deb.yml could then never save its own, because the key was taken. Every
   canary .deb has been a from-scratch release build for as long as both keys existed.
   Same collision on the arm64 pair, and a third participant in
   linux-client-screenshots.yml. Split into -debug-/-release- key families; that job
   reads deb's tree via restore-keys but keeps its own exact key so it can never win
   the save race and replace a full tree with its single-crate one.

Also: one scripts/ci/ensure-sccache.sh replaces ten copy-pasted bootstrap blocks that
had already drifted into two dialects (GNU tar --wildcards vs bsdtar), every Rust job
now ends with --show-stats so a cache regression is visible instead of just "CI got
slower", and deb.yml's web install joins every other CI install on --ignore-scripts.

No behaviour change to any artifact: same compilers, same flags, same outputs.
2026-08-13 11:57:13 +02:00
enricobuehler 5d8682d7b7 feat(vdisplay): explain a cinnamon/muffin compositor pin instead of listing backends
PUNKTFUNK_COMPOSITOR=cinnamon is the first thing a Mint or LMDE user
reaches for, and the bare list of accepted values invites the
next-closest guess — 'mutter', since Muffin is a Mutter fork — which
starts a session that fails deep inside an org.gnome.Mutter.ScreenCast
call Muffin does not serve. No value of the variable can work, so say
that and name headless gamescope, which needs no desktop compositor.
2026-08-13 11:50:05 +02:00
enricobuehler 030bc8a1c2 docs(debian): use deb822 sources for non-free, not apt-add-repository
software-properties-common is not available in Debian 13, so the
apt-add-repository line could not have worked. Debian 13 keeps its
sources in deb822 format; edit Components there instead (verified in a
trixie container — the NVIDIA driver it then offers is 550, above our
535 floor).
2026-08-13 11:47:20 +02:00
enricobuehler 346385bad8 fix(deb): ship punktfunk-gamescope on apt at last, and support Debian 13
`punktfunk-gamescope` had never been published to the apt registry — not 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 lacks libxcb-errors-dev and has 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 v0.26.0 and v0.27.0 both released
with the package missing while docs-site told apt users to install it. The same
tags shipped it fine for Arch, Fedora 44 and Bazzite.

It now builds in 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 — measured by installing and running it on both — because the build also
vendors libdisplay-info via the new `--extra-fallback` option: linked against
the distro copy it demands `libdisplay-info2` on trixie, which Ubuntu 26.04 does
not have (it carries libdisplay-info3). The option is opt-in, so the
Arch/Fedora/nix outputs are byte-for-byte unchanged. Ubuntu 24.04 gets no
gamescope package and cannot — its wayland is too old to run one however built.

Debian 13 is now a documented host target. That needed no packaging change at
all: 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. Verified by installing: host, web console and
plugin runner install, resolve every soname and run. The desktop client stays
Ubuntu-26.04-only (built there, floors at `libc6 >= 2.43`; Debian 13 has 2.41).

Compositor detection now answers Cinnamon (Mint, LMDE) with the route that works
instead of advice 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 — so no
value of PUNKTFUNK_COMPOSITOR makes a Cinnamon desktop host a virtual display.
The error names headless gamescope, which needs no desktop compositor. The XDG
sniff moved into a pure function so those branches are testable; Cinnamon is
matched before GNOME, since it is a GNOME derivative and the generic arm would
otherwise hand it the Mutter backend (caught by the new test).

New `smoke-install` job installs every published package from the registry in
pristine ubuntu:24.04, ubuntu:26.04 and debian:trixie images, asserts each
binary resolves its libraries and runs, and insists the version served is the
one this run built. Nothing in deb.yml had ever installed a package it produced,
which is how both of the above survived unnoticed.

⚠ Bootstrap: seed `punktfunk-gamescope-trixie:latest` into the LAN registry once
(docker.yml builds it thereafter) or the new job cannot start.
2026-08-13 11:43:54 +02:00
enricobuehler 0026143164 Merge pull request 'fix(bazzite): the shipped template pinned ATTACH, so Game Mode mirrored the box's screen instead of giving the client its own display' (#189) from worktree-no-attach-default into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m9s
apple / swift (push) Successful in 1m44s
ci / rust-arm64 (push) Successful in 1m35s
windows-host / package (push) Failing after 1m31s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
android / android (push) Failing after 3m35s
deb / build-publish-client-arm64 (push) Successful in 3m12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 6m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 3m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
apple / screenshots (push) Successful in 6m7s
deb / build-publish-host (push) Successful in 6m41s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m9s
arch / build-publish (push) Successful in 11m45s
deb / build-publish (push) Successful in 12m33s
docker / deploy-docs (push) Failing after 7m31s
ci / rust (push) Successful in 18m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s
Reviewed-on: #189
2026-08-13 09:24:21 +00:00
enricobuehler ba16237c35 fix(bazzite): the shipped template pinned ATTACH, so Game Mode mirrored the box's screen instead of giving the client its own display
apple / swift (pull_request) Successful in 1m46s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 8m43s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 3m13s
ci / rust-arm64 (pull_request) Successful in 3m18s
ci / rust (pull_request) Successful in 15m34s
Field report: "on Bazzite when using gaming mode it is mirroring the main display
instead of giving the client its own." It is our own template that does it.

`packaging/bazzite/host.env` set `PUNKTFUNK_GAMESCOPE_ATTACH=1`, and every install
path — rpm, deb, Arch, nix — ships that file as `/usr/share/punktfunk/host.env.bazzite`
with the docs telling people to copy it verbatim. So the recommended Bazzite setup
turned the attach override ON for everyone.

That override is rung 2 of `pick_gamescope_mode`, ABOVE `dedicated_launch` at rung 3.
The rung comment calls the operator overrides a debug/CI escape hatch, which is right —
but we were shipping one as a distro default, so on a Bazzite box the managed takeover
and the dedicated game session were both unreachable. A game launched from a client's
library could not get a session of its own either, which is the case the dedicated
route exists for. With a physical display connected, attach then takes the
`physical_display_connected()` arm and streams the box's own head at the box's own
mode: the mirror the reporter saw.

The template now forces nothing and lets the per-connect detection answer, which on a
box with `gamescope-session-plus` is MANAGED. Attach stays available, documented as the
opt-in it is, with the mirror and the dedicated-session cost stated. Because managed
depends on the `punktfunk` group to stop the display manager, the template now says so
where someone choosing a model will read it, rather than only in the distro guide.

Also fixes the off-switch. Both overrides were read with `var_os(..).is_some()`, so
`PUNKTFUNK_GAMESCOPE_ATTACH=0` meant ATTACH ON — the opposite of what the line says,
and of every other knob on this host. They now use the shared `env_on` grammar, so
`0|false|off|no` disable and a bare `=1` keeps working. Anyone who "turned attach off"
in an older host.env had it on the whole time.

Note an upgrade never rewrites an existing `~/.config/punktfunk/host.env`, so boxes set
up from an older template keep the pin until the line is deleted by hand; the Bazzite
and HDR pages now say that.

Verified: `scripts/xcheck.sh linux` check + clippy `-D warnings` clean, pf-vdisplay
206/0 under rust:1.96, `cargo fmt --all --check` clean. Gate proved non-vacuous against
a planted `compile_error!` in routing.rs.
2026-08-13 09:45:52 +02:00
enricobuehler 981f32b8f6 Merge pull request 'Gamescope streams could tear pink at 120 fps — zero-copy handed the capture buffer back while the VCN was still reading it' (#188) from worktree-gamescope-tear-race into main
ci / bun-nix (push) Successful in 24s
ci / rust-arm64 (push) Successful in 1m29s
apple / swift (push) Successful in 1m44s
ci / web (push) Successful in 4m45s
ci / docs-site (push) Successful in 6m3s
ci / rust (push) Successful in 6m19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 30s
deb / build-publish (push) Successful in 7m18s
apple / screenshots (push) Successful in 6m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 39s
deb / build-publish-host (push) Successful in 8m3s
deb / build-publish-client-arm64 (push) Successful in 4m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m54s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m25s
android / android (push) Successful in 11m52s
arch / build-publish (push) Successful in 14m31s
windows-host / package (push) Successful in 13m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
docker / deploy-docs (push) Successful in 7m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m41s
Reviewed-on: #188
2026-08-13 07:24:07 +00:00
enricobuehler a9a1b923a2 fix(pf-capture): withhold zero-copy buffers from the producer until the encoder is done reading them
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m14s
ci / rust-arm64 (pull_request) Successful in 4m52s
ci / rust (pull_request) Successful in 4m57s
android / android (pull_request) Successful in 7m6s
The raw-dmabuf passthrough handed the SPA buffer back to gamescope at
.process return while the encode thread had not yet imported - let alone
read - its dmabuf, and nothing ordered the producer's writes against the
consumer's read (no explicit sync; the implicit-fence wait measures
NoFence on every compositor x vendor pairing we have). On the direct-VCN
arms (native NV12, RGB-direct EFC) the captured buffer IS the encode
source for the whole ring-2-deep encode plus the phase-lock hold, so at
120 fps gamescope cycles back into the buffer mid-encode and the stream
ships torn frames: luma/chroma desync (magenta tint) plus block
corruption propagating through the P-chain until the next intra. Field
report: Nobara, gamescope mode at 120 fps - KDE sessions were clean
because cursor_blend routes them to the compute-CSC copy arm whose read
window is microseconds.

The fix defers the requeue: a published passthrough frame carries a
FrameHold (new on DmabufFrame), and the buffer rejoins the producer's
pool only when the last clone drops. The Vulkan encoder clones the hold
into the ring slot at submit and releases it when the slot's fence
retires (poll/backpressure/reset), extending "the producer must not
rewrite this" across exactly the GPU read. The host loop's repeat path
is fixed by the same mechanism: a re-submitted frame now aliases a
buffer the producer never got back, instead of whatever gamescope last
composited into it.

Bookkeeping lives in a per-stream HoldBook (loop-thread mutations only):
holds release through a pw channel onto the loop thread, a generation
tag keeps a stale release from requeueing a renegotiated pool's reused
address, and at most pool_depth - HOLD_POOL_RESERVE buffers are ever
withheld - a pool at the old floor of 2 cannot spare any and falls back
(with one warn) to the previous racy contract. PUNKTFUNK_ZEROCOPY_HOLD=0
restores the old behavior outright for field bisects.

Gates (.25): cargo check + clippy --all-targets -D warnings on
pf-frame/pf-capture/pf-encode/punktfunk-host; pf-capture 68/68 (4 new
HoldBook tests), pf-encode 75/75 (+15 ignored, host-feature set);
workspace cargo fmt --check clean. punktfunk-host's
hooks::prep_runs_do_in_order_and_undo_in_reverse fails on that box on
pristine main too (pre-existing; crate untouched here).
2026-08-13 09:00:23 +02:00
enricobuehler 124cb66324 Merge pull request 'Wake-on-LAN: support WoWLAN so Wi-Fi hosts wake like wired ones' (#187) from worktree-wowlan-support into main
apple / swift (push) Successful in 1m40s
ci / rust-arm64 (push) Successful in 1m41s
ci / bun-nix (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m45s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 45s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
ci / rust (push) Successful in 5m55s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 22s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m55s
ci / web (push) Successful in 1m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m42s
ci / docs-site (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 48s
deb / build-publish (push) Failing after 6m34s
deb / build-publish-host (push) Successful in 14m19s
android / android (push) Successful in 10m58s
release / apple (push) Successful in 10m0s
flatpak / build-publish (push) Successful in 12m38s
windows-host / package (push) Successful in 16m30s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m19s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m2s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m20s
2026-08-12 22:51:57 +00:00
108 changed files with 5587 additions and 2926 deletions
+19 -19
View File
@@ -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.
]
+10 -1
View File
@@ -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
View File
@@ -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
+52
View File
@@ -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
+15 -1
View File
@@ -2,6 +2,9 @@
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# ⚠ ALL FIVE Rust lockfiles, each named with its own `--file`: a bare `cargo audit`
# reads only the root one, which is how the drivers lock went unscanned for so
# long despite already being in this job's `paths:` filter.
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# plugin-kit (@punktfunk/plugin-kit).
@@ -11,7 +14,7 @@
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
@@ -47,6 +50,9 @@ on:
paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'packaging/windows/pf-vkhdr-layer/Cargo.lock'
- 'tools/win-input-matrix/Cargo.lock'
- 'tools/hid-descriptor-dump/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
@@ -83,7 +89,15 @@ jobs:
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
# Bare `cargo audit` scans ONLY the root Cargo.lock. The other three Rust workspaces are
# separate locks and were silently never scanned — the drivers one despite already being
# in this job's `paths:` filter, so edits to it triggered a run that then ignored it.
# Each needs its own `--file`. `pf-vkhdr-layer` had no lockfile at all until 2026-08-13.
cargo audit
cargo audit --file packaging/windows/drivers/Cargo.lock
cargo audit --file packaging/windows/pf-vkhdr-layer/Cargo.lock
cargo audit --file tools/win-input-matrix/Cargo.lock
cargo audit --file tools/hid-descriptor-dump/Cargo.lock
bun-audit:
strategy:
+2 -7
View File
@@ -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: |
+56 -18
View File
@@ -23,6 +23,12 @@ on:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
#
# RUSTC_WRAPPER covers RUST compilations and nothing else. The C/C++ half of this workspace —
# aws-lc-sys, openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys, pyrowave —
# was paid in full on every run until the CMAKE_*_COMPILER_LAUNCHER / CC_* wiring below existed.
# Linking is the third phase and is cacheable by nothing: that one is addressed in the builder
# images with mold (ci/cargo-config-mold.toml).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
@@ -30,6 +36,11 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# Route CMake-driven C/C++ through the same cache. Safe at workflow level: it names no
# triple, and cmake-rs overrides it per-invocation with a `-D` flag when cc-rs reports a
# wrapper, so the two can never double-wrap into `sccache sccache cc`.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -40,18 +51,25 @@ jobs:
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90
env:
# cc-rs recognises `sccache` as a compiler wrapper when it leads CC/CXX, and cmake-rs then
# forwards it as -DCMAKE_C_COMPILER_LAUNCHER, so this covers both build-script styles.
#
# ⚠ JOB-scoped, NOT workflow-scoped, and it must stay that way: the `rust-arm64` job below
# runs in the cross image, which sets CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc
# (ci/rust-ci-arm64cross.Dockerfile) — a wrapper that strips arm64 include dirs off
# HOST-targeted compiles so ffmpeg-sys-next's probe resolves against the amd64 headers.
# Setting this at workflow level would silently overwrite that wrapper and break the cross
# build in a way that looks like a header mismatch, not a CI config error.
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
@@ -105,8 +123,17 @@ jobs:
# out of disk mid-build and actions/cache saved a truncated target/ (a dep's .rmeta
# went missing -> E0463 "can't find crate"). A suffix bump wouldn't help — restore-keys
# would fall back to the poisoned prefix — so the prefix itself is versioned.
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
#
# `-debug-`: THIS JOB BUILDS DEBUG. deb.yml builds RELEASE and used to share this exact
# key, with a comment claiming the release build "reuses ci.yml's clean artifacts" — it
# never could. actions/cache is first-saver-wins on an exact key and this job is the
# faster of the two, so what landed under the shared key was always a debug-only target/
# (target/debug, no target/release). deb.yml restored a tree containing nothing it could
# use and, because the key was already taken, never got to save its own — so every
# release build re-linked from scratch, forever. Splitting the profiles into separate key
# families is the fix; do not merge them again, however tempting the dedupe looks.
key: cargo-target-debug-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-debug-v3-${{ env.rustc }}-
- name: Format
run: cargo fmt --all --check
@@ -220,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"
@@ -242,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: |
@@ -259,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
View File
@@ -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 }}
+6
View File
@@ -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.
+2 -2
View File
@@ -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/**'
+24 -10
View File
@@ -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
-555
View File
@@ -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 }}"
+63 -8
View File
@@ -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.
+1 -1
View File
@@ -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
+318
View File
@@ -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 }
}
+4 -4
View File
@@ -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.
+2 -2
View File
@@ -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.
-196
View File
@@ -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 }
}
-169
View File
@@ -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 }}
+77
View File
@@ -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
@@ -220,6 +261,42 @@ Helldivers 2 at 1% lows of 25 FPS, cured by uninstalling). Two mechanisms, bo
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
+580 -726
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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) |
+1503 -616
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -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.
+18
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
# 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), and, far worse, would lose the aarch64
# `--cfg aes_armv8` / `--cfg polyval_armv8` flags from the workspace's own .cargo/config.toml,
# which are worth a measured ~3x on the decrypt path. audit.yml's miri gf8 step is the one
# place in the repo that sets RUSTFLAGS, and its comment already carries this warning; keep it
# that way. Never "simplify" this file into a RUSTFLAGS export.
#
# 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 two files touch DISJOINT keys: the workspace file defines
# only `target.'cfg(target_arch = "aarch64")'.rustflags`, this one only
# `target.x86_64-unknown-linux-gnu.rustflags`, and cargo JOINS a matching cfg-spec table with
# the triple table rather than picking one. 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"]
+11
View File
@@ -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
+68
View File
@@ -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"
+10
View File
@@ -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
+12
View File
@@ -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
+2 -1
View File
@@ -15,7 +15,8 @@ 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"
log = "0.4"
-1
View File
@@ -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]
+1
View File
@@ -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()
+1
View File
@@ -33,6 +33,7 @@ mod ui_trust;
#[cfg(target_os = "linux")]
fn main() -> gtk::glib::ExitCode {
punktfunk_core::tls::install_default_provider();
app::run()
}
+8 -1
View File
@@ -10,7 +10,14 @@ 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"
-1
View File
@@ -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"] }
+9 -3
View File
@@ -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`
@@ -84,7 +91,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
mdns-sd = "0.20"
async-channel = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+2 -2
View File
@@ -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
+1
View File
@@ -58,6 +58,7 @@ fn main() {
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
}
set_app_user_model_id();
punktfunk_core::tls::install_default_provider();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
+3 -1
View File
@@ -29,7 +29,9 @@ 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.
+318 -25
View File
@@ -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());
}
}
+8 -6
View File
@@ -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
+16 -3
View File
@@ -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` —
@@ -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"
@@ -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).
+39 -31
View File
@@ -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()),
}
}
+9 -1
View File
@@ -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.
+5 -1
View File
@@ -12,7 +12,11 @@ repository.workspace = true
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
pf-presenter = { path = "../pf-presenter" }
# MenuEvent/MenuPulse (the gamepad service's menu mode drives the library).
pf-client-core = { path = "../pf-client-core" }
# `default-features = false` like every other consumer (pf-presenter, cli, session, clients/windows):
# pf-client-core's default is `pyrowave`, which compiles the vendored PyroWave C++ — fatal on
# Windows ARM64. Whether that backend is on is the session binary's call (it forwards a `pyrowave`
# feature); this crate needs none of it, and taking defaults here quietly turned it on.
pf-client-core = { path = "../pf-client-core", default-features = false }
# Skia on the presenter's VkDevice (`vulkan`); `textlayout` = skparagraph/harfbuzz for
# the typography the console library needs (~15 MB stripped, prebuilt binaries exist for
+3 -3
View File
@@ -84,9 +84,9 @@ windows = { version = "0.62", features = [
"Win32_Storage_FileSystem",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
# D3DKMTSetProcessSchedulingPriorityClass — raise the host's WDDM GPU scheduling priority
# above a running game so PyroWave's compute-shader encode isn't starved (enc/windows/pyrowave.rs).
"Wdk_Graphics_Direct3D",
# ("Wdk_Graphics_Direct3D" used to be here for D3DKMTSetProcessSchedulingPriorityClass. That
# call lives in pf-frame's dxgi.rs and is resolved via GetProcAddress on gdi32 because
# windows-rs has no stable binding for it — so nothing in this crate ever used the feature.)
] }
[features]
@@ -2803,6 +2803,7 @@ mod tests {
plane1: None,
offset: 0,
stride: 64 * 4,
hold: None,
}
};
let fd_count = || std::fs::read_dir("/proc/self/fd").expect("procfs").count();
@@ -936,6 +936,7 @@ mod tests {
plane1: None,
offset: 0,
stride: 1920 * 4,
hold: None,
}),
cursor,
}
+30 -1
View File
@@ -586,6 +586,12 @@ struct Frame {
pts_ns: u64,
keyframe: bool,
recovery_anchor: bool,
/// The captured dmabuf's deferred-requeue hold ([`pf_frame::FrameHold`]), cloned at submit and
/// dropped when this slot retires (fence signaled — `poll`/backpressure/`reset`). This is what
/// extends "the producer must not rewrite the buffer" across the whole asynchronous GPU read:
/// the host's own clone only lives until it takes the NEXT frame, which with a ring of 2 is
/// before this slot's encode finished. `None` for non-dmabuf sources or un-held frames.
src_hold: Option<pf_frame::FrameHold>,
}
pub struct VulkanVideoEncoder {
@@ -2274,7 +2280,9 @@ impl VulkanVideoEncoder {
// First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled
// bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility
// barrier so the shader read sees the content the producer wrote out-of-band this frame
// (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off).
// (single-GPU coherent). The barrier orders nothing against the PRODUCER — content
// stability across this read is the frame's deferred-requeue hold (`Frame::src_hold`):
// the producer does not get the buffer back to rewrite until this slot's fence retires.
let (old, src_qf, dst_qf) = if fresh {
(
vk::ImageLayout::UNDEFINED,
@@ -3875,11 +3883,24 @@ impl VulkanVideoEncoder {
),
Err(e) => return Err(e.into()),
}
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release
// its hold so the capture layer requeues the producer's buffer.
self.frames[slot].src_hold = None;
let done = self.read_slot(slot)?;
self.pending.push_back(done);
}
let slot = self.ring;
self.ring = (self.ring + 1) % self.frames.len();
// Take over the frame's deferred-requeue hold for this occupancy BEFORE recording: the
// producer must not get the buffer back until this slot's fence retires (poll /
// backpressure / reset), because the encode reads the imported dmabuf for its whole
// duration — the host's own clone drops as soon as it takes the next frame. Assigned
// even if `record_submit` then fails: an over-hold until the slot's next tenant is
// harmless, a released-while-referenced buffer is the exact race this closes.
self.frames[slot].src_hold = match &frame.payload {
FramePayload::Dmabuf(d) => d.hold.clone(),
_ => None,
};
self.record_submit(slot, frame, wire)?;
self.in_flight.push_back(slot);
Ok(())
@@ -4002,6 +4023,9 @@ impl Encoder for VulkanVideoEncoder {
Err(e) => return Err(e.into()),
}
self.in_flight.pop_front();
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release its
// hold so the capture layer requeues the producer's buffer.
self.frames[slot].src_hold = None;
// SAFETY: fence signaled ⟹ this slot's CSC+encode is complete; read its bitstream.
Ok(Some(unsafe { self.read_slot(slot)? }))
}
@@ -4064,6 +4088,11 @@ impl Encoder for VulkanVideoEncoder {
}
self.in_flight.clear();
self.pending.clear();
// The waits above proved every slot's GPU read is done — release the captured-dmabuf
// holds so the capture layer (possibly mid-rebuild itself) gets its buffers back.
for f in &mut self.frames {
f.src_hold = None;
}
self.ring = 0;
self.first_frame = true;
self.force_kf = false;
+3
View File
@@ -692,6 +692,9 @@ fn encode_one(
plane1: req.plane1,
offset: req.offset,
stride: req.stride,
// The deferred-requeue hold stays host-side: this backend is synchronous at depth 1
// (see below), so the host's frame — hold and all — outlives the whole encode.
hold: None,
}),
cursor,
};
+23 -2
View File
@@ -231,6 +231,23 @@ pub struct CapturedFrame {
pub cursor: Option<CursorOverlay>,
}
/// Keeps the producer's buffer behind a zero-copy frame OUT of the producer's pool.
///
/// The fd on a [`DmabufFrame`] only keeps the buffer object from being *freed*; nothing stops the
/// compositor from *re-rendering into it* once the capture layer hands the buffer back — which it
/// used to do at `.process` return, before the encoder had even imported the dmabuf (the
/// gamescope-at-120fps torn-frame race). This handle is the fix: the PipeWire capture attaches one
/// to every raw-passthrough frame (pool depth permitting) and defers the requeue until the LAST
/// clone drops. A consumer that reads the dmabuf asynchronously (the Vulkan encoder's ring) clones
/// it into whatever tracks the read (its ring slot) and drops it when the GPU is provably done
/// (the slot's fence), so content stability covers exactly the read window. Consumers that finish
/// their read while the frame is alive need to do nothing — the frame's own clone is enough.
///
/// Opaque on purpose: the concrete guard lives in the capture crate; everyone else only clones and
/// drops.
#[cfg(target_os = "linux")]
pub type FrameHold = std::sync::Arc<dyn std::any::Any + Send + Sync>;
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
@@ -238,8 +255,9 @@ pub struct CapturedFrame {
///
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
/// imported there without the compositor's buffer being closed underneath it. Content stability
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
/// capture.
/// across the read window comes from [`hold`](Self::hold) when present (the producer does not get
/// the buffer back until the hold drops); a `None` hold falls back to the old contract — the
/// compositor's pool depth outrunning the import+encode window.
#[cfg(target_os = "linux")]
pub struct DmabufFrame {
pub fd: std::os::fd::OwnedFd,
@@ -253,6 +271,9 @@ pub struct DmabufFrame {
pub plane1: Option<(u32, u32)>,
pub offset: u32,
pub stride: u32,
/// Deferred-requeue hold on the producer's buffer (see [`FrameHold`]); `None` when the
/// capture could not spare a buffer from the pool (shallow pool, or `PUNKTFUNK_ZEROCOPY_HOLD=0`).
pub hold: Option<FrameHold>,
}
/// Where a captured frame's pixels live.
+4 -1
View File
@@ -41,7 +41,10 @@ wayland-backend = "0.3"
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal) + gamescope-EI.
reis = { version = "0.6.1", features = ["tokio"] }
futures-util = "0.3"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
# `macros` is for the `tokio::select!` in the libei and steam_usbip worker loops. It used to be
# absent and compile anyway, borrowed from punktfunk-core's `quic` feature via unification — i.e. an
# unrelated crate dropping it would have broken this one.
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "macros"] }
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
xkbcommon = "0.8"
# Vendored + trimmed usbip server core — presents a virtual Steam Deck over USB/IP for Steam Input.
+18 -4
View File
@@ -22,13 +22,27 @@ publish = false
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Ed25519 over the exact manifest bytes. The workspace is ring-only (no aws-lc-sys — it fails
# on the Windows CI runner), and this is the same primitive the plugin-store index uses.
ring = "0.17"
# Ed25519 over the exact manifest bytes — the same primitive the plugin-store index uses, on the
# workspace's one crypto backend. aws-lc-rs's API is ring-compatible, so the call sites are
# unchanged apart from the crate name.
#
# `prebuilt-nasm` is what lets aws-lc-sys build on Windows x86_64 without NASM installed. rustls
# enables it for its own dependents, but a build that selects THIS crate without one that turns on
# rustls's `aws_lc_rs` feature — `cargo test -p pf-update-check` is exactly that, since its only
# rustls comes from ureq's ring-flavoured dependency — would get no enabler and fail on the CI
# runner. Naming it here makes the crate build standalone instead of relying on who else is in
# the selection.
aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] }
base64 = "0.22"
# Small, sync, bundles webpki roots — no system cert store dependency, which matters on the
# Deck (Decky's embedded Python has no usable roots either; see clients/decky/main.py).
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 deliberately moved to aws-lc-rs.
ureq = { version = "3", default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
"gzip",
] }
[lints]
workspace = true
+19 -16
View File
@@ -79,17 +79,18 @@ pub fn fetch_manifest_blocking(
"no update key is pinned in this build".into(),
));
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
.redirects(3)
.user_agent(user_agent)
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(FETCH_TIMEOUT))
.max_redirects(3)
.user_agent(user_agent.to_string())
.build()
.into();
let url = format!("{base}/{channel}/manifest.json");
let sig_url = format!("{url}.sig");
// Only the MANIFEST leg can report an empty channel; see [`FeedError::NotPublished`].
let body = read_capped(agent.get(&url).call().map_err(manifest_err)?)?;
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
let body = read_capped(&mut agent.get(&url).call().map_err(manifest_err)?)?;
let sig = read_capped(&mut agent.get(&sig_url).call().map_err(fetch_err)?)?;
let sig_text = String::from_utf8(sig)
.map_err(|_| FeedError::Failed("signature file is not text".into()))?;
@@ -100,24 +101,26 @@ pub fn fetch_manifest_blocking(
/// The manifest leg: a 404 here means the channel is empty, not broken.
fn manifest_err(e: ureq::Error) -> FeedError {
match e {
ureq::Error::Status(404, _) => FeedError::NotPublished,
ureq::Error::StatusCode(404) => FeedError::NotPublished,
other => fetch_err(other),
}
}
fn fetch_err(e: ureq::Error) -> FeedError {
FeedError::Failed(match e {
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
ureq::Error::StatusCode(code) => format!("feed returned HTTP {code}"),
other => format!("feed fetch failed: {other}"),
})
}
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, FeedError> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1);
reader
.read_to_end(&mut buf)
fn read_capped(resp: &mut ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, FeedError> {
// cap+1 so an over-cap body is rejected by the length check rather than silently truncated
// into something that would then fail signature verification for the wrong reason.
let buf = resp
.body_mut()
.with_config()
.limit(MAX_MANIFEST_BYTES as u64 + 1)
.read_to_vec()
.map_err(|e| FeedError::Failed(format!("read failed: {e}")))?;
if buf.len() > MAX_MANIFEST_BYTES {
return Err(FeedError::Failed(
@@ -143,7 +146,7 @@ mod tests {
}
fn status(code: u16) -> ureq::Error {
ureq::Error::Status(code, ureq::Response::new(code, "status", "").unwrap())
ureq::Error::StatusCode(code)
}
/// The whole point of the split: an empty channel is not a broken feed.
+8 -7
View File
@@ -40,7 +40,8 @@ pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Res
.decode(sig_text.trim())
.context("signature file is not valid base64")?;
for key in keys {
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
let pk =
aws_lc_rs::signature::UnparsedPublicKey::new(&aws_lc_rs::signature::ED25519, &key.0);
if pk.verify(bytes, &sig).is_ok() {
return Ok(());
}
@@ -52,14 +53,14 @@ pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Res
pub(crate) mod tests {
use super::*;
/// A fresh ring keypair as `(pinned key string, signer)` — the format contract with the
/// A fresh keypair as `(pinned key string, signer)` — the format contract with the
/// CI signers (raw 32-byte key, `ed25519:<base64>`; raw 64-byte signature, base64).
pub(crate) fn keypair() -> (String, ring::signature::Ed25519KeyPair) {
pub(crate) fn keypair() -> (String, aws_lc_rs::signature::Ed25519KeyPair) {
use aws_lc_rs::signature::KeyPair as _;
use base64::Engine as _;
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let rng = aws_lc_rs::rand::SystemRandom::new();
let pkcs8 = aws_lc_rs::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = aws_lc_rs::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_str = format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
+141 -19
View File
@@ -321,11 +321,7 @@ pub fn detect() -> Result<Compositor> {
#[cfg(target_os = "linux")]
{
if let Some(v) = pf_host_config::config().compositor.as_deref() {
return compositor_from_pin(v).ok_or_else(|| {
anyhow::anyhow!(
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
)
});
return compositor_from_pin(v).ok_or_else(|| unknown_pin_error(v));
}
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
@@ -338,20 +334,78 @@ pub fn detect() -> Result<Compositor> {
let desktop = with_env_lock(|| std::env::var("XDG_CURRENT_DESKTOP"))
.unwrap_or_default()
.to_ascii_uppercase();
if desktop.contains("KDE") {
Ok(Compositor::Kwin)
} else if desktop.contains("GNOME") {
Ok(Compositor::Mutter)
} else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
compositor_from_xdg(&desktop)
}
}
/// The error for a `PUNKTFUNK_COMPOSITOR` value that names no backend.
///
/// `cinnamon`/`muffin` get their own answer rather than the bare list: it is the value a Mint or
/// LMDE user reaches for first, and the plain list invites them to try the next-closest name
/// (`mutter` — Muffin *is* a Mutter fork), which starts a session that then fails deep inside a
/// `org.gnome.Mutter.ScreenCast` call Muffin does not serve. There is no working value; say so, and
/// name the route that does work.
#[cfg(target_os = "linux")]
fn unknown_pin_error(v: &str) -> anyhow::Error {
const ACCEPTED: &str = "kwin|wlroots|hyprland|mutter|gamescope";
if matches!(
v.trim().to_ascii_lowercase().as_str(),
"cinnamon" | "muffin"
) {
return anyhow::anyhow!(
"PUNKTFUNK_COMPOSITOR='{v}' is not a backend and cannot become one: Cinnamon's \
compositor Muffin has no virtual-output API (no `RecordVirtual`), so it cannot make a \
screen for a client. Do NOT substitute 'mutter' Muffin is a Mutter fork but serves \
none of that interface. Use PUNKTFUNK_COMPOSITOR=gamescope to stream games through a \
headless gamescope, which needs no desktop compositor. See \
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
);
}
anyhow::anyhow!("unknown PUNKTFUNK_COMPOSITOR '{v}' ({ACCEPTED})")
}
/// The last-resort `XDG_CURRENT_DESKTOP` sniff, as a **pure function of the (uppercased) value** so
/// its branches — including the two that only ever produce an error — are testable without mutating
/// process-global env. Called only by [`detect`], after both the operator pin and live-session
/// detection have come up empty.
#[cfg(target_os = "linux")]
fn compositor_from_xdg(desktop: &str) -> Result<Compositor> {
// CINNAMON is tested FIRST, ahead of GNOME, and the order is load-bearing rather than
// stylistic: Cinnamon is a GNOME derivative, so a session that advertises both (`X-Cinnamon`
// alongside a GNOME-compatibility token) would otherwise match the GNOME arm and be handed the
// Mutter backend — which then fails deep in a `org.gnome.Mutter.ScreenCast` call that Muffin
// does not serve, i.e. an obscure D-Bus error instead of the explanation below. The more
// specific desktop wins.
if desktop.contains("CINNAMON") {
// Linux Mint / LMDE report `X-Cinnamon`. Cinnamon is NOT a missing backend we could add —
// its compositor (Muffin) exposes no virtual-output API at all: the fork base is Mutter
// 3.36, and `org.cinnamon.Muffin.ScreenCast` carries only `RecordMonitor` / `RecordWindow`,
// never Mutter 42+'s `RecordVirtual`. Its portal backend (xdg-desktop-portal-xapp)
// implements no ScreenCast either, so the sway/Hyprland portal route is closed too. The
// generic message below would send a Cinnamon user hunting for the setting that turns it
// on; there isn't one. Name the ONE route that does work on that box — a headless
// gamescope, which needs no desktop compositor at all — instead of a dead end.
anyhow::bail!(
"Cinnamon (XDG_CURRENT_DESKTOP='{desktop}') cannot host a virtual display: its \
compositor Muffin has no virtual-output API, so Punktfunk cannot create a screen \
for a client on it. Stream games instead by setting PUNKTFUNK_COMPOSITOR=gamescope \
in host.env the host then spawns its own headless gamescope per connect and needs \
no desktop session. See \
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
)
} else if desktop.contains("KDE") {
Ok(Compositor::Kwin)
} else if desktop.contains("GNOME") {
Ok(Compositor::Mutter)
} else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
}
@@ -811,6 +865,74 @@ mod wlroots;
mod tests {
use super::*;
/// The XDG sniff is the last thing standing between an unrecognized desktop and a useless
/// error, and `mgmt/display.rs` puts that error VERBATIM in the console's `/display/monitors`
/// response — so its exact wording is a user-facing surface, tested as one.
#[cfg(target_os = "linux")]
#[test]
fn xdg_sniff_maps_known_desktops() {
// Real-world values, uppercased the way `detect` hands them over.
assert_eq!(compositor_from_xdg("KDE").unwrap(), Compositor::Kwin);
assert_eq!(compositor_from_xdg("GNOME").unwrap(), Compositor::Mutter);
assert_eq!(
compositor_from_xdg("UBUNTU:GNOME").unwrap(),
Compositor::Mutter
);
assert_eq!(
compositor_from_xdg("HYPRLAND").unwrap(),
Compositor::Hyprland
);
assert_eq!(compositor_from_xdg("SWAY").unwrap(), Compositor::Wlroots);
}
/// Cinnamon must NOT fall into the generic "set PUNKTFUNK_COMPOSITOR" arm: Muffin has no
/// virtual-output API, so there is no value of that variable which makes a Cinnamon desktop
/// host a virtual display. The error has to name gamescope — the one route that works on an
/// LMDE/Mint box — or the user is sent hunting for a setting that does not exist.
#[cfg(target_os = "linux")]
#[test]
fn cinnamon_is_told_to_use_gamescope_not_to_pick_a_backend() {
// `X-Cinnamon` is what Mint and LMDE actually set.
for v in ["X-CINNAMON", "CINNAMON", "X-CINNAMON:GNOME-FLASHBACK"] {
let err = compositor_from_xdg(v)
.expect_err("Cinnamon cannot host a virtual display")
.to_string();
assert!(err.contains("gamescope"), "no gamescope route named: {err}");
assert!(err.contains("Muffin"), "does not say why: {err}");
}
}
/// Pinning `cinnamon` explicitly must not answer with the plain list of accepted values: the
/// next thing a Mint user tries is `mutter` (Muffin is a Mutter fork), which fails much later
/// and much less clearly. A typo'd pin still gets the ordinary list.
#[cfg(target_os = "linux")]
#[test]
fn pinning_cinnamon_explains_instead_of_listing_backends() {
for v in ["cinnamon", "Cinnamon", "muffin", " MUFFIN "] {
let err = unknown_pin_error(v).to_string();
assert!(err.contains("gamescope"), "no working route named: {err}");
assert!(
err.contains("Muffin"),
"does not explain why it cannot work: {err}"
);
}
let typo = unknown_pin_error("kwim").to_string();
assert!(
typo.contains("kwin|wlroots|hyprland|mutter|gamescope"),
"{typo}"
);
assert!(!typo.contains("Muffin"), "{typo}");
}
/// An unknown desktop keeps the generic advice — the Cinnamon arm must not swallow it.
#[cfg(target_os = "linux")]
#[test]
fn unknown_desktop_keeps_the_generic_error() {
let err = compositor_from_xdg("XFCE").unwrap_err().to_string();
assert!(err.contains("PUNKTFUNK_COMPOSITOR"), "{err}");
assert!(!err.contains("Muffin"), "{err}");
}
#[test]
fn active_kind_maps_to_its_backend() {
assert_eq!(
+8 -2
View File
@@ -111,9 +111,15 @@ struct OperatorGamescope {
#[cfg(target_os = "linux")]
fn operator_gamescope() -> &'static OperatorGamescope {
OPERATOR_GAMESCOPE.get_or_init(|| {
// Explicit-off grammar, NOT a presence test. These two used to be `var_os(..).is_some()`,
// which read `PUNKTFUNK_GAMESCOPE_ATTACH=0` as ATTACH ON — the exact opposite of what the
// line says, and of every other knob on this host (`env_on` is shared for that reason).
// Anyone turning a shipped `=1` off does it the way the rest of the file works, and the
// rung this feeds outranks a dedicated game session, so a silent inversion here costs the
// client its own display for the whole stream.
let ov = with_env_lock(|| OperatorGamescope {
managed: std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
attach: std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
managed: pf_host_config::env_on("PUNKTFUNK_GAMESCOPE_MANAGED").unwrap_or(false),
attach: pf_host_config::env_on("PUNKTFUNK_GAMESCOPE_ATTACH").unwrap_or(false),
node: std::env::var("PUNKTFUNK_GAMESCOPE_NODE")
.ok()
.filter(|v| !v.is_empty()),
+4 -2
View File
@@ -16,9 +16,11 @@ publish = false
[target.'cfg(target_os = "windows")'.dependencies]
# `Mode` (the negotiated display mode) is the core wire type; `pf-paths` for the pnp-disabled-monitors
# state file.
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# Just `punktfunk_core::Mode` (win_display.rs), which lives in the ungated `config` module — the
# `quic` feature this used to request dragged quinn/tokio/rcgen/hmac/spake2/opus/rustls into a leaf
# crate's declared closure for one type.
punktfunk-core = { path = "../punktfunk-core", default-features = false }
pf-paths = { path = "../pf-paths" }
anyhow = "1"
tracing = "0.1"
# The pnp-disabled-monitors state file (a `Vec<String>` of instance ids) is serialized as JSON.
serde_json = "1"
+30 -8
View File
@@ -24,6 +24,12 @@ tls = ["dep:rustls", "dep:sha2", "dep:rustls-pki-types"]
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
# never on the per-frame hot path. Off by default so the core stays runtime-free.
quic = ["tls", "dep:quinn", "dep:tokio", "dep:rcgen", "dep:hmac", "dep:spake2", "dep:opus"]
# Blocking-HTTP clients that must speak the SAME pinned TLS as the QUIC plane: `tls::ureq_agent`
# hands ureq a caller-built `rustls::ClientConfig` (which is how `PinVerify` gets installed —
# ureq's own `TlsConfig` has no hook for a custom verifier). Separate from `tls` because the
# cdylib/staticlib embedders (Apple, Android) pin the host themselves over QUIC and have no use
# for an HTTP stack; only the desktop clients and the tray turn this on.
ureq-tls = ["tls", "dep:ureq"]
[dependencies]
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
@@ -39,7 +45,6 @@ aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
chacha20poly1305 = "0.10"
zerocopy = { version = "0.8", features = ["derive"] }
bytes = "1"
socket2 = { version = "0.6", features = [
"all",
] } # SO_SNDBUF/SO_RCVBUF growth (default UDP buffers too small for 4K/5K bursts) + DSCP/SO_PRIORITY media QoS
@@ -52,14 +57,31 @@ zeroize = "1"
# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake.
if-addrs = "0.13"
quinn = { version = "0.11", optional = true }
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
# Crypto backend pinned to `ring` (matching rustls/quinn above) so the whole quic tree is
# ring-only: no aws-lc-rs/aws-lc-sys (heavy C dep, needs cmake) is pulled in. Keeps the
# Android/iOS cdylib lean and the cross-compile cmake-free. `generate_simple_self_signed`
# is backend-agnostic, so the swap is transparent.
rcgen = { version = "0.13", optional = true, default-features = false, features = ["ring", "pem"] }
# Crypto backend is aws-lc-rs, and rustls/quinn/rcgen must all name it: they each select a
# backend independently, so one dissenter pulls a SECOND crypto stack in via feature unification.
# `prefer-post-quantum` puts the X25519MLKEM768 hybrid key exchange first in the TLS 1.3
# handshake, which is the reason the old `ring` pin is gone — ring has no ML-KEM.
# Windows needs no NASM: rustls's `aws_lc_rs` feature enables `aws-lc-rs/prebuilt-nasm`.
# quinn's feature list is its own default set with `rustls-ring` swapped out, nothing more.
quinn = { version = "0.11", optional = true, default-features = false, features = [
"log",
"platform-verifier",
"runtime-tokio",
"rustls-aws-lc-rs",
"bloom",
] }
rustls = { version = "0.23", optional = true, default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std"] }
# `generate_simple_self_signed` is backend-agnostic, so the swap is transparent here.
rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs", "pem"] }
rustls-pki-types = { version = "1", optional = true }
# `rustls-no-provider`, NOT the default `rustls` feature — ureq's `rustls` feature body pulls
# `_ring`, which would drag the whole ring backend back into a tree that has deliberately moved to
# aws-lc-rs. `rustls-webpki-roots` supplies the CA set for the non-pinned origins (cover-art CDNs).
ureq = { version = "3", optional = true, default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
"gzip",
] }
sha2 = { version = "0.10", optional = true }
hmac = { version = "0.12", optional = true }
spake2 = { version = "0.4", optional = true }
+5 -5
View File
@@ -176,7 +176,7 @@ fn server_from_der(
addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
// one that presents none still connects (and is rejected at the app layer when
@@ -254,7 +254,7 @@ pub fn client_pinned_with_identity(
) -> PinnedClient {
let observed = Arc::new(Mutex::new(None));
let ep = (|| {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let builder = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed(
@@ -354,7 +354,7 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
@@ -368,12 +368,12 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
+22 -3
View File
@@ -8,6 +8,25 @@
use std::sync::{Arc, Mutex};
/// A blocking HTTP agent over a caller-built `rustls::ClientConfig` — the only way to give an
/// HTTP client the [`PinVerify`] verifier below.
#[cfg(feature = "ureq-tls")]
pub mod ureq_agent;
/// Install aws-lc-rs as this process's rustls provider. Call once, early, from `main`.
///
/// aws-lc-rs is currently the ONLY backend in the tree, so rustls can infer it and this call is
/// belt-and-braces rather than load-bearing. It is kept because the inference is what breaks
/// first: the moment any dependency drags a second backend in — which is exactly what `ureq 2`
/// used to do, naming `rustls/ring` in its own dependency line where no dependent could switch it
/// off — rustls stops guessing, and every config built through `ClientConfig::builder()` rather
/// than `builder_with_provider` **panics** instead of picking one. Calling this makes the choice
/// explicit and survives that. Idempotent: losing the race to another installer is the expected
/// outcome, not an error, since every caller in this workspace installs the same provider.
pub fn install_default_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. Re-exported as
/// `crate::quic::endpoint::cert_fingerprint` for callers that already reach it there.
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
@@ -91,7 +110,7 @@ impl rustls::client::danger::ServerCertVerifier for PinVerify {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
@@ -105,12 +124,12 @@ impl rustls::client::danger::ServerCertVerifier for PinVerify {
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
+116
View File
@@ -0,0 +1,116 @@
//! A blocking [`ureq::Agent`] that speaks TLS through a caller-supplied
//! [`rustls::ClientConfig`] — which is the only way to get [`PinVerify`](super::PinVerify) into an
//! HTTP client, because ureq's own `TlsConfig` exposes roots, a client cert and an
//! off-switch, but no hook for a custom [`ServerCertVerifier`](rustls::client::danger::ServerCertVerifier).
//!
//! Every caller here pins the host's self-signed leaf by fingerprint (the same trust rule as the
//! QUIC plane), so "just use the default agent" is not an option: the default agent validates
//! against webpki roots, which a self-signed host cert can never satisfy.
//!
//! The connector below is modelled on ureq 3.x's own (crate-private) `RustlsConnector` minus its
//! `TlsConfig`-driven config-building step. It is transport glue, not crypto: the handshake, the
//! verifier and the cipher suites all live in the `ClientConfig` the caller hands in.
use std::io::{Read as _, Write as _};
use std::sync::Arc;
use ureq::unversioned::resolver::DefaultResolver;
use ureq::unversioned::transport::{
Buffers, ConnectionDetails, Connector, Either, LazyBuffers, NextTimeout, TcpConnector,
Transport, TransportAdapter,
};
/// Build an agent whose HTTPS connections use `tls` verbatim, with `config` for everything else
/// (timeouts, redirect policy, buffer sizes) — built by the caller via
/// [`ureq::Agent::config_builder`], since those knobs differ per call site.
pub fn agent(tls: Arc<rustls::ClientConfig>, config: ureq::config::Config) -> ureq::Agent {
let connector = TcpConnector::default().chain(PinnedTlsConnector { config: tls });
ureq::Agent::with_parts(config, connector, DefaultResolver::default())
}
struct PinnedTlsConnector {
config: Arc<rustls::ClientConfig>,
}
impl std::fmt::Debug for PinnedTlsConnector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PinnedTlsConnector").finish()
}
}
impl<In: Transport> Connector<In> for PinnedTlsConnector {
type Out = Either<In, PinnedTlsTransport>;
fn connect(
&self,
details: &ConnectionDetails,
chained: Option<In>,
) -> Result<Option<Self::Out>, ureq::Error> {
let Some(transport) = chained else {
// Unreachable via `agent()` above, which always chains onto a TcpConnector.
return Err(ureq::Error::Tls("no chained transport to wrap in TLS"));
};
// A plain-HTTP URL, or something that already negotiated TLS, passes straight through.
if !details.needs_tls() || transport.is_tls() {
return Ok(Some(Either::A(transport)));
}
let name: rustls::pki_types::ServerName<'_> = details
.uri
.authority()
.ok_or(ureq::Error::Tls("uri has no authority"))?
.host()
.try_into()
.map_err(|_| ureq::Error::Tls("invalid DNS name"))?;
let conn = rustls::ClientConnection::new(self.config.clone(), name.to_owned())?;
let stream = rustls::StreamOwned {
conn,
sock: TransportAdapter::new(transport.boxed()),
};
let buffers = LazyBuffers::new(
details.config.input_buffer_size(),
details.config.output_buffer_size(),
);
Ok(Some(Either::B(PinnedTlsTransport { buffers, stream })))
}
}
struct PinnedTlsTransport {
buffers: LazyBuffers,
stream: rustls::StreamOwned<rustls::ClientConnection, TransportAdapter>,
}
impl std::fmt::Debug for PinnedTlsTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PinnedTlsTransport").finish()
}
}
impl Transport for PinnedTlsTransport {
fn buffers(&mut self) -> &mut dyn Buffers {
&mut self.buffers
}
fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> Result<(), ureq::Error> {
self.stream.get_mut().set_timeout(timeout);
let output = &self.buffers.output()[..amount];
self.stream.write_all(output)?;
Ok(())
}
fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
self.stream.get_mut().set_timeout(timeout);
let input = self.buffers.input_append_buf();
let amount = self.stream.read(input)?;
self.buffers.input_appended(amount);
Ok(amount > 0)
}
fn is_open(&mut self) -> bool {
self.stream.get_mut().get_mut().is_open()
}
fn is_tls(&self) -> bool {
true
}
}
+44 -57
View File
@@ -48,7 +48,15 @@ pf-vdisplay = { path = "../pf-vdisplay" }
# compiles everywhere; the backends are cfg-gated inside it.
pf-clipboard = { path = "../pf-clipboard" }
# M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP).
quinn = "0.11"
# Feature list = quinn's own defaults with `rustls-ring` swapped for `rustls-aws-lc-rs`; every
# crate selecting a rustls backend must agree or feature unification builds both (see core).
quinn = { version = "0.11", default-features = false, features = [
"log",
"platform-verifier",
"runtime-tokio",
"rustls-aws-lc-rs",
"bloom",
] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -66,7 +74,6 @@ mdns-sd = "0.20"
mac_address = "1"
if-addrs = "0.13"
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12"
# GameStream-only (behind the `gamestream` feature): the Moonlight RSA-2048 identity generator +
# pairing signer (cert.rs, pairing.rs) and the legacy-client-cert leniency verifier (tls.rs).
# The native planes use the P-256 identity (src/identity.rs) and never touch this crate — so a
@@ -85,21 +92,28 @@ base64 = "0.22"
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
ureq = "2"
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`, which would
# put the ring backend back into a tree that has deliberately moved to aws-lc-rs.
ureq = { version = "3", default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
"gzip",
] }
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
x509-parser = "0.16"
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
axum-server = "0.8"
# Ring backend, NOT the (default) aws-lc-rs one — matches punktfunk-core + the client so the whole
# tree stays ring-only (no aws-lc-sys: a heavy C dep that fails to build on the Windows CI runner).
# aws-lc-rs backend, matching punktfunk-core + the clients (a dissenting crate would pull a
# second crypto stack in). `prefer-post-quantum` offers X25519MLKEM768 first on the mgmt/native
# TLS 1.3 listeners; classical curves stay in the list, so a client without ML-KEM still connects.
# Keep `tls12` for GameStream/Moonlight clients that negotiate TLS 1.2.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12", "logging"] }
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
# verified fingerprint injected as a request extension. Versions match the workspace lock.
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12", "logging"] }
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
tower = { version = "0.5", features = ["util"] }
@@ -122,10 +136,12 @@ serde_json = "1"
# utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the
# spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted
# verbatim by the `openapi` subcommand. Control plane only — never the per-frame path.
# Plugin-store index signatures: ed25519 verification of a catalog document before any field of
# it is read (store/index.rs). Already in the tree via rustls — the workspace is ring-only, so this
# is the one signature primitive available without pulling aws-lc-sys (which fails on Windows CI).
ring = "0.17"
# SHA-256 over the downloaded installer and its signing-leaf DER (update/windows.rs) — Windows-only
# in practice, the plugin-store's ed25519 path re-exports pf-update-check's verifier. Already in the
# tree via rustls; its API is ring-compatible by design, which is what made the swap mechanical.
# `prebuilt-nasm`: see pf-update-check's copy — it keeps aws-lc-sys building on Windows x86_64
# with no NASM on the box, whatever else the build happens to select.
aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] }
# Semver comparisons for the plugin store: `minHost` gating and the revocation list's version
# ranges (`<0.3.2`). Already in the lockfile transitively.
semver = "1"
@@ -134,8 +150,9 @@ utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] }
[dev-dependencies]
# Drive the management API router in-process (no socket) in the handler tests.
tower = { version = "0.5", features = ["util"] }
# (`tower` drives the management API router in-process in the handler tests, but it is already a
# normal dependency above and those are visible to tests — the dev-dependency re-declaration was
# redundant.)
http-body-util = "0.1"
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
tempfile = "3"
@@ -157,51 +174,21 @@ libc = "0.2"
# Must match the pipewire crate ashpd 0.13 links (libspa/pipewire-sys `links` key is
# unique per build), i.e. 0.9 — NOT the 0.10 the setup doc mentions.
pipewire = "0.9"
# ashpd 0.13 uses the tokio runtime; a current-thread runtime drives the one-time
# portal handshake (control plane — never the per-frame path).
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
# Input injection into headless Sway via the wlroots virtual-input Wayland protocols
# (uinput won't reach a compositor running with WLR_LIBINPUT_NO_DEVICES=1).
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
wayland-protocols-misc = { version = "0.3", features = ["client"] }
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
wayland-protocols = { version = "0.32", features = ["client"] }
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
# `wayland-backend` is referenced by the generated interface tables.
wayland-scanner = "0.31"
wayland-backend = "0.3"
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
serde_json = "1"
# (ashpd 0.13 drives the one-time portal handshake on tokio; the unconditional `tokio = "full"`
# in [dependencies] already covers that — this block used to re-declare a strict subset of it.)
# NOTE: the Wayland stack (wayland-client / -protocols{,-wlr,-misc} / -scanner / -backend),
# `xkbcommon`, `reis`, `khronos-egl`, `ash` and `usbip-sim` used to be declared here. The code that
# used them moved to `pf-inject` (virtual input, libei, USB/IP) and `pf-zerocopy` (EGL/Vulkan dmabuf)
# in the subsystem extraction, and each of those crates declares them itself — only the manifest
# entries were left behind, along with comments describing code this crate no longer contains.
# Verified unused before removal: zero `use`/path references across src/ + build.rs.
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
rusqlite = { version = "0.40", features = ["bundled"] }
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
xkbcommon = "0.8"
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal).
# The `tokio` feature wires reis's event stream into tokio's reactor.
reis = { version = "0.6.1", features = ["tokio"] }
# `StreamExt::next` on reis's tokio event stream in the libei worker loop.
futures-util = "0.3"
# Zero-copy capture (plan §9): EGL imports the PipeWire dmabuf, CUDA maps it, NVENC encodes
# it with no CPU roundtrip. `khronos-egl` (dynamic = load the NVIDIA libEGL at runtime) gives
# eglCreateImage + the dma_buf import; the CUDA driver API (EGL interop) and libgbm are linked
# via hand-rolled FFI in `src/zerocopy/` (no Rust crate exposes the EGL-interop driver calls).
khronos-egl = { version = "6", features = ["dynamic"] }
# Vulkan bridge for LINEAR dmabufs (gamescope): import via VK_EXT_external_memory_dma_buf,
# GPU-copy into an exportable allocation, export OPAQUE_FD → cuImportExternalMemory (the
# officially-supported CUDA pairing; raw dmabuf fds are rejected by the desktop driver).
ash = "0.38"
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
libloading = "0.8"
# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP
# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable
# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE.
usbip-sim = { path = "vendor/usbip-sim" }
[target.'cfg(target_os = "windows")'.dependencies]
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
@@ -300,12 +287,12 @@ winreg = "0.56"
roxmltree = "0.21"
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
wasapi = "0.23"
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
# to/from the DeviceIoControl byte buffers.
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend: the
# control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs, defined ONCE so host<->driver
# ABI drift is a compile error (used from `capture.rs`). The `bytemuck` that serializes those
# structs into the DeviceIoControl buffers is pf-vdisplay's dependency, not this crate's — it was
# declared here too, unused.
pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
# The encode feature flags now FORWARD to the pf-encode subsystem crate (the heavy encoder deps —
# ffmpeg-next, the NVENC SDK, openh264, pyrowave-sys — moved there, plan §W6). Selecting a feature
+1 -1
View File
@@ -459,7 +459,7 @@ pub fn serve(
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
rt.block_on(async move {
// rustls needs a process-wide crypto provider before any TLS config is built.
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let native_opts = crate::native::native_serve_opts(&native);
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
+1 -1
View File
@@ -292,7 +292,7 @@ fn build_server_config(
key_pem: &str,
mandatory: bool,
) -> Result<Arc<ServerConfig>> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
// so we don't pull the unmaintained `rustls-pemfile`.
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
+10 -9
View File
@@ -811,11 +811,12 @@ fn webhook_host_is_internal(url: &str) -> bool {
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
let agent = ureq::builder()
.redirects(0)
.timeout(WEBHOOK_TIMEOUT)
.build();
let mut req = agent.post(url).set("Content-Type", "application/json");
let agent: ureq::Agent = ureq::Agent::config_builder()
.max_redirects(0)
.timeout_global(Some(WEBHOOK_TIMEOUT))
.build()
.into();
let mut req = agent.post(url).header("Content-Type", "application/json");
if let Some(path) = secret_file {
match std::fs::read(path) {
Ok(secret) => {
@@ -829,7 +830,7 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
};
mac.update(json.as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
req = req.set("X-Punktfunk-Signature", &format!("sha256={sig}"));
req = req.header("X-Punktfunk-Signature", &format!("sha256={sig}"));
}
Err(e) => {
// A configured-but-unreadable secret means the operator WANTS signing —
@@ -840,9 +841,9 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
}
}
}
match req.send_string(json) {
Ok(resp) => tracing::debug!(url, status = resp.status(), "webhook delivered"),
Err(ureq::Error::Status(code, _)) => {
match req.send(json) {
Ok(resp) => tracing::debug!(url, status = resp.status().as_u16(), "webhook delivered"),
Err(ureq::Error::StatusCode(code)) => {
tracing::warn!(url, status = code, "webhook rejected by receiver")
}
Err(e) => tracing::warn!(url, error = %e, "webhook delivery failed"),
+27 -16
View File
@@ -86,14 +86,21 @@ fn warm_art_once() {
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
/// art is non-essential, so a failure just leaves the title-only card).
fn fetch_json(url: &str) -> Option<serde_json::Value> {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(10)))
// Don't follow redirects — a redirect target (`3xx` → `http://169.254.169.254/…` or an
// internal host) would be an SSRF pivot from the privileged host. Matches the webhook path
// (security-review 2026-07-17). A rare legitimately-redirecting CDN just yields no art.
.redirects(0)
.build();
let body = agent.get(url).call().ok()?.into_string().ok()?;
.max_redirects(0)
.build()
.into();
let body = agent
.get(url)
.call()
.ok()?
.body_mut()
.read_to_string()
.ok()?;
serde_json::from_str(&body).ok()
}
@@ -103,7 +110,6 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
use base64::Engine as _;
use std::io::Read as _;
if let Some(rest) = url.strip_prefix("data:") {
// data:[<mediatype>][;base64],<payload>
let (meta, data) = rest.split_once(',')?;
@@ -125,22 +131,27 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(10)))
// Don't follow redirects (SSRF pivot): this is called on launcher-cache- and custom-entry-
// supplied URLs, so a `3xx` to an internal/metadata endpoint must not be chased by the
// privileged host. Matches the webhook path (security-review 2026-07-17).
.redirects(0)
.build();
let resp = agent.get(url).call().ok()?;
.max_redirects(0)
.build()
.into();
let mut resp = agent.get(url).call().ok()?;
let ctype = resp
.header("Content-Type")
.headers()
.get("Content-Type")
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let mut bytes = Vec::new();
resp.into_reader()
.take(8 * 1024 * 1024)
.read_to_end(&mut bytes)
// The 8 MiB cap is now the body reader's own limit rather than a `take()` on the stream.
let bytes = resp
.body_mut()
.with_config()
.limit(8 * 1024 * 1024)
.read_to_vec()
.ok()?;
(!bytes.is_empty()).then_some((bytes, ctype))
}
@@ -32,7 +32,6 @@
//! itself would land it outside the captured session and outside that lifetime.
use super::*;
use std::io::Read;
use std::time::Duration;
/// The whole ask, end to end. A plugin resolving one of its own entries is a local lookup against
@@ -96,23 +95,26 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
);
return None;
};
let agent = ureq::AgentBuilder::new().timeout(ASK_TIMEOUT).build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(ASK_TIMEOUT))
.build()
.into();
// Loopback + the plugin's own per-boot secret, exactly what the console proxy presents. The
// registration stores a PORT, never an address (mgmt::plugins D5), so this can only ever dial
// this machine.
// `send_string` + an explicit content type rather than `send_json`: that one needs ureq's `json`
// `send` with an explicit content type rather than `send_json`: that one needs ureq's `json`
// feature, and the body is one field.
let body = serde_json::json!({ "entry": key }).to_string();
let resp = match agent
.post(&format!("http://127.0.0.1:{}/__launch", cred.port))
.set("Authorization", &format!("Bearer {}", cred.secret))
.set("Content-Type", "application/json")
.send_string(&body)
.header("Authorization", &format!("Bearer {}", cred.secret))
.header("Content-Type", "application/json")
.send(&body)
{
Ok(r) => r,
// A plugin that does not know the entry says so with a 404 — the answer a FORGED entry gets,
// and the reason planting one is not enough to make the host run anything.
Err(ureq::Error::Status(404, _)) => {
Err(ureq::Error::StatusCode(404)) => {
tracing::warn!(
plugin,
entry = key,
@@ -120,7 +122,7 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
);
return None;
}
Err(ureq::Error::Status(code, _)) => {
Err(ureq::Error::StatusCode(code)) => {
tracing::warn!(
plugin,
entry = key,
@@ -139,15 +141,20 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
return None;
}
};
let mut buf = Vec::new();
if let Err(e) = resp
.into_reader()
.take((MAX_BODY + 1) as u64)
.read_to_end(&mut buf)
let mut resp = resp;
// cap+1 so an over-cap answer is caught by the length check below rather than truncated.
let buf = match resp
.body_mut()
.with_config()
.limit((MAX_BODY + 1) as u64)
.read_to_vec()
{
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
return None;
}
Ok(b) => b,
Err(e) => {
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
return None;
}
};
if buf.len() > MAX_BODY {
tracing::warn!(
plugin,
@@ -226,7 +233,7 @@ fn validate_reply(plugin: &str, key: &str, reply: LaunchReply) -> Option<PluginL
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::io::{Read, Write};
/// A one-shot HTTP/1.1 stub on an ephemeral loopback port. Returns the port and a handle that
/// yields the raw request text — so the assertions about what the HOST sent (method, path,
@@ -274,13 +281,27 @@ mod tests {
#[test]
fn asks_the_registered_plugin_and_takes_its_answer() {
let (port, server) =
stub_plugin(200, r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#);
// The cwd has to be absolute FOR THE HOST PLATFORM: `/opt/emu` has no drive letter, so
// `Path::is_absolute` is false on Windows and `validate_reply` refuses the recipe — this
// test could never pass there. Same split as `a_working_directory_must_be_absolute`.
// (The `\\` is JSON escaping; the decoded value is `C:\emu`.)
let (answer, cwd) = if cfg!(windows) {
(
r#"{"command":"retroarch 'smw.sfc'","cwd":"C:\\emu"}"#,
r"C:\emu",
)
} else {
(
r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#,
"/opt/emu",
)
};
let (port, server) = stub_plugin(200, answer);
crate::mgmt::register_ui_for_test("stub-launcher", port, "s3cr3t");
let got = ask_plugin_launch("stub-launcher", "snes/smw.sfc").expect("a recipe");
assert_eq!(got.command, "retroarch 'smw.sfc'");
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new("/opt/emu")));
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new(cwd)));
let req = server.join().expect("stub thread");
assert!(req.starts_with("POST /__launch "), "request was {req:?}");
+3
View File
@@ -148,6 +148,9 @@ use spike::{Options, Source};
use std::path::PathBuf;
fn main() {
// Before anything can reach an HTTPS call (the cover-art warmer, webhooks, the plugin-store
// catalog, the update downloader all build default `ureq` agents).
punktfunk_core::tls::install_default_provider();
let filter =
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
// `service run` is launched by the SCM with no console — log to a file instead of stderr.
+44 -19
View File
@@ -14,7 +14,6 @@
use super::index::{Index, MAX_INDEX_BYTES};
use super::sources::Source;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -41,19 +40,20 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
if !source.url.starts_with("https://") {
return Fetched::Failed("source url must be https".into());
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(FETCH_TIMEOUT))
// A signed document doesn't need many hops to reach us; a redirect chain is a good way to
// waste a host's time.
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
.max_redirects(3)
.user_agent(format!("punktfunk-host/{}", super::index::host_version()))
.build()
.into();
let mut req = agent.get(&source.url);
if let Some(tag) = etag {
req = req.set("If-None-Match", tag);
req = req.header("If-None-Match", tag);
}
let resp = match req.call() {
let mut resp = match req.call() {
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
// (the intuitive reading) means every refresh after the first one verifies a signature
@@ -61,13 +61,17 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
Ok(r) if r.status() == 304 => return Fetched::NotModified,
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => {
Err(ureq::Error::StatusCode(code)) => {
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
}
Err(e) => return Fetched::Failed(format!("index fetch failed: {e}")),
};
let new_etag = resp.header("etag").map(str::to_string);
let body = match read_capped(resp) {
let new_etag = resp
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let body = match read_capped(&mut resp) {
Ok(b) => b,
Err(e) => return Fetched::Failed(e),
};
@@ -76,7 +80,7 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
let keys = source.keys();
if !keys.is_empty() {
let sig = match agent.get(&source.sig_url()).call() {
Ok(r) => match read_capped(r) {
Ok(mut r) => match read_capped(&mut r) {
Ok(b) => b,
Err(e) => return Fetched::Failed(format!("signature: {e}")),
},
@@ -105,11 +109,16 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
}
/// Read a response body, refusing anything past the cap without buffering it.
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
resp.into_reader()
.take((MAX_INDEX_BYTES + 1) as u64)
.read_to_end(&mut buf)
fn read_capped(resp: &mut ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, String> {
// Limit is cap+1 so a body of exactly cap+1 comes back intact and is rejected by the length
// check below with our own message; anything larger trips ureq's own limit error. Either way it
// is an Err — unlike ureq 2's `take()`, which truncated silently and then failed the signature
// check with a message that pointed at the wrong thing.
let buf = resp
.body_mut()
.with_config()
.limit((MAX_INDEX_BYTES + 1) as u64)
.read_to_vec()
.map_err(|e| format!("reading the response body failed: {e}"))?;
if buf.len() > MAX_INDEX_BYTES {
return Err(format!("response exceeds the {MAX_INDEX_BYTES}-byte cap"));
@@ -257,18 +266,34 @@ mod tests {
/// on someone's host.
#[test]
fn ureq_returns_304_as_ok() {
use std::io::Write as _;
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
// Drain the request BEFORE answering. Closing a socket that still has unread
// received data makes Windows send an RST rather than a FIN, which destroys the
// response we just wrote — the client then sees a transport error (os error 10053)
// instead of the 304 this test exists to pin. A GET has no body, so the header
// terminator is the whole request.
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
while let Ok(n) = sock.read(&mut chunk) {
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
let _ = sock.flush();
}
});
let resp = ureq::get(&format!("http://{addr}/index.json"))
.set("If-None-Match", "\"x\"")
.header("If-None-Match", "\"x\"")
.call();
let _ = server.join();
+15 -13
View File
@@ -26,7 +26,7 @@ use super::index::{scope_of, Entry};
use super::manifest::{self, Record, Tier};
use anyhow::{bail, Context, Result};
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read};
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
@@ -564,25 +564,27 @@ fn registry_integrity(registry: &str, pkg: &str, version: &str) -> Result<String
let base = registry.trim_end_matches('/');
// npm registry convention: the scope separator is percent-encoded in the packument path.
let url = format!("{base}/{}", pkg.replace('/', "%2f"));
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(20))
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
let resp = agent
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(20)))
.max_redirects(3)
.user_agent(format!("punktfunk-host/{}", super::index::host_version()))
.build()
.into();
let mut resp = agent
.get(&url)
.set("Accept", "application/json")
.header("Accept", "application/json")
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
ureq::Error::StatusCode(404) => {
anyhow::anyhow!("the registry does not know this package")
}
other => anyhow::anyhow!("registry request failed: {other}"),
})?;
let mut body = Vec::new();
resp.into_reader()
.take(16 * 1024 * 1024)
.read_to_end(&mut body)
let body = resp
.body_mut()
.with_config()
.limit(16 * 1024 * 1024)
.read_to_vec()
.context("read the registry response")?;
let doc: serde_json::Value =
serde_json::from_slice(&body).context("registry returned invalid JSON")?;
+20 -11
View File
@@ -168,27 +168,34 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
if !url.starts_with("https://") {
return Err("installer url must be https".into());
}
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(15))
.redirects(3)
.user_agent(&format!(
// Connect timeout only, deliberately no global one: this streams an installer that is tens of
// MB, and a whole-request deadline would abort a slow-but-healthy download.
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(std::time::Duration::from_secs(15)))
.max_redirects(3)
.user_agent(format!(
"punktfunk-host/{} (update-apply)",
env!("PUNKTFUNK_VERSION")
))
.build();
.build()
.into();
let existing = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
let mut req = agent.get(url);
if existing > 0 {
req = req.set("Range", &format!("bytes={existing}-"));
req = req.header("Range", &format!("bytes={existing}-"));
}
let resp = req.call().map_err(|e| match e {
ureq::Error::Status(code, _) => format!("download returned HTTP {code}"),
ureq::Error::StatusCode(code) => format!("download returned HTTP {code}"),
other => format!("download failed: {other}"),
})?;
let resumed = resp.status() == 206;
let content_len: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
let content_len: Option<u64> = resp
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let total = content_len.map(|l| if resumed { existing + l } else { l });
if let Some(t) = total {
preflight_disk(part, t.saturating_mul(DISK_MARGIN))?;
@@ -211,7 +218,9 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
};
progress(received, total);
let mut reader = resp.into_reader();
// Unlimited reader, not `read_to_vec` — the installer is streamed to disk in 64 KiB chunks so
// it never lands in memory, and ureq 3's body-read caps do not apply to this path.
let mut reader = resp.into_body().into_reader();
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf).map_err(|e| format!("read: {e}"))?;
@@ -234,7 +243,7 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), String> {
let mut file = std::fs::File::open(path).map_err(|e| format!("open for hashing: {e}"))?;
let mut ctx = ring::digest::Context::new(&ring::digest::SHA256);
let mut ctx = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256);
let mut buf = [0u8; 128 * 1024];
loop {
let n = file
@@ -366,7 +375,7 @@ pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), St
// cert context above; the slice is consumed (hashed) before the state is closed.
let der =
unsafe { std::slice::from_raw_parts(leaf.pbCertEncoded, leaf.cbCertEncoded as usize) };
let fp = hex(ring::digest::digest(&ring::digest::SHA256, der).as_ref());
let fp = hex(aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, der).as_ref());
if !pins.iter().any(|p| p.eq_ignore_ascii_case(&fp)) {
return Err(format!(
"installer signing-leaf fingerprint {fp} matches none of the manifest's \
+12 -9
View File
@@ -22,17 +22,20 @@ anyhow = "1"
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls(ring) stack and
# custom-verifier pattern as the Linux client's library fetch (crates/pf-client-core/src/library.rs)
# but ring-only (no default aws-lc-rs provider: it needs a C toolchain per target and the agent
# pins the ring provider explicitly anyway).
ureq = { version = "2", default-features = false, features = ["tls"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls stack and
# custom-verifier pattern as the Linux client's library fetch (crates/pf-client-core/src/library.rs),
# on the same aws-lc-rs backend as the rest of the tree (the agent pins the provider explicitly).
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`.
ureq = { version = "3", default-features = false, features = [
"rustls-no-provider",
"rustls-webpki-roots",
] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "logging", "std", "tls12"] }
# The one shared cert-fingerprint pin verifier (`punktfunk_core::tls::PinVerify`) + fingerprint
# hash, instead of the tray hand-rolling its own copy on a trust boundary. The light `tls` feature
# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper; core is a pure-Rust
# leaf (no C toolchain), unlike the host dependency ruled out above.
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls"] }
# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper — much smaller than
# the host dependency ruled out above, though rustls's aws-lc-rs backend does mean a C compiler.
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls", "ureq-tls"] }
[target.'cfg(windows)'.dependencies]
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
+4
View File
@@ -79,6 +79,10 @@ fn parse_args() -> anyhow::Result<Args> {
}
fn main() -> anyhow::Result<()> {
// punktfunk-core is a Windows/Linux-only dependency here (the macOS build is a stub), so the
// provider install follows the same cfg as `run`.
#[cfg(any(windows, target_os = "linux"))]
punktfunk_core::tls::install_default_provider();
let args = parse_args()?;
run(args)
}
+23 -13
View File
@@ -254,7 +254,7 @@ fn poll_loop(
fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
match agent.get(url).call() {
Ok(_) => true,
Err(ureq::Error::Status(..)) => true,
Err(ureq::Error::StatusCode(..)) => true,
Err(_) => false,
}
}
@@ -262,7 +262,13 @@ fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
let body = agent.get(url).call().ok()?.into_string().ok()?;
let body = agent
.get(url)
.call()
.ok()?
.body_mut()
.read_to_string()
.ok()?;
serde_json::from_str(&body).ok()
}
@@ -310,25 +316,29 @@ pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
None
}
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
/// A sync HTTPS agent over the same rustls(aws-lc-rs) stack the rest of the workspace uses, with a
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("rustls default protocol versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)))
.with_no_client_auth();
ureq::AgentBuilder::new()
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(2))
.timeout(Duration::from_secs(2))
// No redirect-following. Neither user of this agent wants it: the summary is a terminal
// JSON route, and the console probe treats any HTTP answer (302 included) as "up", so
// chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
.redirects(0)
.build()
// ureq's `TlsConfig` cannot install a custom verifier, so the agent wraps this `ClientConfig`
// directly (the glue lives in punktfunk-core, shared with the desktop client's library fetch).
punktfunk_core::tls::ureq_agent::agent(
Arc::new(cfg),
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(2)))
.timeout_global(Some(Duration::from_secs(2)))
// No redirect-following. Neither user of this agent wants it: the summary is a
// terminal JSON route, and the console probe treats any HTTP answer (302 included) as
// "up", so chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
.max_redirects(0)
.build(),
)
}
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
+26 -17
View File
@@ -161,21 +161,27 @@ The only settings that matter (GPU zero-copy is on by default):
```sh
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session — SDR, and no cursor (see below)
```
### Gaming Mode: attach vs managed
For Gaming Mode there are two models (pick one; the shipped default is **attach**):
For Gaming Mode there are two models. The template forces **neither** — the host picks per connect,
and on Bazzite (which ships `gamescope-session-plus`) that is **managed**:
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
gamescope session on its own display, and the host attaches to whatever's live without ever
tearing it down (on a headless box, a box-owned autologin session is restarted at the client's
resolution on a mismatch; with a display connected it streams at the box's own mode). Switching
Desktop ↔ Game is rock-solid.
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
refresh — Game Mode on the virtual screen — restoring the box on idle.
- **Managed** (what you get by default here) — the host takes the box's gamescope over and
relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the
virtual screen — restoring the box on idle. This is the model that gives the client a display of
its **own**, and the only one under which a game launched from a client's library gets a
dedicated session. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops
the display manager for the length of the stream, and without that grant it cannot.
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own
display, and the host attaches to whatever's live without ever tearing it down (on a headless
box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a
display connected it streams at the box's own mode). Switching Desktop ↔ Game is rock-solid, and
the cost is that a box with a screen attached serves the client a **mirror** of that screen
rather than its own display. Setting it also outranks a dedicated game session.
`=0` turns the attach override off, the same as removing the line.
Full treatment: [Steam / gamescope → How the host gets a
gamescope](/docs/gamescope#how-the-host-gets-a-gamescope).
@@ -227,14 +233,17 @@ These apply to the **Gaming Mode (gamescope)** path; the KDE Desktop path is una
headless capture can deadlock; between the two, capture works but the Steam overlay (Shift+Tab /
the Quick Access Menu) is never painted into the captured node. Bazzite's current gamescope is
past both; this only bites if you've pinned an old one.
- **The template pins attach, and that costs you both the cursor and HDR.** The sysext ships the
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — and the
`host.env` template above sets `PUNKTFUNK_GAMESCOPE_ATTACH=1`, where the live session is
Bazzite's own stock gamescope. Comment that line out and let the managed default take over: you
get the compositor-drawn pointer and real HDR. To stay on attach instead, set
`PUNKTFUNK_GAMESCOPE_HDR=0` and `PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half
breaks: [gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
- **Forcing attach costs you the cursor, HDR and your own display.** The sysext ships the
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — under
`PUNKTFUNK_GAMESCOPE_ATTACH=1` the live session is Bazzite's own stock gamescope. The managed
default gets you the compositor-drawn pointer, real HDR and a display of the client's own. If you
deliberately stay on attach, also set `PUNKTFUNK_GAMESCOPE_HDR=0` and
`PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half breaks:
[gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
[HDR → Linux + gamescope](/docs/hdr#linux--gamescope) for the failed connect.
⚠ Older templates set `PUNKTFUNK_GAMESCOPE_ATTACH=1` for you — if you copied one, delete that
line from `~/.config/punktfunk/host.env`, because an upgrade never rewrites a file you already
have.
Those are the two that bite on Bazzite. The full set — touch, mouse modes, the clipboard — is on
[gamescope → Known limits](/docs/gamescope#known-limits).
+1 -1
View File
@@ -87,7 +87,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` · `0` *(unset = auto)* | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode — i.e. the client is served a **mirror**, not a display of its own. Setting this also outranks a dedicated game session. No template ships it set; `=0` is the same as leaving it out. |
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. |
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
+227
View File
@@ -0,0 +1,227 @@
---
title: Debian
description: Install the Punktfunk host on Debian 13 with apt — including LMDE and Linux Mint.
---
Install a Punktfunk host on **Debian 13 ("trixie") or newer** from the apt registry. This page
covers the distro-level setup — GPU driver, package, gamepad access. How the host creates its
virtual display and injects input is desktop-specific, so pick your desktop on the
[configure pages](#configure-your-desktop) afterward rather than here.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
> **Which releases.** The host package needs **glibc 2.39 or newer**; Debian 13 has 2.41, so it
> installs and runs there. **Debian 12 (bookworm) has glibc 2.36 and cannot install it** — build
> from source ([Ubuntu appendix](/docs/ubuntu#appendix--build-from-source), which applies here too)
> or upgrade. Check yours with `ldd --version`.
> **The desktop client is not packaged for Debian yet.** `punktfunk-client` is built on Ubuntu 26.04
> and floors at `libc6 >= 2.43` (Debian 13 has 2.41), on top of needing GTK4 ≥ 4.20. On a Debian
> box, stream *to* it with a [different client](/docs/install-client) — the Flatpak, or a build from
> source. The **host**, the **web console** and the **plugin runner** all install normally.
## What works on Debian 13
| Package | Debian 13 | What it is |
|---|---|---|
| `punktfunk-host` | ✅ | The streaming host |
| `punktfunk-web` | ✅ | The browser management console |
| `punktfunk-scripting` | ✅ | The plugin/script runner |
| `punktfunk-gamescope` | ✅ | The patched gamescope (HDR + cursor + real refresh) |
| `punktfunk-client` | ❌ | Desktop client — `libc6 >= 2.43`, see above |
## 1. GPU driver
On **NVIDIA**, the driver lives in Debian's `contrib` / `non-free` / `non-free-firmware`
components, which a default install does not enable. Debian 13 keeps its sources in the deb822
format, so add them there and refresh:
```sh
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' \
/etc/apt/sources.list.d/debian.sources
sudo apt update
sudo apt install nvidia-driver firmware-misc-nonfree
```
Debian 13 ships driver 550, comfortably above the [535 floor](/docs/requirements).
Reboot, then confirm the driver and KMS modeset — Wayland on NVIDIA needs `modeset=1`:
```sh
nvidia-smi
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
```
If modeset is not `Y`:
```sh
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
sudo update-initramfs -u && sudo reboot
```
> **Secure Boot:** with Secure Boot enabled, Debian's DKMS-built NVIDIA module must be signed and
> its key enrolled before it will load. If `nvidia-smi` can't talk to the driver, enrol the MOK
> (`sudo mokutil --import /var/lib/dkms/mok.pub`, reboot, choose **Enrol MOK**) or disable Secure
> Boot in firmware.
On **AMD/Intel** none of the NVIDIA steps apply. Encode runs on the Mesa stack: **Vulkan Video** for
HEVC and AV1 (`mesa-vulkan-drivers`), with **VAAPI** for H.264 and as the fallback —
`mesa-va-drivers` on AMD, `intel-media-va-driver` on Intel (the latter is in `non-free`).
## 2. Install the host (apt)
The registry is public — no auth needed, just trust its signing key:
```sh
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
| sudo tee /etc/apt/sources.list.d/punktfunk.list
sudo apt update
sudo apt install punktfunk-host
```
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
The NVIDIA driver is **not** a dependency — you installed it out of band in step 1. Later updates
are `sudo apt update && sudo apt upgrade`; restart the running host afterwards so it picks up the
new binary:
```sh
systemctl --user restart punktfunk-host
```
The `stable` component above is the stable channel. To track pre-release builds instead, see
[Release Channels](/docs/channels).
## 3. Grant gamepad access
Virtual gamepads inject through `/dev/uinput`, gated by the `input` group. Add yourself and re-login:
```sh
sudo usermod -aG input "$USER" # re-login to apply
```
Also join `punktfunk` if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro) —
it reaches games as a real USB device over usbip, which is what makes Steam Input adopt it. Join it
only on a machine you trust: writing the usbip `attach` file can materialise arbitrary emulated USB
hardware.
```sh
sudo usermod -aG punktfunk "$USER" # re-login to apply
```
## 4. Check it installed
```sh
punktfunk-host --version # the binary is on PATH
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
```
Two hosts on one machine is the most common reason a clean install never streams — see
[Troubleshooting](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
## 5. Open the firewall (if you have one)
**Debian ships no firewall enabled by default**, so out of the box there is nothing to open. If you
run one, the package installs the openers:
```sh
# ufw:
sudo ufw allow punktfunk-native
# firewalld:
sudo firewall-cmd --reload # load the installed definitions
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
```
Add `punktfunk-gamestream` for Moonlight compat and `punktfunk-web` (TCP 47992) to reach the console
from another device. Full port lists are in
[`packaging/debian/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md#firewall).
## Cinnamon, Linux Mint and LMDE
**A Cinnamon desktop cannot host a virtual display, and no setting changes that.** Punktfunk gives
each client its own screen at that device's exact resolution by asking the compositor to create a
virtual output. Cinnamon's compositor, **Muffin**, has no such API: it forked from Mutter 3.36, and
its `org.cinnamon.Muffin.ScreenCast` interface offers only `RecordMonitor` and `RecordWindow`
never the `RecordVirtual` that Mutter gained in 42. Its portal backend
(`xdg-desktop-portal-xapp`) implements no ScreenCast either, so the route that serves Sway and
Hyprland is closed too. This is upstream's to fix, not a Punktfunk setting.
**Which Mint you run decides whether there is any route at all:**
| Edition | Base | Can it host? |
|---|---|---|
| **LMDE 7 "Gigi"** | Debian 13 | ✅ Yes — via gamescope (below) |
| **Linux Mint 22.x** ("Wilma"…"Zena") | Ubuntu 24.04 | ❌ No — see [below](#linux-mint-22x-cannot-host-yet) |
| **Linux Mint 23** | Ubuntu 26.04 | ✅ Expected — due December 2026 |
On **LMDE 7**, what works is **gamescope**: the host starts its own headless gamescope for each
connecting client and runs the game inside it, so it needs no desktop compositor at all. Your
Cinnamon session keeps running untouched; the stream is the game, not the desktop.
```sh
sudo apt install punktfunk-gamescope # LMDE 7 / Debian 13 — not available on Mint 22.x
echo 'PUNKTFUNK_COMPOSITOR=gamescope' >> ~/.config/punktfunk/host.env
systemctl --user restart punktfunk-host
```
The pin is required: auto-detection reads the live session, finds Cinnamon, and stops with an error
rather than guessing. Set a game to launch with
[`PUNKTFUNK_GAMESCOPE_APP`](/docs/gamescope) or per-session launch commands, then see
[Steam / gamescope](/docs/gamescope) for the rest.
> **Install `punktfunk-gamescope`, not Debian's.** Debian ships **no** `gamescope` package at all,
> and the patched build is what gives the stream HDR, a visible cursor, and the client's real
> refresh rate instead of a hardcoded 60 Hz.
If you want to stream the **desktop** from an LMDE box, the answer today is to log into a GNOME or
Sway session instead — Debian 13 ships GNOME 48.7 and sway 1.10, both above the
[floors](/docs/requirements). (Debian 13's KDE is KWin **6.3.6**, below the 6.5.6 floor, so Plasma
is not an option there yet.)
### Linux Mint 22.x cannot host yet
**On Linux Mint 22.x — the current mainstream release, and every version until Mint 23 in December
2026 — there is no working configuration.** `punktfunk-host` will install, which makes this easy to
miss, but nothing on the box can produce a stream:
- **Cinnamon** cannot host a virtual display (above).
- **gamescope is not available and cannot be made available.** Ubuntu 24.04 packages no gamescope,
and the patched `punktfunk-gamescope` cannot run there either: 24.04 is short of what the build
needs on *five* libraries — wayland 1.22.0 (needs ≥ 1.23.1), libinput 1.25 (≥ 1.26), libavif
1.0.4 (≥ 1.2.1), pixman 0.42 (≥ 0.44), and no `libdisplay-info2` or `libxcb-errors0` at all.
- **Switching desktop does not rescue it.** Ubuntu 24.04 ships KWin **5.27** (floor 6.5.6) and GNOME
Shell **46** (floor 48). Only `sway` 1.9 is even a candidate, and that means giving up Cinnamon.
If you want to run a host on Mint hardware today, use **LMDE 7** — it is the same desktop on a
Debian 13 base, where gamescope works. Otherwise wait for **Mint 23** (Ubuntu 26.04 base), where
both the patched gamescope and the newer compositors are available.
## Configure your desktop
How the host creates its virtual display and injects input depends on your desktop, not your distro:
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Hyprland](/docs/hyprland)
- [Sway / wlroots](/docs/sway)
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect your first
[client](/docs/clients). To run the host at boot — including fully **headless** — see
[Running as a Service](/docs/running-as-a-service).
## Next steps
- **Keep it current** — [Updating the Host](/docs/updating).
- **Remove it again** — [Uninstalling](/docs/uninstall).
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
- **Build from source** (Debian 12, or tracking `main`) — the
[Ubuntu appendix](/docs/ubuntu#appendix--build-from-source) applies unchanged; Debian 13's
`libavcodec-dev` is new enough to build against.
+9 -5
View File
@@ -18,8 +18,8 @@ from the install guide for your OS: [Bazzite](/docs/bazzite) or [SteamOS (Host)]
## How the host gets a gamescope
There are three models; the host picks one per session, and you rarely have to. With **nothing
set**, a box that has gamescope session infrastructure (Bazzite, SteamOS, Nobara) gets **managed**;
the [Bazzite template](/docs/bazzite) ships with **attach** chosen instead.
set** — which is what every shipped template does — a box that has gamescope session infrastructure
(Bazzite, SteamOS, Nobara) gets **managed**.
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides
Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display;
@@ -189,7 +189,11 @@ own name and does **not** replace your system gamescope — your Gaming Mode kee
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
- **Fedora, Nobara and other RPM boxes**`sudo dnf install punktfunk-gamescope` from the same
Punktfunk repo the host comes from.
- **Debian / Ubuntu**`sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
- **Debian 13 and Ubuntu 26.04**`sudo apt install punktfunk-gamescope` from the Punktfunk apt
repo. It is the only gamescope those two have: Debian packages none at all, and Ubuntu 26.04's
is 3.16.20 — below the [3.16.22 floor](/docs/requirements), so headless capture deadlocks
against the PipeWire 1.6 that same release ships. **Ubuntu 24.04 cannot run it**: the build needs
wayland ≥ 1.23.1 and noble has 1.22.0, so build from source or move to a newer release.
- **Arch** — the `punktfunk-gamescope` package.
- **SteamOS (Steam Deck installer)** — built and wired automatically by
`scripts/steamdeck/install.sh` / `update.sh`.
@@ -208,8 +212,8 @@ you and the two ways out.
The cursor is the half this page owns. The host leaves the pointer to the compositor whenever the
installed build can paint it (below) — so on an attached session, which can't, nothing draws it and
the stream has no cursor at all. Commenting
`PUNKTFUNK_GAMESCOPE_ATTACH=1` out of the [Bazzite template](/docs/bazzite) and letting the managed
the stream has no cursor at all. Removing
`PUNKTFUNK_GAMESCOPE_ATTACH=1` from your `host.env` and letting the managed
default take over fixes that along with HDR. To stay on attach, point `PUNKTFUNK_GAMESCOPE_BIN` at
your distro's own `gamescope` (`/usr/bin/gamescope`) instead: the host goes back to compositing the
cursor itself, and — since the HDR answer comes from the same binary — stops attempting HDR too.
+7 -6
View File
@@ -76,12 +76,13 @@ available, and offers it. The attached session can't answer that negotiation, so
with no picture, the host latches an SDR downgrade for the rest of its life, and the next connect
streams — in SDR.
That is exactly what the [Bazzite](/docs/bazzite) template ships: it pins attach *and* the sysext
installs `punktfunk-gamescope`. Either comment `PUNKTFUNK_GAMESCOPE_ATTACH=1` out and let the managed
default take over (you get HDR and the compositor-drawn cursor), or stay on attach and set
`PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never happens. Staying on attach also leaves the
stream with no cursor; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that
half.
That combination bites on [Bazzite](/docs/bazzite), where the sysext installs `punktfunk-gamescope`
alongside a stock session gamescope. No template pins attach any more, so the managed default gets
you HDR and the compositor-drawn cursor — but an older template did, and an upgrade never rewrites a
`host.env` you already have, so check yours for `PUNKTFUNK_GAMESCOPE_ATTACH=1` and delete the line.
If you deliberately stay on attach, set `PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never
happens. Staying on attach also leaves the stream with no cursor;
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
`PUNKTFUNK_GAMESCOPE_SDR_NITS` (gamescope's own default is 400). That is the knob when white looks
+2 -2
View File
@@ -36,7 +36,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
<Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." />
<Card title="How It Works" href="/docs/how-it-works" description="The ideas behind Punktfunk in a few minutes — virtual displays, the two protocols, pairing." />
<Card title="Support Matrix" href="/docs/support-matrix" description="What works where — every host desktop, GPU and client app, each cell read out of the code that decides it." />
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
<Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." />
<Card title="Your Game Library" href="/docs/game-library" description="The host finds your installed games by itself — browse a paired host and launch a title straight into the stream." />
<Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." />
@@ -46,7 +46,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
- A **host** with an NVIDIA, AMD, or Intel GPU (there's a software encoder to fall back on without
one) — either a **Linux** machine running one of the [supported setups](/docs/requirements)
(**Ubuntu**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
(**Ubuntu**, **Debian**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
**[Windows](/docs/windows-host) PC**. What each combination can actually do — codecs, HDR, 4:4:4,
cursor, input — is in the [Support matrix](/docs/support-matrix).
- A **client device** to stream to — there are native apps for **macOS, iOS/iPadOS, tvOS, Linux,
+8 -1
View File
@@ -16,13 +16,20 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
| Distro | Package manager | One-command happy path | Guide |
|--------|-----------------|------------------------|-------|
| **Ubuntu** | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Ubuntu 26.04+** ¹ | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Debian 13+** (incl. LMDE) | apt | `sudo apt install punktfunk-host` | [Debian](/docs/debian) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/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) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
| **Fedora (dnf)** | dnf / rpm-ostree | `sudo dnf install punktfunk` | [Fedora](/docs/fedora) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch** | pacman | `sudo pacman -Syu punktfunk-host` (binary repo — always a full `-Syu`, never `-Sy`) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **SteamOS (host)** | on-device script | clone the repo, then `bash ~/punktfunk/scripts/steamdeck/install.sh` (builds on-device) | [SteamOS (Host)](/docs/steamos-host) |
| **NixOS / Nix** | nix flake | `nix run git+https://git.unom.io/unom/punktfunk#punktfunk-host -- serve --gamestream` | [NixOS](#nixos) · [packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md) |
> ¹ **Ubuntu 24.04 LTS installs the package but cannot host.** It ships no compositor that meets
> the [version floors](/docs/requirements) — KWin 5.27 against 6.5.6, GNOME Shell 46 against 48 —
> and no `gamescope`. Use 26.04 or newer. This is also why
> [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet); LMDE 7 (Debian 13)
> can.
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds. On **NixOS** there
+1
View File
@@ -10,6 +10,7 @@
"requirements",
"install",
"ubuntu",
"debian",
"fedora",
"arch",
"bazzite",
+26 -1
View File
@@ -13,9 +13,34 @@ is also available. Setup splits along two axes: you **install** the package per
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## The floor for a working host
**On apt distros that means Ubuntu 26.04 or newer, or Debian 13 or newer.** Both are supported
targets and both install from the same repository.
This floor is about the **desktop**, not the package. A host needs a compositor that can create a
virtual display, and those have version floors of their own ([below](#desktop-session)). Older
releases will happily install `punktfunk-host` and then have nothing that can produce a stream —
so read this as the real requirement, not the package's:
| Release | Package installs | Can actually host |
|---|---|---|
| **Ubuntu 26.04+** | ✅ | ✅ KWin 6.5+, GNOME 48+, gamescope |
| **Debian 13+** | ✅ | ✅ GNOME 48.7, sway 1.10, gamescope (its KWin 6.3.6 is below the floor) |
| Ubuntu 24.04 LTS | ✅ | ❌ KWin 5.27 (floor 6.5.6), GNOME 46 (floor 48), no gamescope available |
| Debian 12 | ❌ glibc 2.36 | ❌ |
Ubuntu 24.04 is called out because the package *does* install there — it is built on 24.04 precisely
so one package spans the range — which makes the gap easy to mistake for a bug. It is not: 24.04
ships no compositor new enough, and no `gamescope` (the patched
[`punktfunk-gamescope`](/docs/gamescope) cannot run there either — 24.04 is too old on wayland,
libinput, libavif and pixman). The same gap is why
[Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
**Distros — install the package:**
- [Ubuntu](/docs/ubuntu)
- [Ubuntu](/docs/ubuntu) — 26.04 or newer for a working host
- [Debian](/docs/debian) — 13 or newer, including LMDE
- [Fedora](/docs/fedora)
- [Arch](/docs/arch)
- [Bazzite](/docs/bazzite)
+7
View File
@@ -71,6 +71,7 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
| gamescope (SteamOS · Bazzite) | ✅ ⁴ | ⚠️ ⁵ | ✅ ⁶ |
| sway ⁹ | ✅ | ✅ | ⚠️ ⁷ |
| Hyprland | ⚠️ ⁸ | ✅ | ⚠️ ⁷ |
| Cinnamon (Mint · LMDE) | ❌ ¹⁰ | ❌ ¹⁰ | ❌ ¹⁰ |
| macOS / anything else | ❌ | ❌ | ❌ |
1. Punktfunk's own IddCx display driver. It requires **Windows 11 22H2 (build 22621) or newer**
@@ -108,6 +109,12 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
(River, dwl, …) cannot host — the session fails at `swaymsg get_outputs`. Their input would work
(they do have the wlroots virtual pointer and keyboard protocols), but with no video there is no
stream. See [Sway / wlroots](/docs/sway).
10. Cinnamon's compositor **Muffin** exposes no virtual-output API and no monitor-capture route we
can reach: it forked from Mutter 3.36, so `org.cinnamon.Muffin.ScreenCast` has only
`RecordMonitor` / `RecordWindow` and never Mutter 42's `RecordVirtual`, and its portal backend
(`xdg-desktop-portal-xapp`) implements no ScreenCast at all. Nothing in Punktfunk can change
this. A Mint or LMDE box can still stream **games** through a headless gamescope, which needs no
desktop compositor — see [Debian → Cinnamon](/docs/debian#cinnamon-linux-mint-and-lmde).
### Input, cursor and HDR
+12 -5
View File
@@ -18,11 +18,18 @@ desktop on the [configure pages](#configure-your-desktop) afterward rather than
> and needs GTK4 ≥ 4.20 and SDL3, so it installs on **26.04 or newer** only — the host has no such
> limit.
> **Debian isn't a supported target.** The packages are built on Ubuntu images and their dependencies
> are resolved against Ubuntu's package names, and nothing in CI builds or tests on Debian. Debian 12
> (bookworm) is below the glibc floor and cannot install them at all. A newer Debian may work, but
> it's untested — build from source ([appendix](#appendix--build-from-source)) if you want to try.
> The `debian` in the repository URL below is the *package format*, not a supported distro.
> **On 24.04 LTS, the package installs but the distro gives it no compositor to drive.** The
> host `.deb` is built for 24.04 and installs cleanly — but streaming needs a compositor that meets
> the [version floors](/docs/requirements), and stock 24.04 meets none of them: KWin **5.27** (floor
> 6.5.6), GNOME Shell **46** (floor 48), and no `gamescope` package at all (nor can the patched one
> run there — 24.04 is too old on wayland, libinput, libavif and pixman). `sway` 1.9 is the only
> candidate. **For a working host, use 26.04**, where the patched gamescope and current KDE/GNOME
> are all available. This is why [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
> **On Debian**, see [Debian](/docs/debian) — the host, console and plugin runner are supported and
> CI-tested on **Debian 13**; the desktop client is not packaged for it yet. Debian 12 (bookworm) is
> below the glibc floor and cannot install anything here. Note that the `debian` in the repository
> URL below is the *package format*, and is the same URL for both distros.
## 1. GPU driver
+16 -1
View File
@@ -126,8 +126,23 @@ build() {
fi
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
# built AND run with bun.
#
# Build-if-missing, matching packaging/debian/build-web-deb.sh. An in-tree CI build runs with
# PF_SRCDIR pointing at the workspace, so when arch.yml has restored web/.output from the shared
# actions cache it is already sitting right here and rebuilding it would be pure duplication —
# this console was being built six times per push across the fleet. A local `makepkg` has no
# .output and builds exactly as before.
#
# The Bun.serve assertion covers BOTH paths: a reused bundle is one more thing that can be wrong,
# and node cannot run a Bun.serve entry, so shipping the wrong preset is a dead console.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
( cd web && bun install --frozen-lockfile && bun run build )
if [ -f web/.output/server/index.mjs ]; then
echo "==> reusing the already-built web console at web/.output"
else
( cd web && bun install --frozen-lockfile && bun run build )
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs \
|| { echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2; exit 1; }
fi
# Plugin/script runner (opt-in): one self-contained JS via `bun build --target=bun` (effect + the
# SDK inlined; the dynamic plugin import stays a runtime import). bun is also its vendored runtime.
+1 -4
View File
@@ -261,9 +261,6 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# PUNKTFUNK_ZEROCOPY=0
#RUST_LOG=info
# Gaming Mode = ATTACH: the box owns its gamescope session; the host captures + follows it.
PUNKTFUNK_GAMESCOPE_ATTACH=1
```
**What each knob means and why these are the Bazzite defaults:**
@@ -272,7 +269,7 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1
|---|---|---|
| *(no compositor / no anchors)* | — | The host **auto-detects** the live session per connect (Gaming Mode gamescope vs the KDE desktop) and follows switches mid-stream; a `systemctl --user` service inherits the right `XDG_RUNTIME_DIR` and the host derives the bus itself. Pinning `PUNKTFUNK_COMPOSITOR` or hardcoding uid-1000 anchors only breaks this — leave them out. |
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` | Create a per-client virtual output at the client's exact WxH@Hz (the flagship "native resolution, no scaling" mode), vs. `portal` which captures an existing monitor. |
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | Gaming Mode model: the **box** owns its gamescope session; the host attaches to whatever's live and never tears it down. Swap for `PUNKTFUNK_GAMESCOPE_MANAGED=1` to have the host relaunch the gaming session headless at the **client's** exact mode instead (see the template's comments). |
| `PUNKTFUNK_GAMESCOPE_ATTACH` | *(unset)* | Gaming Mode model. Left unset the host picks one per connect, which on Bazzite is **managed**: it relaunches the gaming session headless at the **client's** exact mode, so the client gets a display of its own. Set `=1` to force **attach** instead — the box keeps its session and the client is served a mirror of the box's own screen at the box's mode (see the template's comments). |
| `PUNKTFUNK_ZEROCOPY` | `on` *(default)* | GPU zero-copy capture (dmabuf → CUDA → NVENC), on by default. Falls back to CPU automatically if unavailable; set `0` to force the CPU path. |
| `RUST_LOG` | (commented) | Uncomment `RUST_LOG=info` for verbose logs while debugging. |
+22 -12
View File
@@ -20,19 +20,29 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope
# PUNKTFUNK_INPUT_BACKEND=libei|wlr|gamescope|uinput
#
# GAME MODE = ATTACH (the box owns its session; the host follows). The box decides whether it's in
# Steam Gaming Mode or a Desktop — you switch with the normal Steam UI / "Switch to Desktop". The
# host just ATTACHES to whatever's live and captures it; it never tears the session down. So
# switching Desktop<->Game is rock-solid, and when you disconnect the box STAYS in its current
# mode — reconnecting drops you right back where you were. On a resolution mismatch the host
# restarts the box's own game-mode session at the CLIENT's resolution (a foreign/bare gamescope
# instead streams at its own mode).
PUNKTFUNK_GAMESCOPE_ATTACH=1
# GAME MODE: nothing here forces a model — the host picks one per connect. On this box (Bazzite
# ships gamescope-session-plus) that is the MANAGED model: on connect the host takes the box's
# gaming session over and relaunches it headless at the CLIENT's exact resolution, then restores
# the box's own session on a debounced idle. That is what gives the client a display of its OWN
# instead of a copy of the TV, and it is also what lets a game launched from a client's library get
# a dedicated session to itself.
#
# Opt OUT to the MANAGED model instead (host tears the box's gamescope down on connect and launches
# its OWN at the CLIENT's exact resolution; restores on a debounced idle). Client-mode-following, but
# it does not coexist with a box-owned game-mode session — pick one:
# PUNKTFUNK_GAMESCOPE_MANAGED=1 # (and remove PUNKTFUNK_GAMESCOPE_ATTACH above)
# ⚠ The managed takeover has to stop the display manager for the length of the stream, and that is
# privileged: it works for members of the `punktfunk` group and nobody else. Join it once —
# `sudo usermod -aG punktfunk "$USER"`, then log out and back in. Skip it and the takeover cannot
# stop SDDM, which relogin-loops against it for the whole stream and can starve the game. The host
# checks at startup and says so in its log.
#
# Opt IN to the ATTACH model if you would rather the BOX keep ownership: the host captures whatever
# gamescope is live and never tears it down, so Desktop<->Game switching is rock-solid and the box
# stays in its current mode when you disconnect. The cost is that a box driving a physical display
# streams at the BOX's own mode, MIRRORED — the client does not get its own display — and this
# override outranks a dedicated game session too. Remove the line to go back to auto (`=0` also
# turns it off; any other value is on):
# PUNKTFUNK_GAMESCOPE_ATTACH=1
#
# Force the MANAGED model on a box where the auto-detection finds no session infrastructure:
# PUNKTFUNK_GAMESCOPE_MANAGED=1
#
# Follow a Gaming<->Desktop switch MID-STREAM (rebuild the backend in place, no reconnect). This is
# ON BY DEFAULT on Bazzite/SteamOS (the host detects the platform); set =0 to disable it:
+44
View File
@@ -1,5 +1,18 @@
# punktfunk-host — Debian/Ubuntu package (apt)
> **Which distros the published packages install on** — measured by installing them, not inferred
> from the build image (`scripts/ci/deb-install-smoke.sh` asserts this on every run):
>
> | | Ubuntu 24.04 | Ubuntu 26.04 | Debian 13 | Debian 12 |
> |---|---|---|---|---|
> | `punktfunk-host` | ✅ | ✅ | ✅ | ❌ glibc 2.36 < 2.39 |
> | `punktfunk-web` / `punktfunk-scripting` | ✅ | ✅ | ✅ | ✅ |
> | `punktfunk-gamescope` | ❌ wayland 1.22 | ✅ | ✅ | ❌ |
> | `punktfunk-client` | ❌ `libc6 >= 2.43` | ✅ | ❌ `libc6 >= 2.43` | ❌ |
>
> Debian 13 is a supported host target ([docs](https://docs.punktfunk.unom.io/docs/debian)); the
> client is the one gap, since it is built on 26.04 and floors at that release's glibc.
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
and publishes on every push to `main` (a rolling `<next-minor>~ciN.g<sha>` build — the base is
@@ -36,6 +49,37 @@ dropped from `Depends`). The result is **one** host `.deb` that installs on **Ub
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
you stream *to*, which is independent of the host's distro.
## `punktfunk-gamescope` is built on Debian 13, not Ubuntu
The patched gamescope has its own job (`build-publish-gamescope`) in a **Debian 13** image
(`ci/gamescope-trixie.Dockerfile`), and that is not a preference — it is the only apt distro the
tree configures on. Built in the noble host image, as it was until 2026-08, it failed every single
run:
```
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 pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 (and has
no `libxcb-errors-dev`, and only libdisplay-info 0.1.1). Because every rung of that path was a
`::warning::` returning 0, **v0.26.0 and v0.27.0 both shipped with no gamescope .deb** while the
release notes and docs-site said it was apt-installable. Debian 13 has wayland 1.23.1 exactly —
the oldest apt base that works.
Two things make the one package serve both Debian 13 and Ubuntu 26.04:
- **`--extra-fallback libdisplay-info`** (see `packaging/gamescope/build-punktfunk-gamescope.sh`).
Linked against the distro 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.
gamescope vendors the library as a submodule, so the vendored build drops the dependency. Same
reasoning the script already applies to wlroots: a binary we ship must not follow the build
host's shared libraries.
- The **static C++ runtime** the build script already forces, so `libstdc++` never appears in
`NEEDED`. The binary asks only for `GLIBC_2.38`.
**Ubuntu 24.04 gets no gamescope package** and cannot: the wayland floor is a runtime one too.
## Install on a host (one-time)
The registry is public, so no apt auth is needed — just trust the repo's signing key:
@@ -28,7 +28,7 @@ set -euo pipefail
GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843"
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1 EXTRA_FALLBACK=""
while [ $# -gt 0 ]; do
case "$1" in
--rev) REV="${2:?}"; shift 2 ;;
@@ -37,6 +37,15 @@ while [ $# -gt 0 ]; do
--srcdir) SRCDIR="${2:?}"; shift 2 ;;
--jobs) JOBS="${2:?}"; shift 2 ;;
--no-setcap) SETCAP=0; shift ;;
# Extra `force_fallback_for` entries, comma-separated, appended to the mandatory three below.
# Exists for ONE package family: the .deb has to install on both Debian 13 and Ubuntu 26.04,
# and those two disagree on the libdisplay-info SONAME (0.2.0 -> libdisplay-info2 vs 0.3.0 ->
# libdisplay-info3), so a package built against either one is uninstallable on the other.
# Vendoring it makes ONE .deb serve both. Opt-in rather than baked in, so the Arch/Fedora/nix
# packages — which have no such split and are shipping fine — keep producing exactly the binary
# they produce today. Its only caller is the `build-publish-gamescope` job in
# .gitea/workflows/deb.yml, which passes `--extra-fallback libdisplay-info`.
--extra-fallback) EXTRA_FALLBACK="${2:?}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
@@ -111,7 +120,7 @@ export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
meson setup "$BUILD" "$SRCDIR" \
--prefix="$PREFIX" \
--buildtype=release \
-Dforce_fallback_for=libliftoff,vkroots,wlroots \
-Dforce_fallback_for="libliftoff,vkroots,wlroots${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
-Dpipewire=enabled \
-Denable_tests=false \
-Denable_openvr_support=false \
+18
View File
@@ -15,6 +15,23 @@ PF_RELEASE="${PF_RELEASE:-1}"
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
WEB_OPT=()
[ "${PF_WITH_WEB:-0}" = "1" ] && WEB_OPT=(--with web)
# PF_PREBUILT_WEB_OUTPUT: an already-built web/.output to package instead of building the console a
# second time (see the pf_prebuilt_web note in the spec's %build). CI restores one from the shared
# actions cache and points this at it; a plain build leaves it unset and nothing changes.
# Deliberately validated HERE rather than trusted: an empty or half-restored cache directory must
# fall back to building, not produce a console-less package. The spec re-asserts the Bun.serve
# marker on whatever it ends up with.
PREBUILT_WEB_OPT=()
if [ "${PF_WITH_WEB:-0}" = "1" ] && [ -n "${PF_PREBUILT_WEB_OUTPUT:-}" ]; then
if [ -f "${PF_PREBUILT_WEB_OUTPUT}/server/index.mjs" ]; then
# rpmbuild resolves the macro inside the extracted tarball dir, so it must be absolute.
PREBUILT_WEB_ABS="$(cd "$PF_PREBUILT_WEB_OUTPUT" && pwd)"
PREBUILT_WEB_OPT=(--define "pf_prebuilt_web ${PREBUILT_WEB_ABS}")
echo "==> reusing prebuilt web console: $PREBUILT_WEB_ABS"
else
echo "==> PF_PREBUILT_WEB_OUTPUT=$PF_PREBUILT_WEB_OUTPUT has no server/index.mjs — building the console" >&2
fi
fi
# PF_WITH_SCRIPTING=1 builds the punktfunk-scripting subpackage (the plugin/script runner). Same bun
# requirement as web; default off so a bare `rpmbuild`/COPR still works.
SCRIPTING_OPT=()
@@ -61,6 +78,7 @@ fi
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" "${HOST_OPT[@]}" \
"${PREBUILT_WEB_OPT[@]}" \
--define "_topdir $TOP" \
--define "pf_version ${PF_VERSION}" \
--define "pf_release ${PF_RELEASE}" \
+23 -2
View File
@@ -265,9 +265,30 @@ cargo build --release --locked -p punktfunk-tray
%endif
%if %{with web}
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
# TLS entry). bun is both the build tool AND the runtime (vendored in %%install below).
# Management web console: the Nitro SSR bundle (the `bun` preset + our Bun.serve TLS entry). bun is
# both the build tool AND the runtime (vendored in %%install below).
#
# `pf_prebuilt_web` (optional, absolute path to an already-built web/.output) lets CI hand over a
# bundle it has already produced instead of building a second, identical one here. It exists because
# this console was being rebuilt SIX times per push — ci.yml, deb, both RPM legs, arch, and the
# docker app image — at ~2.5 min each. rpm.yml restores it from the shared actions cache and passes
# this macro; see build-rpm.sh. Undefined (a plain rpmbuild, or COPR) takes the build path exactly
# as before, so nothing outside CI changes.
#
# It has to be a MACRO carrying an absolute path, not simply a pre-populated web/.output: this spec
# builds from the `git archive` tarball build-rpm.sh generates, and web/.output is gitignored — it
# is not in the tarball and cannot be, so there is nothing here to find without being told where to
# look.
%if %{defined pf_prebuilt_web}
echo "==> reusing the prebuilt web console from %{pf_prebuilt_web}"
mkdir -p web/.output
cp -a %{pf_prebuilt_web}/. web/.output/
%else
(cd web && bun install --frozen-lockfile && bun run build)
%endif
# Asserted for BOTH paths on purpose. This is the check that says the artifact about to be packaged
# is the bun preset (node cannot run Bun.serve) — a handed-over bundle deserves it at least as much
# as a freshly built one, because a cache is one more place a wrong artifact can come from.
if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then
echo "ERROR: web build is not a bun bundle need the 'bun' preset + custom entry" >&2
exit 1
-18
View File
@@ -407,7 +407,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk",
"wdk-build",
"wdk-sys",
]
@@ -418,7 +417,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk",
"wdk-build",
"wdk-sys",
]
@@ -437,7 +435,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"thiserror",
"wdk",
"wdk-build",
"wdk-iddcx",
"wdk-sys",
@@ -450,7 +447,6 @@ version = "0.0.1"
dependencies = [
"pf-driver-proto",
"pf-umdf-util",
"wdk",
"wdk-build",
"wdk-sys",
]
@@ -754,19 +750,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "wdk"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd496a19ec75c3d98f8be805f62ebde4651fc01babf681b832d8bae9c584d25"
dependencies = [
"cfg-if",
"tracing",
"tracing-subscriber",
"wdk-build",
"wdk-sys",
]
[[package]]
name = "wdk-build"
version = "0.5.1"
@@ -818,7 +801,6 @@ name = "wdk-probe"
version = "0.0.1"
dependencies = [
"pf-driver-proto",
"wdk",
"wdk-build",
"wdk-sys",
]
+8 -3
View File
@@ -27,7 +27,12 @@ unsafe_op_in_unsafe_fn = "deny"
undocumented_unsafe_blocks = "deny"
[workspace.dependencies]
wdk = "0.4.1"
# NOTE: the high-level `wdk` crate (0.4.1) used to be declared here and taken by five driver
# crates. None of them ever referenced `wdk::` — every driver goes through `wdk_sys`, including
# the WDF call macro, which wdk-sys re-exports (see pf-umdf-util, a full WDF crate that never
# declared `wdk` either). Removing it drops a normal (linked) dependency from all five.
# `tracing`/`tracing-subscriber` are still in the lock afterwards, but only as wdk-sys BUILD
# dependencies — build-script machinery, not code in the shipped DLLs.
wdk-sys = "0.5.1"
wdk-build = "0.5.1"
wdk-iddcx = { path = "wdk-iddcx" }
@@ -36,8 +41,8 @@ pf-driver-proto = { path = "../../../crates/pf-driver-proto" }
# Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx`
# ApiSubset (M1 — bindgens iddcx/1.10/IddCx.h reusing wdk_default for WDF type-identity). Redirect ALL
# wdk-sys/wdk-build refs (incl. wdk 0.4.1's transitive deps) to the patched copies so there is exactly
# one (iddcx-capable) wdk-sys in the graph. Pinned; do not chase upstream.
# wdk-sys/wdk-build refs to the patched copies so there is exactly one (iddcx-capable) wdk-sys in the
# graph. Pinned; do not chase upstream.
[patch.crates-io]
wdk-build = { path = "vendor/wdk-build" }
wdk-sys = { path = "vendor/wdk-sys" }
@@ -23,7 +23,6 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
@@ -31,7 +30,7 @@ pf-umdf-util.workspace = true
[features]
default = ["hid"]
hid = ["wdk-sys/hid"]
nightly = ["wdk-sys/nightly", "wdk/nightly"]
nightly = ["wdk-sys/nightly"]
[lints]
workspace = true
@@ -21,7 +21,6 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
@@ -29,7 +28,7 @@ pf-umdf-util.workspace = true
[features]
default = ["hid"]
hid = ["wdk-sys/hid"]
nightly = ["wdk-sys/nightly", "wdk/nightly"]
nightly = ["wdk-sys/nightly"]
[lints]
workspace = true
@@ -20,7 +20,6 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys = { workspace = true, features = ["iddcx"] }
wdk-iddcx.workspace = true
pf-driver-proto.workspace = true
+1 -2
View File
@@ -21,14 +21,13 @@ crate-type = ["cdylib"]
wdk-build.workspace = true
[dependencies]
wdk.workspace = true
wdk-sys.workspace = true
pf-driver-proto.workspace = true
pf-umdf-util.workspace = true
[features]
default = []
nightly = ["wdk-sys/nightly", "wdk/nightly"]
nightly = ["wdk-sys/nightly"]
[lints]
workspace = true

Some files were not shown because too many files have changed in this diff Show More