106 Commits

Author SHA1 Message Date
enricobuehler e919fa6a2e docs(windows): DualSense in-game detection handoff
apple / swift (push) Successful in 57s
android / android (push) Failing after 43s
ci / rust (push) Failing after 30s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 52s
deb / build-publish (push) Successful in 2m17s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m22s
docker / deploy-docs (push) Successful in 17s
windows-host / package (push) Successful in 2m51s
The virtual DualSense is a correct, complete DS5 at the HID level (SDL3 reports PS5) and
input works, but a game's native DualSense path (Cyberpunk) doesn't detect the
software-enumerated (SWD) device that SDL/HIDAPI accept. Captures the diagnosis, the on-box
layout + tools (SDL oracle, dualsense-windows-test, driver rebuild recipe), and the on-glass
next experiments (WGI/RawInput/GameInput enumeration) so the work continues from any machine
without agent memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:34:58 +00:00
enricobuehler 6db3525e29 fix(gamepad): working per-session SwDeviceCreate for the Windows DualSense
create_swdevice now succeeds. The two requirements (each E_INVALIDARG otherwise): the
enumerator name must have no underscore (use "punktfunk"), and the completion callback is
mandatory (the docs mark pCallback [in], not optional -- NULL is rejected). Back on the
typed windows-rs SwDeviceCreate (a raw-FFI diagnosis confirmed it's the OS, not the
binding), parameterized by pad index (instance pf_pad_<index>), waiting on the callback.
Per-session device: created on connect, SwDeviceClose'd on drop -- no leftovers, no phantom.

Live-verified on the RTX box: device materializes, the UMDF driver binds, SDL3 identifies it
as a PS5 ("DualSense Wireless Controller"), input flows; removed on disconnect. The
dualsense-windows-test CLI now cycles input + prints any 0x02 feedback for diagnosis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:34:58 +00:00
enricobuehler 6a501f484a ci(audit): ignore RUSTSEC-2023-0071 (rsa Marvin timing sidechannel)
ci / rust (push) Failing after 30s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 1m11s
android / android (push) Successful in 3m34s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 21s
ci / bench (push) Successful in 4m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 48s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 45s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m12s
docker / deploy-docs (push) Successful in 22s
windows-host / package (push) Successful in 3m12s
cargo audit fails on the rsa "Marvin Attack" advisory, which has NO fixed release
(the constant-time rewrite is still unreleased upstream) and rsa is required for
GameStream/Moonlight pairing. The attack targets RSA *decryption* (PKCS#1 v1.5
padding oracle); the host uses rsa ONLY for PKCS#1 v1.5 signing/verifying
(gamestream/cert.rs + pairing.rs), never for decryption, so the vulnerable path is
not exercised. Add the documented .cargo/audit.toml ignore with the justification.

The 3 unmaintained warnings (audiopus_sys / paste / rustls-pemfile) are left visible
on purpose — `cargo audit` does not fail on them, and they carry a maintenance signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:32:04 +00:00
enricobuehler 72eeedc4da feat(windows): AMD (AMF) + Intel (QSV) hardware encode on the Windows host
The Windows host was NVIDIA-only (NVENC) with an openh264 software fallback. Add
AMD AMF and Intel QSV via libavcodec — the Windows analogue of the Linux VAAPI
backend — so one installer serves all three GPU vendors.

- encode/ffmpeg_win.rs: new WinVendor{Amf,Qsv} encoder. System-memory NV12/P010
  readback (default, robust) + opt-in zero-copy D3D11 (PUNKTFUNK_ZEROCOPY: shares
  the capturer's ID3D11Device; AMF takes AV_PIX_FMT_D3D11, QSV derives a QSV frames
  ctx and maps) with a system fallback for the format-group mismatch the capturer's
  video-processor fallback can produce. HDR Main10 (P010 + BT.2020/PQ VUI; an
  Rgb10a2->P010 swscale covers the shader fallback).
- encode.rs: Codec::amf_name/qsv_name; open_video + windows_resolved_backend()
  resolve PUNKTFUNK_ENCODER=auto|nvenc|amf|qsv|sw via a DXGI adapter VendorId probe.
- capture/dxgi.rs: gpu_mode mirrors the resolved backend (D3D11 NV12/P010 for AMF/QSV).
- gamestream/serverinfo.rs: GPU-aware codec advertisement (windows_codec_support;
  AV1 gated to RDNA3+/Arc, like the VAAPI path).
- Cargo.toml: amf-qsv feature (optional ffmpeg-next in the windows target block).
- CI/installer: windows-host.yml sets FFMPEG_DIR + builds --features nvenc,amf-qsv;
  the Inno installer bundles the FFmpeg DLLs; host.env default nvenc -> auto.

CI-green target; AMF/QSV not yet on-glass validated (no AMD/Intel Windows box in the
lab) — NVENC stays live-validated. An adversarial-review pass caught + fixed real
FFI bugs (AV_PIX_FMT_P010 is a macro -> P010LE; windows-rs 0.62 GetImmediateContext/
GetDesc1 return Result; AV_HWFRAME_MAP_* is a bindgen enum with no BitOr).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:31:54 +00:00
enricobuehler fde438a1ed feat(gamepad): SwDeviceCreate per-session devnode (best-effort) + windows self-test
DualSenseWindowsManager now SwDeviceCreate's the pf_dualsense devnode per session
(SwDeviceClose on drop), matching the Linux UHID pad's lifecycle. It's best-effort:
SwDeviceCreate currently hits an unresolved E_INVALIDARG when a completion callback is
passed (an underscore in the enumerator name was a second cause, fixed by using
"punktfunk"), so on failure the host keeps the section + data plane and falls back to
an out-of-band devnode (installer/devgen) — see docs/windows-dualsense-scoping.md.

Add a `dualsense-windows-test` host CLI that drives the manager (create devnode + push
a frame + hold), used to validate the path. Live on the RTX box: the manager creates
the section + pushes report 0x01 and a devnode serves it to a HID read (b1=0xC0,
b8=0x28) — the host-side data plane works end to end.

cargo check + clippy -D warnings clean on x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:34:00 +00:00
enricobuehler 01dc0b616c refactor(windows): trim the inert IOCTL channel from the DualSense driver
The host<->driver channel is the shared-memory section (hidclass blocks the device
stack and UMDF has no control device), so the first-attempt in-driver IOCTL channel
never fired. Remove it: the custom device interface, IOCTL_PFDS_SET_INPUT/GET_OUTPUT,
the output queue, and the on_set_input/complete_one_read/deliver_output helpers. The
driver keeps the HID handshake, the 8ms read timer fed from the shared section, and
on_output_report publishing the game's 0x02 to the section. Rebuilt + reloaded + the
channel still verifies both directions live on the RTX box.

Also list `pf_dualsense` as a second hardware id (alongside `root\pf_dualsense`) so the
host's SwDeviceCreate'd software device binds the same driver as a devgen one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:34:00 +00:00
enricobuehler 4a73102d48 feat(gamepad): virtual DualSense on the Windows host (UMDF shm channel)
Wire the Windows UMDF DualSense driver into the host as a real pad backend, so a
client that requests a DualSense gets a genuine one on a Windows host (instead of
folding to Xbox 360).

- Extract the transport-independent DualSense contract (DsState + from_gamepad,
  serialize_state, parse_ds_output, DUALSENSE_RDESC, feature blobs, DS_* consts)
  out of the Linux-only UHID backend into inject/dualsense_proto.rs, shared by both
  platforms; dualsense.rs is now just the /dev/uhid plumbing.
- Add inject/dualsense_windows.rs: DualSenseWindowsManager mirroring the Linux
  DualSenseManager (same new/handle/apply_rich/pump/heartbeat surface) over a
  DsWinPad that creates the Global\pfds-shm-<idx> section (CreateFileMappingW +
  SDDL D:(A;;GA;;;WD) so WUDFHost can open it), writes serialize_state -> input
  slot, polls output_seq -> parse_ds_output -> rumble/hidout callbacks.
- Un-gate the seam: PadBackend::DualSenseWindows arm; pick_gamepad gains a
  windows flag (DualSense honored on linux||windows; DS4/Xbox One stay Linux-only).

Verified: Linux cargo test gamepad_resolution_precedence + clippy clean; Windows
cargo check + clippy -D warnings clean (on the RTX box). Device lifecycle still
uses an out-of-band devnode (devgen/installer); SwDeviceCreate per session is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:36:53 +00:00
enricobuehler aa159df33f feat(windows): Rust UMDF virtual DualSense driver + shared-memory host channel
A self-authored UMDF2 HID minidriver (packaging/windows/dualsense-driver) that
presents a virtual Sony DualSense (VID 054C/PID 0CE6) on Windows — adaptive
triggers / lightbar / rumble that ViGEm structurally cannot deliver.

Validated live on an RTX box (Win11 25H2, Secure Boot ON): the self-signed driver
loads, Steam recognizes it as a genuine DualSense, and a game's 0x02 output report
reaches the driver. The host<->driver channel is a named shared-memory section
(Global\pfds-shm-<idx>) the host creates and the driver maps from its timer: input
report 0x01 host->driver, output report 0x02 driver->host — input and output proven
both directions live. This bypasses hidclass, which gates both a custom device
interface and custom IOCTLs on the HID node, and UMDF has no control device.

Built in Rust on microsoft/windows-drivers-rs. The load wall was the PE
FORCE_INTEGRITY bit that wdk-build sets via /INTEGRITYCHECK (forces a CI-trusted
page-hash signature a self-signed cert cannot satisfy) — cleared post-build. See
packaging/windows/dualsense-driver/README.md for the build/sign/install recipe.

Deferred: SwDeviceCreate per-session device lifecycle; removing the inert in-driver
IOCTL-channel code; full on-glass session test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:36:39 +00:00
enricobuehler 983adc5347 fix(docs): stop Scalar's global body bg from bleeding into the docs on client-nav
apple / swift (push) Successful in 55s
ci / rust (push) Failing after 37s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m23s
deb / build-publish (push) Successful in 2m21s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
ci / bench (push) Successful in 4m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m33s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m11s
Scalar's /api reference injects a *global* `body { background-color:
var(--scalar-background-1) }` (via its linked stylesheet + a runtime
<style id=scalar-style>) that TanStack doesn't remove on a client-side route
change. After navigating /api -> /docs without a reload, that rule kept
painting the docs body: Scalar's stock gray (#0f0f0f) while .dark-mode lingered
on <body>, or transparent once the class was gone. A hard reload was fine
because the stylesheet was never loaded there.

Fix: give --scalar-background-1 a global fallback = --color-fd-background so any
non-API page paints its own surface while Scalar's sheet lingers; /api itself
overrides it via the higher-specificity body.{dark,light}-mode rule. Also strip
the leftover #scalar-style/#scalar-refs nodes and body mode-class when /api
unmounts so the DOM matches a fresh load. Verified light + dark via headless
CDP: post-nav docs body now equals a fresh reload (#141019 / #f0ebff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:49:20 +00:00
enricobuehler 78c16e5136 fix(docs): Scalar API ref uses brand bg + follows the docs light/dark toggle
ci / rust (push) Failing after 30s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 4m10s
decky / build-publish (push) Successful in 11s
deb / build-publish (push) Successful in 2m29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m50s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 45s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m26s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m19s
Scalar puts .light-mode/.dark-mode on document.body and renders customCss
*before* its built-in theme preset in the same <style> tag, so a bare
.dark-mode override loses at equal specificity and the stock #0f0f0f gray
showed through. Scope the palette to body.{dark,light}-mode (0,1,1) so it beats
both the linked base sheet and the in-component preset, and add a full
light-lavender palette to match the docs light surface.

Drive Scalar's darkMode from the resolved Fumadocs theme (next-themes) instead
of hard-locking it on, so toggling the docs theme switch flips the API
reference too; the React wrapper's updateConfiguration effect live-swaps the
body mode class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:36:32 +00:00
enricobuehler 0205c7b8d6 ci(release): split canary/stable tracks + unified Gitea Releases
ci / rust (push) Failing after 37s
apple / swift (push) Successful in 56s
ci / web (push) Successful in 42s
ci / docs-site (push) Failing after 27m33s
android / android (push) Failing after 28m53s
windows-host / package (push) Failing after 28m55s
deb / build-publish (push) Successful in 2m28s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m4s
flatpak / build-publish (push) Successful in 4m19s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m38s
release / apple (push) Successful in 4m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s
A push to main publishes canary builds to canary channels (fast iteration,
unchanged); a single vX.Y.Z tag releases every platform at one version to the
stable channels and attaches all artifacts (.deb/.rpm/.msix/.apk/.aab/.dmg +
flatpak/decky/host-installer) to one Gitea Release. Collapses the
host-v*/win-v*/host-win-v* tag namespaces into v* — the channel split makes the
version-shadow bug structurally impossible (canary and stable are separate repos,
never a shared version line).

- scripts/ci/gitea-release.{sh,ps1}: one idempotent release helper
  (create-or-fetch + delete-before-upload), replacing 3 copy-pasted inline blocks
  and fixing their latent 409-on-reupload bug; prerelease flag auto-derived from
  the tag (an -rc tag won't shadow "Latest")
- channels: apt canary/stable distributions; rpm *-canary/base groups; flatpak
  canary/stable OSTree branches + a 2nd .Canary.flatpakref; generic-registry
  canary/ vs latest/ aliases; Play internal/alpha; Apple TestFlight vs notarized DMG
- android versionName threaded through gradle (versionCode stays run_number);
  Apple canary = TestFlight-only (no DMG/tvOS); canary base bumped to 0.3.0
- docs: new docs-site channels.md (subscribe table + cut-a-release runbook +
  box migration), refreshed ci.md workflow table + packaging READMEs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:26:38 +00:00
enricobuehler 3e6c9f6060 feat(gamepad): add virtual Xbox One/Series + DualShock 4 pad types
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a
physical Xbox One or PS4 pad on the client gets a near-native matching virtual
pad on the host, auto-resolved from the controller type.

Protocol/core:
- GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/
  from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants
  (compile-time guard ties them to the enum). Single-byte wire form is
  unchanged, so it's forward-compatible (older peers degrade to Auto).

Host (Linux):
- New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation:
  lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers /
  player LEDs / mute. Reuses the DualSense pure state + button mapping; only the
  report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake
  (0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad
  resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane,
  lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane.
- Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S
  USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise.
- PadBackend dispatch + resolver handle both; off Linux the UHID pads and
  One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred.

Clients (auto-resolve physical pad -> virtual type, plus manual settings):
- Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE ->
  Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture +
  lightbar already type-agnostic. Linux settings combo + label updated.
- Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4
  touchpad capture, settings picker entries.
- Android (Kotlin): InputDevice VID/PID auto-detect (matching the other
  clients) + settings entries.
- probe: --gamepad help/aliases.

Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in
catch_unwind so a panic degrades to a logged no-op instead of aborting the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:34:44 +00:00
enricobuehler b3811ff72e fix(web): session-card button overflow + bottom-nav icon alignment
apple / swift (push) Successful in 55s
android / android (push) Successful in 3m59s
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m29s
ci / docs-site (push) Successful in 51s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m52s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m21s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m6s
- Dashboard session card: the header stacks the title above the action buttons
  on narrow screens (flex-col -> sm:flex-row) and the button group wraps
  (flex-wrap), so "Request IDR" / "Stop session" no longer overflow the card.
- Mobile bottom nav: give each label a fixed two-line-tall centered box so a
  1- or 2-line label (labels vary by locale) keeps every tab icon at the same
  height.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:33:33 +00:00
enricobuehler b6b0b6c29e fix(docs): load Scalar's stylesheet so the API reference isn't unstyled
apple / swift (push) Successful in 55s
android / android (push) Failing after 42s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m16s
ci / rust (push) Successful in 4m17s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / bench (push) Successful in 4m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 17s
@scalar/api-reference-react@0.9.47's entry imports createApiReference but does
NOT import its own style.css (nor inject it at runtime), so /api rendered with
no Scalar CSS at all. Import the sheet as a route-scoped <link> (?url +
head.links, same pattern as the root app.css) so it loads for SSR + the
client-side Vue mount. The brand customCss still themes on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:25:22 +00:00
enricobuehler 527c2f677e feat(web): drop material gloss, full punktfunk theme for Scalar, center mobile tabs
ci / web (push) Successful in 38s
apple / swift (push) Successful in 54s
android / android (push) Successful in 3m58s
ci / rust (push) Successful in 4m30s
ci / docs-site (push) Successful in 54s
deb / build-publish (push) Successful in 2m18s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 42s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m13s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
docker / deploy-docs (push) Successful in 19s
- console: remove @unom/ui's specular "material" gloss (drop UnomProviders +
  the material.css import) so components render flat like the marketing site;
  the violet brand + Geist stay.
- mobile bottom tab bar: center the labels (w-full text-center, leading-tight)
  and even out the per-tab layout.
- docs /api: roll the punktfunk dark-violet palette across the whole Scalar
  reference (surfaces/text/sidebar/links/buttons/method colours via the full
  --scalar-* token set), locked to dark (hideDarkModeToggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:19:51 +00:00
enricobuehler f3555d5eb5 feat(web): unify console + docs on @unom/ui; host OpenAPI via Scalar
apple / swift (push) Successful in 55s
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 4m14s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
ci / bench (push) Successful in 4m40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 19s
android / android (push) Successful in 3m12s
Move the management console (web/) off shadcn/ui to the shared @unom/ui
design system the marketing site + docs are built on, on the punktfunk
violet brand over dark chrome:

- Add @unom/ui/@unom/style/motion/radix-ui/zod + Geist; web/.npmrc maps the
  @unom scope (packages are public-read, so CI needs no npm auth).
- styles.css: one dark-violet palette (#141019/#1c1530, brand #6c5bf3 ->
  #a79ff8) exposed under BOTH the shadcn token names the routes use and
  @unom/ui's contract, so routes + components both resolve; pulls in
  @unom/ui's material gloss + easings.
- components/ui/* now back onto @unom/ui (AnimatedButton/InputText/Label/
  AnimatedCard); brand-mark/wordmark/logo replace the generic Radio icon in
  the shell + login.
- MaterialProvider (specular gloss) at the root. No UI sounds, like the site.

docs-site: new /api route renders the host management REST API as an
interactive Scalar reference (reads public/openapi.json, a snapshot of
docs/api/openapi.json), branded violet and linked from the top nav, the
docs sidebar, the landing page, and host-cli.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:00:46 +00:00
enricobuehler 75d5a6d7fb docs(steamos): reframe Steam Deck host page to SteamOS
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 4m21s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 36s
android / android (push) Successful in 10m26s
ci / bench (push) Successful in 4m40s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
deb / build-publish (push) Successful in 2m13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m25s
docker / deploy-docs (push) Successful in 8s
- Rename steam-deck-host.md → steamos-host.md (nav + install table updated).
- Lead with the rationale: SteamOS host support targets the upcoming Steam
  Machine; the Steam Deck is the SteamOS device validated against today.
- Soften the WiFi note: ~250 Mbps was our testing on one device/network,
  not a universal ceiling — other SteamOS hardware/drivers/bands may do more.
- Generalize Deck-specific language to SteamOS devices throughout.
- Document --no-gamestream (secure native-only) + GameStream-compat caveat.
- decky README: drop stale `serve --native` (now just `serve`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:33:49 +00:00
enricobuehler 1fe4161d4d feat(steamdeck): --no-gamestream installer flag for a secure native-only SteamOS host
apple / swift (push) Successful in 55s
android / android (push) Successful in 4m41s
ci / web (push) Successful in 34s
ci / docs-site (push) Successful in 35s
ci / rust (push) Successful in 4m54s
deb / build-publish (push) Successful in 2m9s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m20s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m29s
docker / deploy-docs (push) Successful in 17s
Completes the GameStream-opt-in posture (54b75c9) on the SteamOS path: the installer keeps
Moonlight compat on by default (`serve --gamestream`, the Deck commonly streams to Moonlight),
but `--no-gamestream` now installs a secure native-only host with no GameStream on-path surface
(plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9; native clients only).
Documented in the installer --help; the SteamOS host doc references it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:29:40 +00:00
enricobuehler 54b75c9be4 feat(host): GameStream/Moonlight compat is now opt-in (--gamestream) — secure native-only by default
apple / swift (push) Successful in 55s
windows-host / package (push) Successful in 2m31s
android / android (push) Successful in 4m40s
ci / rust (push) Successful in 4m43s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 34s
deb / build-publish (push) Successful in 2m9s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
ci / bench (push) Successful in 4m44s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m6s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m19s
Follows the security audit (#5/#9): the GameStream-compat plane carries inherent on-path weaknesses
that can't be fixed on the wire without breaking stock Moonlight — its pairing runs over plain HTTP
(#9, MITM-able during the pairing window) and its legacy control encryption can reuse GCM nonces (#5,
a passive eavesdropper can recover/forge input). The native punktfunk/1 plane (SPAKE2 PIN pairing +
per-direction AEAD nonces) has neither. So flip the default to secure-by-default:

- `serve`              → native punktfunk/1 plane + management API ONLY (no GameStream surface).
- `serve --gamestream` → ALSO the GameStream/Moonlight-compat planes (nvhttp pairing, RTSP, ENet
  control, _nvstream mDNS). Opt-in, logged with a trusted-LAN caveat. `--moonlight` is an alias.
- The native plane is now ALWAYS on in `serve` (`--native` is a kept-for-compat no-op); the unified
  GameStream+native host is `serve --gamestream`.

`gamestream::serve` gates the GameStream spawns (nvhttp/rtsp/control/mdns) on the flag; the native
plane + mgmt + native-pairing handle always run.

To avoid silently regressing validated Moonlight deployments, the explicit deployment configs PRESERVE
Moonlight via `--gamestream` (each documents dropping it for a secure native-only host): the Linux
systemd unit, the Steam Deck installer, and the Windows service default (DEFAULT_HOST_CMD). The bare
`serve` default (new/manual use) is secure.

Docs swept to match (host-cli, moonlight, quickstart, install, packaging READMEs, CLAUDE.md, README,
…): Moonlight setup now instructs `--gamestream`; native/console refs use bare `serve`. OpenAPI
regenerated (a stale "run `serve --native`" string). fmt + clippy clean; 94 host tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:19:40 +00:00
enricobuehler 3c55ec37fa fix(security): remaining audit findings — mgmt admin gate, RTSP DoS bounds, FEC drop, ALPN, ct-compare
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 2m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s
Addresses the lower-severity findings from docs/security-review.md (#4-#12). Each fix was
adversarially re-reviewed (5-agent pass); two review catches folded in (the Apple client's
GET /library cert path; an RTSP header-cap bypass + a spawn-panic counter leak).

- #4 [low] mgmt mTLS-paired-cert no longer grants full admin. A paired STREAMING cert authorizes
  only a read-only allowlist (GET /host,/compositors,/status,/clients,/native/clients,/library);
  every state-changing route and every PIN-exposing route (/pair, /native/pair) requires the
  operator's bearer token. New cert_auth_is_a_read_only_allowlist test. (/library kept on the
  allowlist — the native clients browse it cert-only; its mutations stay token-only.)
- #6 [low] RTSP pre-auth DoS bounds: a concurrent-connection cap (RAII slot guard), a per-read
  timeout (slow-loris), and Content-Length/header/message size caps — closing an unauthenticated
  slow-loris / memory-growth / thread-exhaustion vector on TCP 48010.
- #11 [info] A FEC reconstruction failure is now a counted drop (discard the block, keep the
  session) instead of being stream-fatal — a lossy link can't be torn down by one bad block.
- #10 [info] Fixed ALPN ("pkf1") on both native QUIC endpoints (defense-in-depth; a deliberate
  coordinated client+host upgrade — a new host rejects an ALPN-less old client).
- #8 [info] Constant-time GameStream pairing phase-4 hash compare (crypto::ct_eq).
- #7 [low] New VirtualDisplay::set_launch_command carries the launch command per-session on the
  GameStream path (no process-global env stomp under concurrent sessions); native path keeps the
  env under today's single-session model (documented; plumb per-session with concurrent sessions).
- #5 [low] Legacy GameStream GCM nonce reuse: documented as inherent to Nvidia's old-style control
  encryption (Apollo/Moonlight identical; key is client-known) — unfixable on the legacy wire; the
  real fix is V2 control-encryption negotiation. Code comment at control.rs.
- #9 [info] GameStream plain-HTTP pairing: documented (inherent to GFE compat; use punktfunk/1).
- #12 [low] Web global NODE_TLS_REJECT_UNAUTHORIZED: fix designed (undici dispatcher scoped to the
  loopback mgmt fetch) but DEFERRED — needs `bun add undici` in the web build env; reverted to keep
  the web working. Latent-only (the loopback mgmt fetch is the console's only outbound TLS).

fmt + clippy -D warnings clean; 94 host + core tests green; no C-ABI/OpenAPI drift. (The HDR
Steps 1-2 client work in the tree is the user's parallel WIP — deliberately NOT included here.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:50:24 +00:00
enricobuehler 551012bb43 feat(clients): HDR Steps 2-3 — apply mastering metadata + display capability-gate
Continues docs/hdr-pipeline-plan.md. Steps 0/1 + Step 2 (Windows/Android) already
landed in 3526517; this is Step 2 (Apple) + Step 3 (all clients). Client-only — no
core/host/ABI change (the 0xCE/next_hdr_meta/color_info surfaces shipped in Step 0).

Step 2 — clients APPLY the host's HDR metadata (each remaps from the wire form: ST.2086
G,B,R order, mastering luminance in 0.0001 cd/m2):
- Apple: connect via punktfunk_connect_ex5 (resurrects the previously-dead HDR pipeline);
  nextHdrMeta/colorInfo wrappers + HdrMeta SEI-blob builders; the pump drains nextHdrMeta
  -> VideoDecoder.setHdrMeta -> CVBufferSetAttachment of MasteringDisplayColorVolume (24B
  BE) + ContentLightLevelInfo (4B BE) on each HDR pixel buffer (correct for the
  itur_2100_PQ layer; CAEDRMetadata avoided as ambiguous there).

Step 3 — capability-gate: advertise HDR caps ONLY when the display can present it, so an
SDR display gets a proper BT.709 stream instead of PQ it would mis-tone-map; an HDR
display self-tone-maps from the Step-1/2 mastering metadata.
- Windows: present::display_supports_hdr() (DXGI any IDXGIOutput6 colour space == G2084),
  ANDed with the user HDR setting in session.rs; logs the SDR drop.
- Apple: NSScreen.maximumExtendedDynamicRangeColorComponentValue>1 (macOS) /
  UIScreen.main.potentialEDRHeadroom>1 (iOS) in SessionModel.
- Android: Settings.displaySupportsHdr (Display.getHdrCapabilities HDR10/HDR10+) passed
  through a new hdr_enabled jboolean on nativeConnect; session.rs gates the caps.

Validation: Android native (incl. the jboolean gate) builds + clippy clean via cargo-ndk;
fmt clean. Windows (MSVC), Apple (Swift) and the Kotlin side are CI/on-glass validated —
not compilable on the Linux dev box. Deferred to the RTX box: mid-session Reconfigure
SDR-downgrade on monitor move, and confirming the host emits SDR for an SDR client off an
HDR desktop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:46:58 +00:00
enricobuehler 3526517eb1 feat: HDR Step-0 colour-metadata transport + security-audit hardening
ci / rust (push) Failing after 45s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 38s
windows-host / package (push) Successful in 3m26s
android / android (push) Successful in 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
deb / build-publish (push) Successful in 2m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m37s
flatpak / build-publish (push) Successful in 4m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m53s
Two strands, entangled in punktfunk1.rs, committed together (one builds-green tree).

HDR pipeline Step 0 — glass-to-glass colour-metadata transport (docs/hdr-pipeline-plan.md):
- Protocol/ABI: ColorInfo on the Welcome + a 0xCE HdrMeta datagram carry the source colour
  space + HDR10 static mastering metadata (quic.rs, abi.rs connect_ex5 fixing caps=0).
- New platform-independent, unit-tested HDR static-metadata helpers (hdr.rs): chromaticities
  (1/50000), mastering luminance (0.0001 cd/m2), MaxCLL/MaxFALL in HDR10/ST.2086 units.
- Capture/encode hooks (capture.rs, encode.rs set_hdr_meta) + Linux client / probe plumbing.

Security-audit hardening — top 3 from docs/security-review.md, each adversarially verified:
- #1 [HIGH] Secret file permissions. The host key.pem/cert.pem and both trust stores are now
  written owner-only: 0600 + dir 0700 on Unix (mirrors mgmt_token), best-effort
  SYSTEM/Administrators/OWNER-only icacls DACL on Windows (%ProgramData% is Users-readable).
  Closes a local key-disclosure -> host-impersonation gap. New gamestream::{create_private_dir,
  write_secret_file} + a 0600 regression test.
- #2 [HIGH] Native SPAKE2 PIN is single-use. The PIN is consumed the moment the host sends its
  key-confirmation (which lets the client test its one guess), before reading the proof, so any
  completed attempt -- right OR wrong -- disarms the window. A wrong PIN isn't observable
  host-side (the client aborts before sending its proof), so consuming on first attempt is what
  delivers the documented "one online guess" instead of an unbounded brute-force of the static
  4-digit PIN. Test verifies single-use.
- #3 [MEDIUM] RTSP packetSize is bounded ([64,2048] in stream_config) and VideoPacketizer::new
  uses saturating .max(1), killing a PRE-AUTH div-by-zero/underflow panic of the video thread.
  Tests for {0,15,16,17} + out-of-range rejection.

fmt + clippy -D warnings clean; full workspace test suite green (93 host tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:07:59 +00:00
enricobuehler 22a9ce4229 Merge remote-tracking branch 'origin/main'
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 3m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m18s
android / android (push) Successful in 4m27s
ci / rust (push) Successful in 4m43s
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 34s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
deb / build-publish (push) Successful in 2m8s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m5s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 26s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m11s
flatpak / build-publish (push) Successful in 4m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m6s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m41s
# Conflicts:
#	docs-site/content/docs/meta.json
2026-06-21 00:07:36 +00:00
enricobuehler 450bcf1e7b feat(host): Apollo-backlog hardening — cert gate, NVENC RFI, media QoS, async injector
A pass over the apollo-comparison backlog (re-verified against current code).
Lands four items end-to-end plus a Windows-DualSense scoping doc.

- #5/#92/#26 — GameStream paired-cert allow-list. tls.rs surfaces the verified
  peer cert to handlers (serve_https + PeerCertFingerprint, now shared with the
  mgmt API instead of duplicated); nvhttp gates /launch /resume /applist /cancel
  on AppState.paired and reports a real PairStatus; save_paired writes atomically
  (temp+rename). Closes the "mTLS accepts any client cert" hole. + regression test.

- #6/#51/#19/#22 — NVENC caps query -> reference-frame invalidation. nvenc.rs
  query_caps probes nvEncGetEncodeCaps (max dims / 10-bit / custom-VBV / RFI),
  rejecting over-range modes and degrading 10-bit->8-bit instead of an opaque
  InvalidParam. New Encoder::invalidate_ref_frames (default false -> caller
  keyframes); the Windows NVENC path implements real RFI (multi-ref DPB +
  nvEncInvalidateRefFrames, dedup + IDR-on-overflow). control.rs decodes the
  0x0301 lost-frame range (Apollo's IDX_INVALIDATE_REF_FRAMES) -> AppState.rfi_range
  -> encode loop, falling back to a keyframe. NOTE: the Windows NVENC impl is
  RTX-box/CI-pending (can't compile on Linux); adversarially reviewed vs the SDK.

- #43/#72 — media socket QoS + buffer growth. New punktfunk_core::transport::qos:
  grow_socket_buffers (factored out the native plane's 32MB SO_SNDBUF growth so the
  GameStream sockets reuse it) + set_media_qos (opt-in PUNKTFUNK_DSCP=1: DSCP CS5
  video / CS6 audio + Linux SO_PRIORITY, Apollo's scheme). Wired into UdpTransport
  and the GameStream video/audio sockets. Windows IP_TOS needs qWAVE (follow-up).

- #8/#45 — GameStream input injection off the ENet service thread. on_receive no
  longer injects inline (a slow inject head-blocked ENet keepalive/retransmit); it
  forwards to a dedicated injector thread. The hardened InjectorService moved from
  punktfunk1 into crate::inject (shared by both planes) + a coalesce step that sums
  adjacent relative-mouse/scroll deltas while preserving button/key/abs ordering.

Docs: re-verified apollo-comparison.md status (22 items already done/obsolete since
the snapshot) + windows-dualsense-scoping.md (ViGEm can't emulate a DualSense; real
DS5 on Windows needs a VHF virtual-HID driver — web-research pass pending).

fmt + clippy -D warnings clean; full workspace test suite green; no C-ABI/OpenAPI drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:06:30 +00:00
enricobuehler a2a6b858f7 fix(steamdeck): run the web console with node, not bun (Nitro node-server preset)
The management console is a Nitro `node-server` build (per web/vite.config.ts) — it must be
run with `node`, not `bun`. Run under bun it 500s on every page render with
"Cannot find package 'srvx'": bun mis-resolves Nitro's externalized server deps from the
nested SSR chunk at request time. (This was pre-existing — the old manual pfweb.sh ran it
with bun too.)

- Provision `nodejs` in the pf2 distrobox; run the web service with `node .output/server/index.mjs`.
- Use `enable` + `restart` (not `enable --now`) so re-running the installer actually applies
  unit-file changes instead of no-opping against the running service.

Verified on the Deck: web `/login` now returns 200 (was 500), "Listening on http://0.0.0.0:3000",
no srvx error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:32:23 +00:00
enricobuehler f85d51b9f9 feat(steamdeck): one-command host install + docs (build-on-device)
SteamOS is immutable read-only Arch, and the Deck is AMD (VAAPI) — so none of the
checked-in packaging (arch/sysext is NVENC-first + client-oriented, deb/rpm are
soname-mismatched) actually installs a working host on a Steam Deck. The proven path
(distrobox-built native binary + systemd-run units) was 100% manual. Make it one command.

- scripts/steamdeck/install.sh — idempotent installer: ensure the pf2 Debian-trixie
  distrobox + toolchain → build host (+web console) → write config (generated web login
  password) → raise UDP buffers to 32 MB + udev + input group (sudo, skipped gracefully
  if unavailable) → install + start punktfunk-host / punktfunk-web systemd USER services
  with linger. Flags: --open (accept unpaired clients), --no-web, --src=DIR. Builds
  on-device so a rebuild always matches the running SteamOS (no prebuilt-binary fragility
  across OS updates); VAAPI on the Deck's AMD GPU.
- scripts/steamdeck/update.sh — rebuild from current source + restart (config/pairings persist).
- scripts/steamdeck/README.md — deep reference (why on-device, what's installed, gotchas).
- docs-site: new "Steam Deck (Host)" guide + sidebar entry; install.md splits Arch from the
  Steam Deck host path; packaging/arch/README points Deck-host users here and corrects the
  stale "NVENC-only" note (VAAPI host encode landed).

Live-validated on the Deck: installer runs clean, both services come up, host listens
(QUIC :9777 + mgmt :47990), web serves (302→login); on a client connect it takes over the
Game-Mode gamescope session at the client's mode, captures via PipeWire, and VAAPI-encodes
(hevc_vaapi) — full pipeline confirmed in the host journal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:20:00 +00:00
enricobuehler 516efcc3a3 feat(core/fec): adaptive FEC — size recovery to measured loss, not a flat 20%
On a clean link the flat 20% FEC is pure waste: extra wire bytes AND extra
packets. On a packet-rate-bound uplink (the Steam Deck's WiFi tx caps ~22k pps
regardless of bitrate) those extra packets directly cost goodput — measured at
200 Mbps goodput, 20% FEC drove ~10% loss vs ~2.6% at 0% (it saturated the link).

Adaptive FEC closes the loop:
- Client measures the loss FEC is absorbing each ~750 ms window from session stats
  (recovered shards / received, + a bump when a frame went unrecoverable) and sends
  a periodic `LossReport { loss_ppm }` on the control stream (new message;
  `window_loss_ppm` helper, shared + unit-tested). Connector (Apple/Linux/Windows)
  and probe both report; suppressed during a speed test so its filler can't skew it.
- Host maps loss → recovery % (`adapt_fec`: ≈ loss×1.4 + 1pt, clamped 1..50) and
  applies it live via `Session::set_fec_percent` (the wire is self-describing — each
  packet carries its block's data/recovery counts, so the receiver needs no notice).
  A clean link decays to ~1%; loss ramps it up and converges.
- `PUNKTFUNK_FEC_PCT`, when set, now PINS FEC static (disables adaptation) so
  speed-test / measurement runs keep a fixed, known overhead. Unset ⇒ adaptive,
  starting at 10%.

An older host ignores LossReport (unknown control message) and keeps static FEC;
an older client simply never reports and the host holds its start value. Builds +
clippy + fmt + tests green (adapt_fec / window_loss_ppm / loss_report unit tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:31:07 +00:00
enricobuehler 4afdb18cc4 docs: clarify HDR is supported on the Windows host (Linux still blocked)
apple / swift (push) Successful in 55s
android / android (push) Successful in 4m10s
ci / rust (push) Successful in 4m37s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 32s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
ci / bench (push) Successful in 7m9s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m32s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m35s
HDR (10-bit BT.2020 PQ) works end-to-end with the Windows host — it captures
an HDR desktop (WGC FP16 / Desktop-Duplication FP16 for the secure desktop)
and encodes HEVC Main10 to HDR-capable clients (Windows, Android). Only the
Linux host is blocked upstream (no 10-bit compositor capture). Corrected the
roadmap (grid + shipped/blocked), Windows Host page, status, and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:13:09 +02:00
enricobuehler 9f049f965f docs(site): add Windows host install, restructure nav, new public roadmap
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 2m24s
ci / web (push) Successful in 35s
android / android (push) Successful in 3m27s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 4m43s
deb / build-publish (push) Successful in 4m49s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 17s
- install (host): add a Windows (NVIDIA) section with signed-installer and
  certificate-trust steps; note the .cer is the same across releases.
- install-client: clarify the Windows MSIX certificate is the same every
  release (trust once, updates need nothing).
- Move "Project & Internals" out of the public docs site: relocate
  implementation-plan, apple-stage2-presenter, gamescope-multiuser,
  dualsense-haptics, ci, and gamestream-host-plan to docs/; drop them from
  the nav. Move windows-host into Host Setup.
- Rewrite roadmap as a lean public page with an at-a-glance grid and
  current statuses (Windows host shipped/beta, Apple incl. tvOS shipped,
  Android shipped, concurrent sessions + delegated pairing done).
- Fix status.md link to the now-internal implementation plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:52:23 +02:00
enricobuehler f37a304fba fix(core/speed-test): packet-level throughput + paced burst (kill the 0/100% cliff)
The punktfunk/1 speed test was unusable across every client/host: at the start of
a burst a little data got through, then everything read as dropped (~10 MB total).
Two compounding bugs:

1. Receive side measured throughput from fully-reassembled FLAG_PROBE *access
   units* only. The instant loss crossed the 20% FEC budget no AU completed, so the
   figure cliffed to 0 / 100% loss even though most bytes still arrived — a binary
   cliff, not a graded measurement.
2. Send side blasted each filler AU (up to 256 KB ≈ 200 packets) into the socket
   buffer in one unpaced batch, unlike the real video path which paces. On a small
   buffer (e.g. the Steam Deck's 416 KB) a single AU overflowed it, so the test
   measured self-inflicted buffer overflow instead of the link.

Fixes:
- Host `run_probe_burst` keeps each AU a small (~16 KB) burst and paces by the byte
  budget, mirroring `paced_submit`; reports the WIRE packets the kernel accepted and
  the ones the send buffer dropped (stat deltas), separating host-side drops from
  link loss.
- `ProbeResult` gains `wire_packets_sent` + `send_dropped` (back-compat decode: a
  21-byte pre-wire-stats result still decodes, new fields 0).
- Clients (probe + connector) count delivered traffic at the packet level via
  `session.stats()` deltas over the burst window, so throughput/loss degrade
  gracefully. Connector freezes the delivered figure when the host report lands so
  resumed video can't inflate it. New `ProbeOutcome`/`PunktfunkProbeResult` fields:
  `host_drop_pct`, `wire_packets_sent`, `send_dropped`.

Validated on loopback (graded 142→1391 Mbps, host_drop/link_loss split correctly,
no cliff) and live against the Deck: clean to ~200 Mbps goodput / 273 Mbps wire at
0% link loss, host send buffer the wall above that (the lever-#1 target).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:46:17 +00:00
enricobuehler 76f4484ded docs(CLAUDE.md): refresh stale status
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m30s
deb / build-publish (push) Successful in 3m14s
decky / build-publish (push) Successful in 11s
android / android (push) Successful in 3m52s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 5m37s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m20s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m58s
- Add the Windows host (implemented & shipping: DXGI capture, SudoVDA
  virtual display, NVENC, ViGEm, WASAPI, LocalSystem service installer;
  NVIDIA-only, x64-only) — it was absent entirely.
- Add the Android client (full client: AMediaCodec/HDR10 decode, Oboe
  audio + mic, gamepad feedback, discovery, pairing, Compose phone+TV UI;
  Google Play internal testing) and drop the stale "scaffolds" item.
- macOS stage-2 presenter: built + live-validated behind the opt-in flag,
  not "next".
- Concurrent sessions + delegated pairing approval marked done.
- Layout/CI: note Windows host backends and per-client release workflows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:10:51 +02:00
enricobuehler cba3ae48e2 docs: update README + docs site for public readiness
apple / swift (push) Successful in 56s
ci / rust (push) Successful in 1m37s
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 40s
android / android (push) Successful in 3m19s
deb / build-publish (push) Failing after 1m9s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m21s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 26s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m22s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m25s
Refresh the README and documentation for public visitors:

- README: public-facing rewrite with accurate status for all four native
  clients (macOS, Linux, Windows, Android) and the Windows host.
- docs site: fix stale client status (Android is a full client, not a
  scaffold; Windows client is stage-1 complete + signed MSIX), add the
  missing Android client section, correct "which client" guidance.
- Windows host: corrected from "deferred/scoped" to implemented & shipping
  (NVIDIA-only, x64-only) across windows-host, roadmap, status,
  requirements, running-as-a-service, and the README.
- Remove internal infrastructure from public docs (box names, private IPs,
  SSH/token commands, deploy topology); rewrite status.md as a public
  project-status page; sanitize ci.md and implementation-plan.md.
- Update clients/android and clients/apple READMEs to current state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:59:23 +02:00
enricobuehler 2dc54bc651 Merge remote-tracking branch 'origin/main' 2026-06-20 16:30:32 +00:00
enricobuehler 480dee863d feat(host/gamescope): custom-resolution Game-Mode streaming on the Steam Deck
The Steam Deck (SteamOS) ships its OWN gaming session — `gamescope-session.target`
driven by `/usr/lib/steamos/gamescope-session`, not Bazzite's `gamescope-session-plus`.
That script `exec gamescope`s with HARDCODED physical-panel args (`-w 1280 -h 800 -O
'*',eDP-1`) and launches Steam via a SEPARATE `steam-launcher.service`, so the existing
managed-session path (which assumes session-plus) couldn't honor the client's mode — an
attach captured the panel's native 1280x800 instead.

Add a SteamOS branch to the managed-session path: detect it, write a `gamescope` PATH-shim
that rewrites the hardcoded args to `--backend headless -W <client> -H <client> -r <hz>`,
drop a transient user `gamescope-session.service.d` override pointing PATH at the shim +
the mode, then RESTART the whole target so `steam-launcher.service` brings Steam up IN the
headless gamescope at the client's resolution. Attach to the one fresh node (the restart
kills any prior gamescope, so no stale-node attach). Restore-on-disconnect removes the
override + restarts the target back to the physical panel (debounced; skipped if the user
switched to a desktop session). All user-level (`systemctl --user`) — no root.

Also widen `build_pipeline_with_retry` to 8 attempts (~90s): a host-managed gamescope
session cold-starting Steam Big Picture takes 30-60s to first frame, and a first-connect
timeout would tear down the warm session (forcing another cold start on reconnect).
Permanent failures still fail fast via `is_permanent_build_error`.

Validated live on a Steam Deck: Game Mode auto-detected, host takes over headless at the
client's mode (720p / 1080p), Steam Big Picture streamed glass-to-glass to the Mac at the
requested resolution. Single-tenant (concurrent clients at different modes still thrash —
a follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:30:24 +00:00
enricobuehler 618602d802 feat(docs-site): read footer from the per-tenant CMS collection
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m38s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 35s
android / android (push) Successful in 3m40s
deb / build-publish (push) Successful in 3m9s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
ci / bench (push) Successful in 4m47s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m22s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m59s
The shared unom CMS is now multi-tenant; the footer global became a per-tenant
collection. Query footers scoped to tenant.slug = punktfunk instead of the
removed /globals/footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:11:42 +02:00
enricobuehler fdf388436a Merge remote-tracking branch 'origin/main'
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m39s
ci / web (push) Successful in 28s
windows-host / package (push) Successful in 2m25s
ci / docs-site (push) Successful in 40s
android / android (push) Successful in 3m18s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 3m8s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m56s
flatpak / build-publish (push) Successful in 4m46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m13s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m56s
2026-06-20 14:50:09 +00:00
enricobuehler 0f7f1be3c3 fix(core/transport): treat ENOBUFS as a transient drop, not a fatal error
WiFi drivers (e.g. ath11k on the Steam Deck) return ENOBUFS — not
EAGAIN/EWOULDBLOCK — when the tx queue is momentarily full. Rust maps
ENOBUFS to ErrorKind::Uncategorized, so `is_transient_io` (which only
matched WouldBlock/ConnRefused/ConnReset) treated it as a real error and
tore the whole stream down on a single transient burst.

This presented as a vicious Heisenbug on the Deck: the native host
streamed flawlessly on loopback and under a debugger (anything slow
enough not to fill the small ~416 KB wlan0 buffer), but died at full rate
cross-machine over WiFi — flaky hang-or-SIGKILL because tx-queue-full is
probabilistic. Diagnosed live via a forced core dump (gdb on the hung
core): the data-plane thread had bailed on a fatal send error.

Treat ENOBUFS (and asynchronous network-path blips ENETUNREACH /
EHOSTUNREACH / ENETDOWN / EHOSTDOWN) as a lossy drop like WouldBlock —
FEC + the next frame recover. Validated: 6/6 back-to-back cross-machine
streams over the Deck's WiFi, host stable, p50 ~4.4 ms (one run dropped
4/300 frames *gracefully*, 0 mismatched — the fix working as intended).

Also surface a data-plane bind/hole-punch failure directly in punktfunk1
(it was previously only reported after teardown, which a stall could
swallow entirely).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:49:59 +00:00
enricobuehler e88c28c15c feat(docs-site): add a Gitea repo link to the nav
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m33s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 39s
android / android (push) Successful in 3m22s
deb / build-publish (push) Successful in 3m10s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
ci / bench (push) Successful in 4m42s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m44s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m54s
docker / deploy-docs (push) Successful in 23s
Link to https://git.unom.io/unom/punktfunk in the top bar, alongside the
Docs and Website links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:08:44 +02:00
enricobuehler 72ca0419db feat(docs-site): add the site-wide footer, shared with the marketing site
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m32s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 40s
android / android (push) Successful in 3m14s
deb / build-publish (push) Successful in 3m5s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m55s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m42s
docker / deploy-docs (push) Successful in 19s
Pull the same footer from the shared unom CMS global (cms.unom.io) and render
it globally under both the home and docs layouts. Read-only typed fetch in a
server-side root loader (falls back to null on a CMS hiccup). Root-relative
links target the marketing site, so they're resolved against its origin (the
docs don't host /legal/* etc.); themed with Fumadocs tokens for light/dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:42:39 +02:00
enricobuehler 40109056e9 refactor(docs-site): use the vector SVG wordmark instead of the raster
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m35s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 40s
android / android (push) Successful in 3m19s
deb / build-publish (push) Successful in 3m6s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
ci / bench (push) Successful in 4m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 17s
Replace the CSS-mask/webp wordmark with the inline vector from
Export/Punktfunk_Logo-Text_No-Border_Dark.svg (white export background
dropped), painted via currentColor — deep-violet on light, light-violet on
dark. Crisp at any size; drops the now-unused funk-wordmark.{webp,png}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:34:19 +02:00
enricobuehler 3df9dd6d32 feat(docs-site): use the funk wordmark logo instead of "punktfunk" text
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m34s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 38s
android / android (push) Successful in 3m24s
deb / build-publish (push) Successful in 3m6s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
ci / bench (push) Successful in 4m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m44s
docker / deploy-docs (push) Successful in 18s
Swap the plain "punktfunk" text in the nav and landing hero for the real
brand wordmark from the marketing site. The source asset is a single
light-violet variant (made for dark surfaces), so it's painted as a CSS
mask and coloured per theme — deep-violet on light, light-violet on dark —
to stay legible with the docs' light/dark toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:05:23 +02:00
enricobuehler e8bc178d45 feat(docs-site): brand the docs to match the punktfunk website
apple / swift (push) Successful in 54s
deb / build-publish (push) Successful in 3m7s
decky / build-publish (push) Successful in 12s
ci / rust (push) Successful in 1m35s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 39s
android / android (push) Successful in 3m17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 27s
ci / bench (push) Successful in 4m44s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m51s
docker / deploy-docs (push) Successful in 19s
Theme the Fumadocs docs site with the punktfunk identity, mirroring the
marketing site:

- Swap the stock `neutral` preset for `purple`, then override --color-fd-*
  with the violet lens-mark palette (#6c5bf3 / #a79ff8). The brand is the
  violet, not the site's blue marketing background, so the blue is not used
  as a reading surface; dark mode tints the chrome toward the app-icon
  violet-dark (#1c1530).
- Adopt @unom/ui's token contract (--brand/--primary/--accent + bg-brand
  etc.) as the shared token source, and @source its dist.
- Load Geist (the brand typeface) via @fontsource-variable/geist.
- Add the BrandMark lens to the nav + landing hero, wire the brand
  favicon.svg, and add Docs/Website nav links.
- Keep the Fumadocs light/dark toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:52 +02:00
enricobuehler 333f66b45b fix(host/serverinfo): don't advertise an empty codec mask when the VAAPI probe finds nothing
apple / swift (push) Successful in 54s
windows-host / package (push) Successful in 2m21s
android / android (push) Successful in 3m30s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 27s
ci / rust (push) Successful in 5m56s
deb / build-publish (push) Successful in 3m9s
ci / bench (push) Successful in 4m40s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
decky / build-publish (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m51s
docker / deploy-docs (push) Successful in 17s
The Phase 3 GPU-aware codec mask (6922e1c) probes VAAPI on any non-NVIDIA host.
On a GPU-less box (CI container: no /dev/nvidia* -> `auto` picks VAAPI, but there's
no VA display) the probe returns all-false, so the mask was 0 -- the host
advertised NO codecs, and the serverinfo unit test failed.

Fall back to the static superset when the probe yields nothing (VAAPI wasn't
usable, not "the GPU encodes nothing"); quiet ffmpeg's expected "No VA display"
error during the probe; and assert the test against codec_mode_support() rather
than a hardcoded 65793 so it's deterministic regardless of the build host's GPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:52:17 +00:00
enricobuehler 6922e1c467 feat(host): VAAPI codec probe + AMD/Intel packaging + neutral logs (Phase 3)
apple / swift (push) Successful in 55s
ci / rust (push) Failing after 1m35s
ci / web (push) Successful in 28s
windows-host / package (push) Successful in 2m23s
ci / docs-site (push) Successful in 30s
android / android (push) Successful in 3m24s
deb / build-publish (push) Successful in 3m22s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m48s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m51s
docker / deploy-docs (push) Successful in 18s
Polish for AMD/Intel support:
- GameStream serverinfo advertises only codecs the GPU can ACTUALLY encode on
  the VAAPI backend (probed once by opening a tiny encoder per codec). AV1
  encode is narrow (Intel Arc/Xe2+, AMD RDNA3+/RDNA4) and an old iGPU may lack
  HEVC, so a Moonlight client never negotiates a codec the encoder can't open.
  NVENC/Windows keep the Moonlight-validated static mask. Validated on a Radeon
  780M: h264/h265/av1 all probe true -> mask unchanged (65793).
- Packaging: Recommends mesa-va-drivers + intel-media-va-driver (deb) /
  mesa-va-drivers + intel-media-driver (rpm) so the auto-selected VAAPI backend
  works out of the box on AMD/Intel; NVIDIA boxes can --no-install-recommends.
  (Fedora note: stock mesa-va-drivers disables HEVC/AV1 -- needs the freeworld
  variant from RPM Fusion.)
- De-NVIDIA-fy the user-facing encoder log/context strings ("open NVENC" ->
  "open video encoder") now that VAAPI is a first-class backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:41:37 +00:00
enricobuehler 708c62788d feat(host/encode): VAAPI zero-copy dmabuf import (AMD/Intel GPU CSC)
apple / swift (push) Successful in 57s
ci / rust (push) Successful in 1m39s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 3m29s
windows-host / package (push) Successful in 3m39s
deb / build-publish (push) Successful in 3m7s
decky / build-publish (push) Successful in 22s
ci / bench (push) Successful in 4m43s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m22s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m53s
Phase 2 of AMD/Intel support: the VAAPI encoder now takes the capture dmabuf
directly and does the RGB->NV12 colour conversion on the GPU's video engine,
eliminating the host-side de-pad + swscale CSC + upload the CPU path pays.

- capture: a vendor-neutral FramePayload::Dmabuf (dup'd fd + fourcc/modifier/
  layout). When zero-copy is on, the EGL->CUDA importer is unavailable (any
  non-NVIDIA host), and the backend is VAAPI, the capturer advertises LINEAR
  dmabuf and hands the raw buffer to the encoder instead of CPU-copying it.
- encode/vaapi: the encoder self-configures from the first frame's payload (no
  open_video signature change). The dmabuf arm wraps the buffer as an
  AV_PIX_FMT_DRM_PRIME frame and pushes it through a filter graph
  buffer(drm_prime) -> hwmap(vaapi) -> scale_vaapi=nv12 -> buffersink; the
  encoder takes NV12 surfaces straight from the sink. The Phase 1 CPU-upload
  path is kept as the other arm (used when capture produces CPU frames).

Live-validated on a Radeon 780M (real Sway/xdpw desktop capture): correct,
pixel-perfect HEVC, and ~10x less host CPU at 1440p (4.2s -> 0.4s of CPU for
300 frames) -- the de-pad/CSC/upload moves to the GPU. NVIDIA unchanged
(zero-copy still imports to CUDA; the passthrough path only engages on
non-NVIDIA hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:57:00 +00:00
enricobuehler 5e27f65f2e fix(host/capture): mmap the buffer fd ourselves — xdpw MemFd over-reads MAP_BUFFERS
apple / swift (push) Successful in 55s
windows-host / package (push) Successful in 2m28s
android / android (push) Successful in 10m10s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 29s
ci / rust (push) Successful in 11m44s
deb / build-publish (push) Successful in 3m7s
decky / build-publish (push) Successful in 34s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m57s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m54s
The CPU de-pad path trusted PipeWire's MAP_BUFFERS slice (`d.data()`, length =
`data.maxsize`). xdg-desktop-portal-wlr hands MemFd ScreenCast buffers whose
maxsize exceeds the bytes PipeWire actually maps into our process, so reading to
maxsize ran off the end of the mapping and SIGSEGV'd the capture thread —
crashing every CPU-path capture on Sway/wlroots (and thus any non-NVIDIA host,
which has no CUDA zero-copy importer and always falls back to this path).

mmap the fd ourselves, sized to its real length (fstat), for any fd-backed
buffer (MemFd SHM or DmaBuf); fall back to `d.data()` then drop. The existing
`needed > avail` guard now drops cleanly instead of over-reading. This also
subsumes the original "MAP_BUFFERS didn't map a Vulkan dmabuf" fallback.

Verified: fixes real Sway-desktop portal capture -> VAAPI HEVC on a Radeon 780M
(correct image + colours); the NVIDIA zero-copy path (returns before this code)
and the NVIDIA/KWin CPU path (self-mmap, fd_len == maxsize) both still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:48:49 +00:00
enricobuehler f96e4ec9f8 refactor(host/zerocopy): dlopen libcuda instead of a link-time #[link]
apple / swift (push) Successful in 54s
windows-host / package (push) Successful in 2m15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m14s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 55s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 58s
android / android (push) Successful in 4m10s
audit / cargo-audit (push) Failing after 1m5s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 28s
ci / rust (push) Successful in 5m41s
ci / bench (push) Successful in 5m53s
decky / build-publish (push) Successful in 11s
deb / build-publish (push) Successful in 3m24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m50s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
flatpak / build-publish (push) Successful in 4m9s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m23s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m51s
The host hard-linked libcuda.so.1 on Linux (`#[link(name="cuda")]` in
`zerocopy::cuda`), so the binary wouldn't even *start* on a non-NVIDIA box —
the dynamic loader can't resolve the NEEDED libcuda. That blocked running the
new VAAPI (AMD/Intel) path on a machine without the NVIDIA driver.

Resolve the 18 CUDA Driver API symbols at runtime via `libloading` instead.
Same-named wrapper fns forward to the dlopen'd table (call sites unchanged);
when libcuda is absent they return a non-zero CUresult so `context()` fails
cleanly and the capturer falls back to the CPU path. The library handle is
leaked (process-lifetime, like the shared context).

One Linux binary now runs on NVIDIA (CUDA zero-copy -> NVENC) and on AMD/Intel
(VAAPI, no NVIDIA driver). Verified: the NVIDIA dev box still does dmabuf->CUDA
zero-copy; on a Radeon 780M box the host builds with no libcuda present, the
binary has no NEEDED libcuda entry, and VAAPI encode runs with no stub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:44:57 +00:00
enricobuehler b390dd883b feat(host/encode): VAAPI encode backend for AMD/Intel GPUs (Linux)
apple / swift (push) Successful in 54s
windows-host / package (push) Successful in 2m52s
android / android (push) Successful in 3m4s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 27s
ci / bench (push) Successful in 4m32s
deb / build-publish (push) Successful in 2m56s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m59s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 7m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m10s
docker / deploy-docs (push) Successful in 39s
The Linux host was NVENC/CUDA-only. Add a VAAPI encoder — one libavcodec
backend (h264/hevc/av1_vaapi) covering both AMD (Mesa radeonsi) and Intel
(iHD) — behind the existing `Encoder` trait, and turn `open_video`'s Linux
arm into a vendor dispatcher: `PUNKTFUNK_ENCODER=auto|nvenc|vaapi` (default
auto: NVENC when a CUDA frame or /dev/nvidia* is present, else VAAPI). The
NVIDIA path is unchanged — auto resolves to NVENC on an NVIDIA box and the
bitrate-probe loop moved verbatim into `open_nvenc_probed`.

`VaapiEncoder` mirrors the NVENC hwframes pattern with AV_HWDEVICE_TYPE_VAAPI.
The CPU-input path swscales packed RGB -> NV12 (BT.709 limited, VUI signalled)
and uploads into a pooled VA surface (av_hwframe_transfer_data), preserving the
low-latency model (infinite GOP, on-demand forced IDR, async_depth=1, CBR when
the driver supports it). It works on a non-NVIDIA box with no capture changes:
the capturer already falls back to CPU frames when its EGL->CUDA importer can't
initialise (no libcuda).

Live-validated on a Radeon 780M (RDNA3): hevc/h264/av1_vaapi all encode,
HEVC/H264 decode cleanly with correct BT.709-limited colours, infinite GOP
preserved. Zero-copy dmabuf import (the high-res perf lever) is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:35:49 +00:00
enricobuehler 86979d0abc fix build
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m16s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 29s
android / android (push) Successful in 3m18s
deb / build-publish (push) Successful in 3m7s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 4m32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m47s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m50s
docker / deploy-docs (push) Successful in 35s
improve iOS & iPadOS UI
2026-06-19 15:49:48 +02:00
enricobuehler 53aade0279 docs(packaging/windows): note the host is x64-only (no ARM64)
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m19s
ci / web (push) Successful in 28s
windows-host / package (push) Successful in 2m14s
ci / docs-site (push) Successful in 29s
android / android (push) Successful in 3m15s
deb / build-publish (push) Successful in 3m4s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m43s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m36s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m45s
docker / deploy-docs (push) Successful in 18s
The host is NVIDIA/NVENC + SudoVDA coupled; Windows ARM64 has neither an NVIDIA
driver nor an ARM64 SudoVDA, so an ARM64 host would install but couldn't encode
or make a virtual display. Document the deliberate x64-only scope so it doesn't
get re-litigated. ARM64 stays client-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:23:30 +00:00
enricobuehler 24ee05a4d0 fix(packaging/windows): dodge WOW64 redirection — run ISCC on copies under C:\t
apple / swift (push) Successful in 53s
windows-host / package (push) Successful in 2m14s
ci / rust (push) Successful in 1m13s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 3m32s
deb / build-publish (push) Successful in 3m6s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
ci / bench (push) Successful in 4m48s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m8s
docker / deploy-docs (push) Successful in 6s
Root cause of the persistent ISCC "path not found": ISCC.exe is 32-bit, and the
self-hosted runner runs as SYSTEM, so the checkout lives under
C:\Windows\System32\config\systemprofile\.cache\... . WOW64 file-system
redirection rewrites a 32-bit process's System32 reads to SysWOW64 (where nothing
exists), so ISCC died opening the .iss before it even printed its version line.
(The smoke-test diagnostic compiled fine precisely because it lived at C:\t\out.)

Fix: copy every file ISCC reads (the .iss + host.env.example + README.md) into
the non-redirected build dir C:\t\out and compile from there; BinDir, StageDir,
and OutputDir already live under C:\t. Removed the now-spent smoke diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:27:19 +00:00
enricobuehler d59de1553f fix(packaging/windows): make the .iss pure ASCII (ISCC encoding failure)
apple / swift (push) Successful in 53s
windows-host / package (push) Failing after 2m9s
ci / rust (push) Successful in 1m13s
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 29s
android / android (push) Successful in 3m14s
deb / build-publish (push) Successful in 3m5s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m46s
docker / deploy-docs (push) Successful in 18s
The smoke-test diagnostic proved Inno itself is healthy (a trivial ASCII script
compiled), while the real .iss died before the "Compiler engine version" line —
i.e. at script open, not during compile. The difference: the real .iss was UTF-8
with non-ASCII chars (→, —) in comments, which ISCC 6.4+ rejects without a UTF-8
BOM (and the German-locale runner misreads). Replace them with ASCII (->, -).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:22:00 +00:00
enricobuehler e905801567 diag(packaging/windows): isolate the ISCC "path not found" failure
apple / swift (push) Successful in 54s
windows-host / package (push) Failing after 2m10s
ci / rust (push) Successful in 1m16s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 29s
android / android (push) Successful in 3m16s
deb / build-publish (push) Successful in 3m8s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m42s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m45s
docker / deploy-docs (push) Successful in 19s
All [Files] sources are validated-present yet ISCC still errors before any
"Compiling" output (no line number) — so it's startup/[Setup]-internal, not a
source path. Add an explicit [Languages] (compiler:Default.isl) to rule out the
auto-added default language, and on ISCC failure dump the Inno install dir +
run a trivial [Setup]-only smoke script to tell "Inno broken" from "my script".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:13:29 +00:00
enricobuehler 43e0be4cf4 fix(packaging/windows): pass installer source files as validated absolute defines
apple / swift (push) Successful in 54s
windows-host / package (push) Failing after 2m23s
ci / rust (push) Successful in 1m20s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 3m32s
deb / build-publish (push) Successful in 3m7s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m38s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m43s
docker / deploy-docs (push) Successful in 6s
The {#SourcePath} relative-traversal for host.env.example/README kept tripping
ISCC ("path not found", error 2) regardless of the separator, so drop it: compute
the two paths absolutely in pack-host-installer.ps1, Test-Path them (clear PS error
if missing), and pass /DHostEnv + /DReadme. The .iss [Files] now reference the
absolute defines — no {#SourcePath}, no ..\.. traversal. Also prints "source ok"
for each so a future failure is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:44:30 +00:00
enricobuehler bd3f417d4b feat(windows-client): cross-compile + ship ARM64 (aarch64) off the x64 runner
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m20s
ci / web (push) Successful in 29s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m7s
ci / docs-site (push) Successful in 30s
android / android (push) Successful in 3m20s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m52s
deb / build-publish (push) Successful in 3m40s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m16s
decky / build-publish (push) Successful in 12s
ci / bench (push) Successful in 4m58s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m34s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m38s
docker / deploy-docs (push) Successful in 18s
windows.yml + windows-msix.yml gain an x86_64/aarch64 target matrix. ARM64 is
cross-compiled on the one x64 Windows runner — the x64 MSVC toolset ships the
ARM64 cross compiler, aarch64-pc-windows-msvc is tier-2 with host tools, and
SDL3/libopus (build-from-source) cross-compile cleanly. The only arch-specific
external dep is FFmpeg's import libs: the matrix points FFMPEG_DIR at a per-arch
tree (x64 C:\Users\Public\ffmpeg, arm64 C:\Users\Public\ffmpeg-arm64, both
FFmpeg 7.x / avcodec-61). Per-arch short CARGO_TARGET_DIR avoids a shared target
dir; fmt + test run only for x64 (aarch64 can't execute on the x64 host).

pack-msix.ps1 gains -Arch x64|arm64 (stamps the manifest ProcessorArchitecture,
arch-suffixes the .msix/.cer); windows-msix.yml matrixes both arches and
publishes ..._x64.msix / ..._arm64.msix. setup-windows-runner.ps1 provisions the
rustup target + the ARM64 FFmpeg tree (idempotent).

Verified live on the runner (home-windows-1): debug+release cross-build green,
clippy -D warnings green, and MSIX pack produces a valid arm64 package (manifest
arch=arm64; bundled exe/SDL3/avcodec/reactor-bootstrap all PE machine 0xAA64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:44:24 +00:00
enricobuehler aef552f04a feat(host/windows): HDR scRGB→P010 in a shader — NVENC native P010, off the SM
apple / swift (push) Successful in 55s
deb / build-publish (push) Successful in 3m9s
decky / build-publish (push) Successful in 13s
ci / rust (push) Successful in 1m14s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 30s
windows-host / package (push) Failing after 2m19s
android / android (push) Successful in 3m12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m38s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m42s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m47s
docker / deploy-docs (push) Successful in 18s
On the Windows WGC HDR path the FP16 scRGB capture was fed to NVENC as
R10G10B10A2 (BT.2020 PQ), and NVENC did the RGB→YUV CSC internally on the
contended SM — adding to the encode_ms wall under a GPU-saturating game.
(NVIDIA's D3D11 VideoProcessor can't do RGB→P010 for HDR; that path renders
green, confirmed live — so the convert must be ours.)

New `HdrP010Converter` fuses the tone-map with the BT.2020 RGB→YUV matrix and
emits P010 (10-bit limited range) directly: a luma pass → an R16_UNORM plane
RTV (full-res) and a chroma pass → an R16G16_UNORM plane RTV (half-res, 2x2
box average) of a DXGI_FORMAT_P010 texture. NVENC then takes native P010 and
skips its SM-side convert.

Gated behind PUNKTFUNK_HDR_SHADER_P010 (default OFF → the existing
R10→NVENC path is byte-for-byte unchanged). Colour validated by a new
`hdr-p010-selftest` subcommand: a synthetic scRGB pattern → P010 → readback,
compared to a BT.2020 PQ 10-bit reference — max abs error Y=0.99 / Cb=0.82 /
Cr=0.75 codes on an RTX 4090. Live-validated HDR colours correct (no green).
Build + clippy (--features nvenc -D warnings) green on x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:54:00 +00:00
enricobuehler 22aff1c7ac fix(android): invoke cargo by absolute path in cargoNdk task
android / android (push) Successful in 3m50s
deb / build-publish (push) Successful in 3m11s
ci / bench (push) Successful in 4m45s
apple / swift (push) Successful in 56s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
decky / build-publish (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m34s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m42s
docker / deploy-docs (push) Successful in 18s
Gradle's Exec resolves command[0] via the JVM/daemon's inherited PATH, not
the environment("PATH", …) set on the task (that only reaches the spawned
child). A GUI Android Studio launch — and any daemon it starts — has no
~/.cargo/bin on its PATH, so a bare "cargo" fails with "A problem occurred
starting process 'command 'cargo''". Use the already-computed cargoBin
absolute path; the env PATH still lets cargo/cargo-ndk find their subtools.

Also refresh the README prereqs: add the missing cmake;3.22.1 SDK package
(the cmake crate builds libopus with it) and drop the broken
`brew --prefix openjdk@21` JAVA_HOME hint in favour of `java_home -v 21`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:09:44 +02:00
enricobuehler 822fde1e89 fix(rpm): derive the libcuda link stub from source (fixes undefined cu* symbols)
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m10s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 3m22s
deb / build-publish (push) Successful in 3m9s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 4m37s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m57s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m2s
docker / deploy-docs (push) Successful in 13s
The Fedora RPM build linked punktfunk-host against a synthesized libcuda stub
with a FROZEN symbol list baked into ci/fedora-rpm.Dockerfile. The priority-
stream work added cuCtxGetStreamPriorityRange / cuStreamCreateWithPriority /
cuStreamSynchronize / cuMemcpy2DAsync_v2, which weren't in that list, so the
link failed with "undefined symbol".

build-rpm.sh now regenerates /usr/lib64/libcuda.so.1 from every cu* symbol the
host source references (grep of crates/punktfunk-host/src), before rpmbuild — so
a new cu* call can never silently break the link again. Self-maintaining and
needs no builder-image rebuild (it supersedes the Dockerfile's frozen stub).
Verified the 23 extracted symbols compile and cover the 4 that were undefined.

Also fix the bogus %changelog weekday (Sun -> Mon, Jun 15 2026 is a Monday) that
rpmbuild warned on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:29:16 +00:00
enricobuehler d7aa528d7e fix(android): settings dropdowns trapped D-pad/controller focus
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m20s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 30s
deb / build-publish (push) Successful in 3m5s
decky / build-publish (push) Successful in 13s
android / android (push) Successful in 3m29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m20s
docker / deploy-docs (push) Successful in 6s
ExposedDropdownMenuBox anchors on a read-only OutlinedTextField, and a text field
captures D-pad focus -- directional keys never escape it, so on a TV/controller you
got stuck on the first select. Replace SettingDropdown with a clickable Surface +
DropdownMenu (no text field): D-pad moves between settings, A opens the menu, A
selects an item. Adds a primary-colour focus border so the focused setting reads
across a room.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:20:55 +00:00
enricobuehler 3074b30988 ci(runner): cap the act_runner cache + 30-min prune (fix recurring disk-full)
apple / swift (push) Successful in 53s
android / android (push) Successful in 10m42s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
ci / rust (push) Successful in 11m39s
ci / bench (push) Successful in 4m43s
deb / build-publish (push) Successful in 3m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
decky / build-publish (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m24s
docker / deploy-docs (push) Failing after 8s
The hourly docker-prune could never reclaim the real disk filler: the act_runner
cache server's blob store (cache.dir:"" -> /root/.cache/actcache/cache) lives in
the long-running runner container's WRITABLE LAYER, which docker prune can't see.
It grew to ~66 GB and filled the 125 GB disk on its own.

- New docker-prune.sh holds the logic (inline ExecStart= broke under systemd's
  own $-expansion, which emptied $SZ/$(...) before sh ran them — silently no-oping
  the burst guard). The unit now just calls the script.
- Caps the actcache: clears the blobs once they exceed ~20 GB (act_runner
  repopulates; keys are content-hashed, so only stale entries drop).
- Burst guard lowered 85%->80% and now also clears the actcache.
- Timer hourly -> every 30 min; image/cache `until` 12h -> 6h.

Live: cleared 66 GB on home-runner-1 (93% -> 20%), deployed + verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:53:08 +00:00
enricobuehler 7dad881d98 fix(packaging/windows): add '\' after {#SourcePath} in the .iss [Files]
apple / swift (push) Successful in 53s
android / android (push) Has been cancelled
ci / rust (push) Successful in 4m14s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 33s
deb / build-publish (push) Successful in 2m8s
ci / bench (push) Successful in 4m40s
decky / build-publish (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m57s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 6m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m28s
windows-host / package (push) Failing after 2m17s
docker / deploy-docs (push) Successful in 21s
ISPP's {#SourcePath} has no trailing backslash, so {#SourcePath}..\..\scripts
resolved to ...\packaging\windows..\..\scripts (invalid component "windows..")
-> ISCC error 2 "path not found". Add the explicit separator (a double backslash
is harmless on Windows if a future ISPP ever adds the trailing one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:36:01 +00:00
enricobuehler 68744d5743 fix(packaging/windows): vendor SudoVDA driver (no upstream release) + real nefcon URL
android / android (push) Successful in 4m38s
apple / swift (push) Successful in 58s
windows-host / package (push) Failing after 3m2s
ci / rust (push) Successful in 4m39s
ci / web (push) Successful in 30s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 22s
ci / bench (push) Successful in 4m39s
ci / docs-site (push) Successful in 31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m19s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 6m52s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m58s
The first CI run failed only on the SudoVDA download: SudoMaker/SudoVDA has no
releases (source-only repo; Apollo embeds the driver in its installer), so there
was nothing to fetch. Vendor the prebuilt SIGNED driver in-repo instead.

- packaging/windows/sudovda/: SudoVDA.inf/.cat/.dll + sudovda.cer (derived from
  the .cat signer CN=sudovda@su.mk), pulled from the dev-box driver store.
  v1.10.9.289, Class=Display, HWID Root\SudoMaker\SudoVDA, MIT/CC0.
- fetch-sudovda.ps1 -> stage-sudovda.ps1: stage the vendored driver + fetch
  nefcon from its real pinned release (v1.17.40, sha256 812bae7e…, x64/nefconc.exe).
- pack-host-installer.ps1: call stage-sudovda.ps1; README updated with the
  driver-refresh recipe.

The rest of the pipeline already passed on the first run (host built --features
nvenc via the llvm-dlltool import lib; ISCC + signtool found; signed with the
real CN=unom cert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:22:52 +00:00
enricobuehler bfbe5ab888 docs(host-latency): mark Tier 2A landed + validated; Tier 3A FFI validated on MSVC
apple / swift (push) Successful in 54s
android / android (push) Failing after 51s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 29s
ci / rust (push) Failing after 4m4s
ci / bench (push) Failing after 3m23s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 49s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Successful in 8m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 6m51s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m11s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:40:24 +00:00
enricobuehler 1fc6f73784 perf(host/linux): NV12 GPU convert — feed NVENC native YUV, off the contended SM (Tier 2A)
apple / swift (push) Successful in 54s
windows-host / package (push) Failing after 2m18s
ci / web (push) Successful in 32s
ci / rust (push) Failing after 5m2s
decky / build-publish (push) Successful in 11s
android / android (push) Failing after 49s
ci / docs-site (push) Successful in 35s
ci / bench (push) Failing after 3m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m49s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 28s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Successful in 5m54s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 11s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1m36s
The Linux zero-copy tiled-GL path can now produce NV12 (BT.709 limited range)
on the GPU and feed NVENC native YUV, deleting NVENC's internal RGB->YUV CSC —
which runs on the SM/3D-compute engine a saturating game pins at 100% (the
game-vs-encode contention headache). Windows already does this via the D3D11
video processor; this closes the Linux gap. See docs/host-latency-plan.md §2A.

Gated behind PUNKTFUNK_NV12 (default OFF → the RGB/BGRx path is byte-for-byte
unchanged; zero regression). Only the tiled EGL/GL path converts; the
LINEAR/Vulkan-bridge (gamescope) path stays RGB.

- zerocopy/egl.rs: Nv12Blit — BT.709 limited Y pass (R8, full-res) + UV pass
  (RG8, half-res, GL_LINEAR 2x2 average); both CUDA-registered; import_nv12.
- zerocopy/cuda.rs: two-plane DeviceBuffer (Y W*H@1B + interleaved UV
  (W/2)*2 x H/2), paired Y+UV pool, copy_mapped_nv12 + copy_nv12_to_device,
  on the per-thread priority stream (dmabuf-recycle sync preserved).
- encode/linux.rs: nvenc_input(Nv12)->NV12; submit_cuda copies two planes into
  NVENC's surface; VUI signalled BT.709 limited (colorspace/range/primaries/trc).
- capture/linux.rs: gate (PUNKTFUNK_NV12 && tiled), report format Nv12.
- main.rs + zerocopy/mod.rs: `nv12-selftest` subcommand.

Validated on RTX 5070 Ti two ways: (1) nv12-selftest — synthetic RGBA->NV12
round-trip vs a BT.709 reference, max abs error Y=0.56/U=0.33/V=0.26 LSB;
(2) live capture->NV12->NVENC->decode of animated red content matches the RGB
path's colour (avg RGB 230,18,18 vs 231,18,20). build/clippy/fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:39:11 +00:00
enricobuehler a58b6b8e76 fix(windows-client): clear clippy -D warnings on MSVC
apple / swift (push) Successful in 53s
windows-msix / package (push) Successful in 1m4s
windows / build (push) Successful in 57s
ci / bench (push) Failing after 2s
android / android (push) Failing after 2m46s
ci / web (push) Successful in 32s
ci / docs-site (push) Failing after 16s
deb / build-publish (push) Failing after 1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 1s
docker / deploy-docs (push) Has been skipped
decky / build-publish (push) Failing after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 1s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 0s
ci / rust (push) Failing after 2m16s
The cfg(windows) code can't be lint-checked on the Linux dev box, so three
-D warnings slipped through (caught by windows.yml; the FFI + shaders compiled
fine):
- gpu.rs: SetMultithreadProtected returns a must-use BOOL -> `let _ =`.
- video.rs: drop the unused GpuFrame::ten_bit field (present keys off `hdr`;
  the value is still computed locally for the first-frame log).
- present.rs: GpuView::frame is an RAII keep-alive (its Drop returns the decoder
  surface to the pool), never read -> #[allow(dead_code)].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:21:18 +00:00
enricobuehler 0cc36fa130 feat(windows-client): D3D11VA zero-copy hw decode + HDR10 present + GUI polish
windows-msix / package (push) Successful in 1m2s
apple / swift (push) Successful in 54s
windows / build (push) Failing after 1m2s
android / android (push) Failing after 48s
ci / web (push) Failing after 6s
ci / docs-site (push) Failing after 1s
ci / bench (push) Failing after 0s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 1s
docker / deploy-docs (push) Has been skipped
ci / rust (push) Failing after 2m0s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m18s
The client was pure software HEVC decode + CPU swscale->RGBA + a full-frame
dynamic-texture upload every frame -- the reason performance was poor on a GPU
box (the GPU sat idle while the CPU churned). This adds a hardware path, HDR,
and a GUI pass.

Performance -- D3D11VA zero-copy:
- gpu.rs (new): one D3D11 device (hardware + VIDEO_SUPPORT, WARP fallback,
  multithread-protected) shared by decoder and presenter via a Send/Sync
  OnceLock. Sharing is mandatory -- a decoded texture is only bindable on the
  device that created it. windows-rs COM interfaces are !Send/!Sync, so the
  unsafe impl is sound only under the multithread protection + disjoint
  decode(video ctx)/present(immediate ctx) split.
- video.rs: D3d11vaDecoder (raw FFI mirroring the Linux VAAPI module). The
  COM-typed AVD3D11VA{Device,Frames}Context are declared here (stable FFmpeg
  ABI) to avoid ffmpeg-sys binding the d3d11 headers; get_format builds a frames
  ctx with BindFlags=SHADER_RESOURCE so the NV12/P010 array slices are
  sampleable. av_frame_clone guard keeps each surface out of the reuse pool
  until the presenter drops it. Software decode stays as the fallback
  (DecoderPref Auto/Hardware/Software; auto falls back on init/decode error).
- present.rs: shared device; per-plane SRVs over the array slice
  (NV12->R8/R8G8, P010->R16/R16G16) + three pixel shaders (RGBA passthrough,
  NV12/BT.709, P010/BT.2020-PQ). present() now takes the frame by value so the
  GPU surface survives re-presents.

HDR:
- Detected in-band (transfer == SMPTE2084), same signal as the other clients.
  Swapchain flips to R10G10B10A2 + ST.2084 + HDR10 metadata. New Settings toggle
  gates advertising VIDEO_CAP_10BIT|HDR; host still gates 10-bit behind its own
  PUNKTFUNK_10BIT + actual-HDR-content checks.

GUI (windows-reactor):
- Host cards with accent-monogram avatars + colored status pills, InfoBar for
  errors/pairing hints, ToggleSwitch settings (+ HDR, decoder, bitrate), button
  icons, a richer connecting screen, and a stream HUD with GPU/CPU-decode + HDR
  status chips.

Not yet on-glass validated: the Linux dev box can't compile the cfg(windows)
code (ffmpeg/windows crates unfetched; WARP has no hw decode) -- only
cargo fmt checks it here. API shapes verified against the windows-rs/reactor
source and the YUV->RGB coefficients checked by hand, but D3D11VA + shaders +
the GUI need a real build (Windows CI / build VM) and on-glass test on the RTX
box. The host-side HDR encode path is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:16:07 +00:00
enricobuehler af9bb54785 feat(android): D-pad / game-controller focus navigation (TV + phone)
apple / swift (push) Successful in 54s
windows-host / package (push) Failing after 2m14s
android / android (push) Has been cancelled
ci / web (push) Successful in 29s
ci / docs-site (push) Failing after 17s
ci / rust (push) Successful in 4m35s
ci / bench (push) Failing after 4m33s
decky / build-publish (push) Successful in 13s
deb / build-publish (push) Successful in 3m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 32s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m11s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m12s
docker / deploy-docs (push) Successful in 9s
Make a controller drive the Compose UI when not streaming, so the menus work on a TV
remote AND on a controller paired to a phone:
- MainActivity maps gamepad face buttons to the keys Compose's focus system
  understands (A -> DPAD_CENTER to activate, B -> BACK); D-pad *keys* already move
  focus and pass through untouched.
- For controllers whose D-pad reports as HAT axes (or to navigate with the left
  stick), dispatchGenericMotionEvent converts AXIS_HAT_X/Y / AXIS_X/Y into discrete
  D-pad key events, edge-detected so a held direction moves focus exactly once.
- HostCard draws a clear primary-colour focus border (the default state layer is too
  subtle across a room on TV).

All gated on "not streaming" -- during a stream the controller still forwards to the
host unchanged. Compile-verified (./gradlew :app:assembleDebug); the focus behaviour
itself needs on-device validation (no KVM here for a TV emulator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:14:29 +00:00
enricobuehler 112a054c35 perf(host): latency hardening for the game-vs-encode GPU contention collapse
Verified, prioritized analysis in docs/host-latency-plan.md (multi-agent
investigation + adversarial verification). Lands the two low-risk tiers:

Tier 2B — Linux scheduling hygiene:
- boost_thread_priority now nices the capture/encode (-10) and send (-5)
  threads on Linux (setpriority, best-effort; no-op without CAP_SYS_NICE),
  and the wrong "gamescope caps the game" doc-comment is corrected.
- CUDA context created with CU_CTX_SCHED_BLOCKING_SYNC (frees a core on the
  shared box instead of busy-spinning on completion).
- Copies moved off the default stream onto a per-thread highest-priority
  CUDA stream (cuStreamCreateWithPriority, graceful NULL-stream fallback)
  with a per-stream sync that no longer blocks on the other worker thread's
  in-flight copies. Stream priority is measure-then-keep (NVIDIA Linux may
  ignore it); never regresses.

Tier 3A — Windows session tuning (new session_tuning.rs, raw C-ABI FFI,
no-op off Windows): once-per-process 1ms timer + DwmEnableMMCSS + HIGH
priority class; per-thread MMCSS "Games" + keep-display-awake. Wired into
both the native (boost_thread_priority) and GameStream (stream.rs) paths.
We had zero session tuning before (Apollo streaming_will_start parity).

Tier 2A (Linux NV12 convert) is specified but intentionally not landed:
it is colour-correctness-critical and needs A/B validation on a GPU box
with a display (green-screen risk). Builds + clippy + fmt green on Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:05:57 +00:00
enricobuehler 16d3b7767e feat(packaging): signed Inno Setup installer for the Windows host + CI
apple / swift (push) Successful in 54s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Failing after 6m18s
android / android (push) Failing after 2m12s
ci / web (push) Successful in 38s
ci / rust (push) Failing after 1m40s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 2m35s
decky / build-publish (push) Successful in 24s
ci / bench (push) Successful in 4m32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m35s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m33s
docker / deploy-docs (push) Successful in 22s
MSIX (the client's format) can't install the host's LocalSystem secure-desktop
service or the SudoVDA kernel driver, so the host ships as a signed Inno Setup
setup.exe that runs elevated and delegates to the existing idempotent
`punktfunk-host service install`.

- packaging/windows/punktfunk-host.iss: lay exe into Program Files, optional
  SudoVDA driver task, run service install/start; [Code] stops+waits the service
  before file copy on upgrade; uninstall runs service uninstall.
- pack-host-installer.ps1: cert (reuses MSIX_CERT_PFX_B64 / self-signed CN=unom),
  sign inner exe + setup.exe, fetch/stage SudoVDA, run ISCC, export public .cer.
- fetch-sudovda.ps1 / install-sudovda.ps1: pinned SudoVDA + nefcon download, cert
  import, gated device-node create (no phantom dup), pnputil install (warn-not-abort).
- nvenc/: synthesize nvencodeapi.lib via llvm-dlltool from a 2-export .def so
  --features nvenc links with no GPU/SDK at build time.
- .gitea/workflows/windows-host.yml: build (nvenc) -> clippy -> ISCC -> sign ->
  publish setup.exe + .cer to the generic registry pkg punktfunk-host-windows.
  Tag host-win-v* -> X.Y.Z (+ latest/ alias); main push -> rolling 0.2.<run>.
- setup-windows-runner.ps1: provision Inno Setup; docs: installer instructions.

SudoVDA/nefcon release URLs+SHA-256s in fetch-sudovda.ps1 are placeholders
(baseline v0.2.1) — fetch warns + prints the computed hash until pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:05:20 +00:00
enricobuehler f4cff765ed fix(decky): scrub PyInstaller LD_LIBRARY_PATH before spawning system flatpak
apple / swift (push) Successful in 53s
android / android (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
decky / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
Decky Loader is a PyInstaller binary; it puts its bundled (older) libssl/libcrypto
on LD_LIBRARY_PATH via its /tmp/_MEI* unpack dir, and that env leaked into the
backend's `flatpak run`/`flatpak kill` subprocess. The SYSTEM flatpak's libcurl
+ libostree need newer OPENSSL symbols (3.2/3.3/3.4), so pairing failed with
"libssl.so.3: version OPENSSL_3.3.0 not found". _flatpak_env() now restores
each LD_*_ORIG PyInstaller saved, or drops the var, so the system loader uses
system libs. Reproduced + verified on the Deck (SteamOS 3.8.10, Flatpak 1.16.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:03:39 +00:00
enricobuehler b9e50faa40 polish(android): grouped Settings cards + ConnectScreen error banner & search indicator
apple / swift (push) Successful in 54s
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
- Settings: flat list -> Display / Host / Audio / Overlay sections in outlined
  cards (SettingsGroup + ToggleRow helpers) with section headers.
- ConnectScreen: connection errors now show in a filled errorContainer banner
  (was plain red text lost in the layout), and a "Searching the local network..."
  spinner appears while discovery is active but nothing's turned up yet.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:58:35 +00:00
enricobuehler f39230e8f4 fix(android): crash on back-while-streaming (UAF) + Material You theme & connect polish
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m37s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 35s
android / android (push) Successful in 4m23s
deb / build-publish (push) Successful in 2m37s
decky / build-publish (push) Successful in 24s
ci / bench (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m45s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 3m21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m28s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m52s
Crash: DisposableEffect.onDispose called nativeClose(handle) (Box::from_raw frees
the SessionHandle) while the SurfaceView's surfaceDestroyed independently called
nativeStopVideo/Audio/Mic on the same handle -- whichever ran after the close
dereferenced freed memory (SIGSEGV: the consistent back-navigation crash). Add a
one-shot `closed` guard: onDispose marks it before freeing; surfaceDestroyed skips
the native calls once closed (backgrounding still stops the threads when it wins).

Polish:
- Branded Material You theme (Theme.kt): dynamic colour on Android 12+, punktfunk
  brand violets as the pre-12 fallback, replacing the generic darkColorScheme().
- ConnectScreen: "Connecting..." was rendered in error-red with no spinner; now a
  neutral spinner while connecting, red reserved for actual errors.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL (both ABIs + the
Compose changes), debug APK assembles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:51 +00:00
enricobuehler 55cd58e487 fix(android): DataSpace impls Display not Debug — use {ds} in HDR logs
apple / swift (push) Successful in 54s
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
android / android (push) Successful in 4m27s
ci / rust (push) Successful in 2m14s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 4m38s
ndk's DataSpace derives Copy/PartialEq/Eq and impls Display (no Debug), so the
{ds:?} in the HDR dataspace log statements wouldn't compile under cargo-ndk.
Host clippy can't catch it — decode.rs is android-gated. Switch to {ds}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:13:20 +00:00
enricobuehler 586c4d0ddc fix(flatpak): sign the OSTree commit, not just the summary
apple / swift (push) Successful in 54s
android / android (push) Has been cancelled
ci / web (push) Successful in 33s
ci / rust (push) Successful in 4m22s
ci / bench (push) Failing after 4m25s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 37s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish (push) Successful in 6m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m4s
docker / deploy-docs (push) Successful in 6s
flatpak / build-publish (push) Successful in 4m6s
ci / docs-site (push) Successful in 30s
Install failed with "GPG verification enabled, but no signatures found" on the
commit: the deploy step only ran build-update-repo (signs the summary). Add
`flatpak build-sign` to sign the commit objects too — clients with
gpg-verify=true verify the commit, so summary-only signing isn't enough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:10:07 +00:00
enricobuehler 1cd5e0e375 feat(android): HDR (Main10 / BT.2020 PQ) + fix ndk feature gating
apple / swift (push) Successful in 54s
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Mirrors the Apple client's HDR path so the Android client can display HDR from a
Windows HDR host:
- nativeConnect now advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR (was 0), so the
  host upgrades to a Main10 / BT.2020 PQ encode.
- decode.rs detects HDR reactively from the decoder's reported output colour
  (color-transfer ST2084=6 / HLG=7, color-range) -- the AMediaCodec analogue of
  VideoToolbox's format description on Apple -- and signals the Surface dataspace
  (Bt2020[Itu]Pq / Bt2020[Itu]Hlg) so the compositor/display switch to HDR.
  AMediaCodec decodes Main10 from the in-band SPS; no profile override needed.

Also fixes the Android build: set_frame_rate (added in 5262e28) is gated on the
ndk `nativewindow` + `api-level-30` features, which weren't enabled -- so that
commit could not compile under cargo-ndk. Enable
features = ["media","audio","nativewindow","api-level-31"] (minSdk 31): covers
set_frame_rate (api-30), set_buffers_data_space + the DataSpace module (api-28),
and ANativeWindow (nativewindow).

Verified host-side: fmt --all + clippy --workspace (the caps advertise + JNI
surface). The android-gated decode + NDK gating verified against the ndk 0.9
sources; android.yml (cargo-ndk) is the compile gate, and real HDR display needs
an HDR device + Windows HDR host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:09:54 +00:00
enricobuehler 2d697fc26c docs(install-client): real TestFlight + Google Play links
apple / swift (push) Successful in 54s
android / android (push) Has been cancelled
ci / web (push) Successful in 31s
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
ci / docs-site (push) Successful in 51s
ci / rust (push) Successful in 5m22s
ci / bench (push) Failing after 3m36s
Apple is TestFlight-only (no App Store) — link the join URL; drop the App Store
placeholder. Add the live Google Play listing for io.unom.punktfunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:07:08 +00:00
enricobuehler 844f4b86bd docs: add an "Install a Client" page covering every client + install path
apple / swift (push) Successful in 54s
android / android (push) Failing after 2m1s
ci / rust (push) Successful in 1m38s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 2m36s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m21s
docker / deploy-docs (push) Successful in 22s
Per-device install steps in one place: Linux (Flatpak via flatpak.unom.io +
native apt/rpm/Arch), Steam Deck, Windows (signed MSIX from the registry),
macOS (notarized DMG from releases), and iOS/Android (store/beta links). Adds
it to the Connecting nav and cross-links clients.md, whose Linux/Flatpak bullet
now points at the hosted flatpak.unom.io repo instead of the bundle README.

Mobile store/TestFlight URLs are placeholders pending the public listings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:02:02 +00:00
enricobuehler 5262e28b79 feat(android): live stats HUD + low-latency decode tuning
apple / swift (push) Successful in 54s
windows-msix / package (push) Successful in 1m1s
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows / build (push) Successful in 55s
ci / docs-site (push) Successful in 31s
audit / cargo-audit (push) Failing after 1m8s
android / android (push) Failing after 2m12s
ci / web (push) Successful in 31s
ci / bench (push) Successful in 4m31s
ci / rust (push) Successful in 6m31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m44s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m25s
flatpak / build-publish (push) Successful in 5m5s
docker / deploy-docs (push) Successful in 20s
Stats HUD (mirrors the Apple client): the decode thread accumulates FPS, receive
throughput, and capture->client latency (p50/p95, skew-corrected) in Rust
(clients/android/native/src/stats.rs); nativeVideoStats drains a snapshot ~1 Hz
over JNI as a DoubleArray. StreamScreen renders a Compose overlay
(W*H@Hz / fps / Mb/s / latency, + dropped-under-loss), toggled by a Settings
switch (persisted, default on) or a 3-finger tap.

Performance (decode.rs):
- ANativeWindow_setFrameRate(refresh_hz): align display vsync to the stream rate
  (no 60-in-120 judder); safe since minSdk 31 >= API 30.
- Raise the decode thread toward URGENT_DISPLAY (best-effort setpriority) so
  background work can't preempt it under load.
- Codec low-latency hints KEY_PRIORITY=0 (realtime) + KEY_OPERATING_RATE.

Verified host-side: cargo build/clippy/fmt --workspace (the ungated stats + JNI
accessor). The android-gated decode.rs (NDK) and the Kotlin build only in CI
(android.yml: gradle + cargo-ndk) -- APIs verified against crate sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:49:29 +00:00
enricobuehler f1032a7a23 fix(flatpak): pass stable branch to build-bundle (matches --default-branch)
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Successful in 55s
ci / web (push) Successful in 28s
ci / rust (push) Successful in 1m38s
ci / docs-site (push) Successful in 28s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Successful in 3m55s
ci / bench (push) Successful in 4m34s
The CI added --default-branch=stable, so the repo ref is
app/io.unom.Punktfunk/x86_64/stable. build-bundle defaults to `master` when no
branch is given → "Refspec app/io.unom.Punktfunk/x86_64/master not found". Pass
`stable` explicitly in both flatpak.yml and the local build-flatpak.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:48:17 +00:00
enricobuehler 7121b0eb43 fix(apple): disarm CHHapticEngine handlers with no-ops, not nil
apple / swift (push) Successful in 55s
android / android (push) Failing after 1m58s
ci / rust (push) Failing after 1m13s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 30s
decky / build-publish (push) Successful in 10s
deb / build-publish (push) Successful in 2m27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m31s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Successful in 24s
stoppedHandler/resetHandler are non-optional closures on the CI SDK
((StoppedReason)->() and ()->()), so assigning nil fails to compile
(apple.yml). Assign no-op closures to disarm them before engine.stop()
-- same re-entrancy guard intent, type-correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:20:37 +00:00
enricobuehler d9d495a53e feat(flatpak): host a signed OSTree repo at flatpak.unom.io for flatpak update
apple / swift (push) Failing after 40s
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
android / android (push) Successful in 4m53s
The CI only shipped a single-file .flatpak bundle, which has no remote — users
couldn't `flatpak update`. Keep the bundle (Decky fallback) but also sign the
OSTree repo flatpak-builder already produces and publish it to a shared,
reusable unom-wide remote.

- flatpak.yml: pin --default-branch=stable; import the signing key and
  build-update-repo --gpg-sign; generate unom.flatpakrepo + the app .flatpakref
  + index.html; rsync the repo to unom-1 and bring up a static Caddy container.
  The step no-ops until FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* exist (build stays green).
- packaging/flatpak/server/: compose.production.yml + Caddyfile (static file
  server on :3230, mirrors docker.yml deploy-docs).
- unom-flatpak.gpg: committed public signing key (base64 -> GPGKey= in the descriptors).
- README: hosted repo is now the recommended install; documents the one-time
  infra (edge Caddy vhost, infra port 3230, DNS, the GPG secret).

Edge Caddy vhost + infra port allowlist + the secret are applied out-of-band.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:07:27 +00:00
enricobuehler 9c8fa9340c refactor: drop milestone names + consolidate clients; loss-recovery & rumble fixes
apple / swift (push) Failing after 40s
audit / cargo-audit (push) Failing after 1m12s
windows-msix / package (push) Successful in 1m37s
windows / build (push) Successful in 1m14s
android / android (push) Successful in 4m48s
ci / web (push) Successful in 27s
ci / rust (push) Successful in 4m21s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish (push) Successful in 6m3s
flatpak / build-publish (push) Successful in 4m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m16s
docker / deploy-docs (push) Successful in 18s
Two bodies of work in one commit (the rename moved files the fixes also touched).

Naming/structure cleanup (pre-launch):
- Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host,
  m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source->
  Punktfunk1Options/Punktfunk1Source.
- Clients consolidated out of crates/ into clients/: punktfunk-client-rs->
  clients/probe (crate punktfunk-probe), client-linux->clients/linux,
  client-windows->clients/windows, punktfunk-android->clients/android/native
  (crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI
  contract is unchanged). crates/ now holds only core + host.
- Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site,
  kept only in docs/implementation-plan.md. docs/m2-plan.md->
  docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated.

Client loss-recovery (video froze and never recovered after a brief drop):
- Export punktfunk_connection_frames_dropped through the C ABI (the core already
  tracked it for the client keyframe-recovery loop; it was never reachable from
  the ABI clients). Regenerated punktfunk_core.h.
- Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll
  frames_dropped and request a keyframe when it climbs -- the same loss-driven
  recovery Linux/Windows already had. Under infinite GOP the decoder silently
  conceals reference-missing frames, so the decode-error trigger rarely fires.

Apple rumble robustness (worked then went spotty -- DualSense + Xbox):
- Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio
  interruption / server reset) and drop the permanent `broken` latch on a
  transient drive failure; latch only when the controller truly has no haptics.
- Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging.

Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift.
Not runnable on this box (verify in CI): Gitea workflows, gradle/Android,
flatpak, Swift/decky.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:05:58 +00:00
enricobuehler 1faa6c6ad4 ci(android): replace r0adkll with a direct Play Publishing-API upload
ci / rust (push) Successful in 1m39s
ci / web (push) Successful in 32s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
apple / swift (push) Successful in 53s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 4m6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m11s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
r0adkll/upload-google-play hides real API errors behind "Unknown error
occurred." Proved the full upload sequence (insert edit -> upload bundle ->
track update -> validate) succeeds with the service account, so the failure was
r0adkll's opaque error handling and/or a base64-encoded SERVICE_ACCOUNT_JSON
secret.

clients/android/ci/play-upload.py does the same sequence with stdlib + openssl
(no pip), reuses the SERVICE_ACCOUNT_JSON secret, tolerates it being raw JSON or
base64, auto-retries commit with changesNotSentForReview, and prints Google's
actual error. Locally dry-run-validated against the live app (both secret forms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:38:20 +00:00
enricobuehler 72d1b19743 ci(android): publish signed AAB + universal APK to Gitea generic registry
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m38s
ci / web (push) Successful in 34s
ci / docs-site (push) Successful in 32s
android / android (push) Failing after 3m47s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m42s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m24s
docker / deploy-docs (push) Successful in 17s
Build a universal release APK alongside the AAB and push both to the public
generic registry (punktfunk-android/<run_number>/) before the Play upload, so
artifacts are downloadable even while the Play step is still failing. Matches
windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:26:42 +00:00
enricobuehler 9abb9a2496 fix - replace Punktfunkempfänger with Punktfunk
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m38s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 30s
deb / build-publish (push) Successful in 2m36s
decky / build-publish (push) Successful in 23s
ci / bench (push) Successful in 4m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m57s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m16s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m19s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m1s
android / android (push) Failing after 3m27s
2026-06-18 17:56:58 +02:00
enricobuehler 02b1be652d cancel rumble on disconnect
apple / swift (push) Successful in 56s
ci / rust (push) Successful in 1m37s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 33s
android / android (push) Failing after 3m55s
deb / build-publish (push) Successful in 2m27s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 19s
hide system bar in StreamScreen.kt
2026-06-18 17:20:57 +02:00
enricobuehler b8c9f88cfd feat: add .env support for local release builds
apple / swift (push) Successful in 53s
ci / rust (push) Successful in 1m38s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 37s
android / android (push) Failing after 4m20s
deb / build-publish (push) Successful in 2m35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 18s
2026-06-18 12:40:51 +02:00
enricobuehler 22409acba5 fix(ci): use android-36 platform as 37 is missing from sdkmanager channel
apple / swift (push) Has been cancelled
decky / build-publish (push) Successful in 12s
ci / rust (push) Successful in 1m53s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 40s
android / android (push) Failing after 4m46s
deb / build-publish (push) Successful in 2m26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m29s
docker / deploy-docs (push) Successful in 17s
2026-06-18 12:24:00 +02:00
enricobuehler 8f720e0e46 chore: bump version to 0.0.2 to trigger Play Store CI
android / android (push) Failing after 43s
apple / swift (push) Successful in 54s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 28s
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
ci / rust (push) Successful in 1m36s
ci / bench (push) Has been cancelled
2026-06-18 12:22:22 +02:00
enricobuehler a24679ce69 feat: setup CI for Google Play Store submission and refactor UI
android / android (push) Failing after 50s
apple / swift (push) Successful in 54s
deb / build-publish (push) Successful in 2m25s
ci / web (push) Successful in 28s
ci / rust (push) Successful in 1m36s
ci / docs-site (push) Successful in 28s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m25s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m4s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m55s
2026-06-18 11:51:40 +02:00
enricobuehler 6c02acab59 build: update Android NDK to r30 (30.0.14904198)
android / android (push) Failing after 42s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 32s
ci / rust (push) Successful in 1m43s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 2m26s
decky / build-publish (push) Successful in 23s
ci / bench (push) Successful in 4m33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m18s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m25s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m29s
docker / deploy-docs (push) Successful in 23s
2026-06-18 11:13:14 +02:00
enricobuehler 1f7b8eba66 feat(host/windows): auto-install a virtual mic device (Steam Streaming Microphone)
apple / swift (push) Successful in 54s
android / android (push) Successful in 1m56s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
deb / build-publish (push) Successful in 2m31s
ci / rust (push) Successful in 1m40s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m32s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m5s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
So Windows mic passthrough works without the user installing anything: when no virtual-mic
device is present, install Steam Remote Play's SteamStreamingMicrophone.inf (ships under
Steam\drivers\Windows10\{arch}\ next to the speakers INF Apollo uses) via DiInstallDriverW
loaded from newdev.dll — the same mechanism Apollo uses for Steam Streaming Speakers — then
re-find the device. Needs admin (the host runs as SYSTEM); best-effort and safe (no-op if
Steam absent / INF not found / PUNKTFUNK_NO_MIC_INSTALL), falling back to the manual-install
guidance (VB-Audio Cable) otherwise.

Not yet built/validated on the box (down); FFI cross-checked against windows-0.62. Whether
Steam ships SteamStreamingMicrophone.inf at that path is to be confirmed on the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:22:48 +00:00
enricobuehler a7daed5797 feat(host/windows): client→host mic passthrough via a virtual audio device
apple / swift (push) Successful in 55s
ci / web (push) Successful in 27s
ci / rust (push) Successful in 1m40s
android / android (push) Successful in 1m57s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 2m30s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m30s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m12s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / deploy-docs (push) Successful in 18s
The host received the client's mic uplink (0xCB Opus) but dropped it on Windows ("requires
Linux"). Windows has no user-mode way to CREATE a capture endpoint, so target an existing
virtual audio device and write the decoded mic PCM into its RENDER endpoint — the device's
CAPTURE endpoint then surfaces as a microphone host apps record from (the inverse of a
virtual cable). New audio::wasapi_mic::WasapiVirtualMic: finds the device by friendly-name
(Steam Streaming Microphone / VB-Audio CABLE Input / VoiceMeeter / "virtual", override with
PUNKTFUNK_MIC_DEVICE), opens a WASAPI shared event-driven RENDER client (48 kHz stereo f32,
autoconvert), and a dedicated COM thread writes a bounded (~80 ms drop-oldest) inject queue
with silence-fill. open_virtual_mic() gets a Windows arm; mic_service_thread (Opus decode →
push) now compiles for windows too (opus is already a windows dep). Clear error + install
guidance when no virtual device is present.

Linux/cross-platform side cargo-checks; the Windows path is built/validated when the box is
back (the wasapi render API was cross-checked against the docs + the existing capture path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:15:41 +00:00
enricobuehler 3b3e8b4ba9 perf(host/windows): elevate capture/encode/send thread CPU priority (Apollo-parity)
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m36s
android / android (push) Successful in 2m5s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m28s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m20s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m58s
Apollo runs its capture thread at CRITICAL and its encoder thread at ABOVE_NORMAL; we set
none. Our GPU work is already HIGH priority, but the GPU scheduler can only favour commands
we've SUBMITTED — a normal-priority thread descheduled by a CPU-heavy game submits the
convert/encode late, so the HIGH GPU priority never bites (consistent with the measured
"NVENC engine idle yet the encode waits ~15 ms"). Raise the WGC helper's capture+encode
loop and the single-process capture+encode loop to THREAD_PRIORITY_HIGHEST, and the
transmit thread to ABOVE_NORMAL, via a cross-platform boost_thread_priority() (Windows-only
effect — the Linux host caps the game via gamescope so its threads aren't starved).

Not yet built/validated on the GPU box (it's down); the cross-platform side compiles
(cargo check) and the Windows calls are cross-checked against the windows-0.62 API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:12:29 +00:00
enricobuehler 9771aa8815 fix(host/windows): binary-search clamp NVENC bitrate to the codec-level max (not ×¾ step-down)
ci / web (push) Successful in 28s
ci / rust (push) Successful in 1m42s
ci / docs-site (push) Successful in 28s
apple / swift (push) Successful in 55s
android / android (push) Successful in 1m55s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m5s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m54s
When a client requests a bitrate above the GPU's HEVC/AV1 level ceiling, NVENC rejects
initialize_encoder. The old probe stepped the rate down by ×¾ each retry, undershooting
the real ceiling badly (a 1 Gbps request landed ~300 Mbps even with the level cap near
800). Replace it with a binary search over [floor, requested] that converges (±20 Mbps)
on the HIGHEST rate NVENC accepts and clamps to that — so the stream uses the full
codec-level bitrate. Factored the session open/config/init into try_open_session() for
the probe; split-encode rejection is disambiguated from a bitrate-cap rejection (retry
once with split disabled) and the floor fallback also tries split-disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:42:00 +00:00
enricobuehler a4df75132a fix(host/windows): HEVC/AV1 HIGH tier so high client bitrates aren't quartered
android / android (push) Successful in 1m56s
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m35s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 2m26s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 18s
NVENC defaulted to Main tier, whose per-level bitrate ceiling at 5K (HEVC Level 6.2
Main ≈ 240 Mbps) made initialize_encoder reject a high client bitrate; the existing
probe-and-step-down then silently dropped a ~1 Gbps request by ×¾ to ~240-320 Mbps —
visible color/motion compression on fast scenes. Set HIGH tier (≈800 Mbps for HEVC,
higher for AV1) + autoselect level so the requested bitrate goes through. `tier`/`level`
are u32 (HIGH=1, AUTOSELECT=0) shared across the HEVC/AV1 union offset; the step-down
remains as a safety net. Not yet built/validated on-box (box offline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:33:31 +00:00
enricobuehler 4cc57d5c39 perf(host/windows): move capture→encode off the 3D engine (NV12/P010 video-processor path, zero-copy, GPU priority)
apple / swift (push) Successful in 56s
ci / rust (push) Successful in 1m36s
android / android (push) Successful in 1m56s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
deb / build-publish (push) Successful in 2m26s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m15s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m58s
The Windows host capped at ~60 fps with 35-40 ms latency on a GPU-heavy game:
the per-frame capture→encode path shared the 3D engine with the game and got
scheduled behind it. Rework to minimize 3D-engine work per frame:

- VideoConverter (D3D11 video processor): capture → NVENC-native NV12/P010 so
  NVENC skips its internal RGB→YUV (a 3D/compute step). Wired into both DDA
  (dxgi.rs) and WGC (wgc.rs). New PixelFormat::Nv12/P010 + NVENC YUV input.
- GPU scheduling hardening (Apollo-style): D3DKMTSetProcessSchedulingPriorityClass
  HIGH, absolute SetGPUThreadPriority, SetMaximumFrameLatency(1).
- WGC SDR zero-copy (hold pool frames; no CopyResource). DDA keeps a fast
  CopyResource to decouple its single-frame acquire/release from the async convert.
- Pipelined helper encode loop (PUNKTFUNK_ENCODE_DEPTH, default 1) + perf split
  (cap_wait / encode / write).

Live on the RTX 4090: hard 60 fps ceiling removed (now scene-scaling 40-200+),
latency much reduced. Residual cap in GPU-pinned scenes is the irreducible RGB→YUV
convert (no fixed-function unit on NVIDIA — VideoProcessing engine reads 0%) waiting
behind an uncapped game under WDDM context time-slicing; Linux avoids it via
gamescope capping the game to the display refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:08:03 +00:00
enricobuehler 15d3d423fa feat(decky): full-featured Gaming-Mode client — fullscreen page, pairing, focus-correct launch
apple / swift (push) Successful in 56s
ci / rust (push) Successful in 1m48s
android / android (push) Successful in 2m11s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
deb / build-publish (push) Successful in 2m24s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m32s
flatpak / build-publish (push) Successful in 4m1s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m43s
The plugin was a QAM launcher whose stream never appeared, with no
pairing. Three fixes, plus a headless --pair mode on the GTK client:

- Stream actually starts (MoonDeck's proven mechanism): gamescope only
  focuses the process tree Steam launched via reaper, so a flatpak
  spawned from the (root) backend is invisible. The frontend now
  registers ONE hidden non-Steam shortcut pointing at bin/punktfunkrun.sh,
  passes the host as the shortcut's Steam launch options, and starts it
  with SteamClient.Apps.RunGame — gamescope then fullscreen-focuses it.
  The wrapper execs `flatpak run io.unom.Punktfunk --connect <host>`.
- Fullscreen page: routerHook.addRoute("/punktfunk") — host list,
  per-host Pair/Stream, and a settings section (resolution/refresh/
  bitrate/gamepad/mic, written to client-gtk-settings.json).
- Pairing: a gamepad-navigable PIN keypad. The host shows the PIN; the
  backend runs the SPAKE2 ceremony headlessly via the client's new
  `--pair <PIN> --connect host` CLI mode (app.rs), persisting the host
  as paired so the stream then connects silently. Same flatpak =>
  shared identity store, verified live (ceremony against a real host).
- Backend (main.py): discover / pair / runner_info / get_settings /
  set_settings / kill_stream; uses DECKY_USER_HOME so paths resolve to
  the deck user's flatpak install regardless of the plugin's root flag.

CI (decky.yml) and the sideload packager now ship bin/punktfunkrun.sh.
The Steam-shortcut launch and headless-pairing env follow MoonDeck
exactly but need a Deck in Gaming Mode to fully confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:17:14 +00:00
enricobuehler 67608944f0 feat(client-linux): controller + keyboard shortcuts to exit fullscreen
On the Steam Deck there was no way out of fullscreen — no F11 key, and the
header bar (with the fullscreen button) is hidden while fullscreen.

- Controller: a Moonlight-style escape chord (L1+R1+Start+Select) held
  together leaves fullscreen and releases input capture. The gamepad
  service latches the chord (fires once per press) and signals the stream
  page over an async channel; four simultaneous buttons no game uses as a
  deliberate combo, so it can't trigger during play.
- Keyboard: F11 already toggled fullscreen (checked before input
  forwarding, so it works while captured) — now surfaced.
- Discoverability: entering fullscreen flashes a 4s hint listing both
  exits (F11 · L1+R1+Start+Select).

The escape future is aborted on page-hidden so a stale session can't act
on the shared window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:16:47 +00:00
enricobuehler 25c8dd58c7 fix(flatpak): drop the Windows client from the workspace for the offline build
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m45s
android / android (push) Successful in 2m2s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 31s
deb / build-publish (push) Successful in 2m40s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m33s
flatpak / build-publish (push) Successful in 4m2s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m13s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m46s
The lock prune (a5b99b2) stopped flatpak-builder full-cloning windows-rs
(disk-fill), but exposed the next layer: `cargo --offline --locked -p
punktfunk-client-linux` resolves the WHOLE workspace, so it still tried
to load the now-un-vendored windows-rs source for the
punktfunk-client-windows member (its windows-rs git deps are
cfg(windows)-gated, but cargo resolves all targets regardless) and
failed: "can't checkout ... you are in the offline mode".

Drop the Windows client from the workspace members inside the sandbox
build (sed on the copied Cargo.toml — the flatpak never compiles it) and
remove --locked (the lock no longer matches the reduced member set;
--offline still pins every crate to the vendored cargo-sources.json, so
the build stays reproducible). android stays — it has no git deps.

Verified locally: removing the member, `cargo build -p
punktfunk-client-linux --offline` Finishes with zero windows-rs
involvement; manifest YAML still valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:12:21 +00:00
enricobuehler d5757980f8 style(host): rustfmt — align video_caps comment in m3 test call-sites
apple / swift (push) Successful in 53s
ci / web (push) Successful in 32s
ci / rust (push) Successful in 1m36s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 2m26s
decky / build-publish (push) Successful in 22s
ci / bench (push) Successful in 4m31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m40s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m19s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m17s
android / android (push) Has been cancelled
docker / deploy-docs (push) Successful in 17s
cargo fmt --all over the merged connect() call-sites (the video_caps/
launch args landed without a fmt pass). Comment-alignment only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:48:23 +00:00
enricobuehler a5b99b2928 fix(flatpak): prune microsoft/windows-rs git crates before vendoring
apple / swift (push) Successful in 55s
deb / build-publish (push) Successful in 2m26s
decky / build-publish (push) Successful in 10s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 33s
android / android (push) Successful in 1m52s
ci / bench (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
flatpak / build-publish (push) Failing after 2m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m7s
docker / deploy-docs (push) Has been cancelled
ci / rust (push) Failing after 48s
The flatpak CI was failing at "Downloading sources" with "No space left
on device": flatpak-cargo-generator walks the whole workspace Cargo.lock
and emits a `type: git` source for the windows-rs crates (windows +
windows-reactor + ~12 sub-crates, pinned by punktfunk-client-windows),
and flatpak-builder then FULL-clones that multi-GB repo — for a bundle
that only ever compiles `-p punktfunk-client-linux` and never touches a
windows-* crate.

New packaging/flatpak/prune-windows-lock.py writes a copy of Cargo.lock
with the windows-rs git packages stripped (matches on the `source =`
line, so a crate that merely lists a windows dependency is kept;
dependency-free so it also runs on the Deck's stock python). Both the CI
and build-flatpak.sh feed that pruned lock to the generator. The
committed Cargo.lock is untouched — cargo --offline only needs vendored
sources for the crates it actually builds, and the windows-rs crates are
not in the Linux client's dependency closure.

Verified locally: 14 crates pruned (507 -> 493 packages), zero windows-rs
`source =` lines remain, output parses as TOML, all Linux-client deps
(gtk4/ffmpeg-sys-next/sdl3/pipewire) intact.

This unblocks the flatpak build carrying the VAAPI green-screen fix
(64b1679) for the Steam Deck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:43:11 +00:00
enricobuehler 41b289780f Merge remote-tracking branch 'origin/main'
apple / swift (push) Successful in 55s
ci / rust (push) Failing after 1m4s
ci / web (push) Successful in 36s
ci / docs-site (push) Successful in 30s
android / android (push) Successful in 2m27s
deb / build-publish (push) Successful in 2m24s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m47s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m55s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m14s
flatpak / build-publish (push) Failing after 2m41s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m15s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
2026-06-17 07:24:27 +00:00
enricobuehler 64b167946f fix(client-linux): VAAPI green screen on AMD — flatten NV12 planes across DRM layers
First AMD test (Steam Deck, Mesa radeonsi) showed a mostly-green image
with red whites — the classic fingerprint of NV12 chroma read as 0.

Root cause (confirmed against FFmpeg/GTK/mpv source): FFmpeg's VAAPI
export uses VA_EXPORT_SURFACE_SEPARATE_LAYERS unconditionally, so an
NV12 surface comes back as TWO single-plane layers — layers[0]=R8
(luma), layers[1]=GR88 (chroma) — sharing one object/fd, the UV plane
reached via offset. map_dmabuf took layers[0] only and used its format
(R8) as the GTK fourcc, so GdkDmabufTexture got a luma-only texture
with the chroma plane dropped → chroma defaults to 0 → green field,
red highlights.

Fix (matches mpv's dmabuf_interop_gl flatten pattern):
- Derive the combined fourcc from the decoder's sw_format
  (AVHWFramesContext.sw_format → NV12 → DRM_FORMAT_NV12), NOT from the
  per-plane component formats. The frame format is absent from the
  separate-layer descriptor and must be deduced from sw_format.
- Flatten every plane across every layer in declared order (Y then UV),
  each with its own fd (objects[plane.object_index].fd), offset, pitch.
- One-time descriptor dump (objects/layers/formats/modifier) so a new
  driver's real layout is visible in the logs.
- Unit test locks the DRM FourCC magic numbers (NV12=0x3231564e).

Software decode (swscale, reads colorspace from the VUI) was always
correct, which isolated the bug to this path. PUNKTFUNK_DECODER=software
is the immediate workaround on an un-rebuilt binary. Awaiting Steam Deck
reconfirm (no AMD VAAPI on the NVIDIA dev box to live-verify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:24:27 +00:00
enricobuehler 9537efdcd5 feat(client/windows): HDR10 (BT.2020 PQ) decode + present
apple / swift (push) Successful in 54s
windows-msix / package (push) Successful in 1m8s
windows / build (push) Successful in 1m14s
android / android (push) Failing after 1m43s
ci / rust (push) Failing after 48s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 29s
deb / build-publish (push) Successful in 3m5s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m35s
flatpak / build-publish (push) Failing after 4m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m54s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m12s
Light up the dormant 10-bit/HDR path end to end on the Windows client.

- core: NativeClient::connect gains a video_caps param threaded into the Hello. The Windows
  client advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR; every other caller (the C ABI shim,
  Linux, Android, host test connects) passes 0, so the 8-bit BT.709 path is unchanged. The
  host already gates a Main10/PQ encode on these bits + PUNKTFUNK_10BIT.
- video.rs: a PQ frame (color_trc == SMPTE2084) converts 10-bit YUV → X2BGR10 (== DXGI
  R10G10B10A2) with the BT.2020 matrix via sws_setColorspaceDetails; swscale applies only
  the matrix + range, so the PQ-encoded samples pass through untouched.
- present.rs: on an HDR frame the swapchain flips in place (ResizeBuffers) to R10G10B10A2 +
  DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 + HDR10 metadata; the passthrough shader is
  unchanged and the compositor maps PQ→display. Switched to ALPHA_MODE_IGNORE so the 10-bit
  padding bits don't render transparent. SDR stays 8-bit B8G8R8A8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:18:30 +02:00
enricobuehler 5cbd249d09 fix(client/windows): first on-glass pass — component routing, pointer lock, stats HUD
The first real run on a display surfaced three issues the headless/dev-VM build never hit:

- Route each hook-using screen (hosts/pair/stream) as its own component() instead of
  calling it with the shared cx. Calling hooks on the parent cx changed the hook order
  when the screen flipped, tripping reactor's Rules-of-Hooks guard and aborting the moment
  you navigated to the stream page.
- Mouse: replace the absolute path (which swallowed WM_MOUSEMOVE and so froze the OS cursor,
  snapping the host pointer back to one point) with proper pointer lock — hide + ClipCursor
  + recentre, shipping relative MouseMove scaled by the Contain-fit factor. Ctrl+Alt+Shift+Q
  now actually toggles capture: track modifier state from the hook's own event stream
  (GetAsyncKeyState doesn't see keys we suppress in our own LL hook), and flush held
  keys/buttons on release so nothing sticks on the host.
- Add the stats HUD overlay (mode · fps · Mb/s · capture→client/decode latency), mirroring
  the Apple client. Stats live in root state and reach the stream page as a prop (a child's
  own async-state update is pruned when props are unchanged), fed by a small poll thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:18:29 +02:00
298 changed files with 27472 additions and 5822 deletions
+20
View File
@@ -0,0 +1,20 @@
# cargo-audit configuration — consumed by `.gitea/workflows/audit.yml` (`cargo audit`).
#
# Silence only advisories that are KNOWN-UNFIXABLE and either not applicable to how we use the crate
# 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 / paste / rustls-pemfile). Those are left visible on purpose
# so we keep getting the maintenance signal — they do not fail CI.
[advisories]
ignore = [
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
"RUSTSEC-2023-0071",
]
+90 -5
View File
@@ -1,4 +1,4 @@
# Android client CI (Gitea Actions). Builds the Rust JNI core (crates/punktfunk-android) via
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
#
@@ -12,6 +12,10 @@ name: android
on:
push:
branches: [main]
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
# push is canary (Play `internal`).
tags: ['v*']
pull_request:
workflow_dispatch:
@@ -41,12 +45,12 @@ jobs:
- name: Android SDK
uses: android-actions/setup-android@v3
- name: NDK r28 LTS + platform 36 + build-tools + CMake (libopus cross-build)
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
# Keep platforms;android-36 (android-37 isn't in the runner's sdkmanager channel yet —
# "Failed to find package"); AGP auto-installs the compileSdk-37 platform during the build.
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" "ndk;28.2.13676358" "cmake;3.22.1"
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
# auto-download it if needed during the build.
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
- name: Caches (cargo + gradle)
uses: actions/cache@v4
@@ -65,4 +69,85 @@ jobs:
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
env:
VERSION_CODE: ${{ github.run_number }}
run: ./gradlew :app:assembleDebug --stacktrace
# Single source of the version name + the Play track for the release steps below. versionCode
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
- name: Version + channel
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: |
case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
echo "android version $VN -> Play track '$TRACK'"
- name: Build Release (signed AAB + universal APK)
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
working-directory: clients/android
env:
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
RELEASE_KEYSTORE_FILE: "../release.jks"
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
# AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key.
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
# 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).
# 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
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
REGISTRY: git.unom.io
OWNER: unom
PKG: punktfunk-android
VERSION: ${{ github.run_number }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
AAB=clients/android/app/build/outputs/bundle/release/app-release.aab
APK=clients/android/app/build/outputs/apk/release/app-release.apk
base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG"
# 1) immutable, run-number-versioned store (sideload + provenance)
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/$VERSION/punktfunk-android-r$VERSION.aab"
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$VERSION/punktfunk-android-r$VERSION.apk"
echo "published store version $VERSION (versionCode)"
# 2) channel alias for a predictable sideload URL: stable -> latest/, canary -> canary/
case "$GITHUB_REF" in refs/tags/v*) ALIAS=latest ;; *) ALIAS=canary ;; esac
curl -fsS -o /dev/null --user "enricobuehler:$REGISTRY_TOKEN" -X DELETE "$base/$ALIAS/punktfunk-android.apk" || true
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$ALIAS/punktfunk-android.apk"
echo "sideload alias: $base/$ALIAS/punktfunk-android.apk"
# 3) on a real release, attach the .aab + .apk to the unified Gitea Release (X.Y.Z names)
case "$GITHUB_REF" in
refs/tags/v*)
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$AAB" "punktfunk-${VERSION_NAME}.aab"
upsert_asset "$RID" "$APK" "punktfunk-${VERSION_NAME}.apk"
;;
esac
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
# promotion to production in the Play console.
- name: Upload to Google Play
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: |
echo "uploading to Play track '$PLAY_TRACK'"
python3 clients/android/ci/play-upload.py \
--package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
+32 -14
View File
@@ -13,16 +13,16 @@ name: deb
on:
push:
branches: [main]
# HOST-scoped tags only. The Apple client uses `v*` (release.yml); those must NOT trigger a
# host publish — a `v0.1.1` client tag previously shipped a host package versioned 0.1.1 that
# outranked every rolling build (the version-shadow). Host releases use `host-v*`.
tags: ['host-v*']
# Single project version: a `vX.Y.Z` tag is THE release for every platform (see
# docs-site channels.md). The old version-shadow (a client tag shipping a host package
# that outranked rolling builds) is now structurally impossible — main publishes to the
# `canary` apt distribution, tags to `stable`, so the two never share a version line.
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
DISTRIBUTION: stable
COMPONENT: main
jobs:
@@ -34,19 +34,22 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Version
# host-vX.Y.Z tag -> X.Y.Z (a real host release). A main push -> 0.2.0~ciN.g<sha>: the '~'
# sorts it BELOW the eventual 0.2.0 tag, it climbs monotonically by run number, AND it sits
# ABOVE the stray 0.1.1, so `apt upgrade` truly moves boxes forward. Computed BEFORE the
# build so it's stamped into the binary (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
# A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}" ;;
*) V="0.2.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "package version $V"
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "package version $V -> apt distribution '$DIST'"
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
# deps are also baked into the rust-ci image, but this job runs against the image
@@ -55,7 +58,8 @@ jobs:
- name: dpkg-dev + client link deps
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev \
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
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.
@@ -124,3 +128,17 @@ jobs:
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
# On a real release, also attach the .debs to the unified Gitea Release so they're on the
# downloads page next to every other platform's artifact (canary builds live in the apt
# `canary` distribution above — no release page for those).
- name: Attach .debs to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
+25 -28
View File
@@ -56,19 +56,20 @@ jobs:
pnpm install --frozen-lockfile
pnpm run build # rollup -> clients/decky/dist/index.js
- name: Version
# Tag v1.2.3 -> 1.2.3; main push -> 0.0.1-ciN.g<sha>. Used only for the registry
# version path + the zip name (the plugin.json version is the source of truth Decky
# reads after install).
- name: Version + channel
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g<sha>
# (`canary/` alias). Used for the registry version path + the zip name (the plugin.json
# version is the source of truth Decky reads after install — bump it in the release commit).
working-directory: ${{ gitea.workspace }}
run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "decky version $V"
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
echo "decky version $V -> alias '$ALIAS'"
- name: Assemble store-layout zip
working-directory: ${{ gitea.workspace }}
@@ -76,12 +77,16 @@ jobs:
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
STAGE="$RUNNER_TEMP/decky"
DEST="$STAGE/$PLUGIN"
rm -rf "$STAGE"; mkdir -p "$DEST/dist"
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
cp clients/decky/plugin.json "$DEST/"
cp clients/decky/package.json "$DEST/"
cp clients/decky/main.py "$DEST/"
cp clients/decky/dist/index.js "$DEST/dist/"
cp clients/decky/README.md "$DEST/"
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
chmod 0755 "$DEST/bin/punktfunkrun.sh"
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
cp LICENSE-MIT "$DEST/LICENSE"
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
@@ -98,29 +103,21 @@ jobs:
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
"$BASE/$VERSION/punktfunk.zip"
echo "published $BASE/$VERSION/punktfunk.zip"
# 2) Stable `latest/punktfunk.zip` — this is the link to paste into Decky's
# "install from URL". The generic registry rejects re-uploading an existing
# version/file (409), so delete the prior `latest` first (ignore 404 on run #1).
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link
# to paste into Decky's "install from URL". The generic registry rejects re-uploading
# an existing version/file (409), so delete the prior alias first (ignore 404 on run #1).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"$BASE/latest/punktfunk.zip" || true
"$BASE/$ALIAS/punktfunk.zip" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
"$BASE/latest/punktfunk.zip"
echo "install-from-URL link: $BASE/latest/punktfunk.zip"
"$BASE/$ALIAS/punktfunk.zip"
echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip"
- name: Attach zip to the Gitea release (tags only)
if: startsWith(gitea.ref, 'refs/tags/')
- name: Attach zip to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
working-directory: ${{ gitea.workspace }}
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
ID=$(curl -sf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST "$API/releases/$ID/assets?name=punktfunk-${VERSION}.zip" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$RUNNER_TEMP/punktfunk.zip" >/dev/null
echo "attached punktfunk-${VERSION}.zip to release $GITHUB_REF_NAME"
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$RUNNER_TEMP/punktfunk.zip" "punktfunk-${VERSION}.zip"
+5
View File
@@ -58,16 +58,21 @@ jobs:
- name: Build
run: |
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
EXTRA=""
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
$EXTRA \
"${{ matrix.context }}"
- name: Push
run: |
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}"
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
+125 -31
View File
@@ -26,7 +26,7 @@ on:
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
paths:
- 'crates/punktfunk-client-linux/**'
- 'clients/linux/**'
- 'crates/punktfunk-core/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
@@ -40,6 +40,8 @@ env:
APP_ID: io.unom.Punktfunk
MANIFEST: packaging/flatpak/io.unom.Punktfunk.yml
PACKAGE: punktfunk-client-flatpak # generic-registry package name
REPO_URL: https://flatpak.unom.io # shared unom OSTree repo (reusable across unom apps)
DEPLOY_DIR: unom-flatpak # ~/<dir> on unom-1 (compose + ./site tree)
jobs:
build-publish:
@@ -61,34 +63,47 @@ jobs:
- name: Tooling
run: |
# flatpak-cargo-generator.py (master) needs aiohttp + tomlkit (NOT the old `toml`).
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
gnupg2 rsync openssh-clients
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
git config --global --add safe.directory "$PWD"
- name: Version
# Tag v1.2.3 -> 1.2.3; a main push -> 0.0.1-ciN.g<sha> (sorts before a real release,
# increases by run number — newest main build always wins). The generic registry
# version string allows letters/dots/hyphens.
- name: Version + channel
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
# 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
# on a stable box never jumps to a canary build. The generic-registry version string allows
# letters/dots/hyphens.
run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
*) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
echo "flatpak version $V"
echo "FLATPAK_BRANCH=$BRANCH" >> "$GITHUB_ENV"
echo "ALIAS=$ALIAS" >> "$GITHUB_ENV"
echo "flatpak version $V -> branch '$BRANCH' alias '$ALIAS'"
- name: Generate offline cargo sources
# flatpak builds with no network; vendor every crate from Cargo.lock into
# cargo-sources.json next to the manifest (referenced by the manifest's
# punktfunk-client module).
#
# Prune the microsoft/windows-rs git crates first: they belong to
# punktfunk-client-windows, which the flatpak never builds, and leaving them in makes
# flatpak-builder full-clone that multi-GB repo at build time → "No space left on
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
# untouched; cargo --offline only needs sources for the crates it compiles.
run: |
curl -fsSL -o /tmp/flatpak-cargo-generator.py \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
python3 /tmp/flatpak-cargo-generator.py Cargo.lock \
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
-o packaging/flatpak/cargo-sources.json
- name: Build the flatpak (install deps from Flathub, offline build)
@@ -97,14 +112,20 @@ jobs:
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
# container-safe path (no FUSE).
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
# (manifest sets no branch).
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--default-branch="$FLATPAK_BRANCH" \
--install-deps-from=flathub \
--repo="$PWD/repo" \
"$PWD/build-dir" "$MANIFEST"
- name: Export single-file bundle
run: |
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID"
# Branch must be passed explicitly (matches --default-branch above); build-bundle
# otherwise defaults to `master` and errors "Refspec … not found".
flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" "$FLATPAK_BRANCH"
ls -lh "$BUNDLE"
- name: Publish to the Gitea generic registry
@@ -116,28 +137,101 @@ jobs:
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
"$BASE/$VERSION/$BUNDLE"
echo "published $BASE/$VERSION/$BUNDLE"
# 2) Stable `latest/punktfunk-client.flatpak` alias for the Decky fallback + scripts.
# The generic registry rejects re-uploading an existing version/file (409), so
# delete the prior `latest` file first (ignore 404 on the first ever run).
# 2) Channel alias (stable release -> latest/, canary main build -> canary/) for the
# Decky fallback + scripts. The generic registry rejects re-uploading an existing
# version/file (409), so delete the prior alias file first (ignore 404 on run #1).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"$BASE/latest/punktfunk-client.flatpak" || true
"$BASE/$ALIAS/punktfunk-client.flatpak" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
"$BASE/latest/punktfunk-client.flatpak"
echo "published $BASE/latest/punktfunk-client.flatpak"
"$BASE/$ALIAS/punktfunk-client.flatpak"
echo "published $BASE/$ALIAS/punktfunk-client.flatpak"
- name: Attach bundle to the Gitea release (tags only)
if: startsWith(gitea.ref, 'refs/tags/')
# Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on
# unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors
# docker.yml's deploy-docs (DEPLOY_* = the unom-ci-deploy key). No-ops cleanly until the GPG
# secret + DEPLOY_* exist, so the bundle build stays green during setup.
- name: Sign + deploy the OSTree repo to unom-1 (flatpak.unom.io)
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
ID=$(curl -sf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST "$API/releases/$ID/assets?name=$BUNDLE" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$BUNDLE" >/dev/null
echo "attached $BUNDLE to release $GITHUB_REF_NAME"
set -euo pipefail
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
exit 0
fi
# 1) Import the signing key into a throwaway keyring; sign the repo.
export GNUPGHOME="$(mktemp -d)"; chmod 700 "$GNUPGHOME"
echo "$FLATPAK_GPG_PRIVATE_KEY" | base64 -d | gpg --batch --import
KEYID="$(gpg --list-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')"
# build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are
# required — clients with gpg-verify=true verify the commit, so summary-only signing
# fails the pull with "GPG verification enabled, but no signatures found".
flatpak build-sign "$PWD/repo" "$APP_ID" "$FLATPAK_BRANCH" \
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
flatpak build-update-repo --generate-static-deltas \
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
# 2) Build the install descriptors (GPGKey = the committed public key, base64).
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
rm -rf site && mkdir -p site
cat > site/unom.flatpakrepo <<EOF
[Flatpak Repo]
Title=unom
Url=$REPO_URL/repo/
Homepage=https://punktfunk.unom.io
Comment=unom Flatpak applications
GPGKey=$GPGKEY
EOF
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
# the server always offers both (the stable ref only resolves once a release has built the
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
write_ref() { # <filename> <branch> <title>
cat > "site/$1" <<EOF
[Flatpak Ref]
Name=$APP_ID
Branch=$2
Url=$REPO_URL/repo/
Title=$3
Homepage=https://punktfunk.unom.io
IsRuntime=false
GPGKey=$GPGKEY
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
EOF
}
write_ref "${APP_ID}.flatpakref" stable "Punktfunk"
write_ref "${APP_ID}.Canary.flatpakref" canary "Punktfunk (Canary)"
cat > site/index.html <<EOF
<!doctype html><meta charset=utf-8><title>unom flatpak repo</title>
<h1>unom Flatpak repository</h1>
<p>Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates).</p>
<p><b>Stable</b> (recommended — only moves on releases):</p>
<pre>flatpak install --user $REPO_URL/${APP_ID}.flatpakref
flatpak run $APP_ID</pre>
<p><b>Canary</b> (latest main build, unstable):</p>
<pre>flatpak install --user $REPO_URL/${APP_ID}.Canary.flatpakref</pre>
<p>Or add the whole remote: <code>flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo</code></p>
EOF
# 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
- name: Attach bundle 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" "$BUNDLE"
+37 -20
View File
@@ -46,6 +46,19 @@ name: release
on:
push:
# Canary: a relevant main push uploads the iOS + macOS builds to TestFlight (Apple's own
# canary channel) — no notarized DMG, no tvOS (those are 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:
@@ -87,8 +100,8 @@ jobs:
- name: Version from tag
run: |
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
*) V="0.0.${GITHUB_RUN_NUMBER}" ;;
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
@@ -105,8 +118,16 @@ jobs:
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Build PunktfunkCore.xcframework (mac + iOS; + tvOS on stable tags)
# tvOS uses nightly -Zbuild-std (slow) — build it only for a real release, not on every
# canary main push.
run: |
TV=""
case "$GITHUB_REF" in refs/tags/v*) TV="BUILD_TVOS=1" ;; esac
# `env` (not a bare prefix): a $TV-expanded `NAME=val` word is NOT re-promoted to a shell
# assignment, so `BUILD_IOS=1 $TV bash …` would try to RUN `BUILD_TVOS=1` (exit 127). env
# treats its leading NAME=val args as assignments post-expansion; empty $TV is a no-op.
env BUILD_IOS=1 $TV bash scripts/build-xcframework.sh
- name: Stage App Store Connect API key
env:
@@ -116,6 +137,9 @@ jobs:
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
@@ -154,23 +178,14 @@ jobs:
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
echo "DMG=$DMG" >> "$GITHUB_ENV"
- name: Attach DMG to Gitea release
if: startsWith(gitea.ref, 'refs/tags/')
- name: Attach DMG to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
# Create the release (409 -> already exists, fetch it instead).
ID=$(curl -sf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \
|| curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST "$API/releases/$ID/assets?name=Punktfunk-$VERSION.dmg" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$DMG" >/dev/null
echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME"
. 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'
@@ -278,7 +293,9 @@ jobs:
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: tvOS — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Stable only — the tvOS xcframework slice is built just for releases (above), and the
# App Store Connect record + runner platform are tvOS prerequisites.
if: startsWith(gitea.ref, 'refs/tags/v') && (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
+32 -13
View File
@@ -13,9 +13,10 @@ name: rpm
on:
push:
branches: [main]
# HOST-scoped tags only — the Apple client's `v*` tags (release.yml) must NOT publish a host
# RPM (a `v0.1.1` client tag previously shipped a host 0.1.1 that shadowed every rolling build).
tags: ['host-v*']
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
# version-shadow (a release outranking rolling builds in one group) is structurally gone.
tags: ['v*']
workflow_dispatch:
env:
@@ -66,20 +67,22 @@ jobs:
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- name: Version
# host-vX.Y.Z tag -> X.Y.Z-1 (a real host release); main push -> 0.2.0-0.ciN.g<sha>, whose
# "0." release sorts BELOW the eventual 0.2.0-1 yet climbs by run number AND outranks the
# stray 0.1.1, so `rpm-ostree upgrade` truly moves to the newest build. The spec %build
# stamps PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha>
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
# stable->canary box re-point still moves forward. The spec %build stamps
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}"; R="1" ;;
*) V="0.2.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
*) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
esac
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
echo "rpm $V-$R"
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
echo "rpm $V-$R -> group '$GROUP'"
- name: Build RPM
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
@@ -101,6 +104,22 @@ jobs:
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
echo "uploading $rpm"
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/${{ matrix.group }}/upload"
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done
echo "published to $OWNER/rpm/$GROUP"
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
- name: Attach .rpms 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 rpm in dist/*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
base="$(basename "$rpm" .rpm)"
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
done
echo "published to $OWNER/rpm/${{ matrix.group }}"
+151
View File
@@ -0,0 +1,151 @@
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
# SudoVDA virtual-display driver) from one signed setup.exe. Runs on the self-hosted Windows runner
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
#
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
# CreateProcessAsUserW's into the interactive session for secure-desktop capture, and bundles a
# kernel/IDD driver — neither is expressible in MSIX's sandbox. The real install logic already lives
# in `punktfunk-host service install` (crates/punktfunk-host/src/service.rs); the installer just lays
# the exe down and calls it elevated. Packaging internals: packaging/windows/README.md.
#
# Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group)
#
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
# unified Gitea Release).
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
#
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
#
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
# .def with llvm-dlltool (no GPU/SDK at build time).
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
name: windows-host
on:
push:
branches: [main]
paths:
- 'crates/punktfunk-host/**'
- 'crates/punktfunk-core/**'
- 'packaging/windows/**'
- 'scripts/windows/host.env.example'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-host.yml'
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
PKG: punktfunk-host-windows
jobs:
package:
runs-on: windows-amd64
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Configure + version
shell: pwsh
run: |
# CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc,
# opus) the host pulls; CARGO_WORKSPACE_DIR mirrors the client workflows. Both via GITHUB_ENV
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the same BtbN gpl-shared x64 tree the Windows CLIENT links against (provisioned
# by scripts/ci/setup-windows-runner.ps1). 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.
if (-not $env:FFMPEG_DIR) {
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
$env:GITHUB_REF_NAME -replace '^v', ''
} else {
"0.3.$($env:GITHUB_RUN_NUMBER)"
}
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "host version $v"
- name: Generate NVENC import lib
shell: pwsh
run: |
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build (release, nvenc + amf-qsv)
shell: pwsh
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
- name: Clippy (host, Windows)
shell: pwsh
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
- name: Ensure Inno Setup
shell: pwsh
run: |
if (-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and -not (Get-Command iscc -ErrorAction SilentlyContinue)) {
Write-Output "installing Inno Setup via choco"
choco install innosetup -y --no-progress
}
- name: Pack + sign installer
shell: pwsh
env:
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& packaging/windows/pack-host-installer.ps1 `
-Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out
- name: Publish to Gitea generic registry
shell: pwsh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
# Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort.
$PSNativeCommandUseErrorActionPreference = $false
function Publish-File($f, $url) {
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
Write-Output "published $url"
}
$files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" }
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
foreach ($f in $files) {
$an = $aliasNames[$f]; if (-not $an) { continue }
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
Publish-File $f "$base/$alias/$an"
}
# On a real release, also attach the signed installer (+ its .cer) to the unified Gitea Release.
- name: Attach host installer 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:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+83 -28
View File
@@ -1,18 +1,23 @@
# Build the punktfunk Windows client as a signed MSIX and publish it 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 the self-hosted Windows runner (host mode;
# scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows SDK's
# makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
# 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 the self-hosted Windows runner
# (host mode; scripts/ci/setup-windows-runner.ps1) — the MSVC/WinUI/FFmpeg toolchain + the Windows
# SDK's makeappx/signtool are baked into the runner's daemon env, same as windows.yml.
#
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
# windows.yml for the cross-build rationale + the BOM/MAX_PATH runner gotchas.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: crates/punktfunk-client-windows/packaging/README.md. BOM/MAX_PATH runner
# gotchas baked into the daemon env + windows.yml: see that workflow.
# Packaging internals: clients/windows/packaging/README.md.
#
# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so:
# win-vX.Y.Z tag -> X.Y.Z.0 (a real Windows-client release; `win-v*` is its own tag namespace,
# kept off the host's `host-v*` and the Apple `v*` to avoid the
# version-shadow class of bug — see deb.yml).
# main push / dispatch -> 0.2.<run_number>.0 (rolling; climbs monotonically by run number).
# 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 -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
# Published to the generic registry + the `canary/` alias.
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
#
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed
@@ -25,12 +30,12 @@ on:
push:
branches: [main]
paths:
- 'crates/punktfunk-client-windows/**'
- 'clients/windows/**'
- 'crates/punktfunk-core/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml'
tags: ['win-v*']
tags: ['v*']
workflow_dispatch:
env:
@@ -41,30 +46,47 @@ env:
jobs:
package:
runs-on: windows-amd64
timeout-minutes: 60
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg
td: C:\t
- arch: arm64
target: aarch64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg-arm64
td: C:\t-a64
steps:
- uses: actions/checkout@v4
- name: Configure + version
shell: pwsh
run: |
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR=C:\t dodges the
# MAX_PATH wall in the CMake-from-source crates (see windows.yml). Both via GITHUB_ENV.
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR (per-arch, short)
# dodges the MAX_PATH wall in the CMake-from-source crates (see windows.yml). FFMPEG_DIR
# selects the arch's import libs + is read by pack-msix.ps1 for the runtime DLLs. All via
# GITHUB_ENV.
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') {
($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.')
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
$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 {
@('0', '2', $env:GITHUB_RUN_NUMBER)
@('0', '3', $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"
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
- name: Build (release)
shell: pwsh
run: cargo build --release -p punktfunk-client-windows
run: cargo build --release -p punktfunk-client-windows --target ${{ matrix.target }}
- name: Pack + sign MSIX
shell: pwsh
@@ -72,19 +94,52 @@ jobs:
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
-Version $env:MSIX_VERSION -TargetDir C:\t\release -OutDir C:\t\msix
& 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) {
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" }
Write-Output "published $url"
}
foreach ($f in $files) {
$name = Split-Path $f -Leaf
$url = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)/$($env:MSIX_VERSION)/$name"
curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url"
Write-Output "published $name -> $url"
# 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 }
}
+38 -15
View File
@@ -1,18 +1,30 @@
# Windows client CI — runs on the self-hosted Windows runner (home-windows-1, host mode; see
# scripts/ci/setup-windows-runner.ps1). Build + clippy + fmt + test the WinUI 3 client
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3) on x86_64-pc-windows-msvc.
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
#
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, FFmpeg,
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Two
# per-checkout vars are set in a step:
# 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
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
# so fmt + test run only for x64.
#
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# / per-arch vars are set in a step:
# - CARGO_WORKSPACE_DIR windows-reactor's build.rs unwraps it + stages the Win App SDK
# NuGets/winmd under it (from GITHUB_WORKSPACE).
# - CARGO_TARGET_DIR=C:\t the runner's host workdir is buried deep under
# - 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.
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
#
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (the
@@ -24,14 +36,14 @@ on:
push:
branches: [main]
paths:
- 'crates/punktfunk-client-windows/**'
- 'clients/windows/**'
- 'crates/punktfunk-core/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
pull_request:
paths:
- 'crates/punktfunk-client-windows/**'
- 'clients/windows/**'
- 'crates/punktfunk-core/**'
- 'Cargo.lock'
- 'Cargo.toml'
@@ -41,7 +53,11 @@ on:
jobs:
build:
runs-on: windows-amd64
timeout-minutes: 60
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc]
steps:
- uses: actions/checkout@v4
@@ -49,24 +65,31 @@ jobs:
shell: pwsh
run: |
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Per-arch FFmpeg import libs (the runner provisions both — setup-windows-runner.ps1).
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
node --version
Write-Output "workspace: $env:GITHUB_WORKSPACE"
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
- name: Build
shell: pwsh
run: cargo build -p punktfunk-client-windows
run: cargo build -p punktfunk-client-windows --target ${{ matrix.target }}
- name: Clippy (-D warnings)
shell: pwsh
run: cargo clippy -p punktfunk-client-windows --all-targets -- -D warnings
run: cargo clippy -p punktfunk-client-windows --all-targets --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 -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows
run: cargo test -p punktfunk-client-windows --target ${{ matrix.target }}
+8
View File
@@ -20,3 +20,11 @@ xcuserdata/
# Windows App SDK staging by windows-reactor build.rs
/temp/
/winmd/
# Client crate build artifacts (clients moved out of crates/ -> clients/ 2026-06-18)
/clients/*/target
/clients/*/*/target
# Python bytecode (e.g. clients/android/ci tooling)
__pycache__/
*.pyc
+24
View File
@@ -0,0 +1,24 @@
{
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:punktfunk}/clients/apple",
"name": "Debug PunktfunkClient (clients/apple)",
"target": "PunktfunkClient",
"configuration": "debug",
"preLaunchTask": "swift: Build Debug PunktfunkClient (clients/apple)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:punktfunk}/clients/apple",
"name": "Release PunktfunkClient (clients/apple)",
"target": "PunktfunkClient",
"configuration": "release",
"preLaunchTask": "swift: Build Release PunktfunkClient (clients/apple)"
}
]
}
+119 -47
View File
@@ -6,10 +6,10 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
## Where the work stands
- **M1 (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
regression-tested (`a913042`).
- **M2 (GameStream host): working end-to-end with a stock Moonlight client.** Validated live
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
control, audio, and video at the **client's native resolution and refresh** — the host
@@ -28,11 +28,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
back-channel; validated live — pad created/destroyed with the session). Management REST API +
checked-in OpenAPI doc (`mgmt.rs`).
- **M3 (`punktfunk/1`, the native protocol): full session planes, validated live.** QUIC
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
plane = the hardened M1 `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
(inexpressible in GameStream), host creates the native virtual output at the client's
requested mode. `m3-host` is a **persistent listener** (sessions back to back;
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
@@ -41,15 +41,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
default; `--allow-tofu`/`--open` accept unpaired clients).
**LAN auto-discovery**: both `serve --native` and `m3-host` advertise the native service over
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
pin)/`pair`(required|optional)/`id`; `punktfunk-client-rs --discover` lists hosts, Apple clients
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
@@ -58,18 +58,46 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
box → dev box over the LAN: **p50 1.30 ms** (the 1.57 ms inter-box clock offset removed).
`punktfunk-client-rs` is the
`punktfunk-probe` is the
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
env > uinput Xbox 360; DualSense (UHID) only on Linux hosts.
env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
LEDs / mute). The UHID pads need a Linux host; off Linux they (and One/Series) fold into Xbox 360.
Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4,
Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for
DS4 get Xbox 360 for now.
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
`--features amf-qsv`), SendInput + **ViGEm** gamepads (`inject/gamepad_windows.rs`), WASAPI loopback
+ virtual mic (`audio/wasapi_*`). Ships as a **signed Inno Setup installer** that registers a
`LocalSystem` SCM service launching into the interactive session for secure-desktop (UAC/lock-screen)
capture (`service.rs`), bundles the SudoVDA driver + the FFmpeg DLLs, and is published by
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
`PUNKTFUNK_ENCODER=auto` (the host.env default) detects the DXGI adapter vendor → **NVENC** (NVIDIA,
direct SDK, `encode/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
(`encode/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
probed per-GPU on AMF/QSV (`windows_codec_support``serverinfo`, AV1 gated). **HDR (10-bit)**: WGC
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
host only** (the Linux host stays 8-bit, blocked upstream). **AMF/QSV is CI-green but not yet
on-glass validated** (no AMD/Intel Windows box in the lab); NVENC is live-validated. Newer/less
battle-tested than the Linux host. Packaging: `packaging/windows/`.
## What's left
1. **M4 — client decode + present: macOS stage 1 done, first light achieved
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
@@ -85,20 +113,22 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
motion sign/scale derived, not yet live-verified. Tests: `swift test` in
`clients/apple` (unit + real-codec round trip),
`test-loopback.sh` (Swift client vs synthetic m3-hosts on loopback — runs on macOS;
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
includes the pairing ceremony + `--require-pairing` gate),
`RemoteFirstLightTests` (full pipeline over the LAN). See
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
`tools/latency-probe` (scaffold), iOS variant.
**Linux stage 1 done, first light 2026-06-12** (`crates/punktfunk-client-linux`, binary
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter**
(`VTDecompressionSession` + `CAMetalLayer`) is built and live-validated on glass behind the opt-in
`punktfunk.presenter` flag (~11 ms p50 capture→present), to become the default after a few
resolution/HDR checks. Next: make stage 2 the default, glass-to-glass numbers via
`tools/latency-probe`, iOS/iPadOS/tvOS variants.
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
against `serve --native` on this box: 1080p60, steady 60 fps, capture→decoded p50
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
@@ -108,48 +138,80 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode; needs an
Intel/AMD client box to live-verify the hw path. Next: the stage-2 raw-Wayland
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
reconfirm. Next: the stage-2 raw-Wayland
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
**Windows stage 1 done 2026-06-15** (`crates/punktfunk-client-windows`, binary
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain** (WARP fallback for
the GPU-less dev box; runtime-compiled fullscreen-triangle shaders, Contain-fit letterbox),
driven by reactor's per-frame `on_rendering`. **FFmpeg software HEVC decode** (D3D11VA hw decode
is the follow-up), **WASAPI** render + mic capture, **SDL3** gamepads (rumble/lightbar/DualSense),
`mdns-sd` discovery, and the full trust surface — all **in-app**: host list (live mDNS + saved +
manual), settings (resolution/refresh/mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch
re-pair. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
driven by reactor's per-frame `on_rendering`. **FFmpeg HEVC decode with a D3D11VA
zero-copy hardware path** (`gpu.rs` shares one D3D11 device — hardware+`VIDEO_SUPPORT`, WARP
fallback, multithread-protected — between the decoder and presenter; the decoder outputs
NV12/P010 `ID3D11Texture2D` array slices with `BIND_SHADER_RESOURCE` and the presenter samples
them via per-plane SRVs + YUV→RGB shaders — NV12/BT.709, P010/BT.2020-PQ; **software CPU decode
stays as the robust fallback**, auto-selected with a `DecoderPref` override). **HDR10**: the
client advertises 10-bit/HDR (Settings toggle), detects PQ in-band (`transfer == SMPTE2084`),
and flips the swapchain to `R10G10B10A2` + ST.2084 with HDR10 metadata. **WASAPI** render + mic
capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full trust
surface — all **in-app**: a polished WinUI shell (host cards w/ monogram + status pills,
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **(D3D11VA + HDR present + the
GUI polish are written against the windows-rs/reactor APIs but not yet on-glass validated — the
dev VM is headless/WARP; needs the RTX box.)** **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
+ fmt green on `x86_64-pc-windows-msvc` (on the dev VM). **windows-reactor is unpublished** (git
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
dep pinned to commit `b4129fcc`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies
with `set_swap_chain`); its `build.rs` downloads the Win App SDK NuGets + needs `CARGO_WORKSPACE_DIR`
set (in the VM build env; `/temp`+`/winmd` gitignored). Gotcha: `CARGO_HOME` must be an ASCII path
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. Next: **on-glass
validation** (the dev VM is headless/Session-0 → the WinUI window needs a display: RDP or the RTX
box), D3D11VA hw decode + 10-bit/HDR present, RAWINPUT relative-mouse pointer-lock, and a per-host
speed test in the UI.
validation** of the D3D11VA decode + HDR present + GUI on the RTX box (the dev VM is
headless/Session-0/WARP → the WinUI window + hardware decode need a real display+GPU: RDP or the
RTX box), then RAWINPUT relative-mouse pointer-lock and a per-host speed test in the UI.
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
(`feedback.rs`), `NsdManager` mDNS discovery, SPAKE2 PIN pairing + TOFU (Keystore identity +
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms
at high res).
3. **punktfunk/1 protocol growth**: concurrent sessions (today: one at a time, extras wait
in the accept queue). **Done:** unified host (`serve --native` runs GameStream + the
punktfunk/1 QUIC host in one process) with native pairing driven over the mgmt API /
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
fingerprint change. Next (see roadmap): **delegated pairing approval** (an already-paired device
approves a new one).
4. **M2 polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
own app.
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
and unit/live-capture tested — both still need a live Moonlight confirmation (select
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
5. **Native clients** (`clients/{apple,android}` scaffolds) consuming `punktfunk_core.h`.
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
@@ -176,7 +238,10 @@ workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
provisioned by `scripts/ci/setup-macos-runner.sh`).
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner.
## Layout
@@ -187,11 +252,17 @@ crates/punktfunk-host/
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs · native_pairing.rs
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless test/measurement tool)
crates/punktfunk-client-linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
clients/decky/ Steam Deck Decky plugin
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
web/ TanStack web console over the mgmt API (status · devices · pairing)
packaging/ Fedora/Bazzite RPM · bootc · COPR (packaging/bazzite/README.md)
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
tools/{loss-harness,latency-probe}/ measurement (plan §10)
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
include/punktfunk_core.h generated C header
@@ -209,7 +280,7 @@ include/punktfunk_core.h generated C header
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
ceiling.
- **M1 security hardening stays intact**: reassembler bounds attacker-controlled fields
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
ABI `struct_size` checks. Regression tests exist — keep them green.
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
@@ -228,14 +299,14 @@ scanout → KWin `--drm` impossible; everything renders offscreen via `renderD12
# launcher menu is EMPTY (no apps, no System Settings).
bash scripts/headless/run-headless-kde.sh 1920x1080
# host (shell 2):
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
# across sessions — bound it with --max-sessions):
cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 10 --max-sessions 1
cargo run -rp punktfunk-client-rs -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
```
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
@@ -244,7 +315,8 @@ or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1`, `PUNKTFUNK_GAMESCOPE_APP=...`,
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
test), `PUNKTFUNK_FEC_PCT=N`.
test), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy).
## Conventions
Generated
+17 -15
View File
@@ -2540,11 +2540,12 @@ dependencies = [
]
[[package]]
name = "punktfunk-android"
name = "punktfunk-client-android"
version = "0.0.1"
dependencies = [
"android_logger",
"jni",
"libc",
"log",
"ndk",
"opus",
@@ -2571,20 +2572,6 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "punktfunk-client-rs"
version = "0.0.1"
dependencies = [
"anyhow",
"mdns-sd",
"opus",
"punktfunk-core",
"quinn",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "punktfunk-client-windows"
version = "0.0.1"
@@ -2655,6 +2642,7 @@ dependencies = [
"hyper-util",
"khronos-egl",
"libc",
"libloading",
"mdns-sd",
"nvidia-video-codec-sdk",
"openh264",
@@ -2693,6 +2681,20 @@ dependencies = [
"xkbcommon",
]
[[package]]
name = "punktfunk-probe"
version = "0.0.1"
dependencies = [
"anyhow",
"mdns-sd",
"opus",
"punktfunk-core",
"quinn",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "quick-error"
version = "1.2.3"
+4 -4
View File
@@ -3,10 +3,10 @@ resolver = "2"
members = [
"crates/punktfunk-core",
"crates/punktfunk-host",
"crates/punktfunk-client-rs",
"crates/punktfunk-client-linux",
"crates/punktfunk-client-windows",
"crates/punktfunk-android",
"clients/probe",
"clients/linux",
"clients/windows",
"clients/android/native",
"tools/latency-probe",
"tools/loss-harness",
]
+111 -61
View File
@@ -1,95 +1,145 @@
# punktfunk
*A ground-up low-latency desktop streaming stack, built Linux-first, with a shared Rust
protocol core and native clients per platform.*
**Low-latency desktop and game streaming, Linux-first.** Run the host on a Linux machine — or a
Windows PC — with an NVIDIA GPU, connect from a Mac, PC, phone, tablet, or TV, and stream your desktop
or games — each device at its **own native resolution and refresh rate**, over your local network.
`punktfunk` is a placeholder codename. The bet: ship a **Linux virtual-display streaming
host** that speaks the existing Moonlight protocol (every Moonlight/Artemis client works
day one), then break the ~1 Gbps FEC wall with a **GF(2¹⁶) Leopard-RS** transport as a
negotiated extension. See [`docs/implementation-plan.md`](docs/implementation-plan.md).
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI.
## What makes it different
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
letterboxing, no scaling, no rearranging your real monitors.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
reconnect on a pinned identity. No accounts, no cloud. Hosts auto-advertise over mDNS, so clients
find them on the network without typing an IP.
## Status
| Milestone | State |
| Component | State |
|-----------|-------|
| **M1 — `punktfunk-core` + C ABI** | ✅ done & hardened (FEC, packetization, AES-GCM, session, adversarial-review fixes, `punktfunk_core.h`) |
| **M2 — GameStream host → stock Moonlight** | ✅ live end-to-end: pairing, RTSP, audio, per-client virtual output at native res, GPU zero-copy NVENC, gamepads |
| **M3 — `punktfunk/1` native protocol** | ✅ validated live: QUIC control + GF(2¹⁶) FEC/AES data plane, SPAKE2 PIN pairing, mid-stream mode renegotiation |
| **M4 — client decode + present (Apple)** | 🟡 macOS first light: AnnexB→VideoToolbox HEVC on glass + input/pairing over `punktfunk/1` (`clients/apple`); iOS + presenter next |
| **Web console + management API** | ✅ TanStack web console (`web/`) over the OpenAPI mgmt API: host status, paired devices, on-demand native pairing (arm → show PIN) |
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (NVIDIA, x64) | 🟡 Implemented & shipping as a signed installer (DXGI capture · SudoVDA virtual display · NVENC · WASAPI · ViGEm); NVIDIA-only, newer than the Linux host |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing |
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA
(RTX 5070 Ti & RTX 4090, driver 595): trust-on-first-use pairing that persists, an app
catalog, RTSP/ENet/audio, and **video at the client's exact resolution and refresh** via a
per-session virtual output (KWin, gamescope, Mutter, Sway backends), encoded with GPU
**zero-copy** (dmabuf → CUDA/Vulkan → NVENC) at up to 5120×1440@240. The native
**`punktfunk/1`** protocol adds a QUIC control plane and a GF(2¹⁶) Leopard-FEC + AES-GCM data
plane (p50 ~0.8 ms capture→reassembled at 720p120). Its trust model is **SPAKE2 PIN pairing by
default** — a new host requires the PIN ceremony; trust-on-first-use is an explicit host opt-in
(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs. Both
run from **one process** (`serve --native`), managed through a REST API + web console. Builds
against FFmpeg 7 or 8; deployed live on Bazzite. Full status: [`CLAUDE.md`](CLAUDE.md);
roadmap, setup guides & progress: the docs site ([`docs-site/`](docs-site) — Fumadocs;
`bun run dev`), with the canonical [roadmap](docs-site/content/docs/roadmap.md) and
[status](docs-site/content/docs/status.md) there. Design notes stay in [`docs/`](docs).
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
## Install (host)
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
The package registries are the real distribution channel — pick your distro and run one command.
Per-distro setup (add the repo, first-run, web console) lives in the linked READMEs.
## Install the host
| Distro | One-command happy path | Details |
|--------|------------------------|---------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [`packaging/debian/README.md`](packaging/debian/README.md) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(after adding the repo; or the bootc image)* | [`packaging/rpm/README.md`](packaging/rpm/README.md) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS/Deck)* | [`packaging/arch/README.md`](packaging/arch/README.md) |
Pick your platform and install from its package registry — the per-platform guide covers adding the
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
Windows host (NVIDIA-only) also ships as a signed installer.
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status);
`punktfunk-client` is the GTK4 desktop client (also shipped via apt/RPM/Arch/Flatpak). After install,
run `punktfunk-host serve --native` inside your desktop session, then pair from the web console.
| Platform | Install | Guide |
|--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (NVIDIA, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
Building from source (below) is a fallback.
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
## Layout
## Connect a client
```
crates/
punktfunk-core/ protocol · FEC · pacing · crypto · quic — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ Linux host: vdisplay · capture · encode · inject · gamestream · m3 · mgmt · native_pairing
punktfunk-client-rs/ punktfunk/1 reference client (M3 headless; M4 adds decode+present)
clients/{apple,android}/ native client scaffolds (import punktfunk_core.h); apple = macOS first light
web/ TanStack web console (host status · paired devices · pairing) over the mgmt API
packaging/ Fedora/Bazzite RPM · bootc image · COPR (see packaging/bazzite/README.md)
include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/{latency-probe,loss-harness}/ measurement (plan §10)
docs/{implementation-plan,roadmap,windows-host,dualsense-haptics}.md
```
| Streaming to… | Use |
|---|---|
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
| Android phone or TV | The **Android app** (`clients/android`) |
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
Each client discovers hosts on the network automatically and does a one-time
[PIN pairing](https://docs.punktfunk.unom.io/docs/pairing). Per-device install steps:
**[/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
## Build & test (from source)
For development, or as an install fallback where no package is available:
```sh
cargo build --workspace # green on Linux and macOS
cargo build --workspace # the Rust core, host, Linux client, and probe (Linux & macOS)
cargo test --workspace # unit + loopback + proptest + C ABI harness
cargo clippy --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
```
The C header regenerates from `crates/punktfunk-core/src/abi.rs` on every build (cbindgen via
`build.rs`) into `include/punktfunk_core.h`.
`build.rs`) into `include/punktfunk_core.h`. The Apple, Android, and Windows clients have their own
toolchains (Xcode/`swift build`, Gradle, and `cargo` on the MSVC target) — see each client's README
and the [docs site](https://docs.punktfunk.unom.io).
## Layout
```
crates/
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ Linux host: virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
clients/
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · Oboe)
probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
docs/ design notes & deep-dive plans
include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement)
```
## Design invariants
- **One core, linked everywhere.** Protocol/FEC/crypto/pacing live in `punktfunk-core` exactly
once, exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig`
carries its own `struct_size`).
- **No async on the hot path.** The per-frame pipeline uses native threads only;
`tokio`/`quinn` are gated behind the off-by-default `quic` feature (control plane only).
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block) for Moonlight compat;
GF(2¹⁶) (≤65535 shards/block, SIMD, O(n log n)) to push past ~1 Gbps.
- **One core, linked everywhere.** Protocol, FEC, and crypto live in `punktfunk-core` exactly once,
exposed over a stable, versioned C ABI (`punktfunk_abi_version()`, `PunktfunkConfig` carries its own
`struct_size`). Every native client links the same core.
- **No async on the hot path.** The per-frame pipeline uses native threads only; `tokio`/`quinn` are
gated behind the off-by-default `quic` feature (control plane only).
- **Native client resolution, no scaling.** Each session gets a virtual output at exactly the
client's WxH@Hz; each compositor keeps its own backend behind a shared `VirtualDisplay` trait.
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block) for Moonlight compatibility; GF(2¹⁶)
(≤65535 shards/block, SIMD, O(n log n)) for `punktfunk/1` to push past ~1 Gbps.
## License
+9
View File
@@ -0,0 +1,9 @@
# Punktfunk Android Release Secrets
# Copy this file to .env and fill in the values.
# DO NOT COMMIT THE .env FILE!
RELEASE_KEYSTORE_FILE=../punktfunk-release.jks
RELEASE_KEYSTORE_PASSWORD=
RELEASE_KEY_ALIAS=punktfunk-key
RELEASE_KEY_PASSWORD=
VERSION_CODE=1
+4
View File
@@ -9,3 +9,7 @@ captures/
# Native libraries produced by cargo-ndk — regenerated by the :kit cargoNdk* tasks.
**/src/main/jniLibs/
# Secrets
.env
*.jks
+34 -21
View File
@@ -11,7 +11,7 @@ machine, trust logic) instead of re-porting it into Kotlin.
| Side | Owns |
|------|------|
| **Rust** (`crates/punktfunk-android``libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
| **Rust** (`clients/android/native``libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
The single seam is `io.unom.punktfunk.kit.NativeBridge``Java_io_unom_punktfunk_kit_NativeBridge_*`.
@@ -19,21 +19,26 @@ The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktf
## Layout
```
crates/punktfunk-android/ Rust cdylib (workspace member)
src/lib.rs JNI_OnLoad + abiVersion/coreVersion (native-link proof)
src/session.rs session handle lifecycle (connect/close); plane pumps = TODO
clients/android/native/ Rust cdylib (workspace member) — links punktfunk-core directly
src/lib.rs JNI seam (connect/pair, input, plane getters, abi/core version)
src/session.rs session lifecycle + plane pumps
src/decode.rs AnnexB → AMediaCodec HEVC hardware decode → SurfaceView (incl. HDR10)
src/audio.rs · src/mic.rs Opus + Oboe playback / mic uplink (jitter ring)
src/feedback.rs rumble + HID output (lightbar / adaptive triggers)
src/stats.rs live video stats
clients/android/ Gradle project (this dir)
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
app/ :app — Compose application (MainActivity)
kit/ :kit — Android library: NativeBridge + the cargo-ndk build
build.gradle.kts cargoNdk{Debug,Release} → src/main/jniLibs/<abi>/*.so
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
kit/ :kit — NativeBridge · discovery (NsdManager) · Gamepad · Keymap ·
security (Keystore identity + known-host store) · cargo-ndk build
```
## Prerequisites (already set up on the dev Mac)
## Prerequisites
- Android SDK + **NDK r28 LTS** (`28.2.13676358`), `platforms;android-37.0`, `build-tools;37.0.0`
- **JDK 21** for Gradle/AGP (the machine default JDK 25 is too new for AGP 9.2)
- Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`, `build-tools;37.0.0`,
**`cmake;3.22.1`** (`sdkmanager "cmake;3.22.1"` — the `cmake` crate builds libopus with it)
- **JDK 21** for Gradle/AGP (AGP 9.2 runs on JDK 1721, *not* a newer default JDK like 25)
- Rust + `rustup target add aarch64-linux-android x86_64-linux-android` + `cargo install cargo-ndk`
Toolchain pinned: AGP 9.2.0 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM 2026.05.01 ·
@@ -44,10 +49,11 @@ compileSdk 37 · targetSdk 36 · minSdk 31 · ABIs arm64-v8a + x86_64.
**Android Studio:** open `clients/android` — it uses its bundled JBR 21 automatically. The
`cargoNdk*` task builds the `.so` as part of the normal build.
**CLI** (the machine default is JDK 25, so point Gradle at JDK 21):
**CLI** (point Gradle at a JDK 21 if your machine default is newer, e.g. JDK 25):
```sh
export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home"
# Adoptium/Temurin 21 (installed by the Android Studio setup, or `brew install temurin@21`):
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
cd clients/android
./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first
./gradlew :app:installDebug # onto a running emulator/device
@@ -55,15 +61,22 @@ cd clients/android
# Emulators (created during env setup): emulator -avd pf_phone | emulator -avd pf_tv
```
The debug APK lands in `app/build/outputs/apk/debug/`. The scaffold screen calls
`NativeBridge.abiVersion()` across JNI — a live ABI version proves the whole native stack is wired.
The debug APK lands in `app/build/outputs/apk/debug/`. Launch it, pick a host from the list, pair,
and stream.
## Status
- **Scaffold (done):** Gradle modules, cargo-ndk wiring, JNI native-link proof, phone+TV-installable
manifest. `crates/punktfunk-core` `rcgen` switched to the `ring` backend so the client `.so` is
aws-lc-free.
- **Next (M4 Android stage 1):** video decode (`AMediaCodec` async`SurfaceView`), audio
(Opus + Oboe + jitter ring), input capture → `send_input`, pairing/identity (Keystore-wrapped),
mDNS discovery, the phone/TV Compose UI. The Rust-side homes are stubbed in
`crates/punktfunk-android/src/session.rs` with port pointers to `crates/punktfunk-client-linux`.
A working native client (phone + Android TV), at parity with the Linux and Apple apps for the core
streaming experience:
- **Video** — `AMediaCodec` hardware HEVC decode`SurfaceView`, including **HDR10** (Main10 /
BT.2020 PQ), with low-latency decode tuning and a live stats HUD.
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
game-controller focus navigation for the couch (TV + phone).
- **Discovery & trust** — `NsdManager` mDNS host list, SPAKE2 PIN pairing and TOFU, with a
Keystore-wrapped client identity and a known-host store.
- **UI** — Compose host list / settings / stream screens, Material You theming.
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
`crates/punktfunk-core` uses the `ring` `rcgen` backend so the client `.so` is aws-lc-free.
+37 -3
View File
@@ -1,5 +1,7 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
id("com.android.application")
// AGP 9 built-in Kotlin: NO org.jetbrains.kotlin.android. The Compose compiler plugin is
@@ -12,17 +14,49 @@ android {
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now.
defaultConfig {
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
val envFile = project.rootProject.file(".env")
val props = Properties()
if (envFile.exists()) {
envFile.inputStream().use { props.load(it) }
}
applicationId = "io.unom.punktfunk"
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "0.0.1"
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
versionCode = vCode?.toInt() ?: 1
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
}
signingConfigs {
create("release") {
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
val envFile = project.rootProject.file(".env")
val props = Properties()
if (envFile.exists()) {
envFile.inputStream().use { props.load(it) }
}
val ksFile = props.getProperty("RELEASE_KEYSTORE_FILE") ?: System.getenv("RELEASE_KEYSTORE_FILE")
if (ksFile != null) {
storeFile = file(ksFile)
storePassword = props.getProperty("RELEASE_KEYSTORE_PASSWORD") ?: System.getenv("RELEASE_KEYSTORE_PASSWORD")
keyAlias = props.getProperty("RELEASE_KEY_ALIAS") ?: System.getenv("RELEASE_KEY_ALIAS")
keyPassword = props.getProperty("RELEASE_KEY_PASSWORD") ?: System.getenv("RELEASE_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false // scaffold; enable R8 + shrinkResources later
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
+17
View File
@@ -0,0 +1,17 @@
# Punktfunk ProGuard Rules
# Keep the Native Bridge and its methods for JNI
-keep class io.unom.punktfunk.kit.NativeBridge { *; }
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep the models that might be serialized or accessed via JNI
-keep class io.unom.punktfunk.models.** { *; }
-keep class io.unom.punktfunk.kit.discovery.** { *; }
-keep class io.unom.punktfunk.kit.security.** { *; }
# Compose rules are usually handled by the plugin, but we can add more if needed
-keepclassmembers class **.R$* {
public static <fields>;
}
@@ -0,0 +1,91 @@
package io.unom.punktfunk
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.models.Tab
@Composable
fun App() {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
var tab by remember { mutableStateOf(Tab.Connect) }
AnimatedContent(
targetState = streamHandle != 0L,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "StreamTransition"
) { isStreaming ->
if (isStreaming) {
// Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
} else {
Scaffold(
bottomBar = {
NavigationBar {
Tab.entries.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, contentDescription = t.label) },
label = { Text(t.label) },
)
}
}
},
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
AnimatedContent(
targetState = tab,
transitionSpec = {
if (targetState.ordinal > initialState.ordinal) {
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
} else {
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
}
},
label = "TabTransition"
) { targetTab ->
when (targetTab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
onBack = { tab = Tab.Connect },
)
}
}
}
}
}
}
}
@@ -0,0 +1,516 @@
package io.unom.punktfunk
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import io.unom.punktfunk.components.EmptyHostsState
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.HostStatus
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context)
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> nearbyGranted = granted }
LaunchedEffect(Unit) {
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}
DisposableEffect(nearbyGranted) {
discovery.onChange = { discovered = it }
if (nearbyGranted) discovery.start()
onDispose {
discovery.onChange = null
discovery.stop()
}
}
val identityStore = remember { IdentityStore(context) }
val knownHostStore = remember { KnownHostStore(context) }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { obtainIdentity(identityStore) } }
.onSuccess { identity = it }
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
}
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
// straight through and it appears in the saved-hosts list.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
val id = identity
if (id == null) {
status = "Identity not ready yet — try again in a moment"
return
}
connecting = true
status = "Connecting to $targetHost:$targetPort"
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
// Advertise HDR only when this device's display can present it (else the host sends a
// proper SDR stream rather than PQ the panel would mis-tone-map).
val hdrEnabled = displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
// explicit choice is passed through unchanged.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex ?: "",
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled,
)
}
connecting = false
if (handle != 0L) {
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
val fp = NativeBridge.nativeHostFingerprint(handle)
if (fp.isNotEmpty()) {
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
}
}
onConnected(handle)
} else {
status = "Connection failed — check host/port, PIN, and logcat"
discovery.start()
}
}
}
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
val known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase()
val name = dh?.name ?: targetHost
when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) ->
doConnect(targetHost, targetPort, known.name, known.fpHex)
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
known != null -> pendingTrust =
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
dh?.pairingRequired == false -> pendingTrust =
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
else -> pendingTrust =
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
}
}
val sheetState = rememberModalBottomSheetState()
var showManualSheet by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp))
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
status?.let {
// While connecting it's progress (spinner, neutral); otherwise it's a
// result/error (red). Previously every status showed in error-red, so a
// normal "Connecting…" looked like a failure.
if (connecting) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
// Result/error: a filled error container reads as a real failure banner,
// not just red text lost in the layout.
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
}
Spacer(Modifier.height(16.dp))
}
}
}
if (savedHosts.isEmpty() && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
}
}
if (savedHosts.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionLabel("Saved hosts")
}
items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh ->
HostCard(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
enabled = !connecting,
onConnect = { connect(kh.address, kh.port) },
onForget = {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
)
}
}
if (discovered.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard(
name = dh.name,
address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) },
onForget = null,
)
}
}
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's
// working rather than looking idle/empty.
if (nearbyGranted && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(96.dp))
}
}
AnimatedVisibility(
visible = true, // Static for now, could be based on scroll if needed
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
) {
ExtendedFloatingActionButton(
onClick = { showManualSheet = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
)
}
}
if (showManualSheet) {
ModalBottomSheet(
onDismissRequest = { showManualSheet = false },
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
) {
Text("Add a host", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(4.dp))
Text(
"Enter its address. You'll pair with the host's PIN on first connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(20.dp))
Button(
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = {
val h = host.trim()
val p = port.toIntOrNull() ?: 9777
scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false
connect(h, p)
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Connect ($w×$h@$hz)") }
}
}
}
pendingTrust?.let { pt ->
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Trust this host?") },
text = {
Column {
Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") }
Text(
"This host allows trust-on-first-use, but that can't tell an impostor " +
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
)
}
},
confirmButton = {
TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }) {
Text("Trust (TOFU)")
}
},
dismissButton = {
Row {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
Text("Pair with PIN…")
}
TextButton({ pendingTrust = null }) { Text("Cancel") }
}
},
)
PendingTrust.Kind.FP_CHANGED -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Host identity changed") },
text = {
Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
"with the host's PIN to continue.",
)
},
confirmButton = {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") }
},
dismissButton = {
TextButton({ pendingTrust = null }) { Text("Cancel") }
},
)
PendingTrust.Kind.PAIR -> {
var pin by remember(pt) { mutableStateOf("") }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!pairing) pendingTrust = null },
title = { Text("Pair with PIN") },
text = {
Column {
Text("Enter the 4-digit PIN shown on the host.")
OutlinedTextField(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { Text("PIN") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("This device") },
singleLine = true,
)
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(
enabled = !pairing && pin.length == 4 && identity != null,
onClick = {
val id = identity
if (id != null) {
pairing = true
err = null
scope.launch {
val fp = withContext(Dispatchers.IO) {
NativeBridge.nativePair(
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
)
}
pairing = false
if (fp.isNotEmpty()) {
// Verified host fp — save as a paired known host.
knownHostStore.save(
KnownHost(pt.host, pt.port, pt.name, fp, paired = true),
)
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp)
} else {
err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
}
},
) { Text(if (pairing) "Pairing…" else "Pair") }
},
dismissButton = {
TextButton(enabled = !pairing, onClick = { pendingTrust = null }) { Text("Cancel") }
},
)
}
}
}
}
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
@@ -1,101 +1,19 @@
package io.unom.punktfunk
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
/**
@@ -118,7 +36,7 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
PunktfunkTheme {
Surface(modifier = Modifier.fillMaxSize()) { App() }
}
}
@@ -161,667 +79,56 @@ class MainActivity : ComponentActivity() {
}
}
}
} else if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation keys the focus system understands; D-pad *keys* already move
// focus on their own, so they fall through to super untouched.
val mapped = when (event.keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
else -> 0
}
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
}
return super.dispatchKeyEvent(event)
}
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
private var lastNavDir = 0
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (streamHandle != 0L && axisMapper?.onMotion(event) == true) return true
if (streamHandle != 0L) {
if (axisMapper?.onMotion(event) == true) return true
return super.dispatchGenericMotionEvent(event)
}
// Not streaming: turn the gamepad HAT / left stick into discrete D-pad focus moves, so a
// controller navigates the menus even when its D-pad reports as axes (not key events) and
// for stick-based navigation. Edge-detected so a held direction moves focus exactly once.
if (event.isFromSource(InputDevice.SOURCE_JOYSTICK) ||
event.isFromSource(InputDevice.SOURCE_GAMEPAD)
) {
val x = event.getAxisValue(MotionEvent.AXIS_HAT_X)
.let { if (it != 0f) it else event.getAxisValue(MotionEvent.AXIS_X) }
val y = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
.let { if (it != 0f) it else event.getAxisValue(MotionEvent.AXIS_Y) }
val dir = when {
x <= -0.5f -> KeyEvent.KEYCODE_DPAD_LEFT
x >= 0.5f -> KeyEvent.KEYCODE_DPAD_RIGHT
y <= -0.5f -> KeyEvent.KEYCODE_DPAD_UP
y >= 0.5f -> KeyEvent.KEYCODE_DPAD_DOWN
else -> 0
}
if (dir != lastNavDir) {
lastNavDir = dir
if (dir != 0) {
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
}
} else if (dir != 0) {
return true // already moved for this push; swallow until the stick returns to centre
}
}
return super.dispatchGenericMotionEvent(event)
}
}
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
private enum class Tab(val label: String, val icon: ImageVector) {
Connect("Connect", Icons.Filled.Home),
Settings("Settings", Icons.Filled.Settings),
}
/**
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
*/
private data class PendingTrust(
val host: String,
val port: Int,
val name: String,
val advertisedFp: String?,
val kind: Kind,
) {
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
}
@Composable
private fun App() {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
var streamHandle by remember { mutableStateOf(0L) } // 0 = not streaming
var tab by remember { mutableStateOf(Tab.Connect) }
if (streamHandle != 0L) {
// Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
} else {
Scaffold(
bottomBar = {
NavigationBar {
Tab.entries.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, contentDescription = t.label) },
label = { Text(t.label) },
)
}
}
},
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
when (tab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
onBack = { tab = Tab.Connect },
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context)
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> nearbyGranted = granted }
LaunchedEffect(Unit) {
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}
DisposableEffect(nearbyGranted) {
discovery.onChange = { discovered = it }
if (nearbyGranted) discovery.start()
onDispose {
discovery.onChange = null
discovery.stop()
}
}
val identityStore = remember { IdentityStore(context) }
val knownHostStore = remember { KnownHostStore(context) }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { obtainIdentity(identityStore) } }
.onSuccess { identity = it }
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
}
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
// straight through and it appears in the saved-hosts list.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
val id = identity
if (id == null) {
status = "Identity not ready yet — try again in a moment"
return
}
connecting = true
status = "Connecting to $targetHost:$targetPort"
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex ?: "",
settings.bitrateKbps, settings.compositor, settings.gamepad,
)
}
connecting = false
if (handle != 0L) {
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
val fp = NativeBridge.nativeHostFingerprint(handle)
if (fp.isNotEmpty()) {
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
}
}
onConnected(handle)
} else {
status = "Connection failed — check host/port, PIN, and logcat"
discovery.start()
}
}
}
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
val known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase()
val name = dh?.name ?: targetHost
when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) ->
doConnect(targetHost, targetPort, known.name, known.fpHex)
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
known != null -> pendingTrust =
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
dh?.pairingRequired == false -> pendingTrust =
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
else -> pendingTrust =
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
}
}
val sheetState = rememberModalBottomSheetState()
var showManualSheet by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(8.dp))
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
status?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
}
if (savedHosts.isEmpty() && discovered.isEmpty()) {
EmptyHostsState()
}
if (savedHosts.isNotEmpty()) {
SectionLabel("Saved hosts")
savedHosts.forEach { kh ->
HostCard(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
enabled = !connecting,
onConnect = { connect(kh.address, kh.port) },
onForget = {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
)
}
Spacer(Modifier.height(20.dp))
}
if (discovered.isNotEmpty()) {
SectionLabel("Discovered on the network")
discovered.forEach { dh ->
HostCard(
name = dh.name,
address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) },
onForget = null,
)
}
Spacer(Modifier.height(20.dp))
}
Spacer(Modifier.height(96.dp)) // clearance so the last card scrolls clear of the FAB
}
ExtendedFloatingActionButton(
onClick = { showManualSheet = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
)
}
if (showManualSheet) {
ModalBottomSheet(
onDismissRequest = { showManualSheet = false },
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
) {
Text("Add a host", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(4.dp))
Text(
"Enter its address. You'll pair with the host's PIN on first connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(20.dp))
Button(
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = {
val h = host.trim()
val p = port.toIntOrNull() ?: 9777
scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false
connect(h, p)
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Connect ($w×$h@$hz)") }
}
}
}
pendingTrust?.let { pt ->
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Trust this host?") },
text = {
Column {
Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") }
Text(
"This host allows trust-on-first-use, but that can't tell an impostor " +
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
)
}
},
confirmButton = {
TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }) {
Text("Trust (TOFU)")
}
},
dismissButton = {
Row {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
Text("Pair with PIN…")
}
TextButton({ pendingTrust = null }) { Text("Cancel") }
}
},
)
PendingTrust.Kind.FP_CHANGED -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Host identity changed") },
text = {
Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
"with the host's PIN to continue.",
)
},
confirmButton = {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") }
},
dismissButton = {
TextButton({ pendingTrust = null }) { Text("Cancel") }
},
)
PendingTrust.Kind.PAIR -> {
var pin by remember(pt) { mutableStateOf("") }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!pairing) pendingTrust = null },
title = { Text("Pair with PIN") },
text = {
Column {
Text("Enter the 4-digit PIN shown on the host.")
OutlinedTextField(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { Text("PIN") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("This device") },
singleLine = true,
)
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(
enabled = !pairing && pin.length == 4 && identity != null,
onClick = {
val id = identity
if (id != null) {
pairing = true
err = null
scope.launch {
val fp = withContext(Dispatchers.IO) {
NativeBridge.nativePair(
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
)
}
pairing = false
if (fp.isNotEmpty()) {
// Verified host fp — save as a paired known host.
knownHostStore.save(
KnownHost(pt.host, pt.port, pt.name, fp, paired = true),
)
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp)
} else {
err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
}
},
) { Text(if (pairing) "Pairing…" else "Pair") }
},
dismissButton = {
TextButton(enabled = !pairing, onClick = { pendingTrust = null }) { Text("Cancel") }
},
)
}
}
}
}
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
private fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
/** Left-aligned section header above each block of the connect screen. */
@Composable
private fun SectionLabel(text: String) {
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
/** Trust state of a host, shown as a colored pill on its card. */
private enum class HostStatus(val label: String) {
PAIRED("Paired"),
PAIRING("PIN pairing"),
TOFU("Trust on first use"),
}
/**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Forget. Tapping the card connects.
*/
@Composable
private fun HostCard(
name: String,
address: String,
status: HostStatus,
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
) {
ElevatedCard(
onClick = onConnect,
enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HostAvatar(name)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
StatusPill(status)
}
if (onForget != null) {
var menu by remember { mutableStateOf(false) }
Box {
IconButton(enabled = enabled, onClick = { menu = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "More")
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
}
}
} else {
Spacer(Modifier.width(8.dp))
}
}
}
}
/** A circular avatar with the host's first letter (Apple-contact style). */
@Composable
private fun HostAvatar(name: String) {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
/** A small colored dot + label for the host's trust state. */
@Composable
private fun StatusPill(status: HostStatus) {
val color = when (status) {
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
}
}
/** Shown when there are no saved or discovered hosts. */
@Composable
private fun EmptyHostsState() {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No hosts yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Hosts on your network show up here automatically.\nTap “Add host” to enter one by address.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val context = LocalContext.current
val activity = context as? MainActivity
val window = activity?.window
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() }
onDispose {
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
activity?.axisMapper = null
activity?.streamHandle = 0L
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
NativeBridge.nativeClose(handle)
}
}
BackHandler { onDisconnect() }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
NativeBridge.nativeStartVideo(handle, holder.surface)
NativeBridge.nativeStartAudio(handle)
if (micWanted) NativeBridge.nativeStartMic(handle)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
}
})
}
},
)
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
// 2-finger drag → scroll. (Physical-mouse pointer capture comes in a later increment.)
Box(
Modifier.fillMaxSize().pointerInput(handle) {
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
var moved = false
var maxFingers = 1
while (true) {
val ev = awaitPointerEvent()
val fingers = ev.changes.count { it.pressed }
if (fingers == 0) break
if (fingers > maxFingers) maxFingers = fingers
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
val d = primary.positionChange()
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
moved = true
if (fingers >= 2) {
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
val sy = (-d.y / 4f).toInt()
val sx = (d.x / 4f).toInt()
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
} else {
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
}
}
ev.changes.forEach { it.consume() }
}
if (!moved && maxFingers == 1) {
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
}
}
},
)
}
}
@@ -1,6 +1,7 @@
package io.unom.punktfunk
import android.content.Context
import android.view.Display
/**
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
@@ -16,6 +17,8 @@ data class Settings(
val compositor: Int = 0,
val gamepad: Int = 0,
val micEnabled: Boolean = false,
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
val statsHudEnabled: Boolean = true,
)
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
@@ -31,6 +34,7 @@ class SettingsStore(context: Context) {
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true),
)
fun save(s: Settings) {
@@ -42,6 +46,7 @@ class SettingsStore(context: Context) {
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled)
.apply()
}
@@ -53,6 +58,7 @@ class SettingsStore(context: Context) {
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_MIC = "mic_enabled"
const val K_HUD = "stats_hud_enabled"
}
}
@@ -71,6 +77,21 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
return Triple(maxOf(w, h), minOf(w, h), hz)
}
/**
* True when this device's display can actually present HDR10, so we should advertise HDR to the
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
* rather than BT.2020 PQ the panel would mis-tone-map (the washed-out/dark failure). Mirrors the
* capability gate the Apple/Windows clients apply.
*/
fun displaySupportsHdr(context: Context): Boolean {
val display = runCatching { context.display }.getOrNull() ?: return false
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
val caps = display.hdrCapabilities ?: return false
return caps.supportedHdrTypes.any {
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
}
}
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
val native = nativeDisplayMode(context)
@@ -121,9 +142,11 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope",
)
/** index = GamepadPref wire byte. */
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
val GAMEPAD_OPTIONS = listOf(
"Automatic",
"Xbox 360",
"DualSense",
"Xbox One",
"DualShock 4",
)
@@ -5,21 +5,25 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -29,13 +33,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
/**
* Stream settings. Edits are persisted immediately via [onChange]; [onBack] returns to the connect
* screen. Resolution/refresh "Native" resolve from the device display at connect time.
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
* resolve from the device display at connect time.
*/
@Composable
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
@@ -48,13 +54,23 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
BackHandler(onBack = onBack)
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
val micLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> update(s.copy(micEnabled = granted)) }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineMedium)
val (nw, nh, nhz) = nativeDisplayMode(context)
SettingsGroup("Display") {
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
@@ -65,7 +81,7 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
SettingDropdown(
label = "Refresh rate",
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl (${nhz} Hz)" else lbl) },
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
selected = s.hz,
) { hz -> update(s.copy(hz = hz)) }
@@ -74,9 +90,11 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
options = BITRATE_OPTIONS,
selected = s.bitrateKbps,
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
}
SettingsGroup("Host") {
SettingDropdown(
label = "Compositor (virtual-display host backend)",
label = "Compositor",
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.compositor,
) { c -> update(s.copy(compositor = c)) }
@@ -86,20 +104,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.gamepad,
) { g -> update(s.copy(gamepad = g)) }
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
val micLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> update(s.copy(micEnabled = granted)) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Microphone", style = MaterialTheme.typography.bodyLarge)
Text(
"Send your mic to the host's virtual microphone",
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
SettingsGroup("Audio") {
ToggleRow(
title = "Microphone",
subtitle = "Send your mic to the host's virtual microphone",
checked = s.micEnabled,
onCheckedChange = { on ->
when {
@@ -111,11 +121,65 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
},
)
}
SettingsGroup("Overlay") {
ToggleRow(
title = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
checked = s.statsHudEnabled,
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
)
}
}
}
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
@OptIn(ExperimentalMaterial3Api::class)
/** A titled group of settings rendered inside an outlined card. */
@Composable
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp),
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}
}
/** A title + subtitle on the left, a Switch on the right. */
@Composable
private fun ToggleRow(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
* on a pick. A primary-colour border marks D-pad focus.
*/
@Composable
private fun <T> SettingDropdown(
label: String,
@@ -124,20 +188,35 @@ private fun <T> SettingDropdown(
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty()
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
onClick = { expanded = true },
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused },
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -0,0 +1,227 @@
package io.unom.punktfunk
import android.Manifest
import android.content.pm.PackageManager
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.NativeBridge
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val context = LocalContext.current
val activity = context as? MainActivity
val window = activity?.window
val controller = remember(window) {
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var showStats by remember { mutableStateOf(SettingsStore(context).load().statsHudEnabled) }
LaunchedEffect(handle) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
}
}
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) }
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
controller?.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars())
}
activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() }
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
activity?.axisMapper = null
activity?.streamHandle = 0L
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
NativeBridge.nativeClose(handle)
}
}
BackHandler { onDisconnect() }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
NativeBridge.nativeStartVideo(handle, holder.surface)
NativeBridge.nativeStartAudio(handle)
if (micWanted) NativeBridge.nativeStartMic(handle)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// Surface gone (backgrounding, or on the way out). Stop the threads that
// render to it — but only while the session is still open. Once
// DisposableEffect has closed it, the handle is freed; dereferencing it
// here is the use-after-free that crashed on back-navigation.
if (!closed.get()) {
NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
}
}
})
}
},
)
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
}
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer
// capture comes in a later increment.)
Box(
Modifier.fillMaxSize().pointerInput(handle) {
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
var moved = false
var maxFingers = 1
while (true) {
val ev = awaitPointerEvent()
val fingers = ev.changes.count { it.pressed }
if (fingers == 0) break
if (fingers > maxFingers) maxFingers = fingers
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
val d = primary.positionChange()
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
moved = true
if (fingers >= 2) {
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
val sy = (-d.y / 4f).toInt()
val sx = (d.x / 4f).toInt()
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
} else {
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
}
}
ev.changes.forEach { it.consume() }
}
if (!moved && maxFingers == 1) {
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
} else if (!moved && maxFingers >= 3) {
showStats = !showStats // quick in-stream HUD toggle
}
}
},
)
}
}
/**
* The live stats overlay — mirrors the Apple client's HUD. Reads the 10-double layout from
* [NativeBridge.nativeVideoStats]:
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`.
*/
@Composable
private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
if (s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val dropped = s[9].toLong()
Column(
modifier = modifier
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
if (latValid) {
val tag = if (skew) "" else " (same-host)"
Text(
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
if (dropped > 0) {
Text(
"dropped $dropped",
color = Color(0xFFFFB0B0),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
}
}
@@ -0,0 +1,43 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// punktfunk brand violets (from the app icon: #6C5BF3 / #A79FF8 / #D2C9FB on a #16132A indigo).
// Used as the fallback dark scheme on pre-Android-12 devices; on 12+ we defer to Material You.
private val BrandDark = darkColorScheme(
primary = Color(0xFFA79FF8),
onPrimary = Color(0xFF1B1442),
primaryContainer = Color(0xFF4C3FB3),
onPrimaryContainer = Color(0xFFE5E0FF),
secondary = Color(0xFFC8C2EC),
onSecondary = Color(0xFF2E2A4D),
tertiary = Color(0xFF8FD0E8),
onTertiary = Color(0xFF053543),
background = Color(0xFF131129),
onBackground = Color(0xFFE5E1F2),
surface = Color(0xFF1A1733),
onSurface = Color(0xFFE5E1F2),
surfaceVariant = Color(0xFF2A2647),
onSurfaceVariant = Color(0xFFC7C2DE),
)
/**
* App theme — always dark (a streaming client reads best on a dark canvas, and the immersive
* stream view assumes it), but uses **Material You** dynamic colour on Android 12+ so the UI
* harmonises with the user's wallpaper, falling back to the punktfunk brand violets below that.
*/
@Composable
fun PunktfunkTheme(content: @Composable () -> Unit) {
val scheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicDarkColorScheme(LocalContext.current)
} else {
BrandDark
}
MaterialTheme(colorScheme = scheme, content = content)
}
@@ -0,0 +1,185 @@
package io.unom.punktfunk.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.models.HostStatus
/** Left-aligned section header above each block of the connect screen. */
@Composable
fun SectionLabel(text: String) {
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
/**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Forget. Tapping the card connects.
*/
@Composable
fun HostCard(
name: String,
address: String,
status: HostStatus,
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
var focused by remember { mutableStateOf(false) }
ElevatedCard(
onClick = onConnect,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.onFocusChanged { focused = it.isFocused }
.then(
if (focused) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CardDefaults.elevatedShape)
} else {
Modifier
},
),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HostAvatar(name)
Spacer(Modifier.height(12.dp))
Text(
name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
Text(
address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
StatusPill(status)
}
if (onForget != null) {
var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) {
Icon(
Icons.Filled.MoreVert,
contentDescription = "More",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
}
}
}
}
}
}
/** A circular avatar with the host's first letter (Apple-contact style). */
@Composable
fun HostAvatar(name: String) {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
/** A small colored dot + label for the host's trust state. */
@Composable
fun StatusPill(status: HostStatus) {
val color = when (status) {
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
}
}
/** Shown when there are no saved or discovered hosts. */
@Composable
fun EmptyHostsState() {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No hosts yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Hosts on your network show up here automatically.\nTap “Add host” to enter one by address.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,35 @@
package io.unom.punktfunk.models
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.graphics.vector.ImageVector
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
enum class Tab(val label: String, val icon: ImageVector) {
Connect("Connect", Icons.Filled.Home),
Settings("Settings", Icons.Filled.Settings),
}
/**
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
*/
data class PendingTrust(
val host: String,
val port: Int,
val name: String,
val advertisedFp: String?,
val kind: Kind,
) {
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
}
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
PAIRING("PIN pairing"),
TOFU("Trust on first use"),
}
+2 -2
View File
@@ -5,7 +5,7 @@
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
// 2026.05.01 · compileSdk 37 · targetSdk 36 · minSdk 31.
plugins {
id("com.android.application") version "9.2.0" apply false
id("com.android.library") version "9.2.0" apply false
id("com.android.application") version "9.2.1" apply false
id("com.android.library") version "9.2.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Upload a signed AAB to Google Play via the Publishing API — a direct replacement for
r0adkll/upload-google-play, which swallows real API errors into "Unknown error occurred."
Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints Google's actual
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
tolerates it being raw JSON *or* base64-encoded JSON.
Usage:
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
python3 play-upload.py --package io.unom.punktfunk \
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
nothing). Use it to dry-run the credentials/AAB without touching the live track.
"""
import argparse, base64, json, os, subprocess, sys, tempfile, time
import urllib.request, urllib.parse, urllib.error
API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
class ApiError(Exception):
def __init__(self, code, method, url, body):
super().__init__(f"HTTP {code} from {method} {url}\n{body}")
self.code, self.body = code, body
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def call(method, url, token=None, data=None, content_type=None, want_json=True):
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=300) as r:
body = r.read()
except urllib.error.HTTPError as e:
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
return json.loads(body) if (want_json and body) else body
def load_sa():
raw = os.environ.get("SERVICE_ACCOUNT_JSON", "")
if not raw.strip():
sys.exit("ERROR: SERVICE_ACCOUNT_JSON env is empty")
try: # raw JSON (what r0adkll expects)
return json.loads(raw)
except json.JSONDecodeError:
try: # or base64-encoded JSON (common mistake)
sa = json.loads(base64.b64decode(raw))
print("note: SERVICE_ACCOUNT_JSON was base64-encoded; decoded it.")
return sa
except Exception:
sys.exit("ERROR: SERVICE_ACCOUNT_JSON is neither valid JSON nor base64-encoded JSON")
def access_token(sa) -> str:
now = int(time.time())
header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
claims = _b64url(json.dumps({
"iss": sa["client_email"],
"scope": "https://www.googleapis.com/auth/androidpublisher",
"aud": sa["token_uri"], "iat": now, "exp": now + 3600,
}).encode())
signing_input = f"{header}.{claims}".encode()
with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as f:
f.write(sa["private_key"])
keyfile = f.name
try:
sig = subprocess.run(["openssl", "dgst", "-sha256", "-sign", keyfile],
input=signing_input, capture_output=True, check=True).stdout
finally:
os.unlink(keyfile)
jwt = f"{header}.{claims}.{_b64url(sig)}"
tok = call("POST", sa["token_uri"],
data=urllib.parse.urlencode({
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": jwt}).encode(),
content_type="application/x-www-form-urlencoded")
return tok["access_token"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--package", required=True)
ap.add_argument("--aab", required=True)
ap.add_argument("--track", default="internal")
ap.add_argument("--status", default="completed")
ap.add_argument("--no-commit", action="store_true")
a = ap.parse_args()
if not os.path.isfile(a.aab):
sys.exit(f"ERROR: AAB not found: {a.aab}")
sa = load_sa()
tok = access_token(sa)
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
app = f"{API}/{a.package}"
try:
edit = call("POST", f"{app}/edits", token=tok)["id"]
with open(a.aab, "rb") as f:
blob = f.read()
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
print(f"uploaded versionCode={vc}")
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
data=json.dumps({"track": a.track,
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
content_type="application/json")
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
if a.no_commit:
call("POST", f"{app}/edits/{edit}:validate", token=tok)
print("validated (dry-run) OK — deleting edit, nothing published")
call("DELETE", f"{app}/edits/{edit}", token=tok, want_json=False)
return
try:
call("POST", f"{app}/edits/{edit}:commit", token=tok)
except ApiError as e:
if "changesNotSentForReview" in e.body:
print("commit needs changesNotSentForReview=true — retrying")
call("POST", f"{app}/edits/{edit}:commit?changesNotSentForReview=true", token=tok)
else:
raise
print(f"COMMITTED: versionCode={vc} live on track '{a.track}' ({a.status})")
except ApiError as e:
print(f"\nPLAY API ERROR:\n{e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+10 -5
View File
@@ -7,7 +7,7 @@ plugins {
id("com.android.library")
}
val ndkVer = "28.2.13676358" // r28 LTS — matches the SDK NDK installed for cargo-ndk
val ndkVer = "30.0.14904198" // r30-beta1 — matches the SDK NDK installed for cargo-ndk
android {
namespace = "io.unom.punktfunk.kit"
@@ -32,7 +32,7 @@ dependencies {
}
// ------------------------------------------------------------------------------------------------
// cargo-ndk: cross-compile crates/punktfunk-android into this module's jniLibs/<abi>/ so the
// cargo-ndk: cross-compile clients/android/native (punktfunk-client-android) into this module's jniLibs/<abi>/ so the
// resulting libpunktfunk_android.so is packaged into the app (and any AAR this module produces).
// NDK r28+ aligns to 16 KB pages by default — no extra linker flags. Prereqs (see clients/android
// /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`.
@@ -57,7 +57,7 @@ fun androidSdkDir(): String {
fun registerCargoNdk(taskName: String, release: Boolean) =
tasks.register<Exec>(taskName) {
group = "rust"
description = "cargo-ndk build of punktfunk-android (${if (release) "release" else "debug"})"
description = "cargo-ndk build of punktfunk-client-android (${if (release) "release" else "debug"})"
workingDir = repoRoot
val sdk = androidSdkDir()
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
@@ -78,13 +78,18 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
environment("LIBOPUS_STATIC", "1")
environment("LIBOPUS_NO_PKG", "1")
// Resolve cargo by ABSOLUTE path: Gradle's Exec resolves command[0] via the JVM's
// inherited PATH, NOT the environment("PATH", …) set above (that only reaches the spawned
// child). A GUI Android Studio launch (and any daemon it started) has no ~/.cargo/bin on
// its PATH, so a bare "cargo" fails to start. The env PATH above still lets cargo/cargo-ndk
// find their subtools.
val cmd = mutableListOf(
"cargo", "ndk",
"$cargoBin/cargo", "ndk",
"-t", "arm64-v8a", "-t", "x86_64",
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
"--platform", "31",
"-o", file("src/main/jniLibs").absolutePath,
"build", "-p", "punktfunk-android",
"build", "-p", "punktfunk-client-android",
)
if (release) cmd += "--release"
commandLine(cmd)
@@ -44,6 +44,71 @@ object Gamepad {
const val AXIS_LT = 4
const val AXIS_RT = 5
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
const val PREF_AUTO = 0
const val PREF_XBOX360 = 1
const val PREF_DUALSENSE = 2
const val PREF_XBOXONE = 3
const val PREF_DUALSHOCK4 = 4
// USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C
private const val VID_MICROSOFT = 0x045E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
private val PID_XBOXONE = setOf(
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
)
/**
* Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the
* Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type
* auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` /
* `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as
* is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the
* host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a
* physical pad we resolve it concretely, matching the other native clients.
*/
fun prefFor(dev: InputDevice?): Int {
if (dev == null) return PREF_XBOX360
val vid = dev.vendorId
val pid = dev.productId
return when {
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
else -> PREF_XBOX360
}
}
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? {
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
val s = d.sources
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
) {
return d
}
}
return null
}
/**
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
* type from the first connected controller via [prefFor] (so the host gets the right pad even
* though Android can't tell it the controller type any other way).
*/
fun resolvePref(setting: Int): Int =
if (setting == PREF_AUTO) prefFor(firstPad()) else setting
/**
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
@@ -78,9 +78,19 @@ class GamepadFeedback(private val handle: Long) {
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
fun stop() {
running = false
rumbleThread?.interrupt()
hidoutThread?.interrupt()
runCatching { vm?.cancel() } // drop any held rumble immediately
runCatching { rumbleThread?.join(500) }
runCatching { hidoutThread?.join(500) }
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
// and rendering is a quick best-effort binder call, so each thread observes running=false and
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
runCatching { rumbleThread?.join() }
runCatching { hidoutThread?.join() }
rumbleThread = null
hidoutThread = null
runCatching { lightsSession?.close() }
@@ -92,18 +102,7 @@ class GamepadFeedback(private val handle: Long) {
}
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
private fun resolvePad(): InputDevice? {
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
val s = d.sources
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
) {
return d
}
}
return null
}
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
// ---- Rumble ----
@@ -3,7 +3,7 @@ package io.unom.punktfunk.kit
/**
* The single JNI seam to `libpunktfunk_android.so` (the Rust-heavy client core).
*
* Symbols are implemented in `crates/punktfunk-android`. This object is intentionally thin —
* Symbols are implemented in `clients/android/native`. This object is intentionally thin —
* all protocol logic lives in Rust (`punktfunk-core` + the connector); Kotlin only marshals.
*/
object NativeBridge {
@@ -44,6 +44,7 @@ object NativeBridge {
bitrateKbps: Int,
compositorPref: Int,
gamepadPref: Int,
hdrEnabled: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -75,6 +76,14 @@ object NativeBridge {
/** Stop + join the decode thread without closing the session. No-op on `0`. */
external fun nativeStopVideo(handle: Long)
/**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 10 doubles:
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
* (the two flags are 1.0/0.0). Poll ~1 Hz; each call resets the measurement window.
*/
external fun nativeVideoStats(handle: Long): DoubleArray?
/**
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
* if already started. Best-effort — a failure leaves video streaming.
@@ -1,5 +1,5 @@
[package]
name = "punktfunk-android"
name = "punktfunk-client-android"
description = "punktfunk Android client — JNI bridge ('nativecore') over punktfunk-core (Rust-heavy client model)"
version.workspace = true
edition.workspace = true
@@ -16,7 +16,7 @@ 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).
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
jni = "0.21"
log = "0.4"
@@ -28,7 +28,9 @@ android_logger = "0.14"
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
ndk = { version = "0.9", features = ["media", "audio"] }
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
libc = "0.2"
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
+310
View File
@@ -0,0 +1,310 @@
//! Android video decode (android-only): pull HEVC access units from the connector and render them
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
//!
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
use ndk::data_space::DataSpace;
use ndk::media::media_codec::{
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
};
use ndk::media::media_format::MediaFormat;
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
use punktfunk_core::client::NativeClient;
use punktfunk_core::error::PunktfunkError;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
pub fn run(
client: Arc<NativeClient>,
window: NativeWindow,
shutdown: Arc<AtomicBool>,
stats: Arc<crate::stats::VideoStats>,
) {
boost_thread_priority();
let mode = client.mode();
let codec = match MediaCodec::from_decoder_type("video/hevc") {
Some(c) => c,
None => {
log::error!("decode: no HEVC decoder on this device");
return;
}
};
let mut format = MediaFormat::new();
format.set_str("mime", "video/hevc");
format.set_i32("width", mode.width as i32);
format.set_i32("height", mode.height as i32);
// Generous input buffer so a large keyframe AU is never truncated.
format.set_i32(
"max-input-size",
(mode.width * mode.height).max(2_000_000) as i32,
);
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer).
format.set_i32("low-latency", 1);
// Advisory low-latency hints (KEY_PRIORITY / KEY_OPERATING_RATE), ignored where unsupported:
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
// clocks instead of a power-saving cadence that adds dequeue latency.
format.set_i32("priority", 0); // 0 = realtime
format.set_i32("operating-rate", mode.refresh_hz as i32);
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
// MediaCodec wants it BEFORE configure(), and the host sends a 0xCE right after the handshake,
// so it's typically already queued; wait briefly otherwise. The Surface DataSpace (applied on
// OutputFormatChanged below) carries transfer/primaries regardless — this adds the luminance the
// tone-mapper needs. A non-HDR display still gets sensible SurfaceFlinger tone-mapping.
if client.color.is_hdr() {
match client.next_hdr_meta(Duration::from_millis(250)) {
Ok(meta) => {
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
}
Err(_) => {
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
}
}
}
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
log::error!("decode: configure failed: {e}");
return;
}
if let Err(e) = codec.start() {
log::error!("decode: start failed: {e}");
return;
}
log::info!(
"decode: HEVC decoder started at {}x{}",
mode.width,
mode.height
);
// Tell the display the stream's refresh so Android can pick a matching display mode and align
// vsync (no 60-in-120 judder on high-refresh panels). minSdk 31 ≥ API 30, so the underlying
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines.
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) {
log::warn!(
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}",
mode.refresh_hz
);
}
let mut fed: u64 = 0;
let mut rendered: u64 = 0;
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
// climbs.
let mut last_dropped = client.frames_dropped();
let mut last_kf_req: Option<Instant> = None;
// Capture→client-receipt latency uses the negotiated host-minus-client clock offset (0 if the
// host didn't answer the skew handshake — then the HUD flags it "same-host").
let clock_offset = client.clock_offset_ns;
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
let mut applied_ds: Option<DataSpace> = None;
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
if fed == 0 {
let p = &frame.data;
log::info!(
"decode: first AU {} bytes, head {:02x?}",
p.len(),
&p[..p.len().min(6)]
);
}
fed += 1;
// HUD stat: capture→client-receipt latency = client_now + (hostclient) capture_pts.
let lat_ns = now_realtime_ns() + clock_offset as i128 - frame.pts_ns as i128;
let lat_us =
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
stats.note(frame.data.len(), lat_us, clock_offset != 0);
feed(&codec, &frame.data, frame.pts_ns / 1000);
}
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
Err(_) => break, // session closed
}
rendered += drain(&codec, &window, &mut applied_ds);
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
// reference-missing delta frames that follow and renders them without error, so keying off
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
// decode stays wedged for several frames until the IDR lands, so requesting every frame
// would flood the control stream.
let dropped = client.frames_dropped();
if dropped > last_dropped {
last_dropped = dropped;
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
last_kf_req = Some(now);
let _ = client.request_keyframe();
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
}
}
if fed > 0 && fed % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered}");
}
}
let _ = codec.stop();
log::info!("decode: stopped (fed={fed} rendered={rendered})");
}
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
/// capture `pts_ns` after the skew offset is applied.
fn now_realtime_ns() -> i128 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(0)
}
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
fn boost_thread_priority() {
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls. PRIO_PROCESS
// with a TID targets that one task on Linux — the same idiom `Process.setThreadPriority` uses.
unsafe {
let tid = libc::gettid();
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -10) != 0 {
log::warn!(
"decode: setpriority(-10) failed (non-fatal): {}",
std::io::Error::last_os_error()
);
}
}
}
/// Copy one access unit into a codec input buffer and queue it.
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) {
match codec.dequeue_input_buffer(Duration::from_millis(10)) {
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
let n = {
let dst = buf.buffer_mut();
let n = au.len().min(dst.len());
if n < au.len() {
log::warn!(
"decode: AU {} > input buffer {}, truncated",
au.len(),
dst.len()
);
}
for (slot, &b) in dst.iter_mut().zip(&au[..n]) {
slot.write(b);
}
n
};
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
log::warn!("decode: queue_input_buffer: {e}");
}
}
Ok(DequeuedInputBufferResult::TryAgainLater) => {
// No input buffer free right now; the AU is dropped (FEC/keyframes recover).
}
Err(e) => log::warn!("decode: dequeue_input_buffer: {e}"),
}
}
/// Release any ready output buffers to the surface (render = true), latency-first. Returns the
/// number of frames presented. Also reacts to `OutputFormatChanged` to signal HDR on the Surface.
fn drain(codec: &MediaCodec, window: &NativeWindow, applied_ds: &mut Option<DataSpace>) -> u64 {
let mut n = 0;
loop {
match codec.dequeue_output_buffer(Duration::from_millis(0)) {
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
if let Err(e) = codec.release_output_buffer(buf, true) {
log::warn!("decode: release_output_buffer: {e}");
break;
}
n += 1;
}
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
// The decoder has parsed the SPS and now reports the stream's real colour signalling
// (the AMediaCodec analogue of VideoToolbox's format description on the Apple client).
// If it's HDR (BT.2020 PQ/HLG), tell the Surface so the compositor/display switch to
// HDR; SDR streams leave the default dataspace alone. The decoder itself picks a
// Main10 path from the SPS — no profile override needed. Keep looping (buffers follow).
if let Some(ds) = hdr_dataspace(codec) {
if *applied_ds != Some(ds) {
match window.set_buffers_data_space(ds) {
Ok(()) => {
*applied_ds = Some(ds);
log::info!("decode: HDR stream → Surface dataspace {ds}");
}
Err(e) => log::warn!(
"decode: set_buffers_data_space({ds}) failed (non-fatal): {e}"
),
}
}
}
}
// TryAgainLater / OutputBuffersChanged — nothing to render now.
Ok(_) => break,
Err(e) => {
log::warn!("decode: dequeue_output_buffer: {e}");
break;
}
}
}
n
}
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
let fmt = codec.output_format();
let full_range = fmt.i32("color-range") == Some(1);
match fmt.i32("color-transfer") {
Some(6) => Some(if full_range {
DataSpace::Bt2020Pq
} else {
DataSpace::Bt2020ItuPq
}),
Some(7) => Some(if full_range {
DataSpace::Bt2020Hlg
} else {
DataSpace::Bt2020ItuHlg
}),
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
}
}
/// Serialize [`HdrMeta`](punktfunk_core::quic::HdrMeta) into Android's `KEY_HDR_STATIC_INFO`
/// (`hdr-static-info`) layout: a 25-byte CTA-861.3 / `HDRStaticInfo.Type1` blob — descriptor id 0,
/// then primaries in **R, G, B** order, white point, max/min display luminance, MaxCLL, MaxFALL, all
/// **little-endian** `u16`. Two conversions vs our wire form: HdrMeta stores primaries in ST.2086
/// **G, B, R** order (reorder to R, G, B), and `max_display_mastering_luminance` is in 0.0001-cd/m²
/// units while Android wants **whole nits** (min stays 0.0001-nit). Chromaticities (1/50000) and
/// MaxCLL/MaxFALL (nits) match 1:1.
fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
let [g, b_, r] = m.display_primaries; // ST.2086 G, B, R
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
let fields: [u16; 12] = [
r[0],
r[1],
g[0],
g[1],
b_[0],
b_[1], // R, G, B primaries
m.white_point[0],
m.white_point[1], // white point
max_nits,
min_units, // max (nits) / min (0.0001-nit) display luminance
m.max_cll,
m.max_fall, // MaxCLL / MaxFALL (nits)
];
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
for (i, v) in fields.iter().enumerate() {
out[1 + i * 2..3 + i * 2].copy_from_slice(&v.to_le_bytes());
}
out
}
+120
View File
@@ -0,0 +1,120 @@
//! Host→client gamepad feedback pulls (Option B): blocking JNI shims that forward to the connector's
//! rumble (0xCA) / HID-output (0xCD) planes and return one decoded event. Kotlin owns the poll
//! threads + the Android Vibrator/Lights rendering (see `GamepadFeedback.kt`) — no JNI upcalls, no
//! `JavaVM` attach, no cached method ids. Mirrors the audio plane's one-thread-per-plane contract,
//! except the thread lives in Kotlin and we just expose the blocking pull.
//!
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
//! compile on the host build too (parity with the input shims in [`crate::session`]).
use crate::session::{jni_guard, SessionHandle};
use jni::objects::{JByteBuffer, JObject};
use jni::sys::{jint, jlong};
use jni::JNIEnv;
use punktfunk_core::quic::HidOutput;
use std::time::Duration;
/// Short blocking timeout: long enough not to busy-spin, short enough that the Kotlin poll thread
/// observes its `running=false` flag promptly on teardown.
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
const TAG_LED: u8 = 0x01;
const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jlong {
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
jni_guard(-1, || {
if handle == 0 {
return -1;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// threads (and joins them — unbounded) before nativeClose frees the handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble(PULL_TIMEOUT) {
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
})
}
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
/// Led → `[0x01][r][g][b]` (len 4)
/// PlayerLeds → `[0x02][bits]` (len 2)
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
env: JNIEnv,
_this: JObject,
handle: jlong,
buf: JByteBuffer,
) -> jint {
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
jni_guard(-1, || {
if handle == 0 {
return -1;
}
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
let h = unsafe { &*(handle as *const SessionHandle) };
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
Ok(ev) => ev,
Err(_) => return -1, // timeout or closed — Kotlin loops
};
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return -1,
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return -1,
};
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
let n = match ev {
HidOutput::Led { r, g, b, .. } => {
if cap < 4 {
return -1;
}
out[0] = TAG_LED;
out[1] = r;
out[2] = g;
out[3] = b;
4
}
HidOutput::PlayerLeds { bits, .. } => {
if cap < 2 {
return -1;
}
out[0] = TAG_PLAYER_LEDS;
out[1] = bits;
2
}
HidOutput::Trigger { which, effect, .. } => {
let n = 2 + effect.len();
if cap < n {
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
}
out[0] = TAG_TRIGGER;
out[1] = which;
out[2..n].copy_from_slice(&effect);
n
}
};
n as jint
})
}
@@ -29,6 +29,7 @@ mod feedback;
#[cfg(target_os = "android")]
mod mic;
mod session;
mod stats;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
@@ -11,19 +11,36 @@
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
//!
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! renegotiation. Port the remaining orchestration from `crates/punktfunk-client-linux`.
//! renegotiation. Port the remaining orchestration from `clients/linux`.
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong};
use jni::sys::{jboolean, jdoubleArray, jint, jlong, jsize};
use jni::JNIEnv;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use punktfunk_core::input::{InputEvent, InputKind};
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead.
///
/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 —
/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C
/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the
/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work.
/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an
/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged
/// no-op rather than kill the app.
pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| {
log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)");
default
})
}
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
pub(crate) struct SessionHandle {
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
@@ -40,6 +57,8 @@ pub(crate) struct SessionHandle {
struct VideoThread {
shutdown: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
/// Live decode stats, written by the decode thread and drained ~1 Hz by `nativeVideoStats`.
stats: Arc<crate::stats::VideoStats>,
}
impl SessionHandle {
@@ -142,6 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
bitrate_kbps: jint,
compositor_pref: jint,
gamepad_pref: jint,
hdr_enabled: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -182,6 +202,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
bitrate_kbps.max(0) as u32, // 0 = host default
// Advertise 10-bit + HDR ONLY when this device's display can actually present it (Kotlin
// checks Display.getHdrCapabilities() and passes the result): the host (e.g. Windows) then
// upgrades to a Main10 / BT.2020 PQ encode. On an SDR display we advertise 0 so the host
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
// metadata (see crate::decode).
if hdr_enabled != 0 {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
},
None, // launch: default app
pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous)
@@ -217,10 +248,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
}
})
}
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
@@ -330,13 +363,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
}
};
let shutdown = Arc::new(AtomicBool::new(false));
let stats = Arc::new(crate::stats::VideoStats::new());
let client = h.client.clone();
let sd = shutdown.clone();
let st = stats.clone();
let join = std::thread::Builder::new()
.name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd))
.spawn(move || crate::decode::run(client, window, sd, st))
.ok();
*guard = Some(VideoThread { shutdown, join });
*guard = Some(VideoThread {
shutdown,
join,
stats,
});
}
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
@@ -347,11 +386,59 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_video();
}
})
}
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
/// Returns 10 doubles
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
/// (the two flags are 1.0/0.0), or `null` when no decode thread is running. Poll ~1 Hz from the UI;
/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it
/// links on the host build too (Kotlin only ever calls it on device).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jdoubleArray {
jni_guard(std::ptr::null_mut(), || {
if handle == 0 {
return std::ptr::null_mut();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let snap = match h.video.lock().unwrap().as_ref() {
Some(vt) => vt.stats.drain(),
None => return std::ptr::null_mut(), // not streaming → no stats
};
let mode = h.client.mode();
let buf: [f64; 10] = [
snap.fps,
snap.mbps,
snap.lat_p50_ms,
snap.lat_p95_ms,
if snap.lat_valid { 1.0 } else { 0.0 },
if snap.skew_corrected { 1.0 } else { 0.0 },
mode.width as f64,
mode.height as f64,
mode.refresh_hz as f64,
h.client.frames_dropped() as f64,
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
if env.set_double_array_region(&arr, 0, &buf).is_err() {
return std::ptr::null_mut();
}
arr.into_raw()
})
}
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
@@ -387,11 +474,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_audio();
}
})
}
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
@@ -428,11 +517,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_mic();
}
})
}
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
+93
View File
@@ -0,0 +1,93 @@
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS,
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and
//! resets the window. Pure `std` so it compiles on the host build too (the decode thread is
//! android-only, but `VideoThread` holds the shared handle unconditionally).
use std::sync::Mutex;
use std::time::Instant;
/// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain
/// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS.
pub struct VideoStats {
inner: Mutex<Inner>,
}
struct Inner {
window_start: Instant,
frames: u64,
bytes: u64,
/// capture→client-receipt latency samples for this window, in microseconds.
lat_us: Vec<u64>,
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
skew_corrected: bool,
}
/// A drained, computed view of one window. `lat_valid` is false when no in-range latency sample
/// landed (then p50/p95 are 0 and the HUD hides the latency line, exactly like the Apple client).
pub struct Snapshot {
pub fps: f64,
pub mbps: f64,
pub lat_p50_ms: f64,
pub lat_p95_ms: f64,
pub lat_valid: bool,
pub skew_corrected: bool,
}
impl VideoStats {
// `new`/`note` are driven only by the android-only decode thread; `drain` (the JNI accessor) is
// ungated, so on the host build these two are unreferenced — that's expected, not dead code.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn new() -> VideoStats {
VideoStats {
inner: Mutex::new(Inner {
window_start: Instant::now(),
frames: 0,
bytes: 0,
lat_us: Vec::with_capacity(256),
skew_corrected: false,
}),
}
}
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
let mut g = self.inner.lock().unwrap();
g.frames += 1;
g.bytes += bytes as u64;
g.skew_corrected = skew_corrected;
if let Some(l) = lat_us {
g.lat_us.push(l);
}
}
/// Compute the window's rates + latency percentiles, then reset for the next window.
pub fn drain(&self) -> Snapshot {
let mut g = self.inner.lock().unwrap();
let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3);
let fps = g.frames as f64 / elapsed;
let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed;
let (p50, p95, valid) = if g.lat_us.is_empty() {
(0.0, 0.0, false)
} else {
g.lat_us.sort_unstable();
let n = g.lat_us.len();
let at = |p: f64| g.lat_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0;
(at(0.50), at(0.95), true)
};
let skew = g.skew_corrected;
g.window_start = Instant::now();
g.frames = 0;
g.bytes = 0;
g.lat_us.clear();
Snapshot {
fps,
mbps,
lat_p50_ms: p50,
lat_p95_ms: p95,
lat_valid: valid,
skew_corrected: skew,
}
}
}
@@ -425,6 +425,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
@@ -463,6 +464,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
@@ -500,6 +502,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
@@ -529,6 +532,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = F4H37KF6WC;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
+11 -6
View File
@@ -6,9 +6,14 @@ input datagrams, Opus audio, cert pinning — lives in the shared Rust core (sta
linked as `PunktfunkCore.xcframework`); this package is the Swift shell: decode
(VideoToolbox), present (SwiftUI), input capture.
## Status — first light achieved (2026-06-10)
## Status — working client (macOS, with iOS / tvOS in the shared build)
Validated live, Mac ↔ Linux box over the LAN: gamescope virtual output → NVENC HEVC →
A full streaming client: VideoToolbox HEVC decode, controllers incl. DualSense feedback, host
discovery, PIN pairing, and a network speed test. The lower-latency **stage-2 presenter**
(`VTDecompressionSession``CAMetalLayer`) is built and opt-in (Settings → Presenter); see below.
First light was achieved 2026-06-10 — validated live, Mac ↔ a Linux host over the LAN: gamescope
virtual output → NVENC HEVC →
`punktfunk/1` (GF(2¹⁶) FEC + AES-GCM over UDP, QUIC control) → VideoToolbox →
`AVSampleBufferDisplayLayer` on glass at 1280×720@60, with mouse/keyboard flowing back as
QUIC datagrams into the host's gamescope EIS injector (thousands of events injected during
@@ -20,8 +25,8 @@ full session: video AUs, **Opus audio** (`nextAudio()`), **rumble** (`nextRumble
**DualSense feedback** (`nextHidOutput()` — lightbar, player LEDs, adaptive-trigger
effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`),
and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see
`m3.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
reconnect, wrong-pin rejection). The host (`punktfunk-host m3-host`) is a persistent listener:
`punktfunk1.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
reconnect, wrong-pin rejection). The host (`punktfunk-host punktfunk1-host`) is a persistent listener:
reconnect at will during development.
What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
@@ -127,10 +132,10 @@ bash test-loopback.sh # full loopback proof: builds punktfunk
# (synthetic source — runs on macOS), streams
# byte-verified frames into the Swift client
# against the real host (Linux box, see CLAUDE.md "Running on this box") — m3-host is a
# against the real host (Linux box, see CLAUDE.md "Running on this box") — punktfunk1-host is a
# persistent listener, reconnect at will:
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
# cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60
# cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 60
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
@@ -81,24 +81,50 @@ struct AddHostSheet: View {
#if !os(tvOS)
.formStyle(.grouped)
#endif
#if os(macOS)
// macOS: UNCHANGED Cancel + Spacer + Add in an HStack, both wired to the
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
// keeps this compact and centered.
HStack {
Button("Cancel", role: .cancel) { dismiss() }
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Spacer()
Button("Add Host") { add() }
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.glassProminentButtonStyle()
.keyboardShortcut(.defaultAction)
#endif
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
}
#if os(iOS)
.controlSize(.large)
#endif
.padding(16)
#else
// iOS / iPadOS: NO Cancel the sheet is dismissed by the drag indicator,
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
// so all three are live; if anyone adds it later, restore a Cancel here or there is
// no way back out.) A single FULL-WIDTH primary action reads as the one thing to do.
// The fill must be on the LABEL, not the Button: .frame(maxWidth:.infinity) on the
// Button only widens its hit area and leaves the styled capsule hugging the text
// stretching the label is what makes the glass/bordered pill itself go edge-to-edge.
// .controlSize(.large) gives the tall, thumb-friendly height; .defaultAction lets a
// hardware keyboard / iPad Return submit.
Button { add() } label: {
Text("Add Host").frame(maxWidth: .infinity)
}
.glassProminentButtonStyle()
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(16)
#endif
}
#if os(iOS)
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
// Form + the full-width action row, instead of the half-screen .medium it used to rest
// at. A single fixed detent is enough: the system keeps the content above the keyboard
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
// centered formSheet card). If Dynamic Type grows the rows past this height the Form just
// scrolls inside the detent nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.)
.presentationDetents([.height(320)])
.presentationDragIndicator(.visible)
#endif
#if os(macOS)
.frame(width: 380)
.fixedSize(horizontal: false, vertical: true)
@@ -205,7 +205,13 @@ struct ContentView: View {
Image(systemName: "xmark")
.font(.headline.weight(.semibold))
.frame(width: 36, height: 36)
.background(.regularMaterial, in: Circle())
// Sole touch exit when the HUD is off a floating glass disc
// over the frame (26+, material fallback). interactive: the disc
// IS the tap target, so the glass reacts to press.
.glassBackground(Circle(), interactive: true)
// Match the hit region to the visible disc so every tap also
// triggers the interactive-glass press highlight.
.contentShape(Circle())
}
.buttonStyle(.plain)
.padding(12)
@@ -0,0 +1,69 @@
// GlassStyle.swift the app's single, availability-gated entry point to Apple's "Liquid
// Glass" (iOS / macOS / tvOS 26). Every Liquid Glass symbol (glassEffect, Glass, the
// .glassProminent button style ) is HARD-gated to OS 26: referencing one with our
// deployment targets (macOS 14 / iOS 17 / tvOS 17) is a COMPILE error, not a silent no-op,
// unless it sits behind `if #available`. So all glass in the app routes through the two
// helpers below, each of which falls back to the EXACT look the app shipped before
// (.regularMaterial / .borderedProminent) nothing regresses on older OSes, and the gating
// lives in exactly one file.
import SwiftUI
// MARK: - Glass background
/// Liquid Glass behind a floating / overlay surface, with the pre-26 `.regularMaterial`
/// look as the fallback. Use ONLY on the floating control / overlay layer (the streaming
/// HUD, the trust card, the touch exit chip) never on content tiles or dense forms (HIG).
///
/// `glassEffect()`'s own default shape is a Capsule, so panels MUST pass an explicit shape
/// (a RoundedRectangle / Circle) or they render as a pill. `interactive` makes the glass
/// react to press only meaningful when the glass itself is the tap target.
private struct GlassBackground<S: Shape>: ViewModifier {
let shape: S
var interactive = false
func body(content: Content) -> some View {
if #available(iOS 26, macOS 26, tvOS 26, *) {
content.glassEffect(interactive ? .regular.interactive() : .regular, in: shape)
} else {
content.background(.regularMaterial, in: shape)
}
}
}
extension View {
/// Liquid Glass (26+) or the existing `.regularMaterial` (pre-26) behind a floating
/// surface. Pass the surface's shape explicitly glass defaults to a Capsule otherwise.
func glassBackground<S: Shape>(_ shape: S, interactive: Bool = false) -> some View {
modifier(GlassBackground(shape: shape, interactive: interactive))
}
}
// MARK: - Glass primary button
/// The single prominent action on a floating / overlay or sheet surface: the Liquid-Glass
/// prominent button style on 26+, falling back to `.borderedProminent` (the app's current
/// primary style) below. Apply directly to a `Button`; role / keyboardShortcut / disabled
/// chain after it as usual. tvOS stays `.borderedProminent` always glass chrome fights the
/// focus engine, and keeping it preserves today's tvOS look exactly.
private struct GlassProminentButton: ViewModifier {
func body(content: Content) -> some View {
#if os(tvOS)
content.buttonStyle(.borderedProminent)
#else
if #available(iOS 26, macOS 26, *) {
content.buttonStyle(.glassProminent)
} else {
content.buttonStyle(.borderedProminent)
}
#endif
}
}
extension View {
/// Liquid-Glass prominent style (26+, non-tvOS) or `.borderedProminent`. Drop-in for the
/// `.buttonStyle(.borderedProminent)` on a surface's primary action.
func glassProminentButtonStyle() -> some View {
modifier(GlassProminentButton())
}
}
@@ -66,7 +66,7 @@ struct HomeView: View {
}
}
}
.navigationTitle("Punktfunkempfänger")
.navigationTitle("Punktfunk")
// Browse the LAN for advertised hosts only while the grid is up not during a
// session. The home appears/disappears as the stream swaps in and out.
.onAppear { discovery.start() }
@@ -217,7 +217,7 @@ struct HomeView: View {
Text("Add your punktfunk host with the + button.")
} actions: {
Button("Add Host") { showAddHost = true }
.buttonStyle(.borderedProminent)
.glassProminentButtonStyle()
#if os(iOS)
.controlSize(.large)
#endif
@@ -88,6 +88,8 @@ struct HostCardView: View {
#if !os(tvOS)
// tvOS: the .card button style owns platter + focus motion extra chrome
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
// tiles (it flattens hierarchy over an opaque grid) see GlassStyle.swift.
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay {
if isMostRecent {
@@ -81,7 +81,7 @@ struct LibraryView: View {
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
Button("Retry") { Task { await load() } }
.buttonStyle(.borderedProminent)
.glassProminentButtonStyle()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -150,7 +150,7 @@ struct PairSheet: View {
.padding(.trailing, 8)
}
Button("Pair & Connect") { runCeremony() }
.buttonStyle(.borderedProminent)
.glassProminentButtonStyle()
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
@@ -165,6 +165,15 @@ struct PairSheet: View {
.frame(width: 400)
.fixedSize(horizontal: false, vertical: true)
#endif
#if os(iOS)
// Bottom sheet instead of a full-screen modal (Liquid Glass background on iOS 26).
// .medium rests; .large is included so the sheet grows to keep the Pair/Cancel row
// above the keyboard when the PIN field is focused. Hide the grabber while the ceremony
// is in flight dismissal is disabled then (interactiveDismissDisabled), so a drag
// would only rubber-band; the always-enabled Cancel button is the exit.
.presentationDetents([.medium, .large])
.presentationDragIndicator(busy ? .hidden : .visible)
#endif
.interactiveDismissDisabled(busy)
.onDisappear { token.cancelled = true } // any other dismissal path
#endif
@@ -13,7 +13,7 @@ struct PunktfunkClientApp: App {
#endif
var body: some Scene {
WindowGroup("Punktfunkempfänger") {
WindowGroup("Punktfunk") {
ContentView()
}
// The Stream menu (Disconnect D, Show/Hide Statistics S) a real menu bar on
@@ -5,6 +5,12 @@ import Foundation
import PunktfunkKit
import SwiftUI
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
/// values. NSLock instead of an actor the writer is the (non-async) pump thread.
final class FrameMeter: @unchecked Sendable {
@@ -93,6 +99,7 @@ final class SessionModel: ObservableObject {
compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto,
bitrateKbps: UInt32 = 0,
hdrEnabled: Bool = true,
launchID: String? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false) {
@@ -101,17 +108,36 @@ final class SessionModel: ObservableObject {
activeHost = host
errorMessage = nil
let pin = host.pinnedSHA256
// Capability gate (main-actor screen APIs): only advertise HDR when this display can
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
// BT.2020 PQ the panel would mis-tone-map. The display self-tone-maps HDR from the mastering
// metadata we apply (Step 2) when it IS HDR.
let displayHDR: Bool = {
#if os(macOS)
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
#else
return UIScreen.main.potentialEDRHeadroom > 1.0
#endif
}()
let hdrCapable = hdrEnabled && displayHDR
Task.detached(priority: .userInitiated) {
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main
// actor. The persistent identity is presented on every connect so a paired
// host recognizes this Mac (nil = anonymous, fine for hosts without
// --require-pairing; Keychain/generation failure must not block connecting).
let identity = (try? ClientIdentityStore.shared.load())?.identity
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
// only for actual HDR content (its own gate); the VideoToolbox/Metal present path is
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
let videoCaps: UInt8 = hdrCapable
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
: 0
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
width: width, height: height, refreshHz: hz,
pinSHA256: pin, identity: identity, compositor: compositor,
gamepad: gamepad, bitrateKbps: bitrateKbps, launchID: launchID) }
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
launchID: launchID) }
await MainActor.run { [weak self] in
guard let self else { return }
// The user may have abandoned this attempt (window closed, another host
@@ -511,15 +511,18 @@ struct SettingsView: View {
private static let padTypes: [(label: String, tag: Int)] = [
("Automatic", 0),
("Xbox 360", 1),
("Xbox One", 3),
("DualSense", 2),
("DualShock 4", 4),
]
private static let controllersFooter =
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
+ "and motion), and changes apply from the next session. Two identical controllers "
+ "may swap a manual selection after reconnecting."
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
+ "from the next session. Two identical controllers may swap a manual selection "
+ "after reconnecting."
/// "Use controller" choices: Automatic, every forwardable controller, and so a stale
/// pin stays visible instead of leaving the Picker selection tag-less any pinned id
@@ -537,7 +540,7 @@ struct SettingsView: View {
private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
HStack(spacing: 10) {
Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill")
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(controller.name)
@@ -91,14 +91,14 @@ struct SpeedTestSheet: View {
bitrateKbps = rec
dismiss()
}
.buttonStyle(.borderedProminent)
.glassProminentButtonStyle()
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
if case .failed = phase {
Button("Retry") { run() }
.buttonStyle(.borderedProminent)
.glassProminentButtonStyle()
}
}
}
@@ -112,6 +112,12 @@ struct SpeedTestSheet: View {
.frame(width: 420)
.fixedSize(horizontal: false, vertical: true)
#endif
#if os(iOS)
// Bottom sheet rather than a full-screen modal; .medium stays put as the result view
// swaps in (a measured height would resize the sheet mid-probe).
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
#endif
.onAppear { run() }
.onDisappear { token.cancelled = true }
}
@@ -99,7 +99,9 @@ struct StreamHUDView: View {
#endif
}
.padding(10)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
// Floating HUD over live video the canonical Liquid-Glass overlay surface (26+);
// falls back to .regularMaterial below 26 (see GlassStyle).
.glassBackground(RoundedRectangle(cornerRadius: 10))
.padding(10)
}
}
@@ -38,6 +38,10 @@ struct TrustCardView: View {
.keyboardShortcut(.cancelAction)
#endif
Button("Trust & Connect", action: onTrust)
// Opaque prominent, NOT glass: this card is itself a glass panel
// (.glassBackground below), and glass-on-glass loses contrast a tinted
// bordered button reads cleanly over glass (HIG). The sheet primaries stay
// glass because the system manages the sheet's own glass layering.
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
@@ -58,7 +62,9 @@ struct TrustCardView: View {
}
.padding(28)
.frame(maxWidth: 440)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
// Floating trust card over the blurred stream Liquid Glass on 26+, .regularMaterial
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
.glassBackground(RoundedRectangle(cornerRadius: 18))
}
/// 64 hex chars four groups per line, two lines easy to eyeball against the log.
@@ -6,12 +6,14 @@
// full GCExtendedGamepad state on every valueChanged and diff against the previous
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
//
// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
// 0...65535 (origin top-left, +y down GC's ±1/+y-up is converted here) and motion
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
// unless the session's virtual pad is a DualSense.
// unless the session's virtual pad is a DualSense or DualShock 4 both carry a touchpad
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
// `touchpad*` surface as `GCDualSenseGamepad`).
//
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
// toggle a controller can't click local UI, so it always drives the host while the app
@@ -154,8 +156,9 @@ public final class GamepadCapture {
releaseAll()
if let ext = bound?.extendedGamepad {
ext.valueChangedHandler = nil
(ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil
(ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil
let tp = Self.touchpad(ext)
tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil
}
if let motion = bound?.motion {
motion.valueChangedHandler = nil
@@ -186,11 +189,11 @@ public final class GamepadCapture {
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
sync(ext)
if let ds = ext as? GCDualSenseGamepad {
ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in
if let tp = Self.touchpad(ext) {
tp.primary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
}
ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
}
}
@@ -257,12 +260,29 @@ public final class GamepadCapture {
if g.buttonB.isPressed { b |= GamepadWire.b }
if g.buttonX.isPressed { b |= GamepadWire.x }
if g.buttonY.isPressed { b |= GamepadWire.y }
if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true {
if Self.touchpad(g)?.button.isPressed == true {
b |= GamepadWire.touchpadClick
}
return b
}
/// The touchpad surface of a PlayStation pad present on both `GCDualSenseGamepad` and
/// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we
/// downcast either and project the identical `touchpad*` properties. `nil` for any other
/// controller (Xbox, MFi).
private static func touchpad(
_ g: GCExtendedGamepad
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)? {
if let ds = g as? GCDualSenseGamepad {
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
}
if let ds4 = g as? GCDualShockGamepad {
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
}
return nil
}
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
/// lift treated as the lift signal (a real finger landing on the precise center
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
@@ -8,8 +8,9 @@
// trigger FX DualSenseTriggerEffect.parse GCDualSenseAdaptiveTrigger.
//
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
// only on DualSense sessions the drain always polls both planes with short timeouts and
// never spins, so an Xbox session just renders rumble. GameController profile mutation
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) the
// drain always polls both planes with short timeouts and never spins, so an Xbox session
// just renders rumble. GameController profile mutation
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
// touches neither. When GamepadManager switches the active controller mid-session, the
// old pad is reset (triggers off, player index unset) and the last known feedback state
@@ -58,7 +59,13 @@ private final class RumbleRenderer: @unchecked Sendable {
private var controller: GCController?
private var low: Motor?
private var high: Motor?
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
// on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry
// until the controller changes. A transient engine failure does NOT latch it; it tears down for
// a lazy rebuild instead, so a single hiccup can't kill rumble for the whole session.
private var broken = false
/// Last logged active/silent state for a one-line transition log, not per-event spam.
private var wasActive = false
func retarget(_ c: GCController?) {
queue.async {
@@ -70,8 +77,14 @@ private final class RumbleRenderer: @unchecked Sendable {
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async {
let active = lowAmp != 0 || highAmp != 0
if active != self.wasActive {
self.wasActive = active
log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
}
guard !self.broken else { return }
if (lowAmp != 0 || highAmp != 0), self.low == nil, self.high == nil {
if active, self.low == nil, self.high == nil {
self.setup()
}
if self.high != nil {
@@ -92,7 +105,15 @@ private final class RumbleRenderer: @unchecked Sendable {
/// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger.
private func setup() {
guard let haptics = controller?.haptics else { return }
guard let haptics = controller?.haptics else {
// No haptics engine at all an Xbox controller on an OS/firmware that doesn't expose
// rumble through GameController (works on Android via the standard Vibrator path, but
// Apple's support is controller/OS-dependent), or a Siri Remote. Nothing to retry until
// the controller changes; latch off (retarget clears it) and say so once.
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
broken = true
return
}
let localities = haptics.supportedLocalities
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
low = makeMotor(haptics, .leftHandle)
@@ -100,13 +121,28 @@ private final class RumbleRenderer: @unchecked Sendable {
} else {
low = makeMotor(haptics, .default)
}
if low == nil && high == nil {
broken = true // no usable engine (e.g. Siri Remote) stay silent
if low == nil, high == nil {
// Haptics present but no engine could be built right now (server busy / a transient
// error). Do NOT latch broken the next nonzero amplitude retries setup().
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
}
}
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
// The haptic server can stop or reset the engine out from under us app backgrounding, an
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
// unhandled the players go dead and every later rumble throws, latching rumble off for the
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead.
engine.stoppedHandler = { [weak self] reason in
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
self?.queue.async { self?.teardown() }
}
engine.resetHandler = { [weak self] in
log.info("rumble: haptic engine reset — will rebuild")
self?.queue.async { self?.teardown() }
}
do {
try engine.start()
let event = CHHapticEvent(
@@ -141,14 +177,20 @@ private final class RumbleRenderer: @unchecked Sendable {
}
motor = m
} catch {
log.warning("haptic update failed — rumble disabled: \(error, privacy: .public)")
// A transient failure (the engine stopped/reset between its handler firing and now).
// Tear down so the next nonzero amplitude rebuilds do NOT latch rumble off for the
// session (that was the old "spotty" behaviour).
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
teardown()
broken = true
}
}
private func teardown() {
for m in [low, high].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in }
m.engine.resetHandler = {}
try? m.player.stop(atTime: CHHapticTimeImmediate)
m.engine.stop()
}
@@ -207,9 +249,12 @@ public final class GamepadFeedback {
public func start() {
guard !drainStarted else { return }
drainStarted = true
// No hidout traffic can exist on a non-DualSense session poll that plane
// nonblocking there and let rumble own the wait.
let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
// session a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
let hasHidout = connection.resolvedGamepad == .dualSense
|| connection.resolvedGamepad == .dualShock4
let hidTimeout: UInt32 = hasHidout ? 10 : 0
let thread = Thread { [connection, flag, drainDone, weak self] in
while !flag.isStopped {
do {
@@ -30,11 +30,22 @@ public final class GamepadManager: ObservableObject {
public let productCategory: String
/// The full extended profile exists only these are forwardable.
public let isExtended: Bool
public let isDualSense: Bool
/// The virtual-pad type a physical match resolves to under `.auto`: DualSense
/// `.dualSense`, DualShock 4 `.dualShock4`, an Xbox pad `.xboxOne`, anything
/// else `.xbox360`. (`.auto` is never stored here.)
public let kind: PunktfunkConnection.GamepadType
public let hasLight: Bool
public let hasHaptics: Bool
public let hasMotion: Bool
public let hasAdaptiveTriggers: Bool
/// Specifically a DualSense gates the DualSense-only feedback (adaptive triggers,
/// player LEDs) and the PlayStation glyph in Settings.
public var isDualSense: Bool { kind == .dualSense }
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) gates
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
public var hasTouchpadAndMotion: Bool {
kind == .dualSense || kind == .dualShock4
}
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
public let batteryLevel: Float?
public let isCharging: Bool
@@ -102,7 +113,8 @@ public final class GamepadManager: ObservableObject {
/// Connect-time resolution of the user's controller-type setting: an explicit choice
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense
/// DualSense, anything else Xbox 360); no controller at all defers to the host.
/// DualSense, DualShock 4 DualShock 4, an Xbox pad Xbox One, anything else Xbox
/// 360); no controller at all defers to the host.
public func resolveType(
setting: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType {
@@ -113,7 +125,7 @@ public final class GamepadManager: ObservableObject {
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
rebuild()
guard let active else { return .auto }
return active.isDualSense ? .dualSense : .xbox360
return active.kind
}
private func noteConnected(_ c: GCController) {
@@ -152,20 +164,38 @@ public final class GamepadManager: ObservableObject {
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
let extended = c.extendedGamepad
let ds = extended as? GCDualSenseGamepad
let kind = padKind(extended)
return DiscoveredController(
id: id,
name: c.vendorName ?? c.productCategory,
productCategory: c.productCategory,
isExtended: extended != nil,
isDualSense: ds != nil,
kind: kind,
hasLight: c.light != nil,
hasHaptics: c.haptics != nil,
hasMotion: c.motion != nil,
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration.
hasAdaptiveTriggers: ds != nil,
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
// DualShock 4 has none.
hasAdaptiveTriggers: kind == .dualSense,
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
isCharging: c.battery?.batteryState == .charging,
controller: c)
}
/// Resolve a physical controller's matching virtual-pad type from its GameController
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
private static func padKind(
_ extended: GCExtendedGamepad?
) -> PunktfunkConnection.GamepadType {
guard let extended else { return .xbox360 }
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
// here, so no `@available` guard is needed matching the unguarded
// `GCDualSenseGamepad` use elsewhere in the package.
if extended is GCDualSenseGamepad { return .dualSense }
if extended is GCDualShockGamepad { return .dualShock4 }
if extended is GCXboxGamepad { return .xboxOne }
return .xbox360
}
}
@@ -170,13 +170,18 @@ public final class PunktfunkConnection {
/// Which virtual gamepad the host creates for this session's pads (the
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
/// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) games then see
/// a real DualSense and their lightbar / adaptive-trigger writes come back on the
/// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`.
/// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux)
/// games then see a real PlayStation pad and its lightbar (and, on a DualSense,
/// adaptive-trigger / player-LED) writes come back on the HID-output plane
/// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same
/// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual
/// choice is `resolvedGamepad`.
public enum GamepadType: UInt32, CaseIterable, Sendable {
case auto = 0
case xbox360 = 1
case dualSense = 2
case xboxOne = 3
case dualShock4 = 4
/// Loose name parsing for env/dev hooks, mirroring the host's
/// `GamepadPref::from_name`.
@@ -184,7 +189,9 @@ public final class PunktfunkConnection {
switch name.lowercased() {
case "auto", "default": self = .auto
case "xbox", "xbox360", "x360", "uinput": self = .xbox360
case "dualsense", "ds", "ps5": self = .dualSense
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
default: return nil
}
}
@@ -214,6 +221,20 @@ public final class PunktfunkConnection {
/// (20 000) when 0 was requested. `0` = an older host that didn't report it.
public private(set) var resolvedBitrateKbps: UInt32 = 0
/// The colour signalling the host actually encodes with (CICP code points): `colorPrimaries`
/// (1=BT.709, 9=BT.2020), `colorTransfer` (1=BT.709, 16=PQ, 18=HLG), `colorMatrix`
/// (1=BT.709, 9=BT.2020-NCL), `colorFullRange`. BT.709 limited SDR for an older host. Configure
/// the decoder/presenter from these; mastering metadata arrives via `nextHdrMeta`.
public private(set) var colorPrimaries: UInt8 = 1
public private(set) var colorTransfer: UInt8 = 1
public private(set) var colorMatrix: UInt8 = 1
public private(set) var colorFullRange: Bool = false
/// Encoded bit depth (8 or 10).
public private(set) var bitDepth: UInt8 = 8
/// True when the negotiated stream is HDR (PQ or HLG transfer) drive an HDR present path and
/// drain `nextHdrMeta`.
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
/// Connect and start a session at the requested mode (the host creates a native virtual
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
///
@@ -242,11 +263,14 @@ public final class PunktfunkConnection {
compositor: Compositor = .auto,
gamepad: GamepadType = .auto,
bitrateKbps: UInt32 = 0,
videoCaps: UInt8 = 0,
launchID: String? = nil,
timeoutMs: UInt32 = 10_000
) throws {
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
var observed = [UInt8](repeating: 0, count: 32)
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
// the session; the host resolves it against its own library nil = the host's default.
handle = host.withCString { cs in
@@ -255,16 +279,16 @@ public final class PunktfunkConnection {
withOptionalCString(launchID) { launch in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect_ex4(
punktfunk_connect_ex5(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, launch,
gamepad.rawValue, bitrateKbps, videoCaps, launch,
p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, timeoutMs)
}
}
return punktfunk_connect_ex4(
return punktfunk_connect_ex5(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, launch,
gamepad.rawValue, bitrateKbps, videoCaps, launch,
nil, &observed, cert, key, timeoutMs)
}
}
@@ -289,6 +313,13 @@ public final class PunktfunkConnection {
var br: UInt32 = 0
_ = punktfunk_connection_bitrate(handle, &br)
resolvedBitrateKbps = br
var prim: UInt8 = 1, trc: UInt8 = 1, mtx: UInt8 = 1, fullRange: UInt8 = 0, depth: UInt8 = 8
_ = punktfunk_connection_color_info(handle, &prim, &trc, &mtx, &fullRange, &depth)
colorPrimaries = prim
colorTransfer = trc
colorMatrix = mtx
colorFullRange = fullRange != 0
bitDepth = depth
}
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
@@ -362,6 +393,21 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_request_keyframe(h)
}
/// Cumulative access units the hostclient reassembler dropped as unrecoverable (FEC couldn't
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs the
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
/// reference-missing delta frames the decoder *silently conceals* (a frozen / garbage picture,
/// no decode error and no `.failed` layer), so a decode-error trigger rarely fires. Monotonic
/// for the session; 0 after close. Cheap (an atomic load) safe to poll every pump iteration.
public func framesDropped() -> UInt64 {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return 0 }
var out: UInt64 = 0
_ = punktfunk_connection_frames_dropped(h, &out)
return out
}
/// The currently active session mode (updated by accepted `requestMode` switches).
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
abiLock.lock()
@@ -458,10 +504,11 @@ public final class PunktfunkConnection {
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
}
/// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers);
/// nil on timeout, throws `.closed` once the session ended. Drain from the (single)
/// feedback thread, alongside `nextRumble`. Nothing ever arrives unless
/// `resolvedGamepad == .dualSense` poll with a short timeout, never spin.
/// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
/// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
/// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
/// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) poll with a
/// short timeout, never spin.
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
feedbackLock.lock()
defer { feedbackLock.unlock() }
@@ -493,6 +540,83 @@ public final class PunktfunkConnection {
}
}
/// Video-capability bit: the client can decode a 10-bit (Main10) HEVC stream.
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
public struct HdrMeta: Sendable, Equatable {
public let primariesX: [UInt16] // [green, blue, red]
public let primariesY: [UInt16]
public let whitePointX: UInt16
public let whitePointY: UInt16
public let maxMasteringLuminance: UInt32 // 0.0001 cd/m²
public let minMasteringLuminance: UInt32 // 0.0001 cd/m²
public let maxCLL: UInt16
public let maxFALL: UInt16
/// The 24-byte `mastering_display_colour_volume` payload (big-endian, ST.2086 G,B,R) pass
/// directly to `kCVImageBufferMasteringDisplayColorVolumeKey` or `CAEDRMetadata`'s displayInfo.
public func masteringDisplayColorVolume() -> Data {
var d = Data()
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
func be32(_ v: UInt32) {
d.append(UInt8((v >> 24) & 0xFF)); d.append(UInt8((v >> 16) & 0xFF))
d.append(UInt8((v >> 8) & 0xFF)); d.append(UInt8(v & 0xFF))
}
for i in 0..<3 { be16(primariesX[i]); be16(primariesY[i]) } // G, B, R
be16(whitePointX); be16(whitePointY)
be32(maxMasteringLuminance); be32(minMasteringLuminance)
return d
}
/// The 4-byte `content_light_level_info` payload (big-endian: MaxCLL, MaxFALL) for
/// `kCVImageBufferContentLightLevelInfoKey` or `CAEDRMetadata`'s contentInfo.
public func contentLightLevelInfo() -> Data {
var d = Data()
func be16(_ v: UInt16) { d.append(UInt8(v >> 8)); d.append(UInt8(v & 0xFF)) }
be16(maxCLL); be16(maxFALL)
return d
}
}
/// Pull the next static HDR metadata update; nil on timeout, throws `.closed` once the session
/// ended. Drain from the feedback thread alongside `nextRumble`/`nextHidOutput`. Nothing arrives
/// unless `isHDR` poll with a short timeout, never spin.
public func nextHdrMeta(timeoutMs: UInt32 = 0) throws -> HdrMeta? {
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var out = PunktfunkHdrMeta()
let rc = punktfunk_connection_next_hdr_meta(h, &out, timeoutMs)
switch rc {
case statusOK:
// The fixed C `uint16_t[3]` arrays import as tuples copy them out.
let px = withUnsafeBytes(of: out.display_primaries_x) {
Array($0.bindMemory(to: UInt16.self))
}
let py = withUnsafeBytes(of: out.display_primaries_y) {
Array($0.bindMemory(to: UInt16.self))
}
return HdrMeta(
primariesX: px, primariesY: py,
whitePointX: out.white_point_x, whitePointY: out.white_point_y,
maxMasteringLuminance: out.max_display_mastering_luminance,
minMasteringLuminance: out.min_display_mastering_luminance,
maxCLL: out.max_cll, maxFALL: out.max_fall)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
/// silently dropped after close.
public func send(_ event: PunktfunkInputEvent) {
@@ -113,8 +113,26 @@ public final class Stage2Pipeline {
let recovery = recovery
let thread = Thread {
var format: CMVideoFormatDescription?
var lastFramesDropped = connection.framesDropped()
while token.isLive {
do {
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
// frames that follow often rendering them WITHOUT an error callback so the
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
// Polled every iteration so a total-loss drought recovers the moment packets
// resume and the reassembler counts the gap.
let dropped = connection.framesDropped()
if dropped > lastFramesDropped {
lastFramesDropped = dropped
recovery.request()
}
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
decoder.setHdrMeta(meta)
}
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
onFrame?(au)
if let f = AnnexB.formatDescription(fromIDR: au.data) {
@@ -46,27 +46,44 @@ final class StreamPump {
let thread = Thread {
var format: CMVideoFormatDescription?
var lastKeyframeRequest = Date.distantPast
var lastFramesDropped = connection.framesDropped()
// Coalesced host keyframe request: the decode stays wedged for several frames until
// the IDR lands, so requesting on every frame would flood the control stream.
func requestKeyframeThrottled() {
let now = Date()
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
connection.requestKeyframe()
lastKeyframeRequest = now
}
}
while token.isLive {
do {
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
// only recovery keyframe is one we request. The reassembler drops unrecoverable
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
// frames that follow a frozen / garbage picture, WITHOUT flipping the layer to
// .failed so the .failed check below rarely fires after a real network blip.
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
// iteration (not just per AU) so a total-loss drought still recovers the moment
// packets resume and the reassembler counts the gap.
let dropped = connection.framesDropped()
if dropped > lastFramesDropped {
lastFramesDropped = dropped
requestKeyframeThrottled()
}
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
onFrame?(au)
if let f = AnnexB.formatDescription(fromIDR: au.data) {
format = f // refreshed on every IDR (mode changes included)
}
if layer.status == .failed {
// Decode wedged: flush and re-gate on the next in-band parameter sets
// (resuming with a delta frame can't recover), AND ask the host for a
// fresh IDR. With the host's infinite GOP the next keyframe could be
// far off, so without the request the picture stays frozen the
// intermittent first-connect freeze. Throttled: the layer stays .failed
// across several polls until the IDR lands, and one request suffices.
// Decode wedged hard (the cold-first-connect case a lost/corrupt opening
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
// the layer stays .failed across several polls until the IDR lands.
layer.flush()
format = AnnexB.formatDescription(fromIDR: au.data)
let now = Date()
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
connection.requestKeyframe()
lastKeyframeRequest = now
}
requestKeyframeThrottled()
}
guard let f = format,
let sample = AnnexB.sampleBuffer(au: au, format: f),
@@ -49,6 +49,12 @@ public final class VideoDecoder: @unchecked Sendable {
/// pump can re-gate on the next IDR.
private let onDecodeError: @Sendable (OSStatus) -> Void
/// Latest source HDR mastering metadata (from `PunktfunkConnection.nextHdrMeta`), attached to
/// each decoded HDR pixel buffer so the compositor tone-maps from the real grade. Guarded by its
/// own lock written by the pump thread, read on the VT decode callback.
private let metaLock = NSLock()
private var hdrMeta: PunktfunkConnection.HdrMeta?
public init(
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
onDecodeError: @escaping @Sendable (OSStatus) -> Void = { _ in }
@@ -59,6 +65,14 @@ public final class VideoDecoder: @unchecked Sendable {
deinit { teardown() }
/// Set the source HDR mastering metadata (drained from `PunktfunkConnection.nextHdrMeta`). It's
/// attached to subsequent decoded HDR pixel buffers. Thread-safe; cheap to call on each update.
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
metaLock.lock()
hdrMeta = meta
metaLock.unlock()
}
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
@@ -185,6 +199,22 @@ public final class VideoDecoder: @unchecked Sendable {
let isHDR =
CVPixelBufferGetPixelFormatType(imageBuffer)
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
// Attach the source's mastering display + content light level (ST.2086 / CEA-861.3) so the
// compositor tone-maps from the real grade rather than inferring from the PQ colourspace
// alone. The SEI byte payloads map 1:1 to these CVImageBuffer attachment keys.
if isHDR {
metaLock.lock()
let meta = hdrMeta
metaLock.unlock()
if let meta {
CVBufferSetAttachment(
imageBuffer, kCVImageBufferMasteringDisplayColorVolumeKey,
meta.masteringDisplayColorVolume() as CFData, .shouldPropagate)
CVBufferSetAttachment(
imageBuffer, kCVImageBufferContentLightLevelInfoKey,
meta.contentLightLevelInfo() as CFData, .shouldPropagate)
}
}
onDecoded(
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
}
@@ -1,7 +1,7 @@
// Integration: the Swift wrapper against a real punktfunk/1 host over QUIC + UDP on loopback
// the Swift twin of punktfunk-host's m3.rs::c_abi_connection_roundtrip, this time through the
// statically linked xcframework. Driven by clients/apple/test-loopback.sh, which builds and
// starts `punktfunk-host m3-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
// starts `punktfunk-host punktfunk1-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
import XCTest
@testable import PunktfunkKit
@@ -11,7 +11,7 @@ final class LoopbackIntegrationTests: XCTestCase {
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
let port = UInt16(portStr)
else {
throw XCTSkip("needs a running m3-host — use clients/apple/test-loopback.sh")
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
}
let conn = try PunktfunkConnection(
@@ -139,7 +139,7 @@ final class LoopbackIntegrationTests: XCTestCase {
guard let portStr = env["PUNKTFUNK_PAIRING_PORT"], let port = UInt16(portStr),
let pin = env["PUNKTFUNK_PAIRING_PIN"]
else {
throw XCTSkip("needs an armed m3-host — use clients/apple/test-loopback.sh")
throw XCTSkip("needs an armed punktfunk1-host — use clients/apple/test-loopback.sh")
}
let identity = try generateIdentity()
@@ -5,7 +5,7 @@
//
// Run (host side, on the Linux box):
// PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
// punktfunk-host m3-host --source virtual --seconds 120
// punktfunk-host punktfunk1-host --source virtual --seconds 120
// Then here:
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
@@ -54,7 +54,7 @@ final class RemoteFirstLightTests: XCTestCase {
func testRemoteAudioBothDirections() throws {
let env = ProcessInfo.processInfo.environment
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
}
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
@@ -106,7 +106,7 @@ final class RemoteFirstLightTests: XCTestCase {
func testRemoteStreamDecodesToPixels() throws {
let env = ProcessInfo.processInfo.environment
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
}
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
// PUNKTFUNK_REMOTE_COMPOSITOR=kwin|gamescope| asks the host for a specific
+2 -2
View File
@@ -22,10 +22,10 @@ trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" 2>/dev/null || true' EXIT
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
# handshake, so the Swift test can assert the host→client feedback planes end to end.
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
target/release/punktfunk-host m3-host --port "$PORT" --source synthetic --frames 300 &
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 &
HOST_PID=$!
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
target/release/punktfunk-host m3-host --port "$PAIR_PORT" --source synthetic --frames 300 \
target/release/punktfunk-host punktfunk1-host --port "$PAIR_PORT" --source synthetic --frames 300 \
--require-pairing >"$PAIR_LOG" 2>&1 &
PAIR_PID=$!
sleep 1
+40 -18
View File
@@ -8,28 +8,44 @@ Because Decky plugins run inside Steam's CEF, the panel is built from real Steam
primitives (`@decky/ui`: `PanelSection`, `PanelSectionRow`, `ButtonItem`, `Field`,
`Spinner`) — so it looks and feels native to Gaming Mode.
> **Spike / launcher only.** This is a minimal but functional first cut: discover hosts,
> connect, disconnect. It launches the existing native GTK4 client
> (`punktfunk-client`) over the top of Gaming Mode. An in-stream overlay (latency / bitrate
> HUD, mid-session controls) and a fuller real-Steam-components UI are the next steps.
> Runtime behavior on a real Deck is **untested** — only the build is verified here.
> **Full Gaming-Mode client.** Discovery, a fullscreen page, in-UI SPAKE2 PIN pairing,
> stream settings, and a stream that actually launches fullscreen under gamescope (via a
> Steam shortcut, MoonDeck-style). The video itself is the existing GTK4 flatpak client
> (`io.unom.Punktfunk`) — the plugin discovers, pairs, configures, and *launches it the
> right way* so gamescope focuses it. The Steam-shortcut launch + pairing need a real Deck
> in Gaming Mode to fully confirm.
## What it does
1. **Refresh** — browses the LAN over mDNS for punktfunk/1 hosts (the `_punktfunk._udp`
service) via the backend `discover()`.
2. **Lists discovered hosts** — name, `ip:port`, and a lock icon for whether pairing is
required (`pair=required` in the host's TXT record).
3. **Connect** — selecting a host calls `connect(host, port)`, which launches
`punktfunk-client --connect host:port`; a toast and the status line reflect the result.
4. **Disconnect**`disconnect()` terminates the launched client.
1. **Discover** — browses the LAN over mDNS for punktfunk/1 hosts (`_punktfunk._udp`,
backend `discover()` via `avahi-browse`). Shown in both the QAM panel and a **fullscreen
page** (Decky route `/punktfunk`, via `routerHook.addRoute`).
2. **Pair** — for a `pair=required` host: a gamepad-navigable PIN keypad. The operator arms
pairing on the host (it shows a 4-digit PIN), the user enters it on the Deck, and the
backend runs the SPAKE2 ceremony headlessly via the flatpak client's `--pair` mode
(`pair()`), persisting the host as paired so the stream then connects silently.
3. **Stream** — launches fullscreen in Gaming Mode. The plugin registers ONE hidden
non-Steam shortcut pointing at `bin/punktfunkrun.sh`, passes `PF_HOST` as the shortcut's
Steam launch options, and starts it with `SteamClient.Apps.RunGame` — so gamescope
focuses + fullscreens it. (A flatpak launched directly from the backend is invisible:
gamescope only focuses the process tree Steam launched via `reaper` — gamescope#484.)
The wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>`.
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's
`client-gtk-settings.json` (`get_settings`/`set_settings`), which the launched client reads.
To leave the stream: the in-client controller chord (**L1+R1+Start+Select**) or close the
"game" from the Steam overlay — exiting the client ends the Steam game and returns to
Gaming Mode automatically.
## Architecture
| File | Role |
| --- | --- |
| `src/index.tsx` | Frontend QAM panel (`@decky/ui` + `@decky/api`). |
| `main.py` | Backend `Plugin` class: `discover` / `connect` / `disconnect` / `status` exposed over the Decky bridge. |
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad modal, settings). |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). |
| `main.py` | Backend: `discover` / `pair` / `runner_info` / `get_settings` / `set_settings` / `kill_stream`. |
| `plugin.json` | Decky plugin manifest. |
| `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). |
@@ -65,7 +81,7 @@ argv and a clear `client-not-found` error surface to the UI. The child PID is tr
installed and runnable on the Deck — via `.deb`/RPM/flatpak, or symlinked into
`~/.local/bin`.
- **avahi** (`avahi-daemon` + `avahi-browse`) for discovery — present on SteamOS/Bazzite.
- A punktfunk/1 host on the LAN (`punktfunk-host serve --native` or `m3-host`).
- A punktfunk/1 host on the LAN (`punktfunk-host serve` or `punktfunk1-host`).
## Build
@@ -126,7 +142,13 @@ shows up in the Quick Access Menu.
## Limitations / next steps
- Launcher only — no in-stream overlay yet; the client owns the full session once launched.
- **Needs on-Deck validation in Gaming Mode**: the Steam-shortcut launch (`AddShortcut` /
`RunGame` / the `gameId` encoding) and the headless pairing env are coded to MoonDeck's
proven pattern but verified only at build time here.
- mDNS discovery depends on `avahi-browse`; no manual "add host by IP" entry yet.
- Pairing (PIN ceremony) is handled by the launched client, not the panel.
- Not yet tested on real Deck hardware.
- No in-stream overlay (latency/bitrate HUD) inside the plugin — the client owns the session
once launched; leave it with the L1+R1+Start+Select chord.
- Pairing requires the operator to **arm pairing on the host** (so it shows the PIN); the
plugin can't arm it remotely (no host mgmt token on the Deck).
- Settings are written to the flatpak's sandbox config path; if the client ever moves its
config location, that path mapping must follow.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
#
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
# from the (root) Decky backend produces an unfocused, invisible window.
#
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
# every host:
# PF_HOST host[:port] to connect to (required)
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
#
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
set -u
APPID="${PF_APPID:-io.unom.Punktfunk}"
FLATPAK="${PF_FLATPAK:-flatpak}"
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
exit 2
fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
# Gaming Mode reclaims focus automatically (no manual refocus needed).
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST"
+205 -188
View File
@@ -1,160 +1,95 @@
"""
punktfunk Decky plugin — backend.
Bridges the Gaming-Mode Quick Access panel (``src/index.tsx``) to two host-side
operations:
The Gaming-Mode UI (``src/index.tsx``) calls these methods over the Decky bridge. The actual
STREAM is NOT launched here — it is launched by the frontend through Steam
(SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``),
because gamescope only focuses/fullscreens windows in the process tree Steam launched via
``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484).
The backend's jobs are the things Steam can't do:
* **discover()** — browse the LAN over mDNS for punktfunk/1 hosts advertising the
``_punktfunk._udp`` service, returning name / ip:port / pairing-requirement / cert
fingerprint for each. Implemented by shelling out to ``avahi-browse`` (SteamOS, Bazzite
and most Linux distros ship ``avahi-daemon``); see :func:`Plugin.discover`.
* **connect(host, port)** / **disconnect()** — launch / kill the native GTK4 client
(``punktfunk-client --connect host:port``). The child PID is tracked so a later
:func:`Plugin.disconnect` (or plugin unload) can terminate it.
* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts.
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
identity store the stream uses), so once paired the stream connects silently.
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
the frontend so it can create/point the Steam shortcut.
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
The TXT-record keys parsed here (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the
host advert in ``crates/punktfunk-host/src/discovery.rs``.
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host
advert in ``crates/punktfunk-host/src/discovery.rs``.
"""
import asyncio
import json
import os
import shutil
import stat
from pathlib import Path
import decky
# The native punktfunk/1 client binary (the GTK4/libadwaita Linux client, crate
# ``punktfunk-client-linux``). It is resolved at runtime from PATH and a handful of common
# install locations (see :func:`_resolve_client`). If none exist we fall back to this bare
# name and let the spawn fail loudly — install the client on the Deck (.deb / RPM / flatpak)
# or symlink it into ~/.local/bin.
#
# On SteamOS (read-only /usr, image-based) the settled install path is the flatpak
# ``io.unom.Punktfunk`` (packaging/flatpak/), launched via ``flatpak run`` — see the flatpak
# fallback in :func:`_resolve_client`.
CLIENT_BINARY = "punktfunk-client"
# Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml).
APP_ID = "io.unom.Punktfunk"
# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host).
SERVICE_TYPE = "_punktfunk._udp"
# Candidate locations probed (in order) when the binary is not on PATH. ``$HOME`` is the
# effective user's home as provided by decky.
_CLIENT_CANDIDATES = [
"/usr/bin/punktfunk-client",
"/usr/local/bin/punktfunk-client",
str(Path(decky.HOME) / ".local" / "bin" / "punktfunk-client"),
# Flatpak: launched via `flatpak run` rather than a path — handled in _resolve_client.
]
# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk;
# inside the flatpak sandbox HOME is ~/.var/app/<APP_ID>, so the real on-disk location is this.
# The backend writes settings here so the (sandboxed) client reads them.
def _client_config_dir() -> Path:
return Path(decky.DECKY_USER_HOME) / ".var" / "app" / APP_ID / ".config" / "punktfunk"
def _resolve_client() -> list[str]:
"""Return the argv prefix used to launch the native client.
def _settings_path() -> Path:
return _client_config_dir() / "client-gtk-settings.json"
Resolution order: PATH → well-known absolute paths → flatpak (if the app id is
installed) → bare binary name (so the eventual spawn fails with a clear error).
"""
on_path = shutil.which(CLIENT_BINARY)
if on_path:
return [on_path]
for candidate in _CLIENT_CANDIDATES:
if Path(candidate).exists():
return [candidate]
def _runner_path() -> str:
"""Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh)."""
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
# Flatpak fallback — the canonical install path on the Steam Deck (SteamOS /usr is
# read-only; the flatpak bundles the libadwaita + SDL3 the system lacks). The app id is
# the one the flatpak manifest publishes (packaging/flatpak/io.unom.Punktfunk.yml). If it
# is not installed, `flatpak run <id>` fails and surfaces as a spawn error the user can
# act on (install the bundle: `flatpak install --user punktfunk-client-*.flatpak`).
flatpak = shutil.which("flatpak")
if flatpak:
return [flatpak, "run", "io.unom.Punktfunk"]
decky.logger.warning(
"punktfunk-client not found on PATH or in %s; falling back to bare name",
_CLIENT_CANDIDATES,
def _flatpak() -> str | None:
return shutil.which("flatpak") or (
"/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None
)
return [CLIENT_BINARY]
def _parse_avahi_browse(stdout: str) -> list[dict]:
"""Parse ``avahi-browse -rpt`` output into a list of host dicts.
``avahi-browse -r`` resolves services; ``-p`` makes the output parseable (one record
per line, semicolon-separated, fields escaped with ``\\``); ``-t`` terminates after the
initial cache dump instead of running forever.
Resolved records start with ``=`` and have the columns::
=;iface;protocol;name;type;domain;hostname;address;port;txt
where ``txt`` is a space-separated list of ``"key=value"`` tokens, each already wrapped
in double quotes by avahi, e.g. ``"proto=punktfunk/1" "fp=ab12..." "pair=required"``.
We dedup on the host advert ``id`` TXT key (a host re-advertises across interfaces /
IPv4+IPv6, producing several ``=`` lines for one logical host); when ``id`` is absent we
fall back to ``host:port``.
"""
out: dict[str, dict] = {}
for raw in stdout.splitlines():
line = raw.strip()
if not line.startswith("="):
continue
# Split on unescaped ';'. avahi escapes literal ';' inside fields as '\;', so a
# simple replace-guard split is adequate for the fixed 10-column layout.
parts = line.replace("\\;", "\x00").split(";")
parts = [p.replace("\x00", ";") for p in parts]
if len(parts) < 9:
continue
name = parts[3]
# parts[4] is the service type, parts[5] the domain.
address = parts[7]
port_str = parts[8]
txt = parts[9] if len(parts) > 9 else ""
try:
port = int(port_str)
except ValueError:
port = 0
# Parse TXT tokens: each is a quoted "key=value".
props: dict[str, str] = {}
for token in _split_txt(txt):
if "=" in token:
k, v = token.split("=", 1)
props[k] = v
# Only surface actual punktfunk/1 adverts.
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
continue
entry = {
"name": name,
"host": address,
"port": port,
"pair": props.get("pair", "optional"),
"fp": props.get("fp", ""),
"proto": props.get("proto", ""),
}
key = props.get("id") or f"{address}:{port}"
# Prefer an IPv4 record over IPv6 for the user-facing host string when both exist.
existing = out.get(key)
if existing is None or (":" in existing["host"] and ":" not in address):
out[key] = entry
return list(out.values())
def _flatpak_env() -> dict:
"""Environment for a headless ``flatpak run`` from the backend (no display needed for
pairing). Reconstruct the user-session bits flatpak wants; the backend may not inherit
them. Harmless if some are already set."""
env = dict(os.environ)
# Decky Loader is a PyInstaller binary: it prepends its bundled libs (an older libssl) to
# LD_LIBRARY_PATH (its /tmp/_MEI* unpack dir), and that env leaks into our subprocess. The
# SYSTEM flatpak's libcurl needs OPENSSL_3.3.0 from the SYSTEM libssl, so the bundled libssl
# breaks it ("libssl.so.3: version OPENSSL_3.3.0 not found"). Restore the pre-bundle value
# PyInstaller saved as <VAR>_ORIG, or drop the var so the dynamic loader uses system libraries.
for var in ("LD_LIBRARY_PATH", "LD_PRELOAD"):
orig = env.pop(f"{var}_ORIG", None)
if orig:
env[var] = orig
else:
env.pop(var, None)
env.setdefault("HOME", decky.DECKY_USER_HOME)
uid = os.environ.get("PF_UID") or "1000"
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
env.setdefault(
"DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus"
)
# Ensure flatpak can find the user installation.
env.setdefault(
"PATH", "/usr/bin:/bin:" + env.get("PATH", "")
)
return env
def _split_txt(txt: str) -> list[str]:
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.
avahi prints each TXT item wrapped in double quotes and space-separated, e.g.::
"proto=punktfunk/1" "fp=ab12cd" "pair=required" "id=host-1"
A value can legitimately contain spaces, so we split on the quote boundaries rather
than on whitespace.
"""
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
tokens: list[str] = []
cur: list[str] = []
in_quote = False
@@ -171,23 +106,64 @@ def _split_txt(txt: str) -> list[str]:
return tokens
class Plugin:
# Tracks the launched native client so disconnect()/_unload can terminate it.
_client: asyncio.subprocess.Process | None = None
_connected_host: str | None = None
def _parse_avahi_browse(stdout: str) -> list[dict]:
"""Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``)."""
out: dict[str, dict] = {}
for raw in stdout.splitlines():
line = raw.strip()
if not line.startswith("="):
continue
parts = line.replace("\\;", "\x00").split(";")
parts = [p.replace("\x00", ";") for p in parts]
if len(parts) < 9:
continue
name = parts[3]
address = parts[7]
port_str = parts[8]
txt = parts[9] if len(parts) > 9 else ""
try:
port = int(port_str)
except ValueError:
port = 0
props: dict[str, str] = {}
for token in _split_txt(txt):
if "=" in token:
k, v = token.split("=", 1)
props[k] = v
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
continue
entry = {
"name": name,
"host": address,
"port": port,
"pair": props.get("pair", "optional"),
"fp": props.get("fp", ""),
"proto": props.get("proto", ""),
}
key = props.get("id") or f"{address}:{port}"
existing = out.get(key)
# Prefer IPv4 over IPv6 for the user-facing host string.
if existing is None or (":" in existing["host"] and ":" not in address):
out[key] = entry
return list(out.values())
class Plugin:
async def discover(self) -> list[dict]:
"""Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``."""
avahi = shutil.which("avahi-browse")
if not avahi:
decky.logger.error("avahi-browse not found; install avahi for host discovery")
return []
try:
proc = await asyncio.create_subprocess_exec(
avahi,
"-rpt",
SERVICE_TYPE,
avahi, "-rpt", SERVICE_TYPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -197,78 +173,119 @@ class Plugin:
proc.kill()
decky.logger.warning("avahi-browse timed out")
return []
except Exception: # noqa: BLE001 - surface any spawn failure as "no hosts"
except Exception: # noqa: BLE001
decky.logger.exception("avahi-browse failed")
return []
if stderr:
decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace"))
hosts = _parse_avahi_browse(stdout.decode(errors="replace"))
decky.logger.info("discovered %d punktfunk host(s)", len(hosts))
return hosts
async def connect(self, host: str, port: int) -> dict:
"""Launch the native client against ``host:port``. Returns ``{ok, host, error?}``."""
# Tear down any prior session first.
await self.disconnect()
async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
"""Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode.
argv = _resolve_client() + ["--connect", f"{host}:{port}"]
decky.logger.info("launching client: %s", " ".join(argv))
The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here.
On success the flatpak persists the host to its known-hosts as paired, so a later
stream connects silently. Returns ``{ok, fp?, error?}``.
"""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found"}
argv = [
flatpak, "run", "--arch=x86_64", APP_ID,
"--pair", str(pin).strip(),
"--connect", f"{host}:{port}",
"--name", name,
"--host-label", host,
]
decky.logger.info("pairing: %s", " ".join(argv[:6] + ["<pin>", "--connect", f"{host}:{port}"]))
try:
self._client = await asyncio.create_subprocess_exec(
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
except FileNotFoundError:
decky.logger.error("client binary not found: %s", argv[0])
return {"ok": False, "host": f"{host}:{port}", "error": "client-not-found"}
except Exception as exc: # noqa: BLE001
decky.logger.exception("failed to launch client")
return {"ok": False, "host": f"{host}:{port}", "error": str(exc)}
self._connected_host = f"{host}:{port}"
decky.logger.info("client launched (pid %s) -> %s", self._client.pid, self._connected_host)
return {"ok": True, "host": self._connected_host}
async def disconnect(self) -> dict:
"""Terminate the launched native client, if any."""
proc = self._client
self._client = None
host = self._connected_host
self._connected_host = None
if proc is None or proc.returncode is not None:
return {"ok": True, "host": None}
decky.logger.info("disconnecting client (pid %s)", proc.pid)
try:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0)
except asyncio.TimeoutError:
decky.logger.warning("client did not exit; killing (pid %s)", proc.pid)
proc.kill()
await proc.wait()
except ProcessLookupError:
pass
except Exception: # noqa: BLE001
decky.logger.exception("error terminating client")
return {"ok": True, "host": host}
return {"ok": False, "error": "pairing timed out"}
except Exception as exc: # noqa: BLE001
decky.logger.exception("pairing failed to launch")
return {"ok": False, "error": str(exc)}
async def status(self) -> dict:
"""Return the current connection status for UI refresh on panel open."""
connected = self._client is not None and self._client.returncode is None
return {"connected": connected, "host": self._connected_host if connected else None}
out = stdout.decode(errors="replace")
err = stderr.decode(errors="replace")
if proc.returncode == 0 and "paired " in out:
fp = ""
for tok in out.split():
if tok.startswith("fp="):
fp = tok[3:]
decky.logger.info("paired %s:%s", host, port)
return {"ok": True, "fp": fp}
decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip())
# Surface the client's own one-line reason (wrong PIN / not armed) to the UI.
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
return {"ok": False, "error": reason}
async def runner_info(self) -> dict:
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
shortcut. Also (re)asserts the script's exec bit — packaging can drop it."""
path = _runner_path()
try:
st = os.stat(path)
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
except OSError:
decky.logger.warning("could not chmod runner %s", path)
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
async def get_settings(self) -> dict:
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
try:
return json.loads(_settings_path().read_text())
except (OSError, json.JSONDecodeError):
# The client's own defaults (native display, host-default bitrate, auto pad).
return {
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
"gamepad": "auto", "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False,
}
async def set_settings(self, settings: dict) -> dict:
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
_settings_path().write_text(json.dumps(settings, indent=2))
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write settings")
return {"ok": False, "error": str(exc)}
async def kill_stream(self) -> dict:
"""Force-stop a wedged stream client (``flatpak kill``)."""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found"}
try:
proc = await asyncio.create_subprocess_exec(
flatpak, "kill", APP_ID,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
env=_flatpak_env(),
)
await asyncio.wait_for(proc.wait(), timeout=10.0)
except Exception: # noqa: BLE001
decky.logger.exception("flatpak kill failed")
return {"ok": False}
return {"ok": True}
# ---- Decky lifecycle ----
async def _main(self):
decky.logger.info("punktfunk plugin loaded")
decky.logger.info("punktfunk plugin loaded (runner=%s)", _runner_path())
async def _unload(self):
decky.logger.info("punktfunk plugin unloading; tearing down client")
await self.disconnect()
decky.logger.info("punktfunk plugin unloading")
async def _uninstall(self):
decky.logger.info("punktfunk plugin uninstalled")
+4 -1
View File
@@ -20,9 +20,12 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])
STAGE="$(mktemp -d)"
DEST="$STAGE/$NAME"
mkdir -p "$DEST/dist"
mkdir -p "$DEST/dist" "$DEST/bin"
cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap
cp main.py plugin.json package.json LICENSE "$DEST/"
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
chmod 0755 "$DEST/bin/punktfunkrun.sh"
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
[ -f README.md ] && cp README.md "$DEST/"
+45
View File
@@ -0,0 +1,45 @@
// Bridge to the Python backend (main.py) + shared types.
import { callable } from "@decky/api";
export interface Host {
name: string;
host: string;
port: number;
pair: string; // "required" | "optional"
fp: string;
}
export interface PairResult {
ok: boolean;
fp?: string;
error?: string;
}
export interface RunnerInfo {
runner: string; // absolute path to bin/punktfunkrun.sh
app_id: string; // flatpak app id
exists: boolean;
}
export interface StreamSettings {
width: number; // 0 = native
height: number; // 0 = native
refresh_hz: number; // 0 = native
bitrate_kbps: number; // 0 = host default
gamepad: string; // "auto" | "xbox360" | "dualsense"
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
inhibit_shortcuts: boolean;
mic_enabled: boolean;
}
export const discover = callable<[], Host[]>("discover");
export const pair = callable<
[host: string, port: number, pin: string, name: string],
PairResult
>("pair");
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings",
);
export const killStream = callable<[], { ok: boolean }>("kill_stream");
+352 -120
View File
@@ -1,131 +1,364 @@
import {
ButtonItem,
Dropdown,
Field,
Focusable,
DialogButton,
ModalRoot,
Navigation,
PanelSection,
PanelSectionRow,
SliderField,
Spinner,
ToggleField,
showModal,
staticClasses,
} from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api";
import { FC, useCallback, useEffect, useState } from "react";
import {
callable,
definePlugin,
toaster,
} from "@decky/api";
import { useEffect, useState } from "react";
import { FaTv, FaSyncAlt, FaStop, FaLock, FaLockOpen } from "react-icons/fa";
FaTv,
FaSyncAlt,
FaLock,
FaLockOpen,
FaPlay,
FaArrowLeft,
} from "react-icons/fa";
import {
discover,
getSettings,
pair,
setSettings,
Host,
StreamSettings,
} from "./backend";
import { launchStream } from "./steam";
// ---- Backend bridge (see main.py) ----
const ROUTE = "/punktfunk";
interface Host {
name: string;
host: string;
port: number;
pair: string; // "required" | "optional"
fp: string;
}
interface ConnectResult {
ok: boolean;
host: string | null;
error?: string;
}
interface Status {
connected: boolean;
host: string | null;
}
const discover = callable<[], Host[]>("discover");
const connect = callable<[host: string, port: number], ConnectResult>("connect");
const disconnect = callable<[], { ok: boolean; host: string | null }>("disconnect");
const getStatus = callable<[], Status>("status");
function Content() {
// ----------------------------------------------------------------------------------------
// Discovery hook — shared by the QAM panel and the full page.
// ----------------------------------------------------------------------------------------
function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const [busyHost, setBusyHost] = useState<string | null>(null);
const [connectedHost, setConnectedHost] = useState<string | null>(null);
const refresh = async () => {
const refresh = useCallback(async () => {
setScanning(true);
try {
const found = await discover();
setHosts(found);
toaster.toast({
title: "punktfunk",
body:
found.length === 0
? "No hosts found on the LAN"
: `Found ${found.length} host${found.length === 1 ? "" : "s"}`,
});
setHosts(await discover());
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
};
const onConnect = async (h: Host) => {
const target = `${h.host}:${h.port}`;
setBusyHost(target);
try {
const res = await connect(h.host, h.port);
if (res.ok) {
setConnectedHost(res.host);
toaster.toast({ title: "punktfunk", body: `Connecting to ${h.name}` });
} else {
toaster.toast({
title: "punktfunk",
body:
res.error === "client-not-found"
? "punktfunk-client is not installed"
: `Connect failed: ${res.error ?? "unknown"}`,
});
}
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Connect failed: ${e}` });
} finally {
setBusyHost(null);
}
};
const onDisconnect = async () => {
try {
await disconnect();
setConnectedHost(null);
toaster.toast({ title: "punktfunk", body: "Disconnected" });
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Disconnect failed: ${e}` });
}
};
// On panel open: sync the current connection status and do an initial scan.
useEffect(() => {
getStatus()
.then((s) => setConnectedHost(s.connected ? s.host : null))
.catch(() => {});
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
async function startStream(h: Host) {
try {
await launchStream(h.host, h.port);
Navigation.CloseSideMenus();
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
// The host displays the PIN after the operator arms pairing; the user enters it here.
// ----------------------------------------------------------------------------------------
const PairModal: FC<{
host: Host;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
const back = () => setPin((p) => p.slice(0, -1));
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setPin("");
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton
disabled={busy || pin.length !== 4}
onClick={submit}
>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
// ----------------------------------------------------------------------------------------
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
];
const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "dualsense"];
const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<PanelSection title="Status">
<PanelSectionRow>
<Field label="State" focusable={false}>
{connectedHost ? `Connected — ${connectedHost}` : "Idle"}
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
</PanelSectionRow>
{connectedHost && (
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field label="Gamepad type" childrenContainerWidth="max">
<Dropdown
rgOptions={GAMEPADS.map((g) => ({
data: g,
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
}))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
<ToggleField
label="Stream microphone"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
// ----------------------------------------------------------------------------------------
// One host row on the full page.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host }> = ({ host }) => {
const pairRequired = host.pair === "required";
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{pairRequired ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em" }}>
{pairRequired && (
<DialogButton
style={{ minWidth: "5em" }}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => {}} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
// ----------------------------------------------------------------------------------------
// The fullscreen page (registered as the /punktfunk route).
// ----------------------------------------------------------------------------------------
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
overflowY: "auto",
padding: "0 2.5em 2.5em",
}}
>
<Focusable style={{ display: "flex", alignItems: "center", gap: "1em", marginBottom: "1em" }}>
<DialogButton
style={{ width: "3em", minWidth: "3em" }}
onClick={() => Navigation.NavigateBack()}
>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses.Title} style={{ flex: 1 }}>
punktfunk
</div>
<DialogButton style={{ width: "10em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Focusable>
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "0.5em 0" }}>Hosts</div>
{hosts.length === 0 && !scanning && (
<Field focusable={false}>No hosts discovered on the LAN.</Field>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
))}
<div style={{ fontSize: "1.1em", fontWeight: "bold", margin: "1.5em 0 0.5em" }}>
Stream settings
</div>
<SettingsSection />
</div>
);
};
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
// ----------------------------------------------------------------------------------------
const QamPanel: FC = () => {
const { hosts, scanning, refresh } = useHosts();
return (
<>
<PanelSection title="punktfunk">
<PanelSectionRow>
<ButtonItem layout="below" onClick={onDisconnect}>
<FaStop style={{ marginRight: "0.5em" }} />
Disconnect
<ButtonItem
layout="below"
onClick={() => {
Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus();
}}
>
<FaTv style={{ marginRight: "0.5em" }} />
Open punktfunk
</ButtonItem>
</PanelSectionRow>
)}
</PanelSection>
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
{scanning ? (
@@ -133,39 +366,37 @@ function Content() {
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
{scanning ? "Scanning…" : "Refresh hosts"}
</ButtonItem>
</PanelSectionRow>
</PanelSection>
<PanelSection title="Hosts">
{hosts.length === 0 && !scanning && (
<PanelSectionRow>
<Field focusable={false}>No hosts discovered yet.</Field>
<Field focusable={false}>No hosts found.</Field>
</PanelSectionRow>
)}
{hosts.map((h) => {
const target = `${h.host}:${h.port}`;
const isBusy = busyHost === target;
const pairRequired = h.pair === "required";
return (
<PanelSectionRow key={h.fp || target}>
<PanelSectionRow key={h.fp || `${h.host}:${h.port}`}>
<ButtonItem
layout="below"
disabled={isBusy}
onClick={() => onConnect(h)}
onClick={() =>
pairRequired
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
label={
<span>
{pairRequired ? (
<FaLock style={{ marginRight: "0.4em" }} />
) : (
<FaLockOpen style={{ marginRight: "0.4em" }} />
)}
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{pairRequired ? <FaLock /> : <FaLockOpen />}
{h.name}
</span>
}
description={`${target}${pairRequired ? " · pairing required" : ""}`}
description={`${h.host}:${h.port}`}
>
{isBusy ? "Connecting…" : "Connect"}
{pairRequired ? "Pair & Stream" : "Stream"}
</ButtonItem>
</PanelSectionRow>
);
@@ -173,16 +404,17 @@ function Content() {
</PanelSection>
</>
);
}
};
export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true });
return {
name: "punktfunk",
titleView: <div>punktfunk</div>,
content: <Content />,
titleView: <div className={staticClasses.Title}>punktfunk</div>,
content: <QamPanel />,
icon: <FaTv />,
onDismount() {
// The backend tears the client down on _unload; nothing frontend-side to clean up.
routerHook.removeRoute(ROUTE);
},
};
});
+114
View File
@@ -0,0 +1,114 @@
// Launch the stream as a Steam game so gamescope focuses + fullscreens it.
//
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
import { runnerInfo } from "./backend";
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
// decky-frontend-lib SteamClient.Apps typings.
declare const SteamClient: {
Apps: {
AddShortcut(
name: string,
exePath: string,
startDir: string,
launchOptions: string,
): Promise<number>;
SetShortcutName(appId: number, name: string): void;
SetShortcutExe(appId: number, exe: string): void;
SetShortcutStartDir(appId: number, dir: string): void;
SetAppLaunchOptions(appId: number, options: string): void;
SetAppHidden(appId: number, hidden: boolean): void;
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
TerminateApp(gameId: string, _b: boolean): void;
};
};
const SHORTCUT_NAME = "punktfunk";
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
function gameIdFromAppId(appId: number): string {
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
}
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the
// library (the appId is stable for the life of the shortcut).
const STORAGE_KEY = "punktfunk:shortcutAppId";
function rememberAppId(appId: number) {
try {
localStorage.setItem(STORAGE_KEY, String(appId));
} catch {
/* ignore */
}
}
function recallAppId(): number | null {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v ? Number(v) : null;
} catch {
return null;
}
}
/**
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and
* return its appId. Reuses the remembered one when its exe still matches the current runner
* path (the plugin dir can change across reinstalls).
*/
async function ensureShortcut(): Promise<number> {
const info = await runnerInfo();
if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`);
}
const remembered = recallAppId();
if (remembered != null) {
// Re-point the existing shortcut at the current runner path (cheap + idempotent).
SteamClient.Apps.SetShortcutExe(remembered, info.runner);
SteamClient.Apps.SetShortcutStartDir(
remembered,
info.runner.replace(/\/[^/]*$/, ""),
);
return remembered;
}
const appId = await SteamClient.Apps.AddShortcut(
SHORTCUT_NAME,
info.runner,
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
"",
);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
// Hide it from the library — it's an implementation detail, launched programmatically.
SteamClient.Apps.SetAppHidden(appId, true);
rememberAppId(appId);
return appId;
}
/**
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
*/
export async function launchStream(host: string, port: number): Promise<void> {
const appId = await ensureShortcut();
const target = port && port !== 9777 ? `${host}:${port}` : host;
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
}
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
export function stopStream(): void {
const appId = recallAppId();
if (appId != null) {
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
}
}
@@ -15,7 +15,7 @@ path = "src/main.rs"
# Everything is Linux-gated so `cargo build --workspace` stays green on macOS (the Mac
# client lives in clients/apple); on other platforms this builds as a stub binary.
[target.'cfg(target_os = "linux")'.dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
# PreferencesDialog need libadwaita ≥ 1.5.
@@ -36,6 +36,11 @@ pub fn run() -> glib::ExitCode {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
if let Some(pin) = arg_value("--pair") {
return headless_pair(&pin);
}
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(build_ui);
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
@@ -43,6 +48,66 @@ pub fn run() -> glib::ExitCode {
app.run_with_args(&[] as &[&str])
}
/// The value following `flag` in argv, if present (`--flag value`).
fn arg_value(flag: &str) -> Option<String> {
std::env::args()
.skip_while(|a| a != flag)
.nth(1)
.filter(|v| !v.starts_with("--"))
}
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
/// store the streaming path uses (same binary), so pairing here makes the stream work.
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
fn headless_pair(pin: &str) -> glib::ExitCode {
let Some(target) = arg_value("--connect") else {
eprintln!("--pair requires --connect host[:port]");
return glib::ExitCode::FAILURE;
};
let (addr, port) = match target.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (target.clone(), 9777),
};
// The label the HOST stores this client under (its paired-devices list).
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
let identity = match crate::trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
return glib::ExitCode::FAILURE;
}
};
match NativeClient::pair(
&addr,
port,
(&identity.0, &identity.1),
pin.trim(),
&name,
std::time::Duration::from_secs(90),
) {
Ok(fp) => {
let fp_hex = crate::trust::hex(&fp);
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: arg_value("--host-label").unwrap_or_else(|| addr.clone()),
addr: addr.clone(),
port,
fp_hex: fp_hex.clone(),
paired: true,
});
let _ = known.save();
println!("paired {addr}:{port} fp={fp_hex}");
glib::ExitCode::SUCCESS
}
Err(e) => {
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
glib::ExitCode::FAILURE
}
}
}
/// `--connect host[:port]` — skip the hosts page and start a session immediately
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
/// already pinned at this address connects silently on its stored pin; an unknown host is
@@ -308,7 +373,8 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
},
CompositorPref::Auto,
GamepadPref::Auto,
0,
0, // bitrate_kbps (host default)
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
None, // launch: speed-test probe connect, no game
pin,
Some(identity),
@@ -468,6 +534,7 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
&app.window,
connector,
frames.take().expect("Connected delivered once"),
app.gamepad.escape_events(),
handle.stop.clone(),
inhibit,
&title,
@@ -27,11 +27,51 @@ const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = 10_000.0;
const G: f32 = 9.80665;
/// The controller "escape" chord (Moonlight convention): L1 + R1 + Start + Select held
/// together. Intercepted by the client to leave fullscreen + release input capture — the
/// Deck has no F11 key and fullscreen hides the window chrome, so with a controller this
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
/// is leaving anyway); we only also raise the escape signal.
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
#[derive(Clone, Debug)]
pub struct PadInfo {
pub id: u32,
pub name: String,
pub is_dualsense: bool,
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
pub pref: GamepadPref,
}
impl PadInfo {
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
fn is_dualsense(&self) -> bool {
self.pref == GamepadPref::DualSense
}
/// A short controller-kind label for the Settings list (`""` for a plain Xbox/standard pad).
pub fn kind_label(&self) -> &'static str {
match self.pref {
GamepadPref::DualSense => "DualSense",
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
_ => "",
}
}
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T;
match t {
T::PS5 => GamepadPref::DualSense,
T::PS4 => GamepadPref::DualShock4,
T::XboxOne => GamepadPref::XboxOne,
_ => GamepadPref::Xbox360,
}
}
enum Ctl {
@@ -46,6 +86,9 @@ pub struct GamepadService {
active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
ctl: Sender<Ctl>,
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
/// fullscreen + release capture.
escape_rx: async_channel::Receiver<()>,
}
impl GamepadService {
@@ -54,11 +97,12 @@ impl GamepadService {
let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (escape_tx, escape_rx) = async_channel::unbounded();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into())
.spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
@@ -70,9 +114,16 @@ impl GamepadService {
active,
pinned,
ctl,
escape_rx,
}
}
/// A receiver that yields one `()` each time the controller escape chord is pressed.
/// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it.
pub fn escape_events(&self) -> async_channel::Receiver<()> {
self.escape_rx.clone()
}
pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone()
}
@@ -101,8 +152,7 @@ impl GamepadService {
/// (Swift parity); no pad connected leaves the host's own default.
pub fn auto_pref(&self) -> GamepadPref {
match self.active() {
Some(p) if p.is_dualsense => GamepadPref::DualSense,
Some(_) => GamepadPref::Xbox360,
Some(p) => p.pref,
None => GamepadPref::Auto,
}
}
@@ -210,6 +260,10 @@ struct Worker {
last_axis: [i32; 6],
held_buttons: Vec<u32>,
last_accel: [i16; 3],
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
/// The escape chord is fully held — latched so it fires once, not every poll.
chord_armed: bool,
}
impl Worker {
@@ -224,10 +278,9 @@ impl Worker {
Some(PadInfo {
id,
name: pad.name().unwrap_or_else(|| "Controller".into()),
is_dualsense: matches!(
pref: pref_for_type(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
sdl3::gamepad::GamepadType::PS5
),
})
}
@@ -250,6 +303,26 @@ impl Worker {
}
}
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
/// fires once per press). Called after each button-down updates `held_buttons`.
fn maybe_fire_escape(&mut self) {
if self.chord_armed {
return;
}
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.chord_armed = true;
let _ = self.escape_tx.try_send(());
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
}
}
/// Re-arm once the chord is broken (any of its buttons released).
fn rearm_escape(&mut self) {
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.chord_armed = false;
}
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return };
@@ -270,6 +343,7 @@ fn run(
active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>,
escape_tx: &async_channel::Sender<()>,
) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
// own thread.
@@ -288,6 +362,8 @@ fn run(
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
last_accel: [0; 3],
escape_tx: escape_tx.clone(),
chord_armed: false,
};
let publish = |w: &Worker| {
@@ -372,6 +448,7 @@ fn run(
bit,
1,
);
w.maybe_fire_escape();
}
}
Event::ControllerButtonUp { which, button, .. }
@@ -385,6 +462,7 @@ fn run(
bit,
0,
);
w.rearm_escape();
}
}
Event::ControllerAxisMotion {
@@ -488,13 +566,23 @@ fn run(
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 {
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
let _ = p.set_rumble(low, high, 5_000);
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
// client-render.
if let Err(e) = p.set_rumble(low, high, 5_000) {
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
} else {
tracing::debug!(low, high, "rumble: rendered");
}
} else {
tracing::debug!(low, high, "rumble: received but no active pad to render");
}
}
}
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = w.active_id() else { continue };
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
let Some(pad) = w.opened.get_mut(&id) else {
continue;
};
@@ -96,6 +96,7 @@ fn pump(
params.compositor,
params.gamepad,
params.bitrate_kbps,
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
None, // launch: the Linux client has no library picker yet
params.pin,
Some(params.identity),
@@ -1,6 +1,6 @@
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
//!
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-client-rs`
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-probe`
//! so a box pairs once whichever client it uses.
use anyhow::{anyhow, Context, Result};
@@ -16,7 +16,7 @@ const RESOLUTIONS: &[(u32, u32)] = &[
];
/// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
pub fn show(
@@ -85,10 +85,11 @@ pub fn show(
let pads = gamepads.pads();
let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| {
if p.is_dualsense {
format!("{} · DualSense", p.name)
} else {
let kind = p.kind_label();
if kind.is_empty() {
p.name.clone()
} else {
format!("{} · {kind}", p.name)
}
}));
let forward_row = adw::ComboRow::builder()
@@ -126,6 +127,8 @@ pub fn show(
"Automatic",
"Xbox 360",
"DualSense",
"Xbox One",
"DualShock 4",
]))
.build();
let inhibit_row = adw::SwitchRow::builder()
@@ -129,6 +129,7 @@ pub fn new(
window: &adw::ApplicationWindow,
connector: Arc<NativeClient>,
frames: async_channel::Receiver<DecodedFrame>,
escape_rx: async_channel::Receiver<()>,
stop: Arc<AtomicBool>,
inhibit_shortcuts: bool,
title: &str,
@@ -159,10 +160,21 @@ pub fn new(
hint.set_margin_bottom(24);
hint.set_visible(false);
// Flashed when entering fullscreen — the only exit affordances once the header bar is
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
// only way out on a Steam Deck).
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
fs_hint.add_css_class("osd");
fs_hint.set_halign(gtk::Align::Center);
fs_hint.set_valign(gtk::Align::Start);
fs_hint.set_margin_top(12);
fs_hint.set_visible(false);
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&offload));
overlay.add_overlay(&stats_label);
overlay.add_overlay(&hint);
overlay.add_overlay(&fs_hint);
overlay.set_focusable(true);
let capture = Rc::new(Capture {
@@ -198,8 +210,17 @@ pub fn new(
// the page dies — the window outlives every session.)
let fs_handler = {
let toolbar = toolbar.clone();
let fs_hint = fs_hint.clone();
window.connect_fullscreened_notify(move |w| {
toolbar.set_reveal_top_bars(!w.is_fullscreen());
let fs = w.is_fullscreen();
toolbar.set_reveal_top_bars(!fs);
if fs {
fs_hint.set_visible(true);
let fs_hint = fs_hint.clone();
glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false));
} else {
fs_hint.set_visible(false);
}
})
};
@@ -404,18 +425,39 @@ pub fn new(
let cap = capture.clone();
overlay.connect_unmap(move |_| cap.release());
}
// Controller escape chord (gamepad service) → leave fullscreen + release capture. The
// chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the
// chrome). Aborted on page-hidden so a stale future can't act on the shared window.
let escape_future = {
let window = window.clone();
let cap = capture.clone();
glib::spawn_future_local(async move {
while escape_rx.recv().await.is_ok() {
if window.is_fullscreen() {
window.unfullscreen();
}
cap.release();
}
})
};
// The page's `hidden` fires once navigation away completes (back button, pop on
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
{
let window = window.clone();
let stop_h = stop.clone();
let handlers = RefCell::new(Some((fs_handler, active_handler)));
let escape_future = RefCell::new(Some(escape_future));
page.connect_hidden(move |_| {
tracing::debug!("stream page hidden — ending session");
if let Some((fs, active)) = handlers.borrow_mut().take() {
window.disconnect(fs);
window.disconnect(active);
}
if let Some(f) = escape_future.borrow_mut().take() {
f.abort();
}
if window.is_fullscreen() {
window.unfullscreen();
}
@@ -43,7 +43,8 @@ pub struct CpuFrame {
pub struct DmabufFrame {
pub width: u32,
pub height: u32,
/// DRM fourcc of the layer (NV12 for 8-bit VAAPI output).
/// Combined DRM fourcc of the whole surface (NV12 for 8-bit VAAPI output), derived
/// from the decoder's software format — NOT the per-plane component formats.
pub fourcc: u32,
pub modifier: u64,
pub planes: Vec<DmabufPlane>,
@@ -163,8 +164,27 @@ impl SoftwareDecoder {
let rebuild =
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h);
if rebuild {
let ctx = scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
let mut ctx =
scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
.context("swscale context")?;
// swscale defaults to BT.601 coefficients, but our SDR HEVC stream is BT.709 limited
// range (the host signals BT.709 in the VUI). Without this, YUV→RGB decodes with BT.601
// and SDR colours shift (greens/reds off). Source = limited/studio YUV, destination =
// full-range RGB. Inverse of the host's RGB→YUV CSC (encode/vaapi.rs).
const SWS_CS_ITU709: i32 = 1;
unsafe {
let cs709 = ffmpeg::ffi::sws_getCoefficients(SWS_CS_ITU709);
ffmpeg::ffi::sws_setColorspaceDetails(
ctx.as_mut_ptr(),
cs709, // inv_table: source (YUV) coefficients — BT.709
0, // srcRange: 0 = limited/studio (MPEG)
cs709, // table: destination coefficients (ignored for RGB output)
1, // dstRange: 1 = full-range RGB
0,
1 << 16,
1 << 16, // brightness, contrast, saturation (defaults)
);
}
self.sws = Some((ctx, fmt, w, h));
}
let (sws, ..) = self.sws.as_mut().unwrap();
@@ -292,12 +312,31 @@ impl VaapiDecoder {
/// Map the VAAPI surface to DRM PRIME (zero copy) and lift the descriptor into a
/// `DmabufFrame`. The mapped frame keeps the surface alive via its buffer refs.
///
/// FFmpeg's VAAPI export uses `VA_EXPORT_SURFACE_SEPARATE_LAYERS`, so an NV12 surface
/// comes back as TWO layers (`R8` luma + `GR88` chroma), each one plane — NOT a single
/// `NV12` layer. The previous code took `layers[0]` only: GTK then saw an `R8`
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
bail!("decoder returned a software frame (no VAAPI surface)");
}
// The real pixel layout lives on the hardware frames context, not the
// DRM-PRIME layer formats (those are the per-plane R8/GR88 component formats).
let sw_format = {
let hwfc = (*self.frame).hw_frames_ctx;
if hwfc.is_null() {
bail!("VAAPI frame without a hardware frames context");
}
(*((*hwfc).data as *const ffi::AVHWFramesContext)).sw_format
};
let fourcc = drm_fourcc_for(sw_format)
.ok_or_else(|| anyhow!("unsupported VAAPI output format {sw_format:?}"))?;
let drm = ffi::av_frame_alloc();
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
let r = ffi::av_hwframe_map(drm, self.frame, ffi::AV_HWFRAME_MAP_READ as i32);
@@ -309,11 +348,14 @@ impl VaapiDecoder {
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
let guard = DrmFrameGuard(drm);
let d = &*desc;
if d.nb_layers < 1 {
bail!("DRM descriptor without layers");
if d.nb_layers < 1 || d.nb_objects < 1 {
bail!("DRM descriptor without layers/objects");
}
let layer = &d.layers[0];
let mut planes = Vec::with_capacity(layer.nb_planes as usize);
// Flatten planes across ALL layers, in declared order — the combined fourcc's
// plane order (Y, then UV for NV12) matches the layer order FFmpeg emits.
let mut planes = Vec::new();
for layer in &d.layers[..d.nb_layers as usize] {
for p in &layer.planes[..layer.nb_planes as usize] {
let obj = &d.objects[p.object_index as usize];
planes.push(DmabufPlane {
@@ -322,11 +364,19 @@ impl VaapiDecoder {
stride: p.pitch as u32,
});
}
}
// The whole surface shares one tiling modifier (one BO on radeonsi); GTK takes
// a single modifier for the texture.
let modifier = d.objects[0].format_modifier;
log_descriptor_once(d, sw_format, fourcc, modifier);
Ok(DmabufFrame {
width: (*self.frame).width as u32,
height: (*self.frame).height as u32,
fourcc: layer.format,
modifier: d.objects[0].format_modifier,
fourcc,
modifier,
planes,
guard,
})
@@ -334,6 +384,50 @@ impl VaapiDecoder {
}
}
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
}
/// The combined DRM FourCC for a decoder software pixel format. The host streams 8-bit
/// 4:2:0 (NV12); P010 is here for the eventual 10-bit/HDR path.
fn drm_fourcc_for(sw: ffmpeg_next::ffi::AVPixelFormat) -> Option<u32> {
use ffmpeg_next::ffi::AVPixelFormat::*;
Some(match sw {
AV_PIX_FMT_NV12 => fourcc(b'N', b'V', b'1', b'2'),
AV_PIX_FMT_P010LE => fourcc(b'P', b'0', b'1', b'0'),
_ => return None,
})
}
/// One-time dump of the DRM descriptor layout (objects, layers, planes, modifier) — so a
/// new client/driver combination's real layout is visible in the logs without a debugger.
fn log_descriptor_once(
d: &ffmpeg_next::ffi::AVDRMFrameDescriptor,
sw: ffmpeg_next::ffi::AVPixelFormat,
fourcc: u32,
modifier: u64,
) {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if !ONCE.swap(false, Ordering::Relaxed) {
return;
}
let layers: Vec<(u32, i32)> = d.layers[..d.nb_layers.max(0) as usize]
.iter()
.map(|l| (l.format, l.nb_planes))
.collect();
tracing::info!(
sw_format = ?sw,
chosen_fourcc = format_args!("{:#010x}", fourcc),
nb_objects = d.nb_objects,
nb_layers = d.nb_layers,
?layers,
modifier = format_args!("{:#018x}", modifier),
"VAAPI dmabuf descriptor layout (first frame)"
);
}
impl Drop for VaapiDecoder {
fn drop(&mut self) {
use ffmpeg::ffi;
@@ -345,3 +439,24 @@ impl Drop for VaapiDecoder {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
#[test]
fn drm_fourcc_constants() {
assert_eq!(fourcc(b'N', b'V', b'1', b'2'), 0x3231_564e);
assert_eq!(fourcc(b'P', b'0', b'1', b'0'), 0x3031_3050);
assert_eq!(
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV12),
Some(0x3231_564e)
);
assert_eq!(
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_RGBA),
None
);
}
}
@@ -1,6 +1,6 @@
[package]
name = "punktfunk-client-rs"
description = "punktfunk reference client (M4): VAAPI decode + wgpu/Vulkan present"
name = "punktfunk-probe"
description = "punktfunk reference/probe client: headless punktfunk/1 client for testing + latency measurement"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
@@ -9,7 +9,7 @@ authors.workspace = true
repository.workspace = true
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
quinn = "0.11"
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
anyhow = "1"
@@ -1,4 +1,4 @@
//! `punktfunk-client-rs` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data
//! `punktfunk-probe` — the reference client for `punktfunk/1` (M3): QUIC control plane, UDP data
//! plane, input over QUIC datagrams. Two modes, decided by the host's Welcome:
//!
//! * **verification** (`frames > 0`, synthetic host): byte-checks deterministic test frames;
@@ -27,15 +27,16 @@
//! `gamescope`); the host honors it if available, else auto-detects and reports the resolved
//! choice in its Welcome (logged as `session offer … compositor=…`).
//!
//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the
//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360,
//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`).
//! `--gamepad NAME` requests a host virtual-pad backend
//! (`auto`|`xbox360`|`dualsense`|`xboxone`|`dualshock4`); the host honors it where available (the
//! UHID pads — DualSense, DualShock 4 — need Linux), else falls back to X-Box 360, and reports the
//! resolved choice in its Welcome (logged as `session offer … gamepad=…`).
//!
//! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
//! exits without connecting.
//!
//! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test]
//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
//! (M4 adds VAAPI decode + wgpu present on this skeleton.)
@@ -45,7 +46,8 @@ use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::FLAG_PROBE;
use punktfunk_core::quic::{
endpoint, io, Hello, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, Start, Welcome,
endpoint, io, window_loss_ppm, Hello, LossReport, ProbeRequest, ProbeResult, Reconfigure,
Reconfigured, Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
@@ -177,7 +179,9 @@ fn parse_args() -> Args {
Some(s) => match GamepadPref::from_name(s) {
Some(g) => g,
None => {
eprintln!("--gamepad must be one of: auto, xbox360, dualsense");
eprintln!(
"--gamepad must be one of: auto, xbox360, dualsense, xboxone, dualshock4"
);
std::process::exit(2);
}
},
@@ -193,7 +197,7 @@ fn parse_args() -> Args {
pin,
remode,
pair: get("--pair").map(String::from),
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
name: get("--name").unwrap_or("punktfunk-probe").to_string(),
compositor,
gamepad,
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
@@ -337,7 +341,7 @@ fn discover(secs: u64) -> Result<()> {
println!("{row}");
}
println!(
"\nconnect with: punktfunk-client-rs --connect <addr:port> [--pin <fp> | --pair <PIN>]"
"\nconnect with: punktfunk-probe --connect <addr:port> [--pin <fp> | --pair <PIN>]"
);
}
Ok(())
@@ -401,6 +405,9 @@ async fn session(args: Args) -> Result<()> {
frames = welcome.frames,
compositor = welcome.compositor.as_str(),
gamepad = welcome.gamepad.as_str(),
bit_depth = welcome.bit_depth,
color = ?welcome.color,
hdr = welcome.color.is_hdr(),
"session offer"
);
@@ -433,13 +440,15 @@ async fn session(args: Args) -> Result<()> {
None => None,
};
// Speed-test accumulators: the data-plane loop folds each FLAG_PROBE filler AU in here; the
// --speed-test reporter below reads them once the host's ProbeResult lands. first/last hold
// now_ns timestamps of the receive window (0 = unset).
let probe_recv_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let probe_recv_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let probe_first_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let probe_last_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
let rx_wire_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let rx_wire_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Adaptive-FEC loss feedback: the data loop publishes a windowed loss estimate here; in normal
// stream mode (no speed test / remode) a control-stream task relays it to the host as a
// LossReport so it can size FEC to the link. u32::MAX = "no fresh sample this window".
let loss_ppm = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(u32::MAX));
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
@@ -470,19 +479,25 @@ async fn session(args: Args) -> Result<()> {
}
});
} else if let Some((target_kbps, duration_ms)) = args.speed_test {
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler;
// measure what arrives vs. what it reports sending.
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler; measure
// delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire.
let mut ss = send;
let mut sr = recv;
let (pb, pp, pf, pl) = (
probe_recv_bytes.clone(),
probe_recv_packets.clone(),
probe_first_ns.clone(),
probe_last_ns.clone(),
);
let (rxp, rxb) = (rx_wire_packets.clone(), rx_wire_bytes.clone());
// Per-packet wire size to express delivered bytes as link bytes (header + shard + crypto);
// every shard is zero-padded to shard_payload so all data packets are this exact size.
let crypto_overhead = if welcome.encrypt {
punktfunk_core::packet::CRYPTO_OVERHEAD as u64
} else {
0
};
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
// Baseline the packet-level counters right before the burst (video is paused during it,
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
let base_pkts = rxp.load(Relaxed);
let base_bytes = rxb.load(Relaxed);
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
if io::write_msg(
&mut ss,
@@ -505,37 +520,65 @@ async fn session(args: Args) -> Result<()> {
return;
}
};
// The reliable result can beat the last UDP shards — let them reassemble.
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let recv_bytes = pb.load(Relaxed);
let recv_packets = pp.load(Relaxed);
let (first, last) = (pf.load(Relaxed), pl.load(Relaxed));
let window_ms = if first > 0 && last > first {
(last - first) / 1_000_000
// The reliable result can beat the last UDP shards — let the tail arrive before reading.
// Keep this short: video resumes the instant the burst ends, so a long settle counts
// resumed-video packets against the probe (inflating recv past the host's wire count).
tokio::time::sleep(std::time::Duration::from_millis(60)).await;
let recv_packets = rxp.load(Relaxed).saturating_sub(base_pkts);
// bytes_received counts plaintext (header + shard); add per-packet crypto back for the
// true on-wire byte count.
let recv_wire_bytes =
rxb.load(Relaxed).saturating_sub(base_bytes) + recv_packets * crypto_overhead;
// The host's burst duration is the rate denominator (it sent for this long).
let window_ms = res.duration_ms.max(1) as u64;
let throughput_kbps = recv_wire_bytes.saturating_mul(8) / window_ms;
// Link loss: wire packets the host put out that didn't arrive. host_drop: wire packets
// the host couldn't even hand to the kernel (send buffer too small / can't keep up).
let link_loss = if res.wire_packets_sent > 0 {
(res.wire_packets_sent as i64 - recv_packets as i64).max(0) as f64
/ res.wire_packets_sent as f64
* 100.0
} else {
0
0.0
};
let throughput_kbps = recv_bytes
.saturating_mul(8)
.checked_div(window_ms)
.unwrap_or(0);
let loss_pct = if res.bytes_sent > 0 {
res.bytes_sent.saturating_sub(recv_bytes) as f64 / res.bytes_sent as f64 * 100.0
let offered_wire = res.wire_packets_sent + res.send_dropped;
let host_drop = if offered_wire > 0 {
res.send_dropped as f64 / offered_wire as f64 * 100.0
} else {
0.0
};
tracing::info!(
target_kbps,
host_sent_bytes = res.bytes_sent,
host_sent_packets = res.packets_sent,
recv_bytes,
recv_packets,
window_ms,
throughput_kbps,
loss_pct = format!("{loss_pct:.1}%"),
target_mbps = target_kbps / 1000,
delivered_mbps = throughput_kbps / 1000,
link_loss_pct = format!("{link_loss:.1}%"),
host_drop_pct = format!("{host_drop:.1}%"),
wire_pkts_sent = res.wire_packets_sent,
wire_pkts_recv = recv_packets,
send_dropped = res.send_dropped,
"SPEED TEST complete",
);
});
} else {
// Normal stream mode: relay the data loop's windowed loss estimate to the host as periodic
// LossReports, so it can size FEC to the link (adaptive FEC). The control stream is otherwise
// idle here (remode/speed-test own it in their modes).
let mut ls = send;
let lp = loss_ppm.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
loop {
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
let v = lp.swap(u32::MAX, Relaxed);
if v != u32::MAX
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
.await
.is_err()
{
break; // control stream gone
}
}
});
}
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
@@ -789,12 +832,20 @@ async fn session(args: Args) -> Result<()> {
let conn2 = conn.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
let mut hdr_logged = false;
while let Ok(d) = conn2.read_datagram().await {
if let Some((_, _, opus)) = punktfunk_core::quic::decode_audio_datagram(&d) {
a.fetch_add(1, Relaxed);
ab.fetch_add(opus.len() as u64, Relaxed);
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
r.fetch_add(1, Relaxed);
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
// HDR static metadata (0xCE). Log the first receipt so a loopback test can
// assert the host sent it for an HDR session.
if !hdr_logged {
hdr_logged = true;
tracing::info!(?meta, "HDR static metadata (0xCE)");
}
} else if let Some(hid) = punktfunk_core::quic::HidOutput::decode(&d) {
// The DualSense feedback plane (lightbar / player LEDs / adaptive triggers).
// Log the first few so a playtest can see triggers/LEDs arrive without spam.
@@ -810,12 +861,8 @@ async fn session(args: Args) -> Result<()> {
let cfg = welcome.session_config(Role::Client);
let expected = welcome.frames;
let out_path = args.out.clone();
let (pb, pp, pf, pl) = (
probe_recv_bytes.clone(),
probe_recv_packets.clone(),
probe_first_ns.clone(),
probe_last_ns.clone(),
);
let (rxp_dt, rxb_dt) = (rx_wire_packets.clone(), rx_wire_bytes.clone());
let lp_dt = loss_ppm.clone();
// Express our receive time in the host clock before differencing against the host-stamped
// capture pts. 0 ⇒ same-host or an old host that didn't answer the skew handshake (the latency
@@ -850,7 +897,32 @@ async fn session(args: Args) -> Result<()> {
let mut latencies_us: Vec<u64> = Vec::new();
let mut last_rx = std::time::Instant::now();
let started = std::time::Instant::now();
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
let mut last_loss_report = std::time::Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
loop {
// Mirror packet-level receive counters for the speed-test reporter (reads their delta),
// and publish a windowed loss estimate for the adaptive-FEC LossReport task.
{
use std::sync::atomic::Ordering::Relaxed;
let s = session.stats();
rxp_dt.store(s.packets_received, Relaxed);
rxb_dt.store(s.bytes_received, Relaxed);
if last_loss_report.elapsed() >= std::time::Duration::from_millis(750) {
lp_dt.store(
window_loss_ppm(
s.fec_recovered_shards.wrapping_sub(last_recovered),
s.packets_received.wrapping_sub(last_received),
s.frames_dropped.wrapping_sub(last_dropped),
),
Relaxed,
);
last_loss_report = std::time::Instant::now();
last_recovered = s.fec_recovered_shards;
last_received = s.packets_received;
last_dropped = s.frames_dropped;
}
}
if expected > 0 && ok + mismatched >= expected {
break;
}
@@ -867,15 +939,9 @@ async fn session(args: Args) -> Result<()> {
match session.poll_frame() {
Ok(frame) => {
last_rx = std::time::Instant::now();
// Speed-test filler isn't video: fold it into the probe accumulators and skip
// verification / the --out sink.
// Speed-test filler isn't video: it's measured via the packet-level counters
// mirrored at the loop head — skip verification / the --out sink.
if frame.flags & FLAG_PROBE as u32 != 0 {
use std::sync::atomic::Ordering::Relaxed;
let n = now_ns();
let _ = pf.compare_exchange(0, n, Relaxed, Relaxed);
pl.store(n, Relaxed);
pb.fetch_add(frame.data.len() as u64, Relaxed);
pp.fetch_add(1, Relaxed);
continue;
}
bytes += frame.data.len() as u64;
@@ -13,13 +13,13 @@ name = "punktfunk-client"
path = "src/main.rs"
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
# other native clients live in crates/punktfunk-client-linux and clients/apple); on other
# other native clients live in clients/linux and clients/apple); on other
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
# gating exactly.
[target.'cfg(windows)'.dependencies]
# The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
@@ -26,7 +26,7 @@
Name="unom.Punktfunk"
Publisher="{PUBLISHER}"
Version="{VERSION}"
ProcessorArchitecture="x64" />
ProcessorArchitecture="{ARCH}" />
<Properties>
<DisplayName>Punktfunk</DisplayName>
@@ -1,10 +1,19 @@
# punktfunk Windows client — MSIX packaging
The Windows client ships as a **signed MSIX** so Windows boxes get a real package (Start tile,
clean install/uninstall) instead of a loose exe. CI builds + publishes it from
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
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
touches the client and on `win-v*` release tags.
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
[Release Channels](https://punktfunk.unom.io/docs/channels).
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; the matrix points `FFMPEG_DIR`
at the runner's ARM64 FFmpeg tree, `C:\Users\Public\ffmpeg-arm64`). 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.
## What's in the package
@@ -31,9 +40,9 @@ because it owns raw D3D11, Win32 low-level input hooks, WASAPI and SDL3.
## Versioning
MSIX requires a strictly 4-part numeric version. The workflow computes:
- `win-vX.Y.Z` tag → `X.Y.Z.0` (a real client release; `win-v*` is its own tag namespace, kept off
the host's `host-v*` and Apple's `v*` to avoid the version-shadow bug).
- `main` push / `workflow_dispatch``0.2.<run_number>.0` (rolling, climbs by run number).
- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
the stable `latest/` alias and attached to the unified Gitea Release.
- `main` push / `workflow_dispatch``0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
## Signing & install
@@ -47,8 +56,9 @@ trusted with no further prompt:
```powershell
# once per machine (elevated): trust the publisher
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
# then install (and re-run for each upgrade — no re-trust needed)
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix
# then install the package for your CPU (and re-run for each upgrade — no re-trust needed)
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix # Intel/AMD
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_arm64.msix # ARM64 (Snapdragon, etc.)
```
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
@@ -70,9 +80,16 @@ it changes the package identity → a one-time reinstall).
On the Windows runner / dev VM (MSVC + Windows SDK present), after a release build:
```powershell
cargo build --release -p punktfunk-client-windows
pwsh -File crates/punktfunk-client-windows/packaging/pack-msix.ps1 `
-Version 0.2.0.0 -TargetDir C:\t\release -OutDir C:\t\msix
# x64
cargo build --release -p punktfunk-client-windows --target x86_64-pc-windows-msvc
pwsh -File clients/windows/packaging/pack-msix.ps1 `
-Version 0.2.0.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
# arm64 (cross-compiled; point FFMPEG_DIR at the ARM64 tree)
$env:FFMPEG_DIR = 'C:\Users\Public\ffmpeg-arm64'
cargo build --release -p punktfunk-client-windows --target aarch64-pc-windows-msvc
pwsh -File clients/windows/packaging/pack-msix.ps1 `
-Version 0.2.0.0 -Arch arm64 -TargetDir C:\t\aarch64-pc-windows-msvc\release -OutDir C:\t\msix
```
Validated end-to-end on the build VM (pack → sign → `Add-AppxPackage` → framework-dependency

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

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