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
+36
-15
@@ -1,18 +1,39 @@
|
||||
# Workspace-wide build flags.
|
||||
#
|
||||
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
|
||||
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
|
||||
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
|
||||
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
|
||||
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
|
||||
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
|
||||
# extensions falls back safely.
|
||||
# THERE ARE DELIBERATELY NONE. This file is kept as a tombstone so the aarch64 AES cfgs are not
|
||||
# reintroduced — read this before adding rustflags here.
|
||||
#
|
||||
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
|
||||
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
|
||||
# `--cfg aes_armv8` themselves.
|
||||
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
|
||||
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
|
||||
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
|
||||
[target.'cfg(target_arch = "aarch64")']
|
||||
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
||||
# Until 2026-08-13 this file carried:
|
||||
#
|
||||
# [target.'cfg(target_arch = "aarch64")']
|
||||
# rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
||||
#
|
||||
# because RustCrypto's `aes` 0.8.x enabled the ARMv8-Crypto hardware AES backend on aarch64 ONLY
|
||||
# behind `--cfg aes_armv8`, and `polyval` 0.6.x gated its PMULL (carry-less multiply) GHASH path
|
||||
# behind `--cfg polyval_armv8`. That was a live footgun, not just boilerplate: a RUSTFLAGS
|
||||
# ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY — it does not merge and does not
|
||||
# append — so every aarch64 lane that set its own RUSTFLAGS silently dropped both and fell back to
|
||||
# SOFTWARE AES on the per-packet decrypt path. cargo-ndk sets RUSTFLAGS internally for its linker
|
||||
# configuration, which means every Android arm64-v8a build was hitting exactly that.
|
||||
#
|
||||
# `aes` 0.9 removed the cfg: on aarch64 it runtime-detects with `cpufeatures::new!(features_aes,
|
||||
# "aes")` (lib.rs), the same way x86_64 AES-NI always did. `polyval` 0.7 likewise selects
|
||||
# `backend/intrinsics/armv8.rs` by `target_arch` alone. Neither cfg exists any more — passing them
|
||||
# is inert.
|
||||
#
|
||||
# Measured here before deleting them, `crypto/open_in_place` from benches/pipeline.rs (one 1408-byte
|
||||
# MTU shard, AES-128-GCM, single core, Mac15,14 M3 Ultra, all four runs back to back under the same
|
||||
# background load):
|
||||
#
|
||||
# aes 0.8 + both cfgs 2.19 GiB/s <- what the cfgs bought
|
||||
# aes 0.8, cfgs stripped 225 MiB/s <- the footgun: ~10x slower, software AES
|
||||
# aes 0.9 + both cfgs 5.28 GiB/s
|
||||
# aes 0.9, cfgs stripped 5.28 GiB/s <- identical to 4 s.f.; the cfgs do nothing
|
||||
#
|
||||
# The ChaCha20-Poly1305 series of the same bench was the control: it moved 0.07% across the cfg
|
||||
# toggle at both versions, confirming the toggle reached only the AES path.
|
||||
#
|
||||
# So 0.9 without the cfgs is not merely as fast as 0.8 with them — it is ~2.4x faster, and ~24x
|
||||
# the software fallback. Do not re-add these flags; if a future aarch64 slowdown is suspected,
|
||||
# re-run `cargo bench -p punktfunk-core --bench pipeline -- in_place` and compare against the
|
||||
# table above rather than reaching for a cfg.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+377
-733
File diff suppressed because it is too large
Load Diff
+11
-12
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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>()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
|
||||
@@ -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 (~250–500 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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
|
||||
@@ -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
+1989
-1163
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1968
-1142
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect};
|
||||
use skia_safe::{Canvas, Paint, PathBuilder, Point, RRect, Rect};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GlyphStyle {
|
||||
@@ -294,12 +294,12 @@ fn draw_glyph(
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let (cx, cyf) = ((x + r) as f32, cy as f32);
|
||||
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
|
||||
let mut up = Path::new();
|
||||
let mut up = PathBuilder::new();
|
||||
up.move_to((cx, cyf - th));
|
||||
up.line_to((cx - tw, cyf + th));
|
||||
up.line_to((cx + tw, cyf + th));
|
||||
up.close();
|
||||
canvas.draw_path(&up, &Paint::new(fg(0.85), None));
|
||||
canvas.draw_path(&up.detach(), &Paint::new(fg(0.85), None));
|
||||
}
|
||||
Resolved::Adjust => {
|
||||
// ◀ ▶ — two small solid triangles.
|
||||
@@ -308,18 +308,18 @@ fn draw_glyph(
|
||||
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
|
||||
let gap = (2.6 * k) as f32;
|
||||
let paint = Paint::new(fg(0.85), None);
|
||||
let mut left = Path::new();
|
||||
let mut left = PathBuilder::new();
|
||||
left.move_to((cx - gap, cyf - th));
|
||||
left.line_to((cx - gap - tw, cyf));
|
||||
left.line_to((cx - gap, cyf + th));
|
||||
left.close();
|
||||
canvas.draw_path(&left, &paint);
|
||||
let mut right = Path::new();
|
||||
canvas.draw_path(&left.detach(), &paint);
|
||||
let mut right = PathBuilder::new();
|
||||
right.move_to((cx + gap, cyf - th));
|
||||
right.line_to((cx + gap + tw, cyf));
|
||||
right.line_to((cx + gap, cyf + th));
|
||||
right.close();
|
||||
canvas.draw_path(&right, &paint);
|
||||
canvas.draw_path(&right.detach(), &paint);
|
||||
}
|
||||
Resolved::Key(text) => {
|
||||
let w = keycap_w(fonts, text, k);
|
||||
@@ -377,12 +377,12 @@ fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32
|
||||
}
|
||||
Face::Y => {
|
||||
// △
|
||||
let mut tri = Path::new();
|
||||
let mut tri = PathBuilder::new();
|
||||
tri.move_to((cx, cy - r * 1.2));
|
||||
tri.line_to((cx + r * 1.15, cy + r * 0.85));
|
||||
tri.line_to((cx - r * 1.15, cy + r * 0.85));
|
||||
tri.close();
|
||||
canvas.draw_path(&tri, &p);
|
||||
canvas.draw_path(&tri.detach(), &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect};
|
||||
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, PathBuilder, Point, RRect, Rect};
|
||||
|
||||
const TILE_W: f64 = 340.0;
|
||||
const TILE_H: f64 = 224.0;
|
||||
@@ -526,18 +526,20 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
|
||||
let rr = RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32);
|
||||
if filled {
|
||||
let mut p = Paint::default();
|
||||
p.set_shader(skia_safe::gradient_shader::linear(
|
||||
let colors = [accent(1.0), accent(0.68)];
|
||||
p.set_shader(skia_safe::gradient::shaders::linear_gradient(
|
||||
(
|
||||
Point::new(badge.left, badge.top),
|
||||
Point::new(badge.left, badge.bottom),
|
||||
),
|
||||
skia_safe::gradient_shader::GradientShaderColors::Colors(&[
|
||||
accent(1.0).to_color(),
|
||||
accent(0.68).to_color(),
|
||||
]),
|
||||
None,
|
||||
skia_safe::TileMode::Clamp,
|
||||
None,
|
||||
&skia_safe::gradient::Gradient::new(
|
||||
skia_safe::gradient::Colors::new_evenly_spaced(
|
||||
&colors,
|
||||
skia_safe::TileMode::Clamp,
|
||||
None,
|
||||
),
|
||||
skia_safe::gradient::Interpolation::default(),
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(rr, &p);
|
||||
@@ -586,7 +588,7 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.6 * k) as f32);
|
||||
p.set_anti_alias(true);
|
||||
let mut shackle = Path::new();
|
||||
let mut shackle = PathBuilder::new();
|
||||
let (cx, r) = (x + body_w / 2.0, 3.2 * k);
|
||||
shackle.move_to(((cx - r) as f32, body_top as f32));
|
||||
shackle.arc_to(
|
||||
@@ -600,7 +602,7 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
|
||||
180.0,
|
||||
false,
|
||||
);
|
||||
canvas.draw_path(&shackle, &p);
|
||||
canvas.draw_path(&shackle.detach(), &p);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -236,21 +236,25 @@ impl Overlay for SkiaOverlay {
|
||||
}
|
||||
}
|
||||
};
|
||||
let backend_builder = skvk::BackendContext::new_builder(
|
||||
shared.instance.handle().as_raw() as _,
|
||||
shared.physical_device.as_raw() as _,
|
||||
shared.device.handle().as_raw() as _,
|
||||
(
|
||||
shared.queue.as_raw() as _,
|
||||
shared.queue_family_index as usize,
|
||||
),
|
||||
&get_proc,
|
||||
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, so it caps entry-point
|
||||
// validation at whatever `vkEnumerateInstanceVersion()` reports — byte-for-byte what
|
||||
// the (now removed) `BackendContext::new` did. The presenter owns the instance and its
|
||||
// `VkApplicationInfo`, so pinning a version here would just duplicate its choice.
|
||||
None,
|
||||
);
|
||||
// SAFETY: the instance/physical-device/device handles come from `shared`, which owns them
|
||||
// and outlives this backend context, and `get_proc` above resolves through those same
|
||||
// handles. Skia stores them but does not take ownership — teardown stays ours.
|
||||
let backend = unsafe {
|
||||
skvk::BackendContext::new(
|
||||
shared.instance.handle().as_raw() as _,
|
||||
shared.physical_device.as_raw() as _,
|
||||
shared.device.handle().as_raw() as _,
|
||||
(
|
||||
shared.queue.as_raw() as _,
|
||||
shared.queue_family_index as usize,
|
||||
),
|
||||
&get_proc,
|
||||
)
|
||||
};
|
||||
let backend = unsafe { backend_builder.build() };
|
||||
let mut context = gpu::direct_contexts::make_vulkan(&backend, None)
|
||||
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
|
||||
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
|
||||
|
||||
@@ -10,8 +10,8 @@ use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::{
|
||||
gradient_shader, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect,
|
||||
Point, RRect, Rect, TileMode, Typeface,
|
||||
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
|
||||
RRect, Rect, TileMode, Typeface,
|
||||
};
|
||||
|
||||
// --- Ink ----------------------------------------------------------------------------------
|
||||
@@ -166,18 +166,16 @@ pub(crate) fn panel(
|
||||
sp.set_color4f(accent(alpha), None);
|
||||
}
|
||||
PanelStroke::Gradient | PanelStroke::GradientDashed => {
|
||||
sp.set_shader(gradient_shader::linear(
|
||||
let colors = [fg(0.22), fg(0.04)];
|
||||
sp.set_shader(gradient::shaders::linear_gradient(
|
||||
(
|
||||
Point::new(rect.left, rect.top),
|
||||
Point::new(rect.left, rect.bottom),
|
||||
),
|
||||
gradient_shader::GradientShaderColors::Colors(&[
|
||||
fg(0.22).to_color(),
|
||||
fg(0.04).to_color(),
|
||||
]),
|
||||
None,
|
||||
TileMode::Clamp,
|
||||
None,
|
||||
&gradient::Gradient::new(
|
||||
gradient::Colors::new_evenly_spaced(&colors, TileMode::Clamp, None),
|
||||
gradient::Interpolation::default(),
|
||||
),
|
||||
None,
|
||||
));
|
||||
if matches!(stroke, PanelStroke::GradientDashed) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::library::{BUMP_C, BUMP_K};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Paint, Path, RRect, Rect};
|
||||
use skia_safe::{Canvas, Paint, PathBuilder, RRect, Rect};
|
||||
|
||||
// --- Menu list -----------------------------------------------------------------------------
|
||||
|
||||
@@ -479,11 +479,11 @@ fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
|
||||
p.set_stroke_width((1.8 * r / 4.0) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_anti_alias(true);
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
|
||||
path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
|
||||
path.line_to(((x - dir * r / 2.0) as f32, (cy + r) as f32));
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
}
|
||||
|
||||
// --- On-screen keyboard ----------------------------------------------------------------------
|
||||
@@ -785,12 +785,12 @@ fn draw_space_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Co
|
||||
// ⎵ — an underline bracket.
|
||||
let (w, h) = (16.0 * k, 5.0 * k);
|
||||
let p = stroke_paint(ink, (1.6 * k) as f32);
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((cx - w / 2.0) as f32, (cy - h / 2.0) as f32));
|
||||
path.line_to(((cx - w / 2.0) as f32, (cy + h / 2.0) as f32));
|
||||
path.line_to(((cx + w / 2.0) as f32, (cy + h / 2.0) as f32));
|
||||
path.line_to(((cx + w / 2.0) as f32, (cy - h / 2.0) as f32));
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
}
|
||||
|
||||
fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
|
||||
@@ -799,14 +799,14 @@ fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe
|
||||
let nose = 6.0 * k;
|
||||
let p = stroke_paint(ink, (1.6 * k) as f32);
|
||||
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((l + nose) as f32, t as f32));
|
||||
path.line_to((r as f32, t as f32));
|
||||
path.line_to((r as f32, b as f32));
|
||||
path.line_to(((l + nose) as f32, b as f32));
|
||||
path.line_to((l as f32, cy as f32));
|
||||
path.close();
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
let (xc, xr) = (cx + nose / 2.0, 2.6 * k);
|
||||
canvas.draw_line(
|
||||
((xc - xr) as f32, (cy - xr) as f32),
|
||||
@@ -823,11 +823,11 @@ fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe
|
||||
fn draw_check(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
|
||||
let p = stroke_paint(ink, (1.8 * k) as f32);
|
||||
let r = 5.0 * k;
|
||||
let mut path = Path::new();
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((cx - r) as f32, cy as f32));
|
||||
path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32));
|
||||
path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32));
|
||||
canvas.draw_path(&path, &p);
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user