feat(client-linux): feature parity with the Swift client

Everything the macOS app does that stage 1 lacked, before any new
feature work (user directive):

- Input capture is now a deliberate, reversible STATE (Moonlight-
  style): engaged on stream start and click-into-video (the engaging
  click is suppressed), released by Ctrl+Alt+Shift+Q (toggles) or
  focus loss; held keys/buttons are flushed host-side on release;
  cursor hiding + shortcut inhibition follow the state; HUD hint when
  released. Per-session window handlers disconnect with the page.
- Gamepads: app-lifetime SDL service (GamepadManager parity) — pad
  list + "Forwarded controller" pin in Settings (auto = most recent),
  "Automatic" pad TYPE resolves from the physical pad at connect;
  DualSense touchpad contacts + ~250 Hz motion samples on the 0xCC
  plane (Swift GamepadWire scale constants); feedback grows adaptive-
  trigger replay and player LEDs via raw DS5 effects packets (the
  wire's 11-byte blocks drop into SDL_SendGamepadEffect verbatim);
  held pad state zeroed on pad switch/detach. sdl3 "hidapi" feature.
- Microphone uplink: PipeWire capture -> Opus 20 ms -> 0xCB datagrams
  (validated live: host received 711 mic packets), Settings toggle.
- Speed test per saved host (Swift's "Test Network Speed…"): 2 s
  probe burst, goodput/loss + recommended ~70 % bitrate, one-tap apply.
- Settings: host compositor preference (sent in the Hello), native-
  display resolution/refresh resolved from the window's monitor at
  connect (new default), bitrate ceiling to 3 Gbit/s.
- Hosts page: saved/trusted hosts section for direct pinned reconnect
  (mDNS not required), rebuilt on every page return.

Deliberately not ported: audio device pickers (PipeWire routing owns
this on Linux), resize-to-request_mode (not wired in Swift either),
pointer-lock relative mouse (stage-2 presenter, needs raw Wayland).
DualSense fidelity needs a physical pad to live-verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:11:52 +00:00
parent a8a6224fd8
commit a95984bb4f
11 changed files with 1278 additions and 177 deletions
+3 -2
View File
@@ -28,8 +28,9 @@ ffmpeg-next = "8"
opus = "0.3" opus = "0.3"
pipewire = "0.9" pipewire = "0.9"
# Gamepads: capture + rumble/lightbar feedback (full DualSense fidelity lives here). # Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
sdl3 = "0.18" # need the hidapi driver).
sdl3 = { version = "0.18", features = ["hidapi"] }
mdns-sd = "0.20" mdns-sd = "0.20"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+150 -8
View File
@@ -4,9 +4,9 @@ use crate::session::{SessionEvent, SessionParams};
use crate::trust::{KnownHost, KnownHosts, Settings}; use crate::trust::{KnownHost, KnownHosts, Settings};
use crate::ui_hosts::ConnectRequest; use crate::ui_hosts::ConnectRequest;
use adw::prelude::*; use adw::prelude::*;
use gtk::glib; use gtk::{gdk, glib};
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref; use punktfunk_core::config::{CompositorPref, GamepadPref};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -18,6 +18,8 @@ struct App {
toasts: adw::ToastOverlay, toasts: adw::ToastOverlay,
settings: Rc<RefCell<Settings>>, settings: Rc<RefCell<Settings>>,
identity: (String, String), identity: (String, String),
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
gamepad: crate::gamepad::GamepadService,
/// One session at a time — ignore connects while one is starting/running. /// One session at a time — ignore connects while one is starting/running.
busy: std::cell::Cell<bool>, busy: std::cell::Cell<bool>,
} }
@@ -89,6 +91,7 @@ fn build_ui(gtk_app: &adw::Application) {
toasts, toasts,
settings: Rc::new(RefCell::new(Settings::load())), settings: Rc::new(RefCell::new(Settings::load())),
identity, identity,
gamepad: crate::gamepad::GamepadService::start(),
busy: std::cell::Cell::new(false), busy: std::cell::Cell::new(false),
}); });
@@ -99,7 +102,13 @@ fn build_ui(gtk_app: &adw::Application) {
}, },
{ {
let app = app.clone(); let app = app.clone();
Rc::new(move || crate::ui_settings::show(&app.window, app.settings.clone())) Rc::new(move || {
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad)
})
},
{
let app = app.clone();
Rc::new(move |req| speed_test(app.clone(), req))
}, },
); );
nav.add(&hosts_page); nav.add(&hosts_page);
@@ -244,21 +253,151 @@ fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
dialog.present(Some(&parent)); dialog.present(Some(&parent));
} }
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
fn speed_test(app: Rc<App>, req: ConnectRequest) {
if app.busy.replace(true) {
return;
}
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
let status = gtk::Label::new(Some("Connecting…"));
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
dialog.set_extra_child(Some(&status));
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
dialog.set_response_enabled("apply", false);
dialog.set_close_response("close");
dialog.present(Some(&app.window));
let (tx, rx) =
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
let identity = app.identity.clone();
let (host, port) = (req.addr.clone(), req.port);
std::thread::spawn(move || {
let result = (|| {
let c = NativeClient::connect(
&host,
port,
punktfunk_core::config::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
CompositorPref::Auto,
GamepadPref::Auto,
0,
pin,
Some(identity),
std::time::Duration::from_secs(15),
)
.map_err(|e| format!("connect: {e:?}"))?;
c.request_probe(3_000_000, 2_000)
.map_err(|e| format!("probe: {e:?}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
std::thread::sleep(std::time::Duration::from_millis(250));
let r = c.probe_result();
if r.done {
// Let the last UDP shards land before tearing down.
std::thread::sleep(std::time::Duration::from_millis(400));
return Ok(c.probe_result());
}
if std::time::Instant::now() > deadline {
return Err("probe timed out".to_string());
}
}
})();
let _ = tx.send_blocking(result);
});
glib::spawn_future_local(async move {
let outcome = rx.recv().await;
app.busy.set(false);
match outcome {
Ok(Ok(r)) => {
let mbps = f64::from(r.throughput_kbps) / 1000.0;
let recommended_kbps = r.throughput_kbps / 10 * 7;
status.set_text(&format!(
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
r.loss_pct,
f64::from(recommended_kbps) / 1000.0,
));
dialog.set_response_enabled("apply", true);
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
let settings = app.settings.clone();
let toasts = app.toasts.clone();
dialog.connect_response(Some("apply"), move |_, _| {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
toasts.add_toast(adw::Toast::new(&format!(
"Bitrate set to {:.0} Mbit/s",
f64::from(recommended_kbps) / 1000.0
)));
});
}
Ok(Err(msg)) => status.set_text(&msg),
Err(_) => {}
}
});
}
/// The mode to request: explicit settings, with `0` fields resolved to the native
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
/// native-display default).
fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
let s = app.settings.borrow();
let mut mode = punktfunk_core::config::Mode {
width: s.width,
height: s.height,
refresh_hz: s.refresh_hz,
};
if mode.width == 0 || mode.refresh_hz == 0 {
let monitor = app
.window
.surface()
.zip(gdk::Display::default())
.and_then(|(surf, d)| d.monitor_at_surface(&surf));
if let Some(m) = monitor {
let geo = m.geometry();
let scale = m.scale_factor().max(1);
if mode.width == 0 {
mode.width = (geo.width() * scale) as u32;
mode.height = (geo.height() * scale) as u32;
}
if mode.refresh_hz == 0 {
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
}
}
}
// No monitor info (early call, odd compositor) — a sane floor.
if mode.width == 0 {
(mode.width, mode.height) = (1920, 1080);
}
if mode.refresh_hz == 0 {
mode.refresh_hz = 60;
}
mode
}
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) { fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
if app.busy.replace(true) { if app.busy.replace(true) {
return; return;
} }
let mode = resolve_mode(&app);
let s = app.settings.borrow(); let s = app.settings.borrow();
let params = SessionParams { let params = SessionParams {
host: req.addr.clone(), host: req.addr.clone(),
port: req.port, port: req.port,
mode: punktfunk_core::config::Mode { mode,
width: s.width, compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
height: s.height, // "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
refresh_hz: s.refresh_hz, gamepad: match GamepadPref::from_name(&s.gamepad) {
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
Some(explicit) => explicit,
}, },
gamepad: GamepadPref::from_name(&s.gamepad).unwrap_or(GamepadPref::Auto),
bitrate_kbps: s.bitrate_kbps, bitrate_kbps: s.bitrate_kbps,
mic_enabled: s.mic_enabled,
pin, pin,
identity: app.identity.clone(), identity: app.identity.clone(),
}; };
@@ -300,6 +439,7 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
"{} · {}×{}@{}", "{} · {}×{}@{}",
req.name, mode.width, mode.height, mode.refresh_hz req.name, mode.width, mode.height, mode.refresh_hz
); );
app.gamepad.attach(connector.clone());
let p = crate::ui_stream::new( let p = crate::ui_stream::new(
&app.window, &app.window,
connector, connector,
@@ -317,11 +457,13 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
} }
} }
SessionEvent::Failed(msg) => { SessionEvent::Failed(msg) => {
tracing::warn!(%msg, "connect failed");
app.toast(&msg); app.toast(&msg);
app.busy.set(false); app.busy.set(false);
break; break;
} }
SessionEvent::Ended(err) => { SessionEvent::Ended(err) => {
app.gamepad.detach();
app.nav.pop_to_tag("hosts"); app.nav.pop_to_tag("hosts");
if let Some(e) = err { if let Some(e) = err {
app.toast(&e); app.toast(&e);
+181 -5
View File
@@ -1,16 +1,22 @@
//! Audio playback: decoded PCM → a PipeWire playback stream. //! Audio: playback (decoded PCM → a PipeWire playback stream) and the microphone uplink
//! (PipeWire capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic).
//! //!
//! Mirrors the host's virtual-mic producer (`punktfunk-host::audio::linux`) with the same //! Playback mirrors the host's virtual-mic producer (`punktfunk-host::audio::linux`) with
//! adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded chunks on the //! the same adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded chunks on
//! network clock; PipeWire pulls whole quanta on the device clock. Prime to ~3 quanta //! the network clock; PipeWire pulls whole quanta on the device clock. Prime to ~3
//! before producing, cap the ring so latency stays bounded, re-prime after a real drain. //! quanta before producing, cap the ring so latency stays bounded, re-prime after a real
//! drain.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::client::NativeClient;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::mpsc::{Receiver, SyncSender, TrySendError}; use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
use std::sync::Arc;
const SAMPLE_RATE: u32 = 48_000; const SAMPLE_RATE: u32 = 48_000;
const CHANNELS: usize = 2; const CHANNELS: usize = 2;
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
const MIC_FRAME: usize = 960;
struct Terminate; struct Terminate;
@@ -204,3 +210,173 @@ fn pw_thread(
tracing::debug!("pipewire playback loop exited"); tracing::debug!("pipewire playback loop exited");
Ok(()) Ok(())
} }
/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks,
/// ship them as 0xCB datagrams into the host's virtual PipeWire source.
pub struct MicStreamer {
quit_tx: pipewire::channel::Sender<Terminate>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl MicStreamer {
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
let thread = std::thread::Builder::new()
.name("punktfunk-mic".into())
.spawn(move || {
if let Err(e) = mic_thread(&connector, quit_rx) {
tracing::warn!(error = %e, "mic uplink thread ended");
}
})
.context("spawn mic thread")?;
Ok(MicStreamer {
quit_tx,
thread: Some(thread),
})
}
}
impl Drop for MicStreamer {
fn drop(&mut self) {
let _ = self.quit_tx.send(Terminate);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Capture-side state: accumulated PCM and the Opus encoder (encoding a 20 ms frame is
/// ~100 µs — fine inside the process callback).
struct MicData {
connector: Arc<NativeClient>,
ring: VecDeque<f32>,
encoder: opus::Encoder,
seq: u32,
out: Vec<u8>,
}
fn mic_thread(
connector: &Arc<NativeClient>,
quit_rx: pipewire::channel::Receiver<Terminate>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Stereo, opus::Application::Voip)
.map_err(|e| anyhow::anyhow!("opus encoder: {e}"))?;
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw mic MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw mic Context")?;
let core = context
.connect_rc(None)
.context("pw mic connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-mic-capture",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
},
)
.context("pw mic Stream")?;
let ud = MicData {
connector: connector.clone(),
ring: VecDeque::new(),
encoder,
seq: 0,
out: vec![0u8; 4000],
};
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed(|_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire mic capture stream state");
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let n = data.chunk().size() as usize;
if let Some(slice) = data.data() {
for s in slice[..n.min(slice.len())].chunks_exact(4) {
ud.ring
.push_back(f32::from_le_bytes([s[0], s[1], s[2], s[3]]));
}
}
// Ship every complete 20 ms stereo frame.
while ud.ring.len() >= MIC_FRAME * CHANNELS {
let pcm: Vec<f32> = ud.ring.drain(..MIC_FRAME * CHANNELS).collect();
match ud.encoder.encode_float(&pcm, &mut ud.out) {
Ok(len) => {
let pts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let _ = ud.connector.send_mic(ud.seq, pts, ud.out[..len].to_vec());
ud.seq = ud.seq.wrapping_add(1);
}
Err(e) => tracing::debug!(error = %e, "opus mic encode"),
}
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire mic callback");
}
})
.register()
.context("register mic listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(SAMPLE_RATE);
info.set_channels(CHANNELS as u32);
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize mic format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("mic pod from bytes")?];
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw mic stream connect")?;
mainloop.run();
tracing::debug!("pipewire mic capture loop exited");
Ok(())
}
+422 -69
View File
@@ -1,33 +1,111 @@
//! Gamepad capture + feedback over SDL3, on a dedicated thread. //! App-lifetime gamepad service over SDL3 (mirrors the Swift client's `GamepadManager` +
//! `GamepadCapture`/`GamepadFeedback`).
//! //!
//! Mirrors the Apple client's selection model: exactly one pad is forwarded as pad 0 — //! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
//! the first connected (a pin/auto picker lands with the settings work). SDL3 is the one //! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most
//! library with full DualSense fidelity (touchpad/gyro/lightbar/player LEDs/rumble + //! recently connected), and — while a session is attached — forwards buttons/axes,
//! adaptive triggers via raw effect packets), matching the wire planes; this stage wires //! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on
//! buttons/axes out and rumble/lightbar back. Touchpad/motion capture (0xCC) and //! every pad, lightbar via SDL, and on a real DualSense the raw effects packet
//! adaptive-trigger replay (0xCD `Trigger`) are follow-ups on the same loop. //! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the
//! wire when the active pad switches or the session detaches, so nothing sticks down.
//! //!
//! This thread also owns the rumble and HID-output pull planes (one consumer per plane). //! This thread is also the single consumer of the rumble and HID-output pull planes.
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::HidOutput; use punktfunk_core::quic::{HidOutput, RichInput};
use std::sync::atomic::{AtomicBool, Ordering}; use std::collections::HashMap;
use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
pub fn spawn( /// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
connector: Arc<NativeClient>, /// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
stop: Arc<AtomicBool>, /// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs.
) -> Option<std::thread::JoinHandle<()>> { const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
std::thread::Builder::new() const ACCEL_LSB_PER_G: f32 = 10_000.0;
.name("punktfunk-gamepad".into()) const G: f32 = 9.80665;
.spawn(move || {
if let Err(e) = run(&connector, &stop) { #[derive(Clone, Debug)]
tracing::warn!(error = %e, "gamepad thread ended — pads disabled"); pub struct PadInfo {
} pub id: u32,
}) pub name: String,
.ok() pub is_dualsense: bool,
}
enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<u32>),
}
#[derive(Clone)]
pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
ctl: Sender<Ctl>,
}
impl GamepadService {
pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into())
.spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
{
tracing::warn!(error = %e, "gamepad service failed to start");
}
GamepadService {
pads,
active,
pinned,
ctl,
}
}
pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone()
}
pub fn active(&self) -> Option<PadInfo> {
self.active.lock().unwrap().clone()
}
pub fn pinned(&self) -> Option<u32> {
*self.pinned.lock().unwrap()
}
pub fn set_pinned(&self, id: Option<u32>) {
let _ = self.ctl.send(Ctl::Pin(id));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
pub fn detach(&self) {
let _ = self.ctl.send(Ctl::Detach);
}
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
/// (Swift parity); no pad connected leaves the host's own default.
pub fn auto_pref(&self) -> GamepadPref {
match self.active() {
Some(p) if p.is_dualsense => GamepadPref::DualSense,
Some(_) => GamepadPref::Xbox360,
None => GamepadPref::Auto,
}
}
} }
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) { fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
@@ -78,7 +156,121 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
} }
} }
fn run(connector: &NativeClient, stop: &AtomicBool) -> Result<(), String> { /// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
/// Enable bits select only the fields each update touches, so rumble (driven separately
/// through SDL) and untouched fields keep their state.
#[derive(Default)]
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
let (flag, off) = if which == 1 {
(0x04, Self::RIGHT_TRIGGER)
} else {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x04; // lightbar enable
p[Self::LED_RGB] = r;
p[Self::LED_RGB + 1] = g;
p[Self::LED_RGB + 2] = b;
p
}
fn player_packet(bits: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x10; // player-LED enable
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
}
struct Worker {
subsystem: sdl3::GamepadSubsystem,
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
/// Connection order; the most recently connected is the auto selection.
order: Vec<u32>,
pinned: Option<u32>,
attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6],
held_buttons: Vec<u32>,
last_accel: [i16; 3],
}
impl Worker {
fn active_id(&self) -> Option<u32> {
self.pinned
.filter(|id| self.opened.contains_key(id))
.or_else(|| self.order.last().copied())
}
fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?;
Some(PadInfo {
id,
name: pad.name().unwrap_or_else(|| "Controller".into()),
is_dualsense: matches!(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
sdl3::gamepad::GamepadType::PS5
),
})
}
/// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) {
if let Some(c) = &self.attached {
for b in self.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0);
}
for (id, v) in self.last_axis.iter_mut().enumerate() {
if *v != 0 && *v != i32::MIN {
send(c, InputKind::GamepadAxis, id as u32, 0);
}
*v = i32::MIN;
}
} else {
self.held_buttons.clear();
self.last_axis = [i32::MIN; 6];
}
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return };
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } {
let _ = pad.sensor_set_enabled(s, enabled);
}
}
}
}
}
#[allow(clippy::too_many_lines)]
fn run(
pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>,
) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
// own thread. // own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
@@ -87,59 +279,202 @@ fn run(connector: &NativeClient, stop: &AtomicBool) -> Result<(), String> {
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
let mut active: Option<sdl3::gamepad::Gamepad> = None; let mut w = Worker {
let pad_id = |p: &Option<sdl3::gamepad::Gamepad>| -> Option<u32> { subsystem,
p.as_ref().and_then(|p| p.id().ok()).map(|id| id.0) opened: HashMap::new(),
order: Vec::new(),
pinned: None,
attached: None,
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
last_accel: [0; 3],
}; };
// Last sent wire value per axis id — suppress no-op repeats (SDL re-reports).
let mut last_axis = [i32::MIN; 6];
while !stop.load(Ordering::SeqCst) { let publish = |w: &Worker| {
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
list.reverse(); // most recent first — the Settings list order
*pads_out.lock().unwrap() = list;
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
*pinned_out.lock().unwrap() = w.pinned;
};
loop {
// Control plane from the UI thread.
loop {
match ctl.try_recv() {
Ok(Ctl::Attach(c)) => {
w.attached = Some(c);
w.last_axis = [i32::MIN; 6];
w.set_sensors(true);
}
Ok(Ctl::Detach) => {
w.flush_held();
w.set_sensors(false);
w.attached = None;
}
Ok(Ctl::Pin(id)) => {
let before = w.active_id();
w.pinned = id;
if w.active_id() != before {
w.flush_held();
if w.attached.is_some() {
w.set_sensors(true);
}
}
publish(&w);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
}
}
while let Some(event) = pump.poll_event() { while let Some(event) = pump.poll_event() {
use sdl3::event::Event; use sdl3::event::Event;
let active = w.active_id();
match event { match event {
Event::ControllerDeviceAdded { which, .. } => { Event::ControllerDeviceAdded { which, .. } => {
if active.is_none() { if !w.opened.contains_key(&which) {
match subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) { match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
Ok(pad) => { Ok(pad) => {
tracing::info!( tracing::info!(
name = pad.name().unwrap_or_default(), name = pad.name().unwrap_or_default(),
"gamepad attached as pad 0" "gamepad attached"
); );
active = Some(pad); w.opened.insert(which, pad);
last_axis = [i32::MIN; 6]; w.order.push(which);
if w.attached.is_some() && w.active_id() == Some(which) {
w.set_sensors(true);
}
publish(&w);
} }
Err(e) => tracing::warn!(error = %e, "gamepad open failed"), Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
} }
} }
} }
Event::ControllerDeviceRemoved { which, .. } => { Event::ControllerDeviceRemoved { which, .. } => {
if pad_id(&active) == Some(which) { if w.opened.remove(&which).is_some() {
w.order.retain(|&id| id != which);
if active == Some(which) {
w.flush_held();
}
tracing::info!("gamepad detached"); tracing::info!("gamepad detached");
active = None; publish(&w);
} }
} }
Event::ControllerButtonDown { which, button, .. } => { Event::ControllerButtonDown { which, button, .. }
if pad_id(&active) == Some(which) { if active == Some(which) && w.attached.is_some() =>
if let Some(bit) = button_bit(button) { {
send(connector, InputKind::GamepadButton, bit, 1); if let Some(bit) = button_bit(button) {
} w.held_buttons.push(bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
1,
);
} }
} }
Event::ControllerButtonUp { which, button, .. } => { Event::ControllerButtonUp { which, button, .. }
if pad_id(&active) == Some(which) { if active == Some(which) && w.attached.is_some() =>
if let Some(bit) = button_bit(button) { {
send(connector, InputKind::GamepadButton, bit, 0); if let Some(bit) = button_bit(button) {
} w.held_buttons.retain(|&b| b != bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
0,
);
} }
} }
Event::ControllerAxisMotion { Event::ControllerAxisMotion {
which, axis, value, .. which, axis, value, ..
} if pad_id(&active) == Some(which) => { } if active == Some(which) && w.attached.is_some() => {
let (id, v) = axis_value(axis, value); let (id, v) = axis_value(axis, value);
if last_axis[id as usize] != v { if w.last_axis[id as usize] != v {
last_axis[id as usize] = v; w.last_axis[id as usize] = v;
send(connector, InputKind::GamepadAxis, id, v); send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
}
}
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
Event::ControllerTouchpadDown {
which,
finger,
x,
y,
..
}
| Event::ControllerTouchpadMotion {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: true,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
Event::ControllerTouchpadUp {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: false,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
// Motion: accel events update the cache; each gyro event ships a sample
// (the DualSense reports both at ~250 Hz). Scale convention shared with
// the Swift client — sign/scale derived, not yet live-verified.
Event::ControllerSensorUpdated {
which,
sensor,
data,
..
} if active == Some(which) && w.attached.is_some() => {
use sdl3::sensor::SensorType;
match sensor {
SensorType::Accelerometer => {
for (i, v) in data.iter().enumerate() {
w.last_accel[i] =
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
}
}
SensorType::Gyroscope => {
let mut gyro = [0i16; 3];
for (i, v) in data.iter().enumerate() {
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
}
let _ =
w.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Motion {
pad: 0,
gyro,
accel: w.last_accel,
});
}
_ => {}
} }
} }
_ => {} _ => {}
@@ -149,28 +484,46 @@ fn run(connector: &NativeClient, stop: &AtomicBool) -> Result<(), String> {
// Feedback planes (this thread is their single consumer). The host re-sends // Feedback planes (this thread is their single consumer). The host re-sends
// rumble state periodically, so a generous duration with refresh-on-update is // rumble state periodically, so a generous duration with refresh-on-update is
// safe — a dropped stop heals within ~500 ms. // safe — a dropped stop heals within ~500 ms.
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { if let Some(connector) = w.attached.clone() {
if pad == 0 { while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if let Some(p) = active.as_mut() { if pad == 0 {
let _ = p.set_rumble(low, high, 5_000); if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
} let _ = p.set_rumble(low, high, 5_000);
}
}
loop {
match connector.next_hidout(Duration::ZERO) {
Ok(HidOutput::Led { pad: 0, r, g, b }) => {
if let Some(p) = active.as_mut() {
let _ = p.set_led(r, g, b);
} }
} }
Ok(HidOutput::PlayerLeds { .. }) => {} // TODO: SDL player-index mapping }
Ok(HidOutput::Trigger { .. }) => {} // TODO: DS5 effect packet replay while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
Ok(_) => {} let Some(id) = w.active_id() else { continue };
Err(_) => break, let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
let Some(pad) = w.opened.get_mut(&id) else {
continue;
};
match hid {
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
}
HidOutput::Led { pad: 0, r, g, b } => {
let _ = pad.set_led(r, g, b);
}
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
}
HidOutput::Trigger {
pad: 0,
which,
ref effect,
} if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
_ => {}
}
} }
} }
std::thread::sleep(Duration::from_millis(2)); std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
2
} else {
30
}));
} }
Ok(())
} }
+16 -8
View File
@@ -4,8 +4,8 @@
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread: //! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
//! video+audio here, rumble+hidout on the gamepad thread. //! video+audio here, rumble+hidout on the gamepad thread.
use crate::audio;
use crate::video::{DecodedFrame, Decoder}; use crate::video::{DecodedFrame, Decoder};
use crate::{audio, gamepad};
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use punktfunk_core::PunktfunkError; use punktfunk_core::PunktfunkError;
@@ -17,8 +17,11 @@ pub struct SessionParams {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub mode: Mode, pub mode: Mode,
pub compositor: CompositorPref,
pub gamepad: GamepadPref, pub gamepad: GamepadPref,
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one). /// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
pub pin: Option<[u8; 32]>, pub pin: Option<[u8; 32]>,
pub identity: (String, String), pub identity: (String, String),
@@ -84,7 +87,7 @@ fn pump(
&params.host, &params.host,
params.port, params.port,
params.mode, params.mode,
CompositorPref::Auto, params.compositor,
params.gamepad, params.gamepad,
params.bitrate_kbps, params.bitrate_kbps,
params.pin, params.pin,
@@ -118,14 +121,22 @@ fn pump(
return; return;
} }
}; };
// Audio and gamepads are best-effort: a session without them still streams. // Audio is best-effort: a session without it still streams. Gamepads are the
// app-lifetime service's job (the UI attaches it on Connected).
let player = audio::AudioPlayer::spawn() let player = audio::AudioPlayer::spawn()
.map_err(|e| tracing::warn!(error = %e, "audio disabled")) .map_err(|e| tracing::warn!(error = %e, "audio disabled"))
.ok(); .ok();
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo) let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled")) .map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
.ok(); .ok();
let gamepad_thread = gamepad::spawn(connector.clone(), stop.clone()); let _mic = params
.mic_enabled
.then(|| {
audio::MicStreamer::spawn(connector.clone())
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
.ok()
})
.flatten();
let clock_offset = connector.clock_offset_ns; let clock_offset = connector.clock_offset_ns;
let mut total_frames = 0u64; let mut total_frames = 0u64;
@@ -218,9 +229,6 @@ fn pump(
reason = end.as_deref().unwrap_or("user"), reason = end.as_deref().unwrap_or("user"),
"session ended" "session ended"
); );
stop.store(true, Ordering::SeqCst); // take the gamepad thread down with us stop.store(true, Ordering::SeqCst);
if let Some(t) = gamepad_thread {
let _ = t.join();
}
let _ = ev_tx.send_blocking(SessionEvent::Ended(end)); let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
} }
+16 -6
View File
@@ -104,29 +104,39 @@ impl KnownHosts {
} }
} }
/// App settings, persisted as JSON. Stringly-typed gamepad pref so the file stays /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// readable; parsed with `GamepadPref::from_name` at connect time. /// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings { pub struct Settings {
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
/// resolved at connect time.
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub refresh_hz: u32, pub refresh_hz: u32,
/// Requested encoder bitrate (kbps); 0 = host default. /// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
pub gamepad: String, pub gamepad: String,
/// Grab compositor shortcuts (Alt+Tab, Super…) while streaming. /// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
pub inhibit_shortcuts: bool, pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Settings { Settings {
width: 1920, width: 0,
height: 1080, height: 0,
refresh_hz: 60, refresh_hz: 0,
bitrate_kbps: 0, bitrate_kbps: 0,
gamepad: "auto".into(), gamepad: "auto".into(),
compositor: "auto".into(),
inhibit_shortcuts: true, inhibit_shortcuts: true,
mic_enabled: false,
} }
} }
} }
+68 -3
View File
@@ -1,6 +1,7 @@
//! The hosts page: live mDNS discovery list + manual connect entry. //! The hosts page: saved (trusted) hosts, live mDNS discovery, manual connect entry.
use crate::discovery::{self, DiscoveredHost}; use crate::discovery::{self, DiscoveredHost};
use crate::trust::KnownHosts;
use adw::prelude::*; use adw::prelude::*;
use gtk::glib; use gtk::glib;
use std::cell::RefCell; use std::cell::RefCell;
@@ -22,6 +23,7 @@ pub struct ConnectRequest {
pub fn new( pub fn new(
on_connect: Rc<dyn Fn(ConnectRequest)>, on_connect: Rc<dyn Fn(ConnectRequest)>,
on_settings: Rc<dyn Fn()>, on_settings: Rc<dyn Fn()>,
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
) -> adw::NavigationPage { ) -> adw::NavigationPage {
let list = gtk::ListBox::new(); let list = gtk::ListBox::new();
list.add_css_class("boxed-list"); list.add_css_class("boxed-list");
@@ -132,11 +134,72 @@ pub fn new(
manual_list.set_selection_mode(gtk::SelectionMode::None); manual_list.set_selection_mode(gtk::SelectionMode::None);
manual_list.append(&manual); manual_list.append(&manual);
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time
// the page is shown, so fresh TOFU/pairing entries appear on return.
let saved_label = gtk::Label::new(Some("Saved hosts"));
saved_label.add_css_class("heading");
saved_label.set_halign(gtk::Align::Start);
let saved_list = gtk::ListBox::new();
saved_list.add_css_class("boxed-list");
saved_list.set_selection_mode(gtk::SelectionMode::None);
let rebuild_saved = {
let saved_list = saved_list.clone();
let saved_label = saved_label.clone();
let on_connect = on_connect.clone();
let on_speed_test = on_speed_test.clone();
move || {
saved_list.remove_all();
let known = KnownHosts::load();
saved_label.set_visible(!known.hosts.is_empty());
saved_list.set_visible(!known.hosts.is_empty());
for k in &known.hosts {
let row = adw::ActionRow::builder()
.title(&k.name)
.subtitle(format!(
"{}:{}{}",
k.addr,
k.port,
if k.paired {
" · paired"
} else {
" · trusted"
}
))
.activatable(true)
.build();
let req = ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
pair_required: false,
};
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
speed_btn.set_tooltip_text(Some("Test network speed"));
speed_btn.set_valign(gtk::Align::Center);
speed_btn.add_css_class("flat");
{
let on_speed_test = on_speed_test.clone();
let req = req.clone();
speed_btn.connect_clicked(move |_| on_speed_test(req.clone()));
}
row.add_suffix(&speed_btn);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let on_connect = on_connect.clone();
row.connect_activated(move |_| on_connect(req.clone()));
saved_list.append(&row);
}
}
};
rebuild_saved();
let content = gtk::Box::new(gtk::Orientation::Vertical, 18); let content = gtk::Box::new(gtk::Orientation::Vertical, 18);
content.set_margin_top(24); content.set_margin_top(24);
content.set_margin_bottom(24); content.set_margin_bottom(24);
content.set_margin_start(12); content.set_margin_start(12);
content.set_margin_end(12); content.set_margin_end(12);
content.append(&saved_label);
content.append(&saved_list);
let discovered_label = gtk::Label::new(Some("Hosts on this network")); let discovered_label = gtk::Label::new(Some("Hosts on this network"));
discovered_label.add_css_class("heading"); discovered_label.add_css_class("heading");
discovered_label.set_halign(gtk::Align::Start); discovered_label.set_halign(gtk::Align::Start);
@@ -167,9 +230,11 @@ pub fn new(
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled)); toolbar.set_content(Some(&scrolled));
adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title("Punktfunk") .title("Punktfunk")
.tag("hosts") .tag("hosts")
.child(&toolbar) .child(&toolbar)
.build() .build();
page.connect_shown(move |_| rebuild_saved());
page
} }
+116 -20
View File
@@ -1,22 +1,41 @@
//! Preferences dialog: stream mode, bitrate, gamepad type, capture behavior. Written //! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
//! back to disk when the dialog closes. //! capture behavior. Written back to disk when the dialog closes.
use crate::trust::Settings; use crate::trust::Settings;
use adw::prelude::*; use adw::prelude::*;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
const RESOLUTIONS: &[(u32, u32)] = &[(1280, 720), (1920, 1080), (2560, 1440), (3840, 2160)]; /// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
const REFRESH: &[u32] = &[30, 60, 90, 120, 144, 165, 240]; const RESOLUTIONS: &[(u32, u32)] = &[
(0, 0),
(1280, 720),
(1920, 1080),
(2560, 1440),
(3840, 2160),
];
/// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"]; const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
pub fn show(parent: &impl IsA<gtk::Widget>, settings: Rc<RefCell<Settings>>) { pub fn show(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
) {
let page = adw::PreferencesPage::new(); let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build(); let stream = adw::PreferencesGroup::builder().title("Stream").build();
let res_names: Vec<String> = RESOLUTIONS let res_names: Vec<String> = RESOLUTIONS
.iter() .iter()
.map(|(w, h)| format!("{w} × {h}")) .map(|&(w, h)| {
if w == 0 {
"Native display".to_string()
} else {
format!("{w} × {h}")
}
})
.collect(); .collect();
let res_row = adw::ComboRow::builder() let res_row = adw::ComboRow::builder()
.title("Resolution") .title("Resolution")
@@ -25,40 +44,108 @@ pub fn show(parent: &impl IsA<gtk::Widget>, settings: Rc<RefCell<Settings>>) {
&res_names.iter().map(String::as_str).collect::<Vec<_>>(), &res_names.iter().map(String::as_str).collect::<Vec<_>>(),
)) ))
.build(); .build();
let hz_names: Vec<String> = REFRESH
.iter()
.map(|&r| {
if r == 0 {
"Native".to_string()
} else {
format!("{r} Hz")
}
})
.collect();
let hz_row = adw::ComboRow::builder() let hz_row = adw::ComboRow::builder()
.title("Refresh rate") .title("Refresh rate")
.model(&gtk::StringList::new( .model(&gtk::StringList::new(
&REFRESH &hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
.iter()
.map(|r| format!("{r} Hz"))
.collect::<Vec<_>>()
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
)) ))
.build(); .build();
let bitrate_row = adw::SpinRow::with_range(0.0, 500.0, 5.0); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate"); bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default"); bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
let compositor_row = adw::ComboRow::builder()
.title("Host compositor")
.subtitle("Advisory — the host falls back to auto-detect when unavailable")
.model(&gtk::StringList::new(&[
"Automatic",
"KWin",
"wlroots (Sway/Hyprland)",
"Mutter (GNOME)",
"gamescope",
]))
.build();
stream.add(&res_row); stream.add(&res_row);
stream.add(&hz_row); stream.add(&hz_row);
stream.add(&bitrate_row); stream.add(&bitrate_row);
stream.add(&compositor_row);
let input = adw::PreferencesGroup::builder().title("Input").build(); let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently
// connected; pinning survives until the app exits (Swift parity).
let pads = gamepads.pads();
let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| {
if p.is_dualsense {
format!("{} · DualSense", p.name)
} else {
p.name.clone()
}
}));
let forward_row = adw::ComboRow::builder()
.title("Forwarded controller")
.subtitle(if pads.is_empty() {
"No controllers detected"
} else {
"Exactly one controller is forwarded to the host"
})
.model(&gtk::StringList::new(
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let pinned_i = gamepads
.pinned()
.and_then(|id| pads.iter().position(|p| p.id == id))
.map_or(0, |i| i + 1);
forward_row.set_selected(pinned_i as u32);
{
let svc = gamepads.clone();
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
forward_row.connect_selected_notify(move |row| {
let sel = row.selected() as usize;
svc.set_pinned(if sel == 0 {
None
} else {
ids.get(sel - 1).copied()
});
});
}
let pad_row = adw::ComboRow::builder() let pad_row = adw::ComboRow::builder()
.title("Gamepad type") .title("Gamepad type")
.subtitle("The virtual pad the host creates (DualSense needs a Linux host)") .subtitle("The virtual pad the host creates — Automatic matches the physical pad")
.model(&gtk::StringList::new(&["Auto", "Xbox 360", "DualSense"])) .model(&gtk::StringList::new(&[
"Automatic",
"Xbox 360",
"DualSense",
]))
.build(); .build();
let inhibit_row = adw::SwitchRow::builder() let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts") .title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while streaming") .subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build(); .build();
input.add(&forward_row);
input.add(&pad_row); input.add(&pad_row);
input.add(&inhibit_row); input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build();
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone")
.build();
audio.add(&mic_row);
page.add(&stream); page.add(&stream);
page.add(&input); page.add(&input);
page.add(&audio);
// Seed from the current settings. // Seed from the current settings.
{ {
@@ -66,14 +153,20 @@ pub fn show(parent: &impl IsA<gtk::Widget>, settings: Rc<RefCell<Settings>>) {
let res_i = RESOLUTIONS let res_i = RESOLUTIONS
.iter() .iter()
.position(|&(w, h)| w == s.width && h == s.height) .position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(1); .unwrap_or(0);
res_row.set_selected(res_i as u32); res_row.set_selected(res_i as u32);
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(1); let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32); hz_row.set_selected(hz_i as u32);
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32); pad_row.set_selected(pad_i as u32);
let comp_i = COMPOSITORS
.iter()
.position(|&c| c == s.compositor)
.unwrap_or(0);
compositor_row.set_selected(comp_i as u32);
inhibit_row.set_active(s.inhibit_shortcuts); inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_enabled);
} }
let dialog = adw::PreferencesDialog::new(); let dialog = adw::PreferencesDialog::new();
@@ -86,7 +179,10 @@ pub fn show(parent: &impl IsA<gtk::Widget>, settings: Rc<RefCell<Settings>>) {
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string();
s.inhibit_shortcuts = inhibit_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_row.is_active();
s.save(); s.save();
}); });
dialog.present(Some(parent)); dialog.present(Some(parent));
+154 -54
View File
@@ -1,12 +1,18 @@
//! The stream page: decoded frames into a `GtkGraphicsOffload`-wrapped picture, local //! The stream page: decoded frames into a `GtkGraphicsOffload`-wrapped picture, local
//! input captured and forwarded on the wire contract. //! input captured and forwarded on the wire contract.
//! //!
//! Input mapping: keys are hardware keycodes (evdev + 8 on Wayland) → VK via `keymap`, //! Input capture is a deliberate, reversible STATE (Moonlight-style, mirroring the Swift
//! layout-independent. Mouse is absolute (`MouseMoveAbs` scaled into the negotiated mode //! client): engaged when the stream starts and when the user clicks into the video (that
//! through the letterbox transform) — relative/pointer-lock capture is the stage-2 //! click is suppressed toward the host); released by Ctrl+Alt+Shift+Q (toggles) or focus
//! presenter's job. While streaming, compositor shortcuts are inhibited (configurable); //! loss — held keys/buttons are flushed host-side on release so nothing sticks down.
//! Ctrl+Alt+Shift+Q ends the session, F11 toggles fullscreen — everything else goes to //! While captured the local cursor is hidden (the host renders its own) and compositor
//! the host. //! shortcuts are inhibited (configurable); while released nothing is forwarded and the
//! HUD says how to recapture.
//!
//! Keys are hardware keycodes (evdev + 8 on Wayland) → VK via `keymap`, layout-
//! independent. Mouse is absolute (`MouseMoveAbs` scaled into the negotiated mode through
//! the letterbox transform, surface size packed in `flags`) — pointer-lock relative
//! capture is the stage-2 presenter's job. F11 toggles fullscreen locally.
use crate::keymap; use crate::keymap;
use crate::session::Stats; use crate::session::Stats;
@@ -15,6 +21,9 @@ use adw::prelude::*;
use gtk::{gdk, glib}; use gtk::{gdk, glib};
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -60,6 +69,61 @@ fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y:
send(connector, InputKind::MouseMoveAbs, 0, px, py, flags); send(connector, InputKind::MouseMoveAbs, 0, px, py, flags);
} }
/// The capture state shared by every input controller on the page.
struct Capture {
connector: Arc<NativeClient>,
window: adw::ApplicationWindow,
overlay: gtk::Overlay,
hint: gtk::Label,
inhibit_shortcuts: bool,
captured: Cell<bool>,
/// VKs / GameStream button ids currently held — flushed up on release.
held_keys: RefCell<HashSet<u8>>,
held_buttons: RefCell<HashSet<u32>>,
}
impl Capture {
fn engage(&self) {
if self.captured.replace(true) {
return;
}
self.overlay
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
self.hint.set_visible(false);
if self.inhibit_shortcuts {
if let Some(tl) = self
.window
.surface()
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
{
tl.inhibit_system_shortcuts(None::<&gdk::Event>);
}
}
}
fn release(&self) {
if !self.captured.replace(false) {
return;
}
self.overlay.set_cursor(None);
self.hint.set_visible(true);
if let Some(tl) = self
.window
.surface()
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
{
tl.restore_system_shortcuts();
}
// Flush everything held so nothing sticks down on the host.
for vk in self.held_keys.borrow_mut().drain() {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
for b in self.held_buttons.borrow_mut().drain() {
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
}
}
}
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn new( pub fn new(
window: &adw::ApplicationWindow, window: &adw::ApplicationWindow,
@@ -86,12 +150,31 @@ pub fn new(
stats_label.set_margin_start(12); stats_label.set_margin_start(12);
stats_label.set_margin_top(12); stats_label.set_margin_top(12);
let hint = gtk::Label::new(Some(
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
));
hint.add_css_class("osd");
hint.set_halign(gtk::Align::Center);
hint.set_valign(gtk::Align::End);
hint.set_margin_bottom(24);
hint.set_visible(false);
let overlay = gtk::Overlay::new(); let overlay = gtk::Overlay::new();
overlay.set_child(Some(&offload)); overlay.set_child(Some(&offload));
overlay.add_overlay(&stats_label); overlay.add_overlay(&stats_label);
overlay.add_overlay(&hint);
overlay.set_focusable(true); overlay.set_focusable(true);
// The remote cursor is in the video — hide the local one over the stream.
overlay.set_cursor(gdk::Cursor::from_name("none", None).as_ref()); let capture = Rc::new(Capture {
connector: connector.clone(),
window: window.clone(),
overlay: overlay.clone(),
hint: hint.clone(),
inhibit_shortcuts,
captured: Cell::new(false),
held_keys: RefCell::new(HashSet::new()),
held_buttons: RefCell::new(HashSet::new()),
});
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
@@ -111,13 +194,14 @@ pub fn new(
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.set_content(Some(&overlay)); toolbar.set_content(Some(&overlay));
// Fullscreen = the stream and nothing else. // Fullscreen = the stream and nothing else. (Window handlers are disconnected when
{ // the page dies — the window outlives every session.)
let fs_handler = {
let toolbar = toolbar.clone(); let toolbar = toolbar.clone();
window.connect_fullscreened_notify(move |w| { window.connect_fullscreened_notify(move |w| {
toolbar.set_reveal_top_bars(!w.is_fullscreen()); toolbar.set_reveal_top_bars(!w.is_fullscreen());
}); })
} };
let page = adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title(title) .title(title)
@@ -150,15 +234,18 @@ pub fn new(
{ {
let key = gtk::EventControllerKey::new(); let key = gtk::EventControllerKey::new();
key.set_propagation_phase(gtk::PropagationPhase::Capture); key.set_propagation_phase(gtk::PropagationPhase::Capture);
let conn = connector.clone(); let cap = capture.clone();
let stop_k = stop.clone();
let window_k = window.clone(); let window_k = window.clone();
key.connect_key_pressed(move |_, keyval, keycode, state| { key.connect_key_pressed(move |_, keyval, keycode, state| {
let chord = gdk::ModifierType::CONTROL_MASK let chord = gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK | gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SHIFT_MASK; | gdk::ModifierType::SHIFT_MASK;
if state.contains(chord) && keyval.to_lower() == gdk::Key::q { if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
stop_k.store(true, Ordering::SeqCst); // ends the session → page pops if cap.captured.get() {
cap.release();
} else {
cap.engage();
}
return glib::Propagation::Stop; return glib::Propagation::Stop;
} }
if keyval == gdk::Key::F11 { if keyval == gdk::Key::F11 {
@@ -169,113 +256,126 @@ pub fn new(
} }
return glib::Propagation::Stop; return glib::Propagation::Stop;
} }
if !cap.captured.get() {
return glib::Propagation::Proceed;
}
if let Some(vk) = keycode if let Some(vk) = keycode
.checked_sub(8) .checked_sub(8)
.and_then(|c| keymap::evdev_to_vk(c as u16)) .and_then(|c| keymap::evdev_to_vk(c as u16))
{ {
send(&conn, InputKind::KeyDown, vk as u32, 0, 0, 0); cap.held_keys.borrow_mut().insert(vk);
send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
} }
glib::Propagation::Stop glib::Propagation::Stop
}); });
let conn = connector.clone(); let cap = capture.clone();
key.connect_key_released(move |_, _keyval, keycode, _state| { key.connect_key_released(move |_, _keyval, keycode, _state| {
if let Some(vk) = keycode if let Some(vk) = keycode
.checked_sub(8) .checked_sub(8)
.and_then(|c| keymap::evdev_to_vk(c as u16)) .and_then(|c| keymap::evdev_to_vk(c as u16))
{ {
send(&conn, InputKind::KeyUp, vk as u32, 0, 0, 0); // Flush-on-release may have beaten us to it — only forward if still held.
if cap.held_keys.borrow_mut().remove(&vk) {
send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
} }
}); });
overlay.add_controller(key); overlay.add_controller(key);
} }
// --- Mouse: absolute motion, buttons, wheel --- // --- Mouse: absolute motion, buttons, wheel — forwarded only while captured ---
{ {
let motion = gtk::EventControllerMotion::new(); let motion = gtk::EventControllerMotion::new();
let conn = connector.clone(); let cap = capture.clone();
let target = overlay.downgrade();
motion.connect_motion(move |_, x, y| { motion.connect_motion(move |_, x, y| {
if let Some(w) = target.upgrade() { if cap.captured.get() {
send_abs(&w, &conn, x, y); send_abs(&cap.overlay, &cap.connector, x, y);
} }
}); });
overlay.add_controller(motion); overlay.add_controller(motion);
} }
{ {
let click = gtk::GestureClick::builder().button(0).build(); let click = gtk::GestureClick::builder().button(0).build();
let conn = connector.clone(); let cap = capture.clone();
let target = overlay.downgrade();
click.connect_pressed(move |g, _n, x, y| { click.connect_pressed(move |g, _n, x, y| {
if let Some(w) = target.upgrade() { cap.overlay.grab_focus();
w.grab_focus(); if !cap.captured.get() {
send_abs(&w, &conn, x, y); cap.engage(); // the engaging click is suppressed toward the host
return;
} }
send_abs(&cap.overlay, &cap.connector, x, y);
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) { if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
send(&conn, InputKind::MouseButtonDown, gs, 0, 0, 0); cap.held_buttons.borrow_mut().insert(gs);
send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
} }
}); });
let conn = connector.clone(); let cap = capture.clone();
click.connect_released(move |g, _n, _x, _y| { click.connect_released(move |g, _n, _x, _y| {
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) { if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
send(&conn, InputKind::MouseButtonUp, gs, 0, 0, 0); if cap.held_buttons.borrow_mut().remove(&gs) {
send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
}
} }
}); });
overlay.add_controller(click); overlay.add_controller(click);
} }
{ {
let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES); let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
let conn = connector.clone(); let cap = capture.clone();
scroll.connect_scroll(move |_, dx, dy| { scroll.connect_scroll(move |_, dx, dy| {
if !cap.captured.get() {
return glib::Propagation::Proceed;
}
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is // The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
// positive = down. Smooth fractions survive — libei's discrete scroll is // positive = down. Smooth fractions survive — libei's discrete scroll is
// 120-based too. // 120-based too.
let vy = (-dy * 120.0) as i32; let vy = (-dy * 120.0) as i32;
if vy != 0 { if vy != 0 {
send(&conn, InputKind::MouseScroll, 0, vy, 0, 0); send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
} }
let vx = (dx * 120.0) as i32; let vx = (dx * 120.0) as i32;
if vx != 0 { if vx != 0 {
send(&conn, InputKind::MouseScroll, 1, vx, 0, 0); send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
} }
glib::Propagation::Stop glib::Propagation::Stop
}); });
overlay.add_controller(scroll); overlay.add_controller(scroll);
} }
// --- Capture lifecycle: grab focus + compositor shortcuts while mapped. --- // --- Capture lifecycle ---
{ {
let window = window.clone(); // Engaged when the stream starts (trust is already confirmed by then).
let cap = capture.clone();
overlay.connect_map(move |w| { overlay.connect_map(move |w| {
tracing::debug!("stream overlay mapped");
w.grab_focus(); w.grab_focus();
if inhibit_shortcuts { cap.engage();
if let Some(tl) = window
.surface()
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
{
tl.inhibit_system_shortcuts(None::<&gdk::Event>);
}
}
}); });
} }
{ // Focus loss releases (Alt-Tab away, another window) — Swift does the same.
let window = window.clone(); let active_handler = {
overlay.connect_unmap(move |_| { let cap = capture.clone();
if let Some(tl) = window window.connect_is_active_notify(move |w| {
.surface() if !w.is_active() {
.and_then(|s| s.downcast::<gdk::Toplevel>().ok()) cap.release();
{
tl.restore_system_shortcuts();
} }
}); })
};
{
let cap = capture.clone();
overlay.connect_unmap(move |_| cap.release());
} }
// The page's `hidden` fires once navigation away completes (back button, pop on // The page's `hidden` fires once navigation away completes (back button, pop on
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs. // session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
{ {
let window = window.clone(); let window = window.clone();
let stop_h = stop.clone(); let stop_h = stop.clone();
let handlers = RefCell::new(Some((fs_handler, active_handler)));
page.connect_hidden(move |_| { page.connect_hidden(move |_| {
tracing::debug!("stream page hidden — ending session"); tracing::debug!("stream page hidden — ending session");
if let Some((fs, active)) = handlers.borrow_mut().take() {
window.disconnect(fs);
window.disconnect(active);
}
if window.is_fullscreen() { if window.is_fullscreen() {
window.unfullscreen(); window.unfullscreen();
} }
+2 -2
View File
@@ -173,8 +173,8 @@ impl NvencEncoder {
.and_then(|s| s.parse::<f32>().ok()) .and_then(|s| s.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v > 0.0) .filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0); .unwrap_or(1.0);
let vbv_bits = let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64).clamp(1.0, i32::MAX as f64); .clamp(1.0, i32::MAX as f64);
unsafe { unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32; (*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
} }
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Build a punktfunk-host .deb for Ubuntu/Debian hosts.
#
# Mirrors the Fedora RPM (../rpm/punktfunk.spec): the host binary + the uinput udev rule
# + the systemd *user* unit + headless session helpers + example config + the OpenAPI doc.
#
# Runtime Depends are computed by `dpkg-shlibdeps` from the binary's actual DT_NEEDED, NOT
# hand-listed: the binary pulls a large transitive lib closure (most of it via ffmpeg) and
# the exact soname package names (libavcodec62, libpipewire-0.3-0t64, …) drift across distro
# releases — shlibdeps tracks them automatically and pins them to whatever the BUILD distro
# ships. Build this inside the Ubuntu 26.04 rust-ci image so those names match the target
# boxes exactly. `--ignore-missing-info` drops libcuda.so.1 (the NVIDIA driver lib, linked via
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
#
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-deb.sh
# Output: dist/punktfunk-host_<version>_<arch>.deb
set -euo pipefail
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
ARCH="${ARCH:-amd64}"
PKG="punktfunk-host"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
BIN="target/release/$PKG"
if [ ! -x "$BIN" ]; then
echo "==> building $PKG (release)"
cargo build --release -p "$PKG" --locked
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
DOCDIR="$STAGE/usr/share/doc/$PKG"
SHAREDIR="$STAGE/usr/share/$PKG"
# --- file layout (matches the RPM %install) ----------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
install -Dm0644 scripts/punktfunk-host.service "$STAGE/usr/lib/systemd/user/punktfunk-host.service"
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
install -Dm0644 scripts/host.env.example "$SHAREDIR/host.env.example"
install -Dm0644 packaging/bazzite/host.env "$SHAREDIR/host.env.bazzite"
install -Dm0644 docs/api/openapi.json "$SHAREDIR/openapi.json"
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
install -Dm0644 README.md "$DOCDIR/README.md"
# Debian copyright + changelog (cheap, keeps the package well-formed).
cat > "$DOCDIR/copyright" <<EOF
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: punktfunk
Source: https://git.unom.io/unom/punktfunk
Files: *
Copyright: punktfunk contributors
License: MIT or Apache-2.0
Dual-licensed. Full texts in /usr/share/doc/$PKG/LICENSE-MIT and
/usr/share/doc/$PKG/LICENSE-APACHE.
EOF
printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <noreply@anthropic.com> %s\n' \
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
# --- dependencies ------------------------------------------------------------
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header).
SHLIB_TMP="$(mktemp -d)"
mkdir -p "$SHLIB_TMP/debian"
cat > "$SHLIB_TMP/debian/control" <<EOF
Source: $PKG
Package: $PKG
Architecture: any
Depends: \${shlibs:Depends}
EOF
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
| sed -n 's/^shlibs:Depends=//p')"
rm -rf "$SHLIB_TMP"
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
# Drop the NVIDIA driver lib unconditionally. --ignore-missing-info already skips libcuda on a
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
# provided by whatever driver the host runs, so this must never be a package dependency.
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
| grep -ivE '^(libnvidia-compute|libcuda)' | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
# Manual additions shlibdeps can't see:
# - libei1: input injection (libei) is loaded at runtime, not in DT_NEEDED.
# - pipewire/wireplumber: runtime services (the daemon + session manager), not linked libs.
DEPENDS="$SHDEPS, libei1, pipewire, wireplumber"
# ffmpeg: Ubuntu's ffmpeg ships the NVENC-enabled libav* the binary links AND is the encoder
# runtime; the libav* sonames are already hard Depends via shlibdeps, so the ffmpeg metapackage
# is a Recommends. gamescope = a ready compositor backend; pipewire-pulse = desktop audio.
RECOMMENDS="ffmpeg, gamescope, pipewire-pulse"
SUGGESTS="kwin-wayland, mutter"
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
install -d "$STAGE/DEBIAN"
cat > "$STAGE/DEBIAN/control" <<EOF
Package: $PKG
Version: $VERSION
Architecture: $ARCH
Maintainer: unom <noreply@anthropic.com>
Installed-Size: $INSTALLED_KB
Section: net
Priority: optional
Homepage: https://git.unom.io/unom/punktfunk
Depends: $DEPENDS
Recommends: $RECOMMENDS
Suggests: $SUGGESTS
Description: Low-latency desktop/game streaming host (Moonlight + punktfunk/1)
punktfunk is a Linux-first, low-latency desktop and game streaming host. It speaks
the Moonlight/GameStream protocol (pair a stock Moonlight client) and its own native
punktfunk/1 protocol (GF(2^16) Leopard FEC + AES-GCM, mid-stream mode renegotiation,
client microphone passthrough). Each session gets a virtual output at the client's
exact resolution and refresh via a per-compositor backend (KWin, gamescope, Mutter,
Sway/wlroots), captured zero-copy (dmabuf -> CUDA -> NVENC). Input (mouse, keyboard,
gamepads) is injected back into the session.
.
NVENC + GPU EGL come from the NVIDIA driver (libnvidia-encode / libEGL_nvidia),
installed out of band. After install: add yourself to the 'input' group for virtual
gamepads, then `systemctl --user enable --now punktfunk-host`.
EOF
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:"
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
fi
exit 0
EOF
chmod 0755 "$STAGE/DEBIAN/postinst"
mkdir -p dist
OUT="dist/${PKG}_${VERSION}_${ARCH}.deb"
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
echo "built $OUT"
echo " Depends: $DEPENDS"
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size' || true