Dependency currency wave: skia-safe 0.99, the RustCrypto digest-0.11 family, jni 0.22 and ten more — plus the notices they invalidated #193

Merged
enricobuehler merged 26 commits from worktree-dep-currency-wave into main 2026-08-13 12:41:57 +00:00
64 changed files with 10305 additions and 5998 deletions
+36 -15
View File
@@ -1,18 +1,39 @@
# Workspace-wide build flags.
#
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
# extensions falls back safely.
# THERE ARE DELIBERATELY NONE. This file is kept as a tombstone so the aarch64 AES cfgs are not
# reintroduced — read this before adding rustflags here.
#
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
# `--cfg aes_armv8` themselves.
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
[target.'cfg(target_arch = "aarch64")']
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
# Until 2026-08-13 this file carried:
#
# [target.'cfg(target_arch = "aarch64")']
# rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
#
# because RustCrypto's `aes` 0.8.x enabled the ARMv8-Crypto hardware AES backend on aarch64 ONLY
# behind `--cfg aes_armv8`, and `polyval` 0.6.x gated its PMULL (carry-less multiply) GHASH path
# behind `--cfg polyval_armv8`. That was a live footgun, not just boilerplate: a RUSTFLAGS
# ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY — it does not merge and does not
# append — so every aarch64 lane that set its own RUSTFLAGS silently dropped both and fell back to
# SOFTWARE AES on the per-packet decrypt path. cargo-ndk sets RUSTFLAGS internally for its linker
# configuration, which means every Android arm64-v8a build was hitting exactly that.
#
# `aes` 0.9 removed the cfg: on aarch64 it runtime-detects with `cpufeatures::new!(features_aes,
# "aes")` (lib.rs), the same way x86_64 AES-NI always did. `polyval` 0.7 likewise selects
# `backend/intrinsics/armv8.rs` by `target_arch` alone. Neither cfg exists any more — passing them
# is inert.
#
# Measured here before deleting them, `crypto/open_in_place` from benches/pipeline.rs (one 1408-byte
# MTU shard, AES-128-GCM, single core, Mac15,14 M3 Ultra, all four runs back to back under the same
# background load):
#
# aes 0.8 + both cfgs 2.19 GiB/s <- what the cfgs bought
# aes 0.8, cfgs stripped 225 MiB/s <- the footgun: ~10x slower, software AES
# aes 0.9 + both cfgs 5.28 GiB/s
# aes 0.9, cfgs stripped 5.28 GiB/s <- identical to 4 s.f.; the cfgs do nothing
#
# The ChaCha20-Poly1305 series of the same bench was the control: it moved 0.07% across the cfg
# toggle at both versions, confirming the toggle reached only the AES path.
#
# So 0.9 without the cfgs is not merely as fast as 0.8 with them — it is ~2.4x faster, and ~24x
# the software fallback. Do not re-add these flags; if a future aarch64 slowdown is suspected,
# re-run `cargo bench -p punktfunk-core --bench pipeline -- in_place` and compare against the
# table above rather than reaching for a cfg.
+4 -3
View File
@@ -369,9 +369,10 @@ jobs:
# way — Miri does not implement it — so the gfni branch is simply not covered here.
#
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
# ENTIRELY (.cargo/config.toml:11-13 says so), and that config carries `--cfg aes_armv8` /
# `--cfg polyval_armv8` for aarch64 — worth a measured ~3x decrypt-throughput cliff if
# dropped. Harmless here because this job pins ubuntu-24.04/x86_64; fatal on mac-mini-1.
# ENTIRELY — it does not merge. That used to cost the aarch64 `--cfg aes_armv8` /
# `--cfg polyval_armv8` decrypt flags; the aes 0.9 / polyval 0.7 bump retired those cfgs
# (see the tombstone in .cargo/config.toml), so there is nothing left for an override to
# drop here. Keep the pin anyway: these target-features are meaningless off x86_64.
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
- name: miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
env:
Generated
+270 -336
View File
File diff suppressed because it is too large Load Diff
+377 -733
View File
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -10,21 +10,20 @@
# anywhere else in this repo:
#
# 1. A `RUSTFLAGS` ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY. It does not merge
# and it does not append. Any job that sets RUSTFLAGS silently loses mold here (it still
# builds just with the default linker), and, far worse, would lose the aarch64
# `--cfg aes_armv8` / `--cfg polyval_armv8` flags from the workspace's own .cargo/config.toml,
# which are worth a measured ~3x on the decrypt path. audit.yml's miri gf8 step is the one
# place in the repo that sets RUSTFLAGS, and its comment already carries this warning; keep it
# that way. Never "simplify" this file into a RUSTFLAGS export.
# and it does not append. Any job that sets RUSTFLAGS silently loses mold here it still
# builds, just with the default linker. Never "simplify" this file into a RUSTFLAGS export.
# (This trap used to be far worse: the workspace .cargo/config.toml carried aarch64
# `--cfg aes_armv8` / `--cfg polyval_armv8`, worth ~10x on the decrypt path, and an override
# dropped those too. The RustCrypto aes 0.9 / polyval 0.7 bump retired both cfgs — see the
# tombstone in .cargo/config.toml — so today only mold is at stake here.)
#
# 2. CONFIG FILES MERGE PER KEY, HIGHEST-PRECEDENCE FILE WINS — they do not concatenate. The
# workspace's .cargo/config.toml outranks this one ($CARGO_HOME is the LOWEST precedence).
# Today that is harmless because the two files touch DISJOINT keys: the workspace file defines
# only `target.'cfg(target_arch = "aarch64")'.rustflags`, this one only
# `target.x86_64-unknown-linux-gnu.rustflags`, and cargo JOINS a matching cfg-spec table with
# the triple table rather than picking one. But the moment someone adds an x86_64 rustflags
# entry to the workspace .cargo/config.toml, IT WINS and mold silently stops being used here.
# If that ever happens, move the link-arg into that file instead of duplicating it.
# Today that is harmless because the workspace file defines NO rustflags at all and this one
# defines only `target.x86_64-unknown-linux-gnu.rustflags`. But the moment someone adds an
# x86_64 rustflags entry to the workspace .cargo/config.toml, IT WINS and mold silently stops
# being used here. If that ever happens, move the link-arg into that file instead of
# duplicating it.
#
# 3. aarch64 IS DELIBERATELY NOT WIRED. The cross image links with aarch64-linux-gnu-gcc against a
# multiarch sysroot (ci/rust-ci-arm64cross.Dockerfile); pointing that driver at mold is a
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -18,20 +18,38 @@ crate-type = ["cdylib"]
# the punktfunk/1 control plane, whose TLS runs on aws-lc-rs (see punktfunk-core/Cargo.toml) —
# aws-lc-sys cross-compiles for all three ABIs with the NDK clang cargo-ndk already exports.
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
jni = "0.21"
# 0.22, NOT 0.21 — and the version is load-bearing beyond currency: `rustls-platform-verifier`
# (via quinn-proto, for Android cert verification) already depends on jni 0.22, so pinning 0.21
# here compiled TWO jni copies into the one .so. Matching it collapses them and, with them, the
# whole windows-rs 0.42 generation jni 0.21 dragged in behind `cfg(windows)` (windows-sys 0.45 —
# the oldest crate in the tree — plus windows-targets 0.42.2 and its seven per-arch import libs)
# and jni 0.21's `cesu8`: 11 crates, for a dependency that never even builds on Android.
#
# NOTE for whoever next tries to retire thiserror 1.0 or the jni-sys 0.3/0.4 split: jni is no
# longer why they are here. Both now come solely from `vendor/ndk` 0.9.0 (thiserror 1.0.23,
# jni-sys 0.3) and the crates.io `ndk-sys` 0.6 (jni-sys 0.3). jni-sys 0.3.1 is itself a facade
# over 0.4.1, so the "split" cannot close until ndk + ndk-sys move — and ndk is vendored for one
# visibility patch, so bumping its deps would mean rewriting the vendor rather than a version edit.
jni = "0.22"
log = "0.4"
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
# Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), replacing Android's per-OEM
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
mdns-sd = "0.20"
mdns-sd = "0.21"
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
# compiles this crate (as a host cdylib) — the Android-framework glue (logging, AMediaCodec + AAudio
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
# Default features only, DELIBERATELY: 0.15 added an opt-in `android-api-30` feature that routes
# level filtering through `__android_log_is_loggable_len` so logcat's system-wide/process-wide
# level overrides (`setprop log.tag.*`) are honoured. That symbol is API 30 and the feature
# HARD-LINKS it — on our minSdk-28 floor (Android 9/10) `System.loadLibrary` would fail outright,
# the same way ndk 0.9.0 hard-linking `AMediaCodec_setOnFrameRenderedCallback` broke every
# pre-Android-13 device (see the `ndk-sys` note below). Do not enable it while minSdk is 28.
android_logger = "0.15"
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
+13 -11
View File
@@ -16,9 +16,10 @@
//! wrong, and 1 Hz is plenty for a host picker.
use crate::session::jni_guard;
use jni::objects::JObject;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::jlong;
use jni::JNIEnv;
use jni::EnvUnowned;
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -202,7 +203,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
) -> jlong {
jni_guard(0, || match Discovery::start() {
@@ -216,11 +217,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
// `with_env` subsumes the `jni_guard` this used to carry: it catches panics at the boundary and
// `LogErrorAndDefault` logs then yields `JString::default()` — the null reference the old
// `std::ptr::null_mut()` default returned. Kotlin still sees a null String on failure.
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let out = if handle == 0 {
String::new()
} else {
@@ -229,11 +233,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
let d = unsafe { &*(handle as *const Discovery) };
d.snapshot()
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(out)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
@@ -247,7 +249,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
+88 -78
View File
@@ -8,9 +8,10 @@
//! compile on the host build too (parity with the input shims in [`crate::session`]).
use crate::session::{jni_guard, SessionHandle};
use jni::errors::LogErrorAndDefault;
use jni::objects::{JByteBuffer, JObject};
use jni::sys::{jint, jlong};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::quic::HidOutput;
use std::time::Duration;
@@ -62,7 +63,7 @@ const TAG_HID_RAW: u8 = 0x05;
/// poll thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jlong {
@@ -101,94 +102,103 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
buf: JByteBuffer,
) -> jint {
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
//
// Deliberately `with_env_no_catch` INSIDE `jni_guard`, not the usual `with_env`: every error
// policy resolves a failure to `T::default()`, and `jint::default()` is 0 — a *valid* byte
// count — whereas this method's contract says -1. Letting the panic travel out to `jni_guard`
// keeps the -1 sentinel exact. Every non-panic failure path below likewise returns `Ok(-1)`
// rather than `Err`, so the policy's default is unreachable by construction.
jni_guard(-1, || {
if handle == 0 {
return -1;
}
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
let h = unsafe { &*(handle as *const SessionHandle) };
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
Ok(ev) => ev,
Err(_) => return -1, // timeout or closed — Kotlin loops
};
env.with_env_no_catch(|env| -> jni::errors::Result<jint> {
if handle == 0 {
return Ok(-1);
}
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
let h = unsafe { &*(handle as *const SessionHandle) };
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
Ok(ev) => ev,
Err(_) => return Ok(-1), // timeout or closed — Kotlin loops
};
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return -1,
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return -1,
};
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return Ok(-1),
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return Ok(-1),
};
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
let n = match ev {
HidOutput::Led { pad, r, g, b } => {
if cap < 5 {
return -1;
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
let n = match ev {
HidOutput::Led { pad, r, g, b } => {
if cap < 5 {
return Ok(-1);
}
out[0] = pad;
out[1] = TAG_LED;
out[2] = r;
out[3] = g;
out[4] = b;
5
}
out[0] = pad;
out[1] = TAG_LED;
out[2] = r;
out[3] = g;
out[4] = b;
5
}
HidOutput::PlayerLeds { pad, bits } => {
if cap < 3 {
return -1;
HidOutput::PlayerLeds { pad, bits } => {
if cap < 3 {
return Ok(-1);
}
out[0] = pad;
out[1] = TAG_PLAYER_LEDS;
out[2] = bits;
3
}
out[0] = pad;
out[1] = TAG_PLAYER_LEDS;
out[2] = bits;
3
}
HidOutput::Trigger { pad, which, effect } => {
let n = 3 + effect.len();
if cap < n {
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
HidOutput::Trigger { pad, which, effect } => {
let n = 3 + effect.len();
if cap < n {
return Ok(-1); // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
}
out[0] = pad;
out[1] = TAG_TRIGGER;
out[2] = which;
out[3..n].copy_from_slice(&effect);
n
}
out[0] = pad;
out[1] = TAG_TRIGGER;
out[2] = which;
out[3..n].copy_from_slice(&effect);
n
}
HidOutput::TrackpadHaptic { .. } => {
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
// rumble already rides the universal 0xCA plane).
return -1;
}
HidOutput::HidRaw { pad, kind, data } => {
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
// the virtual pad; Kotlin replays it verbatim on the physical controller.
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
let n = 3 + data.len();
if cap < n {
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
HidOutput::TrackpadHaptic { .. } => {
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
// rumble already rides the universal 0xCA plane).
return Ok(-1);
}
out[0] = pad;
out[1] = TAG_HID_RAW;
out[2] = kind;
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
HidOutput::HidRaw { pad, kind, data } => {
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
// the virtual pad; Kotlin replays it verbatim on the physical controller.
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
let n = 3 + data.len();
if cap < n {
return Ok(-1); // reports are ≤ 64 bytes; Kotlin allocates 128
}
out[0] = pad;
out[1] = TAG_HID_RAW;
out[2] = kind;
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return Ok(-1);
}
};
Ok(n as jint)
})
.resolve::<LogErrorAndDefault>()
})
}
+8 -9
View File
@@ -21,9 +21,10 @@
//! surface, the per-plane pumps (video → AMediaCodec, audio ↔ AAudio, mic uplink), input, and
//! rumble/HID feedback ([`feedback`]). Mode renegotiation is still TODO (see [`session`]).
use jni::objects::JObject;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::jint;
use jni::JNIEnv;
use jni::EnvUnowned;
#[cfg(target_os = "android")]
mod adpf;
@@ -76,7 +77,7 @@ pub extern "system" fn JNI_OnLoad(
/// linked `punktfunk-core` is the one we expect.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
) -> jint {
punktfunk_core::ABI_VERSION as jint
@@ -85,11 +86,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
match env.new_string(env!("CARGO_PKG_VERSION")) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
) -> JString<'local> {
env.with_env(|env| env.new_string(env!("CARGO_PKG_VERSION")))
.resolve::<LogErrorAndDefault>()
}
+10 -13
View File
@@ -5,9 +5,10 @@
//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion
//! to the dial-first connect fix.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::client::NativeClient;
use std::time::Duration;
@@ -16,21 +17,17 @@ use std::time::Duration;
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
timeout_ms: jint,
) -> jboolean {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
Err(_) => return 0,
};
let port = port.clamp(0, u16::MAX as jint) as u16;
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
if NativeClient::probe(&host, port, timeout) {
1
} else {
0
}
env.with_env(|env| -> jni::errors::Result<bool> {
let host: String = host.try_to_string(env)?;
let port = port.clamp(0, u16::MAX as jint) as u16;
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
Ok(NativeClient::probe(&host, port, timeout))
})
.resolve::<LogErrorAndDefault>()
}
+60 -52
View File
@@ -15,9 +15,10 @@
use std::time::Duration;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
use jni::sys::{jboolean, jint, jlong};
use jni::EnvUnowned;
use punktfunk_core::clipboard::ClipEventCore;
use punktfunk_core::error::PunktfunkError;
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
@@ -42,26 +43,24 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
client(handle).map_or(0, |h| {
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
})
client(handle).is_some_and(|h| h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
}
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
/// clipboard-related happens on either side until an `enabled: true` crosses.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
enabled: jboolean,
) {
if let Some(h) = client(handle) {
let _ = h.client.clip_control(enabled != 0, 0);
let _ = h.client.clip_control(enabled, 0);
}
}
@@ -70,7 +69,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl
/// counter, newest wins.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
seq: jint,
@@ -90,7 +89,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or 1.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
seq: jint,
@@ -108,26 +107,32 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
/// clipboard's current text (the host is pasting our offer).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
mut env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
req_id: jint,
text: JString,
) {
let Some(h) = client(handle) else { return };
let Ok(s) = env.get_string(&text) else {
let _ = h.client.clip_cancel(req_id as u32);
return;
};
let _ = h
.client
.clip_serve(req_id as u32, String::from(s).into_bytes(), true);
env.with_env(|env| -> jni::errors::Result<()> {
let Some(h) = client(handle) else {
return Ok(());
};
// An unreadable payload still has to answer the host's `fetch:` — leaving it unanswered
// stalls the paste — so cancel the transfer rather than propagating the error.
let Ok(s) = text.try_to_string(env) else {
let _ = h.client.clip_cancel(req_id as u32);
return Ok(());
};
let _ = h.client.clip_serve(req_id as u32, s.into_bytes(), true);
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
id: jint,
@@ -145,38 +150,41 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
/// can never split a UTF-8 sequence.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
env: JNIEnv,
_this: JObject,
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
let Some(h) = client(handle) else {
return std::ptr::null_mut();
};
let msg = match h.client.next_clip(Duration::from_millis(250)) {
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
format!("offer:{seq}:{}", u8::from(has_text))
}
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
if mime.starts_with("text/plain") {
format!("fetch:{req_id}")
} else {
// We only ever offer text; cancel anything else rather than stall the host.
let _ = h.client.clip_cancel(req_id);
return std::ptr::null_mut();
) -> JString<'local> {
// `JString::default()` is the null reference the old `std::ptr::null_mut()` returned, so the
// "null on timeout" contract in the doc comment above is unchanged.
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let Some(h) = client(handle) else {
return Ok(JString::default());
};
let msg = match h.client.next_clip(Duration::from_millis(250)) {
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
format!("offer:{seq}:{}", u8::from(has_text))
}
}
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
}
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(),
Err(_) => "closed".into(),
};
env.new_string(msg)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
if mime.starts_with("text/plain") {
format!("fetch:{req_id}")
} else {
// We only ever offer text; cancel anything else rather than stall the host.
let _ = h.client.clip_cancel(req_id);
return Ok(JString::default());
}
}
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
}
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
Err(PunktfunkError::NoFrame) => return Ok(JString::default()),
Err(_) => "closed".into(),
};
env.new_string(msg)
})
.resolve::<LogErrorAndDefault>()
}
+103 -95
View File
@@ -1,9 +1,10 @@
//! Connect lifecycle + the trust surface: identity mint, connect (TOFU / pinned), close,
//! host-fingerprint read, and the SPAKE2 PIN pairing ceremony.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::sync::{Arc, Mutex};
@@ -38,14 +39,12 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
/// handle / `""` fingerprint.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
) -> JString<'local> {
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
match env.new_string(token) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
env.with_env(|env| env.new_string(token))
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
@@ -53,9 +52,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
) -> JString<'local> {
let out = match punktfunk_core::quic::endpoint::generate_identity() {
Ok((cert, key)) => format!("{cert}\n-----PUNKTFUNK-KEY-----\n{key}"),
Err(e) => {
@@ -63,10 +62,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
String::new()
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
env.with_env(|env| env.new_string(out))
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSetLowLatencyMode(enabled)` — apply the user's "Low-latency mode
@@ -76,11 +73,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
enabled: jboolean,
) {
punktfunk_core::transport::set_dscp_default(enabled != 0);
punktfunk_core::transport::set_dscp_default(enabled);
}
/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the
@@ -123,7 +120,7 @@ fn force_parts_sysprop() -> bool {
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
@@ -147,31 +144,44 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
Err(_) => return 0,
// Every JNI string this method needs, read up front in the one `Env` scope jni 0.22 grants a
// native method; everything below is pure Rust over owned `String`s. `None` = the mandatory
// `host` could not be read, which is the old `Err(_) => return 0` arm.
type ConnectStrings = Option<(
String,
String,
String,
String,
Option<String>,
Option<String>,
)>;
let strings: ConnectStrings = env
.with_env(|env| -> jni::errors::Result<ConnectStrings> {
let Ok(host) = host.try_to_string(env) else {
return Ok(None);
};
let cert: String = cert_pem.try_to_string(env).unwrap_or_default();
let key: String = key_pem.try_to_string(env).unwrap_or_default();
let pin_hex: String = pin_hex.try_to_string(env).unwrap_or_default();
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a
// game; null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
let launch: Option<String> = launch
.try_to_string(env)
.ok()
.filter(|s: &String| !s.is_empty());
// The host's approval-list / trust-store label for this device; null / blank ⇒ None (the
// host falls back to its fingerprint-derived "device abcd1234" placeholder).
let device_name: Option<String> = device_name
.try_to_string(env)
.ok()
.map(|s: String| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(Some((host, cert, key, pin_hex, launch, device_name)))
})
.resolve::<LogErrorAndDefault>();
let Some((host, cert, key, pin_hex, launch, device_name)) = strings else {
return 0;
};
let cert: String = env
.get_string(&cert_pem)
.map(Into::into)
.unwrap_or_default();
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
let launch: Option<String> = env
.get_string(&launch)
.map(Into::into)
.ok()
.filter(|s: &String| !s.is_empty());
// The host's approval-list / trust-store label for this device; null / blank ⇒ None (the host
// falls back to its fingerprint-derived "device abcd1234" placeholder).
let device_name: Option<String> = env
.get_string(&device_name)
.map(Into::into)
.ok()
.map(|s: String| s.trim().to_string())
.filter(|s| !s.is_empty());
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
None
@@ -184,16 +194,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec
// that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.)
let force_parts = force_parts_sysprop();
let frame_parts = frame_parts_ok != 0 || force_parts;
let frame_parts = frame_parts_ok || force_parts;
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is
// inert client-side unless BOTH probes pass — this line is the one place that says which.
log::info!(
target: "pf.caps",
"decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}",
multi_slice_ok != 0,
frame_parts_ok != 0,
multi_slice_ok,
frame_parts_ok,
if force_parts { " (FORCED by sysprop)" } else { "" },
hdr_enabled != 0,
hdr_enabled,
video_codecs,
);
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
@@ -229,11 +239,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
(if hdr_enabled != 0 {
(if hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
}) | (if multi_slice_ok != 0 {
}) | (if multi_slice_ok {
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
} else {
0
@@ -274,7 +284,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
| if pad_audio_ok {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
@@ -324,7 +334,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -346,7 +356,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -365,10 +375,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
/// connect. `""` on a `0` handle.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jni::sys::jstring {
) -> JString<'local> {
let out = if handle == 0 {
String::new()
} else {
@@ -376,10 +386,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
let h = unsafe { &*(handle as *const SessionHandle) };
hex32(&h.client.host_fingerprint)
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
env.with_env(|env| env.new_string(out))
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended?
@@ -390,17 +398,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
/// handle. Cheap (one atomic load); safe on the UI thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.client.is_session_ended())
h.client.is_session_ended()
})
}
@@ -415,7 +423,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
/// atomic load); safe on the UI thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jint {
@@ -436,7 +444,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
@@ -444,40 +452,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
key_pem: JString<'local>,
pin: JString<'local>,
name: JString<'local>,
) -> jni::sys::jstring {
let g = |e: &mut JNIEnv<'local>, j: &JString<'local>| -> String {
e.get_string(j).map(Into::into).unwrap_or_default()
};
let host = g(&mut env, &host);
let cert = g(&mut env, &cert_pem);
let key = g(&mut env, &key_pem);
let pin = g(&mut env, &pin);
let name = g(&mut env, &name);
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let g = |e: &jni::Env<'local>, j: &JString<'local>| -> String {
j.try_to_string(e).unwrap_or_default()
};
let host = g(env, &host);
let cert = g(env, &cert_pem);
let key = g(env, &key_pem);
let pin = g(env, &pin);
let name = g(env, &name);
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
log::error!("nativePair: missing host/identity");
String::new()
} else {
match NativeClient::pair(
&host,
port as u16,
(&cert, &key), // borrowed identity
&pin,
&name,
Duration::from_secs(60),
) {
Ok(host_fp) => hex32(&host_fp),
Err(e) => {
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
log::error!("nativePair to {host}:{port} failed: {e}");
note_error(&e);
String::new()
let out = if host.is_empty() || cert.is_empty() || key.is_empty() {
log::error!("nativePair: missing host/identity");
String::new()
} else {
match NativeClient::pair(
&host,
port as u16,
(&cert, &key), // borrowed identity
&pin,
&name,
Duration::from_secs(60),
) {
Ok(host_fp) => hex32(&host_fp),
Err(e) => {
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
log::error!("nativePair to {host}:{port} failed: {e}");
note_error(&e);
String::new()
}
}
}
};
match env.new_string(out) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
}
};
env.new_string(out)
})
.resolve::<LogErrorAndDefault>()
}
+127 -114
View File
@@ -6,9 +6,10 @@
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
use jni::errors::LogErrorAndDefault;
use jni::objects::{JByteBuffer, JFloatArray, JObject, JString};
use jni::sys::{jboolean, jint, jlong};
use jni::JNIEnv;
use jni::EnvUnowned;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::quic::{
PenSample, PenTool, RichInput, HID_REPORT_MAX, HOST_CAP_PEN, HOST_CAP_TEXT_INPUT,
@@ -37,7 +38,7 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
dx: jint,
@@ -53,7 +54,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
x: jint,
@@ -70,13 +71,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
button: jint,
down: jboolean,
) {
let kind = if down != 0 {
let kind = if down {
InputKind::MouseButtonDown
} else {
InputKind::MouseButtonUp
@@ -88,7 +89,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
axis: jint,
@@ -105,7 +106,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
/// (libei touchscreen / wlroots / SendInput).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
id: jint,
@@ -130,7 +131,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
vk: jint,
@@ -140,7 +141,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
if vk == 0 {
return;
}
let kind = if down != 0 {
let kind = if down {
InputKind::KeyDown
} else {
InputKind::KeyUp
@@ -153,16 +154,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
u8::from(h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0)
h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0
}
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
@@ -170,16 +171,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSu
/// (design/pen-tablet-input.md §7). `0` handle → false.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
u8::from(h.client.host_caps() & HOST_CAP_PEN != 0)
h.client.host_caps() & HOST_CAP_PEN != 0
}
/// Floats per sample in the `nativeSendPen` flat array.
@@ -199,65 +200,69 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
/// while in range (Kotlin side — see `StylusStream`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
samples: JFloatArray,
count: jint,
) {
if handle == 0 || count <= 0 {
return;
}
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
let flat = &mut buf[..count * PEN_JNI_STRIDE];
if env.get_float_array_region(&samples, 0, flat).is_err() {
return; // short array — a bridge bug, never worth a crash on the input path
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
let n = run.len() / PEN_JNI_STRIDE;
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return; // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 || count <= 0 {
return Ok(());
}
let _ = h.client.send_pen(&batch[..n]);
}
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
let flat = &mut buf[..count * PEN_JNI_STRIDE];
if samples.get_region(env, 0, flat).is_err() {
return Ok(()); // short array — a bridge bug, never worth a crash on the input path
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
let n = run.len() / PEN_JNI_STRIDE;
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return Ok(()); // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
}
let _ = h.client.send_pen(&batch[..n]);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
@@ -266,20 +271,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
mut env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
text: JString,
) {
if handle == 0 {
return;
}
let Ok(s) = env.get_string(&text) else {
return;
};
for ch in String::from(s).chars().filter(|c| !c.is_control()) {
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
}
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 {
return Ok(());
}
let Ok(s) = text.try_to_string(env) else {
return Ok(());
};
for ch in s.chars().filter(|c| !c.is_control()) {
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
@@ -298,7 +307,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
bit: jint,
@@ -309,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
handle,
InputKind::GamepadButton,
bit as u32,
i32::from(down != 0),
i32::from(down),
0,
pad as u32,
);
@@ -320,7 +329,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// (32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
axis_id: jint,
@@ -345,7 +354,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pref: jint,
@@ -375,24 +384,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// the `Auto` rule inside the predicate itself.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
declared_pref: jint,
) -> jboolean {
if handle == 0 {
return 1;
return true;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy
// values read behind `&self`.
let h = unsafe { &*(handle as *const SessionHandle) };
let declared =
punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8);
u8::from(punktfunk_core::config::pad_motion_reaches(
punktfunk_core::config::pad_motion_reaches(
declared,
h.client.requested_gamepad,
h.client.resolved_gamepad,
))
)
}
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
@@ -401,7 +410,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionRe
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
@@ -417,36 +426,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// own report rate (~250500 Hz) — the direct-buffer read avoids a JNI array copy per report.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
buf: JByteBuffer,
len: jint,
) {
if handle == 0 || len <= 0 {
return;
}
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return,
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return,
};
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
let mut data = [0u8; HID_REPORT_MAX];
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
// `n` is bounded by both the buffer capacity and the fixed wire body.
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::HidReport {
pad: (pad as u32 & 0xF) as u8,
len: n as u8,
data,
});
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 || len <= 0 {
return Ok(());
}
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return Ok(()),
};
let ptr = match env.get_direct_buffer_address(&buf) {
Ok(p) if !p.is_null() => p,
_ => return Ok(()),
};
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
let mut data = [0u8; HID_REPORT_MAX];
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
// `n` is bounded by both the buffer capacity and the fixed wire body.
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::HidReport {
pad: (pad as u32 & 0xF) as u8,
len: n as u8,
data,
});
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
@@ -457,7 +470,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
/// the capture diffs, the host holds per-slot state.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
@@ -474,7 +487,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
let _ = h.client.send_rich_input(RichInput::Touchpad {
pad: (pad as u32 & 0xF) as u8,
finger: (finger as u32 & 0x1) as u8,
active: active != 0,
active,
x: (x as i64).clamp(0, 65535) as u16,
y: (y as i64).clamp(0, 65535) as u16,
});
@@ -488,7 +501,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jint,
+119 -130
View File
@@ -1,12 +1,10 @@
//! Plane start/stop: video (HEVC decode → Surface), host→client audio, mic uplink — plus the
//! ~1 Hz decode-stats drain for the HUD.
use jni::objects::JObject;
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
#[cfg(target_os = "android")]
use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
use jni::JNIEnv;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JDoubleArray, JIntArray, JObject, JString};
use jni::sys::{jboolean, jlong};
use jni::EnvUnowned;
use super::{jni_guard, lock_recover, SessionHandle};
@@ -21,7 +19,7 @@ use super::{jni_guard, lock_recover, SessionHandle};
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
mut env: JNIEnv,
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
surface: JObject,
@@ -37,53 +35,58 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
if handle == 0 {
return;
}
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
let decoder = env
.get_string(&decoder_name)
.ok()
.map(String::from)
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = lock_recover(&h.video);
if guard.is_some() {
return; // already streaming
}
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
let window = match unsafe {
ndk::native_window::NativeWindow::from_surface(
env.get_native_interface() as *mut _,
surface.as_raw() as *mut _,
)
} {
Some(w) => w,
None => {
log::error!("nativeStartVideo: no ANativeWindow from Surface");
return;
env.with_env(|env| -> jni::errors::Result<()> {
if handle == 0 {
return Ok(());
}
};
let shutdown = Arc::new(AtomicBool::new(false));
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature: ll_feature != 0,
low_latency_mode: low_latency_mode != 0,
is_tv: is_tv != 0,
present_priority,
smooth_buffer,
panel_hz: panel_fps,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd, st, opts))
.ok();
*guard = Some(VideoThread { shutdown, join });
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
let decoder = decoder_name
.try_to_string(env)
.ok()
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = lock_recover(&h.video);
if guard.is_some() {
return Ok(()); // already streaming
}
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers)
// — a real skew here, not a hypothetical one: `jni` is on jni-sys 0.4 while the vendored
// `ndk` is still on 0.3.
let window = match unsafe {
ndk::native_window::NativeWindow::from_surface(
env.get_raw() as *mut _,
surface.as_raw() as *mut _,
)
} {
Some(w) => w,
None => {
log::error!("nativeStartVideo: no ANativeWindow from Surface");
return Ok(());
}
};
let shutdown = Arc::new(AtomicBool::new(false));
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature,
low_latency_mode,
is_tv,
present_priority,
smooth_buffer,
panel_hz: panel_fps,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd, st, opts))
.ok();
*guard = Some(VideoThread { shutdown, join });
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
@@ -93,21 +96,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JString::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(crate::decode::codec_mime(h.client.codec))
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
@@ -118,21 +119,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JString::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(crate::decode::codec_label(h.client.codec)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(crate::decode::codec_label(h.client.codec))
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
@@ -142,28 +141,26 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
/// device).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JString::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(h.stats.decoder_label()) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
env.new_string(h.stats.decoder_label())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
/// session). No-op on `0`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -211,19 +208,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// 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).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
env: JNIEnv,
_this: JObject,
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jdoubleArray {
jni_guard(std::ptr::null_mut(), || {
) -> JDoubleArray<'local> {
env.with_env(|env| -> jni::errors::Result<JDoubleArray<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JDoubleArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
if lock_recover(&h.video).is_none() {
return std::ptr::null_mut(); // not streaming → no stats
return Ok(JDoubleArray::default()); // not streaming → no stats
}
let snap = h
.stats
@@ -294,15 +291,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
h.client.audio_buffer_ms() as f64,
h.client.audio_av_offset_ms() as f64,
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
if env.set_double_array_region(&arr, 0, &buf).is_err() {
return std::ptr::null_mut();
}
arr.into_raw()
let arr = env.new_double_array(buf.len())?;
arr.set_region(env, 0, &buf)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
@@ -313,14 +306,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
_this: JObject,
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jintArray {
jni_guard(std::ptr::null_mut(), || {
) -> JIntArray<'local> {
env.with_env(|env| -> jni::errors::Result<JIntArray<'local>> {
if handle == 0 {
return std::ptr::null_mut();
return Ok(JIntArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
@@ -330,15 +323,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
mode.height as i32,
mode.refresh_hz as i32,
];
let arr = match env.new_int_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
if env.set_int_array_region(&arr, 0, &buf).is_err() {
return std::ptr::null_mut();
}
arr.into_raw()
let arr = env.new_int_array(buf.len())?;
arr.set_region(env, 0, &buf)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
@@ -348,7 +337,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
/// pure `jni` + an atomic store, so it links on the host build too.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
enabled: jboolean,
@@ -360,7 +349,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
// The current cumulative counters seed the window baselines, so the first snapshot's
// `lost`/`FEC` cover only time the HUD was actually up.
h.stats.set_enabled(
enabled != 0,
enabled,
h.client.frames_dropped(),
h.client.fec_recovered_shards(),
);
@@ -375,7 +364,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
low_latency_mode: jboolean,
@@ -389,7 +378,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
if guard.is_some() {
return; // already playing
}
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) {
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode) {
Some(p) => *guard = Some(p),
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
}
@@ -400,7 +389,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -424,7 +413,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
echo_cancel: jboolean,
@@ -440,7 +429,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
}
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel, h.mic_muted.clone()) {
Some(m) => {
let session_id = m.session_id();
*guard = Some(m);
@@ -459,7 +448,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
@@ -487,7 +476,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
@@ -495,9 +484,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
@@ -512,14 +501,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
std::sync::Arc::clone(&h.client),
pad as u8,
fd,
haptics != 0,
speaker != 0,
haptics,
speaker,
) {
Some(p) => {
*lock_recover(&h.pad_audio) = Some(p);
1
true
}
None => 0,
None => false,
}
})
}
@@ -533,7 +522,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
fd: jni::sys::jint,
seconds: jni::sys::jint,
@@ -556,7 +545,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
@@ -596,7 +585,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
/// no captured audio leaves the process.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
muted: jboolean,
@@ -606,7 +595,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.mic_muted
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
.store(muted, std::sync::atomic::Ordering::Relaxed);
}
})
}
@@ -619,16 +608,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(lock_recover(&h.mic).is_some())
lock_recover(&h.mic).is_some()
})
}
+19 -21
View File
@@ -10,9 +10,10 @@
//! coroutine on the main thread the way it polls the stats HUD.
use super::{jni_guard, SessionHandle};
use jni::objects::JObject;
use jni::sys::{jboolean, jdoubleArray, jint, jlong};
use jni::JNIEnv;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JDoubleArray, JObject};
use jni::sys::{jboolean, jint, jlong};
use jni::EnvUnowned;
/// The `DoubleArray` [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] returns. Kept in
/// one place because Kotlin indexes it positionally; see the Kotlin doc for the field order.
@@ -25,25 +26,25 @@ const PROBE_RESULT_LEN: usize = 6;
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
_env: JNIEnv,
_env: EnvUnowned,
_this: JObject,
handle: jlong,
target_kbps: jint,
duration_ms: jint,
) -> jboolean {
jni_guard(0, || {
jni_guard(false, || {
if handle == 0 {
return 0;
return false;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let target = target_kbps.clamp(0, i32::MAX) as u32;
let duration = duration_ms.clamp(0, i32::MAX) as u32;
match h.client.request_probe(target, duration) {
Ok(()) => 1,
Ok(()) => true,
Err(e) => {
log::warn!("speed test: could not ask the host to probe: {e:?}");
0
false
}
}
})
@@ -56,13 +57,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jdoubleArray {
jni_guard(JObject::null().into_raw(), || {
) -> JDoubleArray<'local> {
// `JDoubleArray::default()` is the null reference the old `JObject::null().into_raw()` returned,
// so Kotlin still reads `null` on every failure path.
env.with_env(|env| -> jni::errors::Result<JDoubleArray<'local>> {
if handle == 0 {
return JObject::null().into_raw();
return Ok(JDoubleArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
@@ -75,14 +78,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult
f64::from(r.elapsed_ms),
r.recv_bytes as f64,
];
match env.new_double_array(PROBE_RESULT_LEN as i32) {
Ok(arr) => {
if env.set_double_array_region(&arr, 0, &values).is_err() {
return JObject::null().into_raw();
}
arr.into_raw()
}
Err(_) => JObject::null().into_raw(),
}
let arr = env.new_double_array(PROBE_RESULT_LEN)?;
arr.set_region(env, 0, &values)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
+19 -22
View File
@@ -3,8 +3,9 @@
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
//! just before connecting to an offline saved host.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::JNIEnv;
use jni::EnvUnowned;
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
@@ -12,29 +13,25 @@ use jni::JNIEnv;
/// Returns true if at least one datagram went out.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
mut env: JNIEnv<'local>,
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
macs_csv: JString<'local>,
last_ip: JString<'local>,
) -> jni::sys::jboolean {
let macs_csv: String = match env.get_string(&macs_csv) {
Ok(s) => s.into(),
Err(_) => return 0,
};
let last_ip: String = env
.get_string(&last_ip)
.map(Into::<String>::into)
.unwrap_or_default();
let macs: Vec<[u8; 6]> = macs_csv
.split(',')
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
.collect();
if macs.is_empty() {
return 0;
}
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
Ok(()) => 1,
Err(_) => 0,
}
env.with_env(|env| -> jni::errors::Result<bool> {
let macs_csv: String = macs_csv.try_to_string(env)?;
// Unlike `macs_csv`, an unreadable `lastIp` is not fatal: the core falls back to the
// subnet broadcast when it has no address, so keep the old lenient default.
let last_ip: String = last_ip.try_to_string(env).unwrap_or_default();
let macs: Vec<[u8; 6]> = macs_csv
.split(',')
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
.collect();
if macs.is_empty() {
return Ok(false);
}
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
Ok(punktfunk_core::wol::send_magic_packet(&macs, ip).is_ok())
})
.resolve::<LogErrorAndDefault>()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host
# advertises (same crate/version the host advertises with).
mdns-sd = "0.20"
mdns-sd = "0.21"
# Opus: multistream DECODE of the host's audio plane (the surround validator) + `--mic-test`'s
# encoder. libopus is already in the graph via `punktfunk-core`'s quic feature; this exposes the
# name directly. Cross-platform (cmake-vendored), so the probe builds + validates everywhere.
+1 -1
View File
@@ -89,7 +89,7 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
# itself (built from source via the bundled CMake on Windows) is pulled transitively by
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
mdns-sd = "0.20"
mdns-sd = "0.21"
async-channel = "2"
serde_json = "1"
tracing = "0.1"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -37,7 +37,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "sy
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
# `RustConnection` is the pure-Rust default (no libxcb link → no new C dependency on the host); the
# `xfixes` feature (auto-pulls `render` + `shape`) is what exposes GetCursorImage/SelectCursorInput.
x11rb = { version = "0.13", default-features = false, features = ["xfixes"] }
x11rb = { version = "0.14", default-features = false, features = ["xfixes"] }
[target.'cfg(target_os = "windows")'.dependencies]
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
+5 -5
View File
@@ -89,7 +89,7 @@ libc = "0.2"
# with libavcodec (`pf-encode`); nothing in this crate does.
opus = "0.3"
mdns-sd = "0.20"
mdns-sd = "0.21"
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
# §4.5) — pure Vulkan compute on the presenter's shared device, so it builds wherever the
@@ -141,16 +141,16 @@ pf-vaadec = { path = "../pf-vaadec" }
# libva itself is dlopen'd, never linked (see `video_vaapi_native`'s module docs): the
# container can then compile and clippy the whole rung without `libva-dev`, and a machine
# without a VAAPI runtime gets a clean refusal instead of a packaging dependency.
libloading = "0.8"
libloading = "0.9"
# The gamescope overlay watcher (`overlay_focus`): read two CARDINAL properties off a
# gamescope root window and block on PropertyNotify. `default-features = false` keeps the
# pure-Rust `RustConnection` — no libxcb link, so no new C dependency on any client package
# — the same stance pf-capture and pf-vdisplay already take on this crate. No extension
# features: root-window properties and an event mask are core X11.
x11rb = { version = "0.13", default-features = false }
x11rb = { version = "0.14", default-features = false }
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
wasapi = "0.24"
# Native D3D11VA decode (M5 of the native-decode program): the hand-declared DXVA buffer
# layouts and the AuPlan → picparams/qmatrix/slice-control conversion that video_d3d11_native
# submits. Windows-only because the rung is; the crate itself is cross-platform CPU code so
@@ -198,7 +198,7 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
# for `video_d3d11_native::parity`, now `cfg(linux)` as well for
# `video_vaapi_native::parity`. A DEV dependency, so no shipped binary gains anything —
# which is also part of why the VAAPI readback cannot reach the production video path.
sha2 = "0.10"
sha2 = "0.11"
[features]
# PyroWave client decode ships in every default build (flatpak included; pyrowave-sys is a
+8 -6
View File
@@ -100,12 +100,14 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
///
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
/// Through `wasapi 0.23` that helper built its argument as
/// `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the `HSTRING` was a temporary, dropped at the
/// end of that statement, so `GetDevice` read freed memory and missed ids that are perfectly valid.
/// `wasapi 0.24` fixed that upstream. Scanning the active collection touches only safe crate APIs,
/// so it cannot regress the same way, and it additionally filters to ACTIVE endpoints — which is
/// why it stays. (`punktfunk-host` routes around the same bug with raw COM instead; this crate
/// cannot, because it pins a different `windows` revision than `wasapi` does, making the two
/// `IMMDevice` types incompatible.)
pub(crate) fn device_by_id(
enumerator: &DeviceEnumerator,
direction: &Direction,
+4 -3
View File
@@ -920,9 +920,10 @@ fn pad_render_thread(
let res = (|| -> anyhow::Result<()> {
const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
// Not `get_device`: that helper resolves through a freed string — see
// [`crate::audio::device_by_id`] (audio_wasapi.rs, mounted as `crate::audio` on
// Windows by lib.rs's `#[path]` swap — there is no `audio_wasapi` module name).
// Not `get_device`: that helper resolved through a freed string through wasapi 0.23, and
// this path additionally wants the ACTIVE-only filter — see [`crate::audio::device_by_id`]
// (audio_wasapi.rs, mounted as `crate::audio` on Windows by lib.rs's `#[path]` swap —
// there is no `audio_wasapi` module name).
let device = crate::audio::device_by_id(&enumerator, &Direction::Render, endpoint_id)
.map_err(|e| anyhow!("correlated endpoint not found: {e:#}"))?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
+12 -1
View File
@@ -22,7 +22,18 @@ pf-client-core = { path = "../pf-client-core", default-features = false }
# the typography the console library needs (~15 MB stripped, prebuilt binaries exist for
# this feature set on x86_64-unknown-linux-gnu AND x86_64-pc-windows-msvc — a source
# build is never triggered on either).
skia-safe = { version = "0.87", features = ["vulkan", "textlayout"] }
#
# The prebuilt-binary claim is the whole reason this dep is affordable, so re-verify it on
# EVERY bump: the build log must say `DOWNLOAD AND INSTALL SUCCEEDED`. skia-bindings does not
# fail when no matching asset exists — it silently falls back to a gn/ninja build of Skia from
# source, which turns a 2-minute CI leg into a multi-hour one. Verified at 0.99.0, both targets:
# skia-binaries-a25a0fdb7d90429aa2d1-<target>-jpegd-jpege-pdf-textlayout-vulkan.tar.gz
# ⚠ The asset name CHANGED across this bump — at 0.87 it was `<target>-pdf-textlayout-vulkan`,
# because `jpeg` was not yet in skia-safe's DEFAULT feature set (0.87: binary-cache, embed-icudtl,
# pdf; 0.99: + jpeg). We take defaults, so the JPEG codecs came along with the bump. That is a
# feature here rather than bloat: `screens/library.rs` feeds host poster art straight to
# `Image::from_encoded`, which silently returned `None` for JPEG posters before.
skia-safe = { version = "0.99", features = ["vulkan", "textlayout"] }
ash = { version = "0.38", features = ["loaded"] }
anyhow = "1"
+9 -9
View File
@@ -7,7 +7,7 @@
use crate::theme::{fg, Fonts, W};
use punktfunk_core::config::GamepadPref;
use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect};
use skia_safe::{Canvas, Paint, PathBuilder, Point, RRect, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlyphStyle {
@@ -294,12 +294,12 @@ fn draw_glyph(
let r = BADGE_D * k / 2.0;
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
let mut up = Path::new();
let mut up = PathBuilder::new();
up.move_to((cx, cyf - th));
up.line_to((cx - tw, cyf + th));
up.line_to((cx + tw, cyf + th));
up.close();
canvas.draw_path(&up, &Paint::new(fg(0.85), None));
canvas.draw_path(&up.detach(), &Paint::new(fg(0.85), None));
}
Resolved::Adjust => {
// ◀ ▶ — two small solid triangles.
@@ -308,18 +308,18 @@ fn draw_glyph(
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
let gap = (2.6 * k) as f32;
let paint = Paint::new(fg(0.85), None);
let mut left = Path::new();
let mut left = PathBuilder::new();
left.move_to((cx - gap, cyf - th));
left.line_to((cx - gap - tw, cyf));
left.line_to((cx - gap, cyf + th));
left.close();
canvas.draw_path(&left, &paint);
let mut right = Path::new();
canvas.draw_path(&left.detach(), &paint);
let mut right = PathBuilder::new();
right.move_to((cx + gap, cyf - th));
right.line_to((cx + gap + tw, cyf));
right.line_to((cx + gap, cyf + th));
right.close();
canvas.draw_path(&right, &paint);
canvas.draw_path(&right.detach(), &paint);
}
Resolved::Key(text) => {
let w = keycap_w(fonts, text, k);
@@ -377,12 +377,12 @@ fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32
}
Face::Y => {
// △
let mut tri = Path::new();
let mut tri = PathBuilder::new();
tri.move_to((cx, cy - r * 1.2));
tri.line_to((cx + r * 1.15, cy + r * 0.85));
tri.line_to((cx - r * 1.15, cy + r * 0.85));
tri.close();
canvas.draw_path(&tri, &p);
canvas.draw_path(&tri.detach(), &p);
}
}
}
+13 -11
View File
@@ -13,7 +13,7 @@ use crate::pointer::{Pointer, PointerKind};
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect};
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, PathBuilder, Point, RRect, Rect};
const TILE_W: f64 = 340.0;
const TILE_H: f64 = 224.0;
@@ -526,18 +526,20 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
let rr = RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32);
if filled {
let mut p = Paint::default();
p.set_shader(skia_safe::gradient_shader::linear(
let colors = [accent(1.0), accent(0.68)];
p.set_shader(skia_safe::gradient::shaders::linear_gradient(
(
Point::new(badge.left, badge.top),
Point::new(badge.left, badge.bottom),
),
skia_safe::gradient_shader::GradientShaderColors::Colors(&[
accent(1.0).to_color(),
accent(0.68).to_color(),
]),
None,
skia_safe::TileMode::Clamp,
None,
&skia_safe::gradient::Gradient::new(
skia_safe::gradient::Colors::new_evenly_spaced(
&colors,
skia_safe::TileMode::Clamp,
None,
),
skia_safe::gradient::Interpolation::default(),
),
None,
));
canvas.draw_rrect(rr, &p);
@@ -586,7 +588,7 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.6 * k) as f32);
p.set_anti_alias(true);
let mut shackle = Path::new();
let mut shackle = PathBuilder::new();
let (cx, r) = (x + body_w / 2.0, 3.2 * k);
shackle.move_to(((cx - r) as f32, body_top as f32));
shackle.arc_to(
@@ -600,7 +602,7 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
180.0,
false,
);
canvas.draw_path(&shackle, &p);
canvas.draw_path(&shackle.detach(), &p);
}
#[cfg(test)]
+11 -11
View File
@@ -3,7 +3,7 @@
use crate::anim::{approach, ease_out_cubic};
use crate::glyphs::{hint_bar, Hint, HintKey};
use crate::theme::{fg, Fonts, PanelStroke, W};
use skia_safe::{gradient_shader, Canvas, Paint, Point, Rect, TileMode};
use skia_safe::{gradient, Canvas, Paint, Point, Rect, TileMode};
use super::{Shell, BOTTOM_BAND};
@@ -170,16 +170,16 @@ impl Shell {
// A soft pool of shade under the centre seats the text against a bright field —
// dark on a dark palette, light on a pale one, so it always separates.
let mut vignette = Paint::default();
vignette.set_shader(gradient_shader::radial(
Point::new(cx as f32, (h / 2.0) as f32),
(w.max(h) * 0.42) as f32,
gradient_shader::GradientShaderColors::Colors(&[
crate::theme::shade(0.5).to_color(),
crate::theme::shade(0.0).to_color(),
]),
None,
TileMode::Clamp,
None,
let shades = [crate::theme::shade(0.5), crate::theme::shade(0.0)];
vignette.set_shader(gradient::shaders::radial_gradient(
(
Point::new(cx as f32, (h / 2.0) as f32),
(w.max(h) * 0.42) as f32,
),
&gradient::Gradient::new(
gradient::Colors::new_evenly_spaced(&shades, TileMode::Clamp, None),
gradient::Interpolation::default(),
),
None,
));
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette);
+16 -12
View File
@@ -236,21 +236,25 @@ impl Overlay for SkiaOverlay {
}
}
};
let backend_builder = skvk::BackendContext::new_builder(
shared.instance.handle().as_raw() as _,
shared.physical_device.as_raw() as _,
shared.device.handle().as_raw() as _,
(
shared.queue.as_raw() as _,
shared.queue_family_index as usize,
),
&get_proc,
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, so it caps entry-point
// validation at whatever `vkEnumerateInstanceVersion()` reports — byte-for-byte what
// the (now removed) `BackendContext::new` did. The presenter owns the instance and its
// `VkApplicationInfo`, so pinning a version here would just duplicate its choice.
None,
);
// SAFETY: the instance/physical-device/device handles come from `shared`, which owns them
// and outlives this backend context, and `get_proc` above resolves through those same
// handles. Skia stores them but does not take ownership — teardown stays ours.
let backend = unsafe {
skvk::BackendContext::new(
shared.instance.handle().as_raw() as _,
shared.physical_device.as_raw() as _,
shared.device.handle().as_raw() as _,
(
shared.queue.as_raw() as _,
shared.queue_family_index as usize,
),
&get_proc,
)
};
let backend = unsafe { backend_builder.build() };
let mut context = gpu::direct_contexts::make_vulkan(&backend, None)
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
+8 -10
View File
@@ -10,8 +10,8 @@ use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
};
use skia_safe::{
gradient_shader, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect,
Point, RRect, Rect, TileMode, Typeface,
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
RRect, Rect, TileMode, Typeface,
};
// --- Ink ----------------------------------------------------------------------------------
@@ -166,18 +166,16 @@ pub(crate) fn panel(
sp.set_color4f(accent(alpha), None);
}
PanelStroke::Gradient | PanelStroke::GradientDashed => {
sp.set_shader(gradient_shader::linear(
let colors = [fg(0.22), fg(0.04)];
sp.set_shader(gradient::shaders::linear_gradient(
(
Point::new(rect.left, rect.top),
Point::new(rect.left, rect.bottom),
),
gradient_shader::GradientShaderColors::Colors(&[
fg(0.22).to_color(),
fg(0.04).to_color(),
]),
None,
TileMode::Clamp,
None,
&gradient::Gradient::new(
gradient::Colors::new_evenly_spaced(&colors, TileMode::Clamp, None),
gradient::Interpolation::default(),
),
None,
));
if matches!(stroke, PanelStroke::GradientDashed) {
+9 -9
View File
@@ -9,7 +9,7 @@ use crate::library::{BUMP_C, BUMP_K};
use crate::pointer::{Pointer, PointerKind};
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Paint, Path, RRect, Rect};
use skia_safe::{Canvas, Paint, PathBuilder, RRect, Rect};
// --- Menu list -----------------------------------------------------------------------------
@@ -479,11 +479,11 @@ fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
p.set_stroke_width((1.8 * r / 4.0) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let mut path = Path::new();
let mut path = PathBuilder::new();
path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
path.line_to(((x - dir * r / 2.0) as f32, (cy + r) as f32));
canvas.draw_path(&path, &p);
canvas.draw_path(&path.detach(), &p);
}
// --- On-screen keyboard ----------------------------------------------------------------------
@@ -785,12 +785,12 @@ fn draw_space_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Co
// ⎵ — an underline bracket.
let (w, h) = (16.0 * k, 5.0 * k);
let p = stroke_paint(ink, (1.6 * k) as f32);
let mut path = Path::new();
let mut path = PathBuilder::new();
path.move_to(((cx - w / 2.0) as f32, (cy - h / 2.0) as f32));
path.line_to(((cx - w / 2.0) as f32, (cy + h / 2.0) as f32));
path.line_to(((cx + w / 2.0) as f32, (cy + h / 2.0) as f32));
path.line_to(((cx + w / 2.0) as f32, (cy - h / 2.0) as f32));
canvas.draw_path(&path, &p);
canvas.draw_path(&path.detach(), &p);
}
fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
@@ -799,14 +799,14 @@ fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe
let nose = 6.0 * k;
let p = stroke_paint(ink, (1.6 * k) as f32);
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
let mut path = Path::new();
let mut path = PathBuilder::new();
path.move_to(((l + nose) as f32, t as f32));
path.line_to((r as f32, t as f32));
path.line_to((r as f32, b as f32));
path.line_to(((l + nose) as f32, b as f32));
path.line_to((l as f32, cy as f32));
path.close();
canvas.draw_path(&path, &p);
canvas.draw_path(&path.detach(), &p);
let (xc, xr) = (cx + nose / 2.0, 2.6 * k);
canvas.draw_line(
((xc - xr) as f32, (cy - xr) as f32),
@@ -823,11 +823,11 @@ fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe
fn draw_check(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
let p = stroke_paint(ink, (1.8 * k) as f32);
let r = 5.0 * k;
let mut path = Path::new();
let mut path = PathBuilder::new();
path.move_to(((cx - r) as f32, cy as f32));
path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32));
path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32));
canvas.draw_path(&path, &p);
canvas.draw_path(&path.detach(), &p);
}
#[cfg(test)]
+2 -2
View File
@@ -54,7 +54,7 @@ libc = "0.2"
# the dep stays unconditional to mirror the host's Linux target — unused-but-declared is harmless).
ash = "0.38"
# `libnvidia-encode.so.1` is dlopen'd at runtime for the direct-SDK NVENC/CUDA backend.
libloading = "0.8"
libloading = "0.9"
# Direct-SDK NVENC (raw `sys::nvEncodeAPI` types; entry points resolved at runtime). `ci-check` =
# vendored bindings, no CUDA toolkit at build.
nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true }
@@ -67,7 +67,7 @@ nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional =
# AMD (AMF) + Intel (QSV) hardware encode via libavcodec (behind `amf-qsv`; link-imports FFmpeg).
ffmpeg-next = { version = "9", optional = true }
# `libnvidia-encode`/`nvEncodeAPI64.dll` resolved at runtime; the NVENC status→cause table dlopen.
libloading = "0.8"
libloading = "0.9"
# Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`.
libvpl-sys = { path = "../libvpl-sys", optional = true }
# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only under
+2 -2
View File
@@ -39,14 +39,14 @@ wayland-protocols = { version = "0.32", features = ["client"] }
wayland-scanner = "0.31"
wayland-backend = "0.3"
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal) + gamescope-EI.
reis = { version = "0.6.1", features = ["tokio"] }
reis = { version = "0.7.1", features = ["tokio"] }
futures-util = "0.3"
# `macros` is for the `tokio::select!` in the libei and steam_usbip worker loops. It used to be
# absent and compile anyway, borrowed from punktfunk-core's `quic` feature via unification — i.e. an
# unrelated crate dropping it would have broken this one.
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "macros"] }
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
xkbcommon = "0.8"
xkbcommon = "0.9"
# Vendored + trimmed usbip server core — presents a virtual Steam Deck over USB/IP for Steam Input.
usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" }
+3 -1
View File
@@ -33,7 +33,9 @@ serde_json = "1"
# runner. Naming it here makes the crate build standalone instead of relying on who else is in
# the selection.
aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] }
base64 = "0.22"
# Feature selection matched to ureq's (and punktfunk-host's) on purpose — 0.23's default-on
# `simd-unsafe` engine stays off, so a currency bump doesn't quietly add unsafe SIMD to the tree.
base64 = { version = "0.23", default-features = false, features = ["std"] }
# Small, sync, bundles webpki roots — no system cert store dependency, which matters on the
# Deck (Decky's embedded Python has no usable roots either; see clients/decky/main.py).
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`, which would
+2 -2
View File
@@ -26,7 +26,7 @@ tracing = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
utoipa = { version = "5", features = ["axum_extras"] }
sha2 = "0.10"
sha2 = "0.11"
hex = "0.4"
[dev-dependencies]
@@ -58,7 +58,7 @@ wayland-backend = "0.3"
bitflags = "2"
# The gamescope bare-spawn splash client (gamescope/splash.rs): pure-Rust X11 core protocol (the
# same no-libxcb-link stance as pf-capture's XFixes cursor source), no extension features needed.
x11rb = { version = "0.13", default-features = false }
x11rb = { version = "0.14", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies]
# Windows-only, all three, and gated here rather than unconditionally so the LINUX build does not
@@ -3791,11 +3791,19 @@ fn plan_bind(
/// the bind only arms for a resolved `punktfunk-gamescope`, whose patch level 2+ paints the pointer
/// into the capture node itself, so `SessionPlan::gamescope_cursor` is false and the reader is
/// never spawned (`session_plan::gamescope_needs_host_cursor`). On the ATTACH route, where the
/// reader IS spawned, it reaches the display over the ABSTRACT socket `@/tmp/.X11-unix/X<n>` —
/// x11rb tries that before the filesystem path, and an abstract socket lives in the network
/// namespace, which this unit does not get one of. If that ever fails, the reader logs and retries
/// forever; the stream runs without a composited pointer. Nothing else host-side opens an X
/// connection: capture is PipeWire, injection is libei/EIS, clipboard is Wayland.
/// reader IS spawned, we never arm this bind — the session is someone else's, started by
/// `gamescope-session-plus`, and its `/tmp` is the real one — so the filesystem socket
/// `/tmp/.X11-unix/X<n>` is exactly where `DISPLAY` says it is and the reader reaches it by path.
/// (`punktfunk-host.service` sets no `PrivateTmp`, on purpose, so the host shares that `/tmp`.)
///
/// That last sentence used to lean on x11rb trying the ABSTRACT socket `@/tmp/.X11-unix/X<n>`
/// first, which would have survived even a bind, since an abstract socket lives in the network
/// namespace and this unit gets none of its own. **x11rb 0.14 dropped the abstract attempt**
/// (`rust_connection::stream`, "Connect to this Unix socket by path"), so the filesystem path is
/// now the only one. Should the two ever have to coexist — a bind armed on a route that also
/// spawns the reader — the reader would not connect; it logs and retries forever, and the stream
/// runs without a composited pointer. Nothing else host-side opens an X connection: capture is
/// PipeWire, injection is libei/EIS, clipboard is Wayland.
struct SessionBind {
wrapper: std::path::PathBuf,
/// The user-owned directory bound over [`X11_SOCKET_DIR`], or `None` when the real one is
+1 -1
View File
@@ -21,7 +21,7 @@ tracing = "0.1"
[dev-dependencies]
# The GPU parity test hashes decoded frames against libavcodec goldens (already
# in the workspace lock via other crates).
sha2 = "0.10"
sha2 = "0.11"
[lints]
workspace = true
+1 -1
View File
@@ -20,7 +20,7 @@ tracing = "0.1"
libc = "0.2"
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `cuda::ffi`.
libloading = "0.8"
libloading = "0.9"
# EGL imports the PipeWire dmabuf, CUDA maps it (`dynamic` = load the NVIDIA libEGL at runtime).
khronos-egl = { version = "6", features = ["dynamic"] }
# Vulkan bridge for LINEAR dmabufs (gamescope): VK_EXT_external_memory_dma_buf import,
+13 -8
View File
@@ -38,12 +38,12 @@ reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the w
# parity is decodable by a stock Moonlight client. (reed-solomon-erasure is Vandermonde and is
# NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause).
fec-rs = { path = "vendor/fec-rs" }
aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
aes-gcm = "0.11" # AES-128-GCM session crypto, matches GameStream
# ChaCha20-Poly1305 session crypto, negotiated by clients without hardware AES (the soft-AES
# armv7 targets — webOS TVs — where GCM caps decrypt at ~100 Mbps; ARX runs 4-7x faster there).
# Same RustCrypto `aead 0.5` generation as aes-gcm: identical trait/nonce/tag shapes, pure Rust,
# Same RustCrypto `aead 0.6` generation as aes-gcm: identical trait/nonce/tag shapes, pure Rust,
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
chacha20poly1305 = "0.10"
chacha20poly1305 = "0.11"
zerocopy = { version = "0.8", features = ["derive"] }
socket2 = { version = "0.6", features = [
"all",
@@ -55,7 +55,12 @@ zeroize = "1"
# Interface enumeration for Wake-on-LAN: computes each NIC's subnet-directed broadcast so a
# magic packet reaches the host's L2 segment on multi-homed clients (VPN/docker/multiple LANs),
# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake.
if-addrs = "0.13"
# `link-local` is named EXPLICITLY, not inherited. mdns-sd declares if-addrs with it, so any build
# containing both (every host and every client) unifies it on regardless — and a crate whose
# enumeration silently changes depending on who else is in the selection is the worst of both. On
# means fe80::/169.254 interfaces are enumerated too, which for WoL is the behaviour we want: a NIC
# is wake-capable whether or not it currently holds a routable address.
if-addrs = { version = "0.15", features = ["link-local"] }
# Crypto backend is aws-lc-rs, and rustls/quinn/rcgen must all name it: they each select a
# backend independently, so one dissenter pulls a SECOND crypto stack in via feature unification.
@@ -72,7 +77,7 @@ quinn = { version = "0.11", optional = true, default-features = false, features
] }
rustls = { version = "0.23", optional = true, default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std"] }
# `generate_simple_self_signed` is backend-agnostic, so the swap is transparent here.
rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs", "pem"] }
rcgen = { version = "0.14", optional = true, default-features = false, features = ["aws_lc_rs", "pem"] }
rustls-pki-types = { version = "1", optional = true }
# `rustls-no-provider`, NOT the default `rustls` feature — ureq's `rustls` feature body pulls
# `_ring`, which would drag the whole ring backend back into a tree that has deliberately moved to
@@ -82,8 +87,8 @@ ureq = { version = "3", optional = true, default-features = false, features = [
"rustls-webpki-roots",
"gzip",
] }
sha2 = { version = "0.10", optional = true }
hmac = { version = "0.12", optional = true }
sha2 = { version = "0.11", optional = true }
hmac = { version = "0.13", optional = true }
spake2 = { version = "0.4", optional = true }
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
# In-core Opus (multistream) DECODE for the C-ABI `punktfunk_connection_next_audio_pcm` path —
@@ -121,7 +126,7 @@ windows-sys = { version = "0.59", features = [
proptest = "1"
# Tier-1 microbenchmarks (benches/pipeline.rs). default-features off → no plotters/HTML (headless
# CI just needs the measurement + target/criterion/**/estimates.json for the regression compare).
criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] }
criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] }
[[bench]]
name = "pipeline"
+5 -1
View File
@@ -10,11 +10,15 @@
//! The GPU capture/NVENC encode path is deliberately out of scope here (no GPU in CI) — that's the
//! Tier-3 stream benchmark on a self-hosted GPU runner. Run locally with `cargo bench -p punktfunk-core`.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use punktfunk_core::crypto::{SessionCrypto, SessionKey};
use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair;
// NOT `criterion::black_box`: it still exists in 0.8 but is deprecated, and now just forwards to
// this one. Benches compile under `--all-targets -D warnings`, so importing criterion's would fail
// the lint gate rather than merely warn.
use std::hint::black_box;
const TAG_LEN: usize = 16; // AEAD authentication tag (GCM and Poly1305 share the size)
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
+25 -26
View File
@@ -30,8 +30,8 @@
use crate::config::Role;
use crate::error::{PunktfunkError, Result};
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
use aes_gcm::{Aes128Gcm, Key, Nonce};
use aes_gcm::aead::{Aead, AeadInOut, KeyInit, Payload};
use aes_gcm::Aes128Gcm;
use chacha20poly1305::ChaCha20Poly1305;
use zeroize::Zeroize;
@@ -95,7 +95,7 @@ impl Zeroize for SessionKey {
}
/// The two negotiated AEADs behind one seal/open surface. Both are the same RustCrypto
/// `aead 0.5` generation (identical trait shapes, nonce/tag types), so each call below is a
/// `aead 0.6` generation (identical trait shapes, nonce/tag types), so each call below is a
/// two-arm match right next to the cipher work itself.
// AES's precomputed round keys (~0.7 KB) dwarf ChaCha's 32-byte state, but there is exactly
// one long-lived `SessionCrypto` per session — boxing the variant would trade that one-off
@@ -117,12 +117,12 @@ pub struct SessionCrypto {
impl SessionCrypto {
pub fn new(key: &SessionKey, salt: [u8; 4], role: Role) -> Self {
let cipher = match key {
SessionKey::Aes128Gcm(k) => {
Cipher::Aes128Gcm(Aes128Gcm::new(Key::<Aes128Gcm>::from_slice(k)))
// `&[u8; N] -> &Array<u8, UN>` is a checked-at-compile-time reference cast (the
// `hybrid_array` successor to `generic-array`'s runtime-length `from_slice`).
SessionKey::Aes128Gcm(k) => Cipher::Aes128Gcm(Aes128Gcm::new(k.into())),
SessionKey::ChaCha20Poly1305(k) => {
Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new(k.into()))
}
SessionKey::ChaCha20Poly1305(k) => Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new(
Key::<ChaCha20Poly1305>::from_slice(k),
)),
};
let own = direction(role);
SessionCrypto {
@@ -142,8 +142,8 @@ impl SessionCrypto {
aad: &aad,
};
match &self.cipher {
Cipher::Aes128Gcm(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
Cipher::ChaCha20Poly1305(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
Cipher::Aes128Gcm(c) => c.encrypt((&nonce).into(), payload),
Cipher::ChaCha20Poly1305(c) => c.encrypt((&nonce).into(), payload),
}
.map_err(|_| PunktfunkError::Crypto)
}
@@ -160,10 +160,10 @@ impl SessionCrypto {
let aad = seq.to_be_bytes();
let tag = match &self.cipher {
Cipher::Aes128Gcm(c) => {
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
c.encrypt_inout_detached((&nonce).into(), &aad, plaintext.into())
}
Cipher::ChaCha20Poly1305(c) => {
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
c.encrypt_inout_detached((&nonce).into(), &aad, plaintext.into())
}
}
.map_err(|_| PunktfunkError::Crypto)?;
@@ -180,8 +180,8 @@ impl SessionCrypto {
aad: &aad,
};
match &self.cipher {
Cipher::Aes128Gcm(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
Cipher::ChaCha20Poly1305(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
Cipher::Aes128Gcm(c) => c.decrypt((&nonce).into(), payload),
Cipher::ChaCha20Poly1305(c) => c.decrypt((&nonce).into(), payload),
}
.map_err(|_| PunktfunkError::Crypto)
}
@@ -201,19 +201,18 @@ impl SessionCrypto {
let split = buf.len() - TAG_LEN;
let (ciphertext, tag) = buf.split_at_mut(split);
let aad = seq.to_be_bytes();
// `split_at_mut` above already fixed this at exactly TAG_LEN, and the two AEADs share the
// one 16-byte tag type (the const asserts at the top of the module), so this reference cast
// is infallible and serves both arms — mapped rather than unwrapped to keep the hot path
// panic-free.
let tag: &aes_gcm::Tag = (&*tag).try_into().map_err(|_| PunktfunkError::Crypto)?;
match &self.cipher {
Cipher::Aes128Gcm(c) => c.decrypt_in_place_detached(
Nonce::from_slice(&nonce),
&aad,
ciphertext,
aes_gcm::Tag::from_slice(tag),
),
Cipher::ChaCha20Poly1305(c) => c.decrypt_in_place_detached(
Nonce::from_slice(&nonce),
&aad,
ciphertext,
chacha20poly1305::Tag::from_slice(tag),
),
Cipher::Aes128Gcm(c) => {
c.decrypt_inout_detached((&nonce).into(), &aad, ciphertext.into(), tag)
}
Cipher::ChaCha20Poly1305(c) => {
c.decrypt_inout_detached((&nonce).into(), &aad, ciphertext.into(), tag)
}
}
.map_err(|_| PunktfunkError::Crypto)?;
Ok(split)
+2 -2
View File
@@ -131,7 +131,7 @@ pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoi
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der());
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
}
@@ -198,7 +198,7 @@ fn server_from_der(
pub fn generate_identity() -> anyhow_result::Result<(String, String)> {
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
Ok((cert.cert.pem(), cert.key_pair.serialize_pem()))
Ok((cert.cert.pem(), cert.signing_key.serialize_pem()))
}
/// Fingerprint of the client certificate a connection presented (host side), if any.
+3 -2
View File
@@ -1,7 +1,7 @@
//! SPAKE2 password-authenticated key exchange for pairing: derive a shared key from the PIN and
//! confirm it against both certificate fingerprints.
use crate::error::{PunktfunkError, Result};
use hmac::{Hmac, Mac};
use hmac::{Hmac, KeyInit, Mac};
use spake2::{Ed25519Group, Identity, Password, Spake2};
/// In-progress SPAKE2 state plus the identity transcript for key confirmation.
@@ -36,8 +36,9 @@ pub fn start(
/// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed
/// by the SPAKE2 shared key and bound to the fingerprint transcript.
fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] {
// `new_from_slice` moved from `Mac` to `KeyInit` in the digest 0.11 / crypto-common 0.2 wave.
let mut mac =
<Hmac<sha2::Sha256> as Mac>::new_from_slice(key).expect("hmac takes any key length");
<Hmac<sha2::Sha256> as KeyInit>::new_from_slice(key).expect("hmac takes any key length");
mac.update(label);
mac.update(transcript);
mac.finalize().into_bytes().into()
+55 -15
View File
@@ -68,26 +68,59 @@ tracing-log = "0.2"
# tracing-log bridge with `ignore_crate("wasapi")`). Already in the tree transitively.
log = "0.4"
axum = "0.8"
mdns-sd = "0.20"
mdns-sd = "0.21"
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
# `mac_address` reads a NIC's hardware address; `if-addrs` maps the routed IP to its interface name.
mac_address = "1"
if-addrs = "0.13"
# `link-local` named explicitly for the same reason as in punktfunk-core: mdns-sd (right above)
# turns it on by unification anyway, and `wake_macs` should not enumerate a different set of NICs
# depending on which other crates share the build.
if-addrs = { version = "0.15", features = ["link-local"] }
tokio = { version = "1", features = ["full"] }
# GameStream-only (behind the `gamestream` feature): the Moonlight RSA-2048 identity generator +
# pairing signer (cert.rs, pairing.rs) and the legacy-client-cert leniency verifier (tls.rs).
# The native planes use the P-256 identity (src/identity.rs) and never touch this crate — so a
# native-only build also sheds the accepted Marvin advisory (RUSTSEC-2023-0071, .cargo/audit.toml).
rsa = { version = "0.9", optional = true }
sha2 = { version = "0.10", features = ["oid"] }
aes = "0.8"
aes-gcm = "0.10"
cbc = { version = "0.1", features = ["alloc"] }
rand = "0.8"
#
# TWO independent `rsa` features, arriving from two different bumps in this wave — both are load-
# bearing, and taking either side alone breaks the build:
#
# `sha2` re-exports the `sha2` that `rsa`'s OWN trait generation speaks. `rsa` 0.9 is built on
# `digest 0.10`, and the rest of this crate is on the `digest 0.11` wave (sha2 0.11 / hmac 0.13) —
# so the digest types that appear as `rsa` TYPE PARAMETERS (`SigningKey<Sha256>` in cert.rs,
# `VerifyingKey<_>` in pairing.rs and tls.rs) must come from `rsa::sha2`, not from the `sha2`
# below. Everything else (plain hashing, HMAC) uses the 0.11 one. Collapsing the two needs `rsa`
# 0.10, which is still release-candidate only — not something the Moonlight pairing ceremony
# should ride.
#
# `getrandom` is NOT one of rsa's defaults; we ask for it because `RsaPrivateKey::new` wants an
# rng implementing rand_core **0.6**'s traits, and since our own `rand` moved to 0.9 no rng we
# hold satisfies that bound any more. The feature exposes `rsa::rand_core::OsRng`, which does —
# see the keygen in gamestream/cert.rs. Getting it from rsa's own re-export is what keeps the two
# rand_core majors from being something this crate has to name.
rsa = { version = "0.9", optional = true, features = ["sha2", "getrandom"] }
# `oid` is default-on in sha2 0.11 and was only ever needed for the RSA signer, which now takes
# its digest from `rsa::sha2` (above).
sha2 = "0.11"
aes = "0.9"
aes-gcm = "0.11"
cbc = { version = "0.2", features = ["alloc"] }
# 0.9, matching punktfunk-core and pf-client-core — the host was the last 0.8 holdout, and that
# was drift, not a pin. NOTE this does NOT take rand 0.8 out of the gamestream build: `rsa` pulls
# it transitively through `num-bigint-dig`, so 0.8 only disappears from the native-only host
# (`--no-default-features`, where `rsa` is absent). What it does buy everywhere is that OUR calls
# all speak one rand major.
rand = "0.9"
hex = "0.4"
# Cover-art delivery in the game library: encode Lutris's local JPEGs into `data:` URLs and decode
# the Epic launcher's base64 `catcache.bin`. Cross-platform (Linux Lutris art + Windows Epic art).
base64 = "0.22"
# `default-features = false` + `std` deliberately: 0.23 added a default-ON `simd-unsafe` feature
# (hand-written AVX2/NEON engines). ureq already declares base64 exactly this way, so taking the
# defaults here would unify the feature ON and pull that unsafe code into every build as a side
# effect of a version bump. Turning it on is a perf decision worth making on purpose, with a
# measurement — not one to inherit silently. `std` covers everything we call (`Engine::encode`,
# `decode` to a `Vec`).
base64 = { version = "0.23", default-features = false, features = ["std"] }
# Blocking HTTP for the library cover-art warmer (no-auth GOG api.gog.com + Xbox displaycatalog),
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
@@ -99,8 +132,12 @@ ureq = { version = "3", default-features = false, features = [
"rustls-webpki-roots",
"gzip",
] }
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
x509-parser = "0.16"
rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem"] }
# ⚠ Do not float this back below 0.18. 0.16 was the LAST crate pulling `thiserror` 1.0 into the
# host graph (via asn1-rs 0.6 / der-parser 9 / oid-registry 0.7); 0.18 moves that chain to asn1-rs
# 0.7 + thiserror 2, so the host now links exactly one thiserror major. `cargo tree -p
# punktfunk-host -i thiserror@1` must keep reporting no match.
x509-parser = "0.18"
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
@@ -122,7 +159,7 @@ tower = { version = "0.5", features = ["util"] }
futures-util = "0.3"
# Webhook signing (X-Punktfunk-Signature: sha256=<hex HMAC>) for operator hooks; pairs with
# the existing sha2. Already in the lockfile transitively.
hmac = "0.12"
hmac = "0.13"
# GameStream control-stream ENet — a c2rust-style transpile of C ENet (158 unsafe sites; raw
# pointer arithmetic, manual allocation). Its port binds only while a pairing exists (rust-safety
# WP0, gamestream/control.rs) and the whole crate exists only behind the `gamestream` feature
@@ -188,7 +225,7 @@ pipewire = "0.9"
rusqlite = { version = "0.40", features = ["bundled"] }
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
libloading = "0.8"
libloading = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
@@ -278,7 +315,10 @@ windows = { version = "0.62", features = [
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
# the `windows` crate above.
windows-service = "0.7"
# ⭐ Keep this at 0.8+: 0.7 was the LAST crate in the tree pulling `windows-sys 0.52`, so it alone
# kept a fourth windows-sys major compiling. 0.8.1 moves to `windows-sys 0.61`, which the tree
# already builds, and that duplicate is gone. (0.8.0 is NOT enough — it lands on 0.59.)
windows-service = "0.8"
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
winreg = "0.56"
@@ -286,7 +326,7 @@ winreg = "0.56"
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
roxmltree = "0.21"
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
wasapi = "0.23"
wasapi = "0.24"
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend: the
# control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs, defined ONCE so host<->driver
# ABI drift is a compile error (used from `capture.rs`). The `bytemuck` that serializes those
@@ -509,9 +509,10 @@ pub(crate) fn restore_default_playback() {
/// Open a device by endpoint id, with a name for error context.
///
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` that one hands `GetDevice` a freed string (see the helper's
/// docs), so it fails at random on ids that are perfectly valid.
/// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's
/// `DeviceEnumerator::get_device`: that one handed `GetDevice` a freed string through 0.23, so it
/// failed at random on ids that are perfectly valid. `wasapi 0.24` fixed that, but we keep the one
/// resolution path — see the helper's docs.
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
super::pad_endpoint::open_wasapi_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
@@ -911,13 +911,17 @@ fn open_mmdevice(endpoint_id: &str) -> Result<IMMDevice> {
/// Open a [`wasapi::Device`] for an endpoint id WITHOUT the crate's `DeviceEnumerator::get_device`.
///
/// `wasapi 0.23` builds that call's argument as
/// `PCWSTR::from_raw(HSTRING::from(device_id).as_ptr())`. The `HSTRING` is a temporary, so it is
/// dropped at the end of THAT statement and `IMMDeviceEnumerator::GetDevice` reads freed memory on
/// the next line. Whether the endpoint is found then depends on what the allocator happened to
/// Through `wasapi 0.23` that call built its argument as
/// `PCWSTR::from_raw(HSTRING::from(device_id).as_ptr())`. The `HSTRING` was a temporary, so it was
/// dropped at the end of THAT statement and `IMMDeviceEnumerator::GetDevice` read freed memory on
/// the next line. Whether the endpoint was found then depended on what the allocator happened to
/// leave behind — a heisenbug whose failure mode is `0x80070002` (ERROR_FILE_NOT_FOUND) for an id
/// that is perfectly valid. [`open_mmdevice`] keeps its wide buffer alive across the call, so
/// resolve there and only borrow the crate's wrapper around the resulting interface.
/// that is perfectly valid. **`wasapi 0.24` fixed this upstream** (the `HSTRING` is now bound to a
/// local that outlives the call), so this helper is no longer load-bearing for correctness.
///
/// We still resolve here, because the `IMMDevice` is wanted in its own right: [`probe_activation`]
/// and the property-store readers below need the raw interface, so routing every lookup through
/// [`open_mmdevice`] keeps ONE resolution path whose errors name the endpoint id.
pub(crate) fn open_wasapi_device(endpoint_id: &str) -> Result<wasapi::Device> {
let dev = open_mmdevice(endpoint_id)?;
wasapi::Device::from_immdevice(dev)
@@ -747,9 +747,10 @@ enum DefaultKind {
Unknown,
}
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` that one hands `GetDevice` a freed string (see the helper's
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
/// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's
/// `DeviceEnumerator::get_device`: that one handed `GetDevice` a freed string through 0.23, and a
/// spurious miss here silently downgrades a capturable default to `Unknown`. `wasapi 0.24` fixed
/// that, but we keep the one resolution path — see the helper's docs.
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
return DefaultKind::Unknown;
@@ -24,7 +24,7 @@ use {
super::AUDIO_PORT,
crate::audio::{self, AudioCapturer},
anyhow::{Context, Result},
cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit},
cbc::cipher::{block_padding::Pkcs7, BlockModeEncrypt, KeyIvInit},
std::net::UdpSocket,
std::sync::atomic::{AtomicBool, Ordering},
std::sync::Arc,
@@ -429,7 +429,7 @@ fn audio_body(
let mut iv = [0u8; 16];
iv[0..4].copy_from_slice(&iv_seq.to_be_bytes());
let ct = Aes128CbcEnc::new(gcm_key.into(), (&iv).into())
.encrypt_padded_vec_mut::<Pkcs7>(&out[..n]);
.encrypt_padded_vec::<Pkcs7>(&out[..n]);
let pkt = build_rtp(seq, timestamp, &ct);
if sock.send(&pkt).is_err() {
tracing::info!(sent, "audio: client unreachable — ending session");
+210 -9
View File
@@ -7,7 +7,9 @@ use pf_paths::config_dir;
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
use rsa::RsaPrivateKey;
use sha2::Sha256;
// `rsa`'s own re-export: this `Sha256` is a TYPE PARAMETER to `SigningKey`, so it has to be the
// one `rsa 0.9`'s `digest 0.10` traits speak — not the crate-wide `sha2 0.11`. See Cargo.toml.
use rsa::sha2::Sha256;
use std::fs;
pub struct ServerIdentity {
@@ -78,14 +80,22 @@ impl ServerIdentity {
}
fn generate() -> Result<(String, String)> {
// The workspace is ring-only (aws-lc-sys breaks Windows CI — see the rustls/rcgen pins), and
// `ring` can *sign* with an existing RSA key but cannot *generate* one: rcgen's ring backend
// returns `KeyGenerationUnavailable` for `generate_for(&PKCS_RSA_SHA256)`. Moonlight requires an
// RSA-2048 identity, so generate the key with the pure-Rust `rsa` crate (already a dep for the
// pairing signer) and hand the PKCS#8 PEM to rcgen, whose ring backend *can* load + self-sign
// with it. Returning that same PEM keeps it byte-identical to what `from_pems` re-parses.
let mut rng = rand::thread_rng();
let priv_key = RsaPrivateKey::new(&mut rng, 2048).context("generate RSA-2048 host key")?;
// rcgen cannot *generate* an RSA key on either backend — `generate_for(&PKCS_RSA_SHA256)`
// returns `KeyGenerationUnavailable`. Moonlight requires an RSA-2048 identity, so generate the
// key with the pure-Rust `rsa` crate (already a dep for the pairing signer) and hand the PKCS#8
// PEM to rcgen, which *can* load an existing RSA key and self-sign with it. Returning that same
// PEM keeps it byte-identical to what `from_pems` re-parses.
//
// This path runs ONLY when no cert exists yet — a fresh install — so an upgraded box never
// re-executes it.
//
// The rng comes from `rsa`'s OWN rand_core re-export, not from our `rand`. `RsaPrivateKey::new`
// is bounded on rand_core **0.6**'s `CryptoRngCore`, and since the host moved to rand 0.9 its
// `ThreadRng` implements rand_core 0.9's traits instead — a different trait of the same name,
// so it no longer satisfies the bound. `rsa::rand_core::OsRng` is the OS CSPRNG under the
// exact traits `rsa` compiled against, which keeps the two rand_core majors from meeting.
let priv_key = RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048)
.context("generate RSA-2048 host key")?;
let key_pem = priv_key
.to_pkcs8_pem(LineEnding::LF)
.context("encode host key as PKCS#8 PEM")?
@@ -109,3 +119,194 @@ fn cert_signature(cert_pem: &str) -> Result<Vec<u8>> {
let x509 = pem.parse_x509().context("parse x509")?;
Ok(x509.signature_value.data.to_vec())
}
/// Coverage for what the aws-lc-rs migration (#192) changed here but shipped unverified.
///
/// `generate()` was already reached by other tests via `ServerIdentity::ephemeral()`, but only ever
/// as an unasserted fixture — nothing checked that what came back was still an RSA-2048 identity,
/// which is the one property Moonlight requires. The handshake behaviour had no coverage at all,
/// and the GameStream TLS path is the single place where a legacy peer meets the new backend, so
/// a backend or feature regression there would surface first in the field.
#[cfg(test)]
mod tests {
use super::*;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::{
ClientConfig, ClientConnection, DigitallySignedStruct, ServerConnection, SignatureScheme,
};
use std::sync::Arc;
/// Moonlight does not chain-verify the host: it pins the cert by SHA-256 out of band, exactly
/// as our own `AcceptAnyClientCert` does in the other direction. Modelling that is what makes
/// this a Moonlight-shaped peer rather than a webpki-clean one.
#[derive(Debug)]
struct PinsOutOfBand(Arc<CryptoProvider>);
impl ServerCertVerifier for PinsOutOfBand {
fn verify_server_cert(
&self,
_e: &CertificateDer,
_i: &[CertificateDer],
_s: &ServerName,
_o: &[u8],
_n: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
m: &[u8],
c: &CertificateDer,
d: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(m, c, d, &self.0.signature_verification_algorithms)
}
fn verify_tls13_signature(
&self,
m: &[u8],
c: &CertificateDer,
d: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(m, c, d, &self.0.signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
fn parts(
cert_pem: &str,
key_pem: &str,
) -> (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>) {
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
.collect::<std::result::Result<Vec<_>, _>>()
.expect("cert pem")
.into_iter()
.map(|c| c.into_owned())
.collect();
let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.expect("key pem")
.clone_key();
(certs, key)
}
/// Shuttle handshake bytes between the two ends until both stop handshaking.
fn pump(client: &mut ClientConnection, server: &mut ServerConnection) {
for _ in 0..40 {
while client.wants_write() {
let mut buf = Vec::new();
client.write_tls(&mut buf).expect("client write");
let mut cur = &buf[..];
while !cur.is_empty() {
if server.read_tls(&mut cur).expect("server read") == 0 {
break;
}
server.process_new_packets().expect("server handshake");
}
}
while server.wants_write() {
let mut buf = Vec::new();
server.write_tls(&mut buf).expect("server write");
let mut cur = &buf[..];
while !cur.is_empty() {
if client.read_tls(&mut cur).expect("client read") == 0 {
break;
}
client.process_new_packets().expect("client handshake");
}
}
if !client.is_handshaking() && !server.is_handshaking() {
return;
}
}
panic!("handshake did not converge");
}
/// Run a mutual handshake against the real `tls::server_config` and return the negotiated
/// (protocol version, key exchange group, number of client certs the server saw).
fn handshake_against_host(
versions: &[&'static rustls::SupportedProtocolVersion],
) -> (String, String, usize) {
let (host_cert, host_key) = generate().expect("host identity");
// A Moonlight client's own identity is an RSA-2048 self-signed cert — the same shape.
let (peer_cert, peer_key) = generate().expect("peer identity");
let server_cfg =
crate::gamestream::tls::server_config(&host_cert, &host_key).expect("server config");
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let (pc, pk) = parts(&peer_cert, &peer_key);
let client_cfg = ClientConfig::builder_with_provider(provider.clone())
.with_protocol_versions(versions)
.expect("client versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinsOutOfBand(provider)))
.with_client_auth_cert(pc, pk)
.expect("client auth cert");
let mut server = ServerConnection::new(server_cfg).expect("server conn");
let mut client = ClientConnection::new(
Arc::new(client_cfg),
ServerName::try_from("punktfunk").unwrap(),
)
.expect("client conn");
pump(&mut client, &mut server);
let version = format!("{:?}", client.protocol_version().expect("version"));
let kx = client
.negotiated_key_exchange_group()
.map(|g| format!("{:?}", g.name()))
.unwrap_or_default();
let seen = server.peer_certificates().map(|c| c.len()).unwrap_or(0);
(version, kx, seen)
}
/// The fresh-install path. rcgen cannot generate an RSA key, so this leans on `rsa` for the key
/// and rcgen only to self-sign — a split that must keep working across a crypto-backend change.
#[test]
fn generate_mints_a_loadable_rsa2048_identity() {
let (cert_pem, key_pem) = generate().expect("generate");
assert!(cert_pem.contains("BEGIN CERTIFICATE"), "cert is not PEM");
assert!(
key_pem.contains("BEGIN PRIVATE KEY"),
"key is not PKCS#8 PEM"
);
// Everything downstream (pairing hashes, the TLS server cert) goes through from_pems.
let identity = ServerIdentity::from_pems(cert_pem, key_pem).expect("from_pems");
// An RSA-2048 signature is exactly 256 bytes; this pins the key size that Moonlight needs
// without depending on an `rsa` accessor that could change shape.
assert_eq!(
identity.signature.len(),
256,
"host cert signature should be RSA-2048 (256 bytes)"
);
}
/// GAP 1 from the #192 handoff: a legacy peer negotiating TLS 1.2 against the new backend.
#[test]
fn moonlight_shaped_peer_completes_a_tls12_mutual_handshake() {
let (version, _kx, client_certs_seen) = handshake_against_host(&[&rustls::version::TLS12]);
assert_eq!(version, "TLSv1_2", "Moonlight negotiates TLS 1.2");
assert_eq!(
client_certs_seen, 1,
"mutual TLS: the host must receive the peer's client cert"
);
}
/// GAP 3 from the #192 handoff: `prefer-post-quantum` was asserted from the rustls source and
/// never observed. Pin it, so a provider or feature regression that silently drops ML-KEM back
/// to a classical group fails here instead of in the field.
#[test]
fn tls13_negotiates_the_post_quantum_group() {
let (version, kx, client_certs_seen) = handshake_against_host(&[&rustls::version::TLS13]);
assert_eq!(version, "TLSv1_3");
assert_eq!(
kx, "X25519MLKEM768",
"post-quantum key exchange must be preferred"
);
assert_eq!(client_certs_seen, 1);
}
}
@@ -798,19 +798,19 @@ fn encrypt_control(key: &[u8; 16], scheme: &Scheme, seq: u32, pt: &[u8]) -> Vec<
/// AES-128-GCM seal (companion to [`gcm_open`]); returns `ciphertext || tag`.
fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec<u8> {
use aes_gcm::aead::consts::{U12, U16};
use aes_gcm::aead::generic_array::GenericArray;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{aes::Aes128, AesGcm};
let p = Payload { msg: pt, aad };
// Each arm's `try_into` is guarded by the length it matched on.
match nonce.len() {
12 => AesGcm::<Aes128, U12>::new_from_slice(key)
.unwrap()
.encrypt(GenericArray::from_slice(nonce), p)
.encrypt(nonce.try_into().expect("12-byte nonce"), p)
.expect("GCM seal"),
16 => AesGcm::<Aes128, U16>::new_from_slice(key)
.unwrap()
.encrypt(GenericArray::from_slice(nonce), p)
.encrypt(nonce.try_into().expect("16-byte nonce"), p)
.expect("GCM seal"),
_ => unreachable!("nonce length"),
}
@@ -820,7 +820,6 @@ fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec<u8> {
/// the tag authenticates. `ct_tag` is `ciphertext || tag` (aes-gcm's expected order).
fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option<Vec<u8>> {
use aes_gcm::aead::consts::{U12, U16};
use aes_gcm::aead::generic_array::GenericArray;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{aes::Aes128, AesGcm};
@@ -828,11 +827,11 @@ fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option<V
match nonce.len() {
12 => AesGcm::<Aes128, U12>::new_from_slice(key)
.ok()?
.decrypt(GenericArray::from_slice(nonce), p)
.decrypt(nonce.try_into().ok()?, p)
.ok(),
16 => AesGcm::<Aes128, U16>::new_from_slice(key)
.ok()?
.decrypt(GenericArray::from_slice(nonce), p)
.decrypt(nonce.try_into().ok()?, p)
.ok(),
_ => None,
}
@@ -3,8 +3,7 @@
//! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the
//! `serverinfo + pairing` section of `design/research/gamestream-protocol-research.json`.
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
use aes::cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use aes::Aes128;
use rand::RngCore;
use sha2::{Digest, Sha256};
@@ -12,7 +11,7 @@ use sha2::{Digest, Sha256};
/// `n` cryptographically-random bytes.
pub fn random<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
rand::thread_rng().fill_bytes(&mut b);
rand::rng().fill_bytes(&mut b);
b
}
@@ -41,24 +40,27 @@ pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] {
/// AES-128-ECB encrypt, no padding: input is zero-extended to a 16-byte multiple.
pub fn ecb_encrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
let cipher = Aes128::new(GenericArray::from_slice(key));
let cipher = Aes128::new(key.into());
let mut out = data.to_vec();
let rem = out.len() % 16;
if rem != 0 {
out.resize(out.len() + (16 - rem), 0);
}
for chunk in out.chunks_mut(16) {
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
// The resize above made `out` a whole number of blocks, so every chunk is exactly 16.
let block: &mut Block<Aes128> = chunk.try_into().expect("16-byte block");
cipher.encrypt_block(block);
}
out
}
/// AES-128-ECB decrypt, no padding: trailing bytes past the last whole block are ignored.
pub fn ecb_decrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
let cipher = Aes128::new(GenericArray::from_slice(key));
let cipher = Aes128::new(key.into());
let mut out = Vec::with_capacity(data.len());
for chunk in data.chunks_exact(16) {
let mut block = *GenericArray::from_slice(chunk);
// `chunks_exact(16)` yields only whole blocks; the short tail is dropped, as before.
let mut block: Block<Aes128> = chunk.try_into().expect("16-byte block");
cipher.decrypt_block(&mut block);
out.extend_from_slice(&block);
}
@@ -10,7 +10,9 @@ use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::{SignatureEncoding, Signer, Verifier};
use rsa::RsaPublicKey;
use sha2::Sha256;
// `rsa`'s own re-export — `VerifyingKey<Sha256>` below is an `rsa 0.9` / `digest 0.10` type
// parameter, distinct from the crate-wide `sha2 0.11`. See Cargo.toml.
use rsa::sha2::Sha256;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
+3 -1
View File
@@ -205,7 +205,9 @@ fn accept_legacy_moonlight_cert(
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::Verifier;
use rsa::{pkcs1v15, pss, RsaPublicKey};
use sha2::{Sha256, Sha384, Sha512};
// `rsa`'s own re-export — these are `pkcs1v15`/`pss` type parameters on `rsa 0.9`
// (`digest 0.10`), not the crate-wide `sha2 0.11`. See Cargo.toml.
use rsa::sha2::{Sha256, Sha384, Sha512};
let Ok((_, x509)) = x509_parser::parse_x509_certificate(cert.as_ref()) else {
return Err(webpki_err);
+2 -1
View File
@@ -820,7 +820,8 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
if let Some(path) = secret_file {
match std::fs::read(path) {
Ok(secret) => {
use hmac::{Hmac, Mac};
// `new_from_slice` moved from `Mac` to `KeyInit` in the digest 0.11 wave.
use hmac::{Hmac, KeyInit, Mac};
let mut mac = match Hmac::<sha2::Sha256>::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => {
+3 -2
View File
@@ -113,8 +113,9 @@ pub fn ephemeral() -> Result<NativeIdentity> {
Ok(NativeIdentity { cert_pem, key_pem })
}
/// Generate the P-256 identity: ring CAN generate EC keys (unlike RSA — see `gamestream::cert`'s
/// note), so rcgen's ring backend does the whole thing. SANs cover the names a browser or a
/// Generate the P-256 identity: rcgen CAN generate EC keys, so it does the whole thing here —
/// unlike RSA, which no rcgen backend will generate (see `gamestream::cert`'s note, where the key
/// comes from the `rsa` crate and rcgen only self-signs it). SANs cover the names a browser or a
/// loopback poller actually dials; LAN IPs are deliberately absent (they change, and the native
/// clients pin the fingerprint rather than verify names).
fn generate() -> Result<(String, String)> {
+1 -1
View File
@@ -59,7 +59,7 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
}
}
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
rand::rng().fill_bytes(&mut buf);
let token = hex::encode(buf);
write_token(&path, env_var, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
@@ -502,14 +502,14 @@ pub(super) async fn negotiate(
let shard_payload = wire_mtu::negotiated_shard_payload(conn, hello.max_shard_payload).await;
let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut key);
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
// of the two to be unique per session (the nonce is salt || sequence under the session
// key), but a constant salt would make a key-reuse bug catastrophic instead of merely
// wrong — this keeps the second line of defense real. Negotiated via Welcome, so clients
// just follow.
let mut salt = [0u8; 4];
rand::thread_rng().fill_bytes(&mut salt);
rand::rng().fill_bytes(&mut salt);
// Session AEAD: ChaCha20-Poly1305 when the client asked for it (VIDEO_CAP_CHACHA20 — the
// soft-AES armv7 targets, whose GCM decrypt caps at ~100 Mbps) and the operator
// kill-switch allows (PUNKTFUNK_CHACHA20, default on — pure rollout safety; perf-only,
@@ -520,7 +520,7 @@ pub(super) async fn negotiate(
let chacha = client_wants_chacha && pf_host_config::config().chacha20;
let key_chacha = chacha.then(|| {
let mut k = [0u8; 32];
rand::thread_rng().fill_bytes(&mut k);
rand::rng().fill_bytes(&mut k);
k
});
tracing::info!(
@@ -35,7 +35,7 @@ pub enum PinAttempt {
fn random_pin() -> String {
use rand::Rng;
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
format!("{:04}", rand::rng().random_range(0..10_000u32))
}
/// A snapshot of the arming window for the management API: `(armed, pin, expires_in_secs)`.
+2 -2
View File
@@ -288,9 +288,9 @@ pub(crate) fn inject_video_drop<T>(packets: &mut Vec<T>) -> u64 {
return 0;
}
use rand::Rng;
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
let before = packets.len();
packets.retain(|_| rng.gen_range(0..100) >= pct);
packets.retain(|_| rng.random_range(0..100) >= pct);
(before - packets.len()) as u64
}
+1 -1
View File
@@ -801,7 +801,7 @@ fn random_password() -> String {
use base64::Engine;
use rand::RngCore;
let mut b = [0u8; 24];
rand::thread_rng().fill_bytes(&mut b);
rand::rng().fill_bytes(&mut b);
base64::engine::general_purpose::STANDARD
.encode(b)
.chars()
+3 -2
View File
@@ -38,8 +38,9 @@ rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs",
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls", "ureq-tls"] }
[target.'cfg(windows)'.dependencies]
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
windows-service = "0.7"
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses,
# and it must stay in lockstep with it — see the windows-sys note on the host's declaration.
windows-service = "0.8"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
+16 -8
View File
@@ -10,14 +10,22 @@ set -euo pipefail
cd "$(dirname "$0")/.."
OUT="${1:-THIRD-PARTY-NOTICES.txt}"
if command -v cargo-about >/dev/null 2>&1; then
echo "==> cargo about generate -> $OUT" >&2
cargo about generate about.hbs --output-file "$OUT"
else
echo "==> cargo-about not installed; using offline fallback" >&2
echo " (install the full generator with: cargo install cargo-about)" >&2
python3 scripts/gen-third-party-notices.py --out "$OUT"
fi
# ⚠ The root file goes through the PYTHON generator, NOT `cargo about` — deliberately, and this
# is not a fallback. `cargo about` only ever sees CARGO dependencies, so it silently omits the
# VENDORED_TREES below: pyrowave, the Granite subset, volk, Vulkan-Headers, the Font Awesome brand
# icons and Simple Icons. Those are third-party sources shipped INSIDE first-party crates, each
# under its own licence, and dropping them from an attribution file is a legal regression rather
# than an untidiness. Measured 2026-08-13: `cargo about` produced 7,274 lines / ~514 crates with
# zero mentions of volk, Vulkan-Headers or Font Awesome, against the python generator's 17,324
# lines / 575 crates with all of them. This script used to prefer cargo-about whenever it was
# installed, so simply HAVING it on your PATH silently degraded the file.
#
# `cargo about` is still what the CI licence GATE runs (.gitea/workflows/audit.yml) — that job
# checks every licence is in the about.toml allowlist and writes to /dev/null, which is a
# different question from what this file must contain. If about.hbs ever learns to emit the
# vendored trees, preferring cargo-about here again would be reasonable.
echo "==> gen-third-party-notices.py -> $OUT" >&2
python3 scripts/gen-third-party-notices.py --out "$OUT"
echo "==> wrote $OUT" >&2
# Regenerate the per-client in-tree copies. EVERY client has one now, because every client SHOWS