From a95984bb4fa5d402fc0d1bfc9493b3bc2943c805 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 12 Jun 2026 21:11:52 +0000 Subject: [PATCH] feat(client-linux): feature parity with the Swift client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/punktfunk-client-linux/Cargo.toml | 5 +- crates/punktfunk-client-linux/src/app.rs | 158 +++++- crates/punktfunk-client-linux/src/audio.rs | 186 ++++++- crates/punktfunk-client-linux/src/gamepad.rs | 491 +++++++++++++++--- crates/punktfunk-client-linux/src/session.rs | 24 +- crates/punktfunk-client-linux/src/trust.rs | 22 +- crates/punktfunk-client-linux/src/ui_hosts.rs | 71 ++- .../punktfunk-client-linux/src/ui_settings.rs | 136 ++++- .../punktfunk-client-linux/src/ui_stream.rs | 208 ++++++-- crates/punktfunk-host/src/encode/linux.rs | 4 +- packaging/debian/build-deb.sh | 150 ++++++ 11 files changed, 1278 insertions(+), 177 deletions(-) create mode 100644 packaging/debian/build-deb.sh diff --git a/crates/punktfunk-client-linux/Cargo.toml b/crates/punktfunk-client-linux/Cargo.toml index 57a3936..3d8b0cb 100644 --- a/crates/punktfunk-client-linux/Cargo.toml +++ b/crates/punktfunk-client-linux/Cargo.toml @@ -28,8 +28,9 @@ ffmpeg-next = "8" opus = "0.3" pipewire = "0.9" -# Gamepads: capture + rumble/lightbar feedback (full DualSense fidelity lives here). -sdl3 = "0.18" +# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs +# need the hidapi driver). +sdl3 = { version = "0.18", features = ["hidapi"] } mdns-sd = "0.20" serde = { version = "1", features = ["derive"] } diff --git a/crates/punktfunk-client-linux/src/app.rs b/crates/punktfunk-client-linux/src/app.rs index 03f7a9a..ec60822 100644 --- a/crates/punktfunk-client-linux/src/app.rs +++ b/crates/punktfunk-client-linux/src/app.rs @@ -4,9 +4,9 @@ use crate::session::{SessionEvent, SessionParams}; use crate::trust::{KnownHost, KnownHosts, Settings}; use crate::ui_hosts::ConnectRequest; use adw::prelude::*; -use gtk::glib; +use gtk::{gdk, glib}; use punktfunk_core::client::NativeClient; -use punktfunk_core::config::GamepadPref; +use punktfunk_core::config::{CompositorPref, GamepadPref}; use std::cell::RefCell; use std::rc::Rc; @@ -18,6 +18,8 @@ struct App { toasts: adw::ToastOverlay, settings: Rc>, 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. busy: std::cell::Cell, } @@ -89,6 +91,7 @@ fn build_ui(gtk_app: &adw::Application) { toasts, settings: Rc::new(RefCell::new(Settings::load())), identity, + gamepad: crate::gamepad::GamepadService::start(), busy: std::cell::Cell::new(false), }); @@ -99,7 +102,13 @@ fn build_ui(gtk_app: &adw::Application) { }, { 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); @@ -244,21 +253,151 @@ fn pin_dialog(app: Rc, req: ConnectRequest) { 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, 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::>(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, req: ConnectRequest, pin: Option<[u8; 32]>) { if app.busy.replace(true) { return; } + let mode = resolve_mode(&app); let s = app.settings.borrow(); let params = SessionParams { host: req.addr.clone(), port: req.port, - mode: punktfunk_core::config::Mode { - width: s.width, - height: s.height, - refresh_hz: s.refresh_hz, + mode, + compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto), + // "Automatic" matches the physical pad (Swift parity); an explicit choice wins. + 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, + mic_enabled: s.mic_enabled, pin, identity: app.identity.clone(), }; @@ -300,6 +439,7 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { "{} · {}×{}@{}", req.name, mode.width, mode.height, mode.refresh_hz ); + app.gamepad.attach(connector.clone()); let p = crate::ui_stream::new( &app.window, connector, @@ -317,11 +457,13 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { } } SessionEvent::Failed(msg) => { + tracing::warn!(%msg, "connect failed"); app.toast(&msg); app.busy.set(false); break; } SessionEvent::Ended(err) => { + app.gamepad.detach(); app.nav.pop_to_tag("hosts"); if let Some(e) = err { app.toast(&e); diff --git a/crates/punktfunk-client-linux/src/audio.rs b/crates/punktfunk-client-linux/src/audio.rs index 895f8d3..f1bce4b 100644 --- a/crates/punktfunk-client-linux/src/audio.rs +++ b/crates/punktfunk-client-linux/src/audio.rs @@ -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 -//! adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded chunks on the -//! network clock; PipeWire pulls whole quanta on the device clock. Prime to ~3 quanta -//! before producing, cap the ring so latency stays bounded, re-prime after a real drain. +//! Playback mirrors the host's virtual-mic producer (`punktfunk-host::audio::linux`) with +//! the same adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded chunks on +//! the network clock; PipeWire pulls whole quanta on the device clock. Prime to ~3 +//! quanta before producing, cap the ring so latency stays bounded, re-prime after a real +//! drain. use anyhow::{Context, Result}; +use punktfunk_core::client::NativeClient; use std::collections::VecDeque; use std::sync::mpsc::{Receiver, SyncSender, TrySendError}; +use std::sync::Arc; const SAMPLE_RATE: u32 = 48_000; 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; @@ -204,3 +210,173 @@ fn pw_thread( tracing::debug!("pipewire playback loop exited"); 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, + thread: Option>, +} + +impl MicStreamer { + pub fn spawn(connector: Arc) -> Result { + let (quit_tx, quit_rx) = pipewire::channel::channel::(); + 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, + ring: VecDeque, + encoder: opus::Encoder, + seq: u32, + out: Vec, +} + +fn mic_thread( + connector: &Arc, + quit_rx: pipewire::channel::Receiver, +) -> 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 = 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 = 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(()) +} diff --git a/crates/punktfunk-client-linux/src/gamepad.rs b/crates/punktfunk-client-linux/src/gamepad.rs index 7d234f2..b38f543 100644 --- a/crates/punktfunk-client-linux/src/gamepad.rs +++ b/crates/punktfunk-client-linux/src/gamepad.rs @@ -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 — -//! the first connected (a pin/auto picker lands with the settings work). SDL3 is the one -//! library with full DualSense fidelity (touchpad/gyro/lightbar/player LEDs/rumble + -//! adaptive triggers via raw effect packets), matching the wire planes; this stage wires -//! buttons/axes out and rumble/lightbar back. Touchpad/motion capture (0xCC) and -//! adaptive-trigger replay (0xCD `Trigger`) are follow-ups on the same loop. +//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the +//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most +//! recently connected), and — while a session is attached — forwards buttons/axes, +//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on +//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet +//! (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::config::GamepadPref; use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; -use punktfunk_core::quic::HidOutput; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use punktfunk_core::quic::{HidOutput, RichInput}; +use std::collections::HashMap; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex}; use std::time::Duration; -pub fn spawn( - connector: Arc, - stop: Arc, -) -> Option> { - std::thread::Builder::new() - .name("punktfunk-gamepad".into()) - .spawn(move || { - if let Err(e) = run(&connector, &stop) { - tracing::warn!(error = %e, "gamepad thread ended — pads disabled"); - } - }) - .ok() +/// Motion scale constants, shared convention with the Swift client (`GamepadWire`): +/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands +/// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs. +const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI; +const ACCEL_LSB_PER_G: f32 = 10_000.0; +const G: f32 = 9.80665; + +#[derive(Clone, Debug)] +pub struct PadInfo { + pub id: u32, + pub name: String, + pub is_dualsense: bool, +} + +enum Ctl { + Attach(Arc), + Detach, + Pin(Option), +} + +#[derive(Clone)] +pub struct GamepadService { + pads: Arc>>, + active: Arc>>, + pinned: Arc>>, + ctl: Sender, +} + +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 { + self.pads.lock().unwrap().clone() + } + + pub fn active(&self) -> Option { + self.active.lock().unwrap().clone() + } + + pub fn pinned(&self) -> Option { + *self.pinned.lock().unwrap() + } + + pub fn set_pinned(&self, id: Option) { + let _ = self.ctl.send(Ctl::Pin(id)); + } + + pub fn attach(&self, connector: Arc) { + 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) { @@ -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, + /// Connection order; the most recently connected is the auto selection. + order: Vec, + pinned: Option, + attached: Option>, + /// Wire state of the active pad — zeroed on the wire at switch/detach. + last_axis: [i32; 6], + held_buttons: Vec, + last_accel: [i16; 3], +} + +impl Worker { + fn active_id(&self) -> Option { + self.pinned + .filter(|id| self.opened.contains_key(id)) + .or_else(|| self.order.last().copied()) + } + + fn pad_info(&self, id: u32) -> Option { + 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>, + active_out: &Mutex>, + pinned_out: &Mutex>, + ctl: &Receiver, +) -> Result<(), String> { // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its // own thread. 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 mut pump = sdl.event_pump().map_err(|e| e.to_string())?; - let mut active: Option = None; - let pad_id = |p: &Option| -> Option { - p.as_ref().and_then(|p| p.id().ok()).map(|id| id.0) + let mut w = Worker { + subsystem, + 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 = 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() { use sdl3::event::Event; + let active = w.active_id(); match event { Event::ControllerDeviceAdded { which, .. } => { - if active.is_none() { - match subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) { + if !w.opened.contains_key(&which) { + match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) { Ok(pad) => { tracing::info!( name = pad.name().unwrap_or_default(), - "gamepad attached as pad 0" + "gamepad attached" ); - active = Some(pad); - last_axis = [i32::MIN; 6]; + w.opened.insert(which, pad); + 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"), } } } 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"); - active = None; + publish(&w); } } - Event::ControllerButtonDown { which, button, .. } => { - if pad_id(&active) == Some(which) { - if let Some(bit) = button_bit(button) { - send(connector, InputKind::GamepadButton, bit, 1); - } + Event::ControllerButtonDown { which, button, .. } + if active == Some(which) && w.attached.is_some() => + { + 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, .. } => { - if pad_id(&active) == Some(which) { - if let Some(bit) = button_bit(button) { - send(connector, InputKind::GamepadButton, bit, 0); - } + Event::ControllerButtonUp { which, button, .. } + if active == Some(which) && w.attached.is_some() => + { + 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 { which, axis, value, .. - } if pad_id(&active) == Some(which) => { + } if active == Some(which) && w.attached.is_some() => { let (id, v) = axis_value(axis, value); - if last_axis[id as usize] != v { - last_axis[id as usize] = v; - send(connector, InputKind::GamepadAxis, id, v); + if w.last_axis[id as usize] != v { + w.last_axis[id as usize] = 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 // rumble state periodically, so a generous duration with refresh-on-update is // safe — a dropped stop heals within ~500 ms. - while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { - if pad == 0 { - if let Some(p) = active.as_mut() { - 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); + if let Some(connector) = w.attached.clone() { + while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { + if pad == 0 { + if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) { + let _ = p.set_rumble(low, high, 5_000); } } - Ok(HidOutput::PlayerLeds { .. }) => {} // TODO: SDL player-index mapping - Ok(HidOutput::Trigger { .. }) => {} // TODO: DS5 effect packet replay - Ok(_) => {} - Err(_) => break, + } + while let Ok(hid) = connector.next_hidout(Duration::ZERO) { + let Some(id) = w.active_id() else { continue }; + let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense); + let 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(()) } diff --git a/crates/punktfunk-client-linux/src/session.rs b/crates/punktfunk-client-linux/src/session.rs index aceb8b0..a57f051 100644 --- a/crates/punktfunk-client-linux/src/session.rs +++ b/crates/punktfunk-client-linux/src/session.rs @@ -4,8 +4,8 @@ //! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread: //! video+audio here, rumble+hidout on the gamepad thread. +use crate::audio; use crate::video::{DecodedFrame, Decoder}; -use crate::{audio, gamepad}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use punktfunk_core::PunktfunkError; @@ -17,8 +17,11 @@ pub struct SessionParams { pub host: String, pub port: u16, pub mode: Mode, + pub compositor: CompositorPref, pub gamepad: GamepadPref, 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). pub pin: Option<[u8; 32]>, pub identity: (String, String), @@ -84,7 +87,7 @@ fn pump( ¶ms.host, params.port, params.mode, - CompositorPref::Auto, + params.compositor, params.gamepad, params.bitrate_kbps, params.pin, @@ -118,14 +121,22 @@ fn pump( 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() .map_err(|e| tracing::warn!(error = %e, "audio disabled")) .ok(); let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo) .map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled")) .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 mut total_frames = 0u64; @@ -218,9 +229,6 @@ fn pump( reason = end.as_deref().unwrap_or("user"), "session ended" ); - stop.store(true, Ordering::SeqCst); // take the gamepad thread down with us - if let Some(t) = gamepad_thread { - let _ = t.join(); - } + stop.store(true, Ordering::SeqCst); let _ = ev_tx.send_blocking(SessionEvent::Ended(end)); } diff --git a/crates/punktfunk-client-linux/src/trust.rs b/crates/punktfunk-client-linux/src/trust.rs index 7052bcb..aad68e1 100644 --- a/crates/punktfunk-client-linux/src/trust.rs +++ b/crates/punktfunk-client-linux/src/trust.rs @@ -104,29 +104,39 @@ impl KnownHosts { } } -/// App settings, persisted as JSON. Stringly-typed gamepad pref so the file stays -/// readable; parsed with `GamepadPref::from_name` at connect time. +/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file +/// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Serialize, Deserialize)] +#[serde(default)] 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 height: u32, pub refresh_hz: u32, /// Requested encoder bitrate (kbps); 0 = host default. pub bitrate_kbps: u32, 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, + /// Stream the default microphone to the host's virtual mic source. + pub mic_enabled: bool, } impl Default for Settings { fn default() -> Self { Settings { - width: 1920, - height: 1080, - refresh_hz: 60, + width: 0, + height: 0, + refresh_hz: 0, bitrate_kbps: 0, gamepad: "auto".into(), + compositor: "auto".into(), inhibit_shortcuts: true, + mic_enabled: false, } } } diff --git a/crates/punktfunk-client-linux/src/ui_hosts.rs b/crates/punktfunk-client-linux/src/ui_hosts.rs index 3dffcca..f5f4454 100644 --- a/crates/punktfunk-client-linux/src/ui_hosts.rs +++ b/crates/punktfunk-client-linux/src/ui_hosts.rs @@ -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::trust::KnownHosts; use adw::prelude::*; use gtk::glib; use std::cell::RefCell; @@ -22,6 +23,7 @@ pub struct ConnectRequest { pub fn new( on_connect: Rc, on_settings: Rc, + on_speed_test: Rc, ) -> adw::NavigationPage { let list = gtk::ListBox::new(); list.add_css_class("boxed-list"); @@ -132,11 +134,72 @@ pub fn new( manual_list.set_selection_mode(gtk::SelectionMode::None); 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(>k::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); content.set_margin_top(24); content.set_margin_bottom(24); content.set_margin_start(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")); discovered_label.add_css_class("heading"); discovered_label.set_halign(gtk::Align::Start); @@ -167,9 +230,11 @@ pub fn new( toolbar.add_top_bar(&header); toolbar.set_content(Some(&scrolled)); - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title("Punktfunk") .tag("hosts") .child(&toolbar) - .build() + .build(); + page.connect_shown(move |_| rebuild_saved()); + page } diff --git a/crates/punktfunk-client-linux/src/ui_settings.rs b/crates/punktfunk-client-linux/src/ui_settings.rs index 03ce54b..1e3d930 100644 --- a/crates/punktfunk-client-linux/src/ui_settings.rs +++ b/crates/punktfunk-client-linux/src/ui_settings.rs @@ -1,22 +1,41 @@ -//! Preferences dialog: stream mode, bitrate, gamepad type, capture behavior. Written -//! back to disk when the dialog closes. +//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone, +//! capture behavior. Written back to disk when the dialog closes. use crate::trust::Settings; use adw::prelude::*; use std::cell::RefCell; use std::rc::Rc; -const RESOLUTIONS: &[(u32, u32)] = &[(1280, 720), (1920, 1080), (2560, 1440), (3840, 2160)]; -const REFRESH: &[u32] = &[30, 60, 90, 120, 144, 165, 240]; +/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. +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 COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; -pub fn show(parent: &impl IsA, settings: Rc>) { +pub fn show( + parent: &impl IsA, + settings: Rc>, + gamepads: &crate::gamepad::GamepadService, +) { let page = adw::PreferencesPage::new(); let stream = adw::PreferencesGroup::builder().title("Stream").build(); let res_names: Vec = RESOLUTIONS .iter() - .map(|(w, h)| format!("{w} × {h}")) + .map(|&(w, h)| { + if w == 0 { + "Native display".to_string() + } else { + format!("{w} × {h}") + } + }) .collect(); let res_row = adw::ComboRow::builder() .title("Resolution") @@ -25,40 +44,108 @@ pub fn show(parent: &impl IsA, settings: Rc>) { &res_names.iter().map(String::as_str).collect::>(), )) .build(); + let hz_names: Vec = REFRESH + .iter() + .map(|&r| { + if r == 0 { + "Native".to_string() + } else { + format!("{r} Hz") + } + }) + .collect(); let hz_row = adw::ComboRow::builder() .title("Refresh rate") .model(>k::StringList::new( - &REFRESH - .iter() - .map(|r| format!("{r} Hz")) - .collect::>() - .iter() - .map(String::as_str) - .collect::>(), + &hz_names.iter().map(String::as_str).collect::>(), )) .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_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(>k::StringList::new(&[ + "Automatic", + "KWin", + "wlroots (Sway/Hyprland)", + "Mutter (GNOME)", + "gamescope", + ])) + .build(); stream.add(&res_row); stream.add(&hz_row); stream.add(&bitrate_row); + stream.add(&compositor_row); 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(>k::StringList::new( + &pad_names.iter().map(String::as_str).collect::>(), + )) + .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 = 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() .title("Gamepad type") - .subtitle("The virtual pad the host creates (DualSense needs a Linux host)") - .model(>k::StringList::new(&["Auto", "Xbox 360", "DualSense"])) + .subtitle("The virtual pad the host creates — Automatic matches the physical pad") + .model(>k::StringList::new(&[ + "Automatic", + "Xbox 360", + "DualSense", + ])) .build(); let inhibit_row = adw::SwitchRow::builder() .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(); + input.add(&forward_row); input.add(&pad_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(&input); + page.add(&audio); // Seed from the current settings. { @@ -66,14 +153,20 @@ pub fn show(parent: &impl IsA, settings: Rc>) { let res_i = RESOLUTIONS .iter() .position(|&(w, h)| w == s.width && h == s.height) - .unwrap_or(1); + .unwrap_or(0); 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); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); 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); + mic_row.set_active(s.mic_enabled); } let dialog = adw::PreferencesDialog::new(); @@ -86,7 +179,10 @@ pub fn show(parent: &impl IsA, settings: Rc>) { s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; 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.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] + .to_string(); s.inhibit_shortcuts = inhibit_row.is_active(); + s.mic_enabled = mic_row.is_active(); s.save(); }); dialog.present(Some(parent)); diff --git a/crates/punktfunk-client-linux/src/ui_stream.rs b/crates/punktfunk-client-linux/src/ui_stream.rs index 3ac8dd9..e6df48d 100644 --- a/crates/punktfunk-client-linux/src/ui_stream.rs +++ b/crates/punktfunk-client-linux/src/ui_stream.rs @@ -1,12 +1,18 @@ //! The stream page: decoded frames into a `GtkGraphicsOffload`-wrapped picture, local //! input captured and forwarded on the wire contract. //! -//! Input mapping: 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) — relative/pointer-lock capture is the stage-2 -//! presenter's job. While streaming, compositor shortcuts are inhibited (configurable); -//! Ctrl+Alt+Shift+Q ends the session, F11 toggles fullscreen — everything else goes to -//! the host. +//! Input capture is a deliberate, reversible STATE (Moonlight-style, mirroring the Swift +//! client): engaged when the stream starts and when the user clicks into the video (that +//! click is suppressed toward the host); released by Ctrl+Alt+Shift+Q (toggles) or focus +//! loss — held keys/buttons are flushed host-side on release so nothing sticks down. +//! While captured the local cursor is hidden (the host renders its own) and compositor +//! 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::session::Stats; @@ -15,6 +21,9 @@ use adw::prelude::*; use gtk::{gdk, glib}; use punktfunk_core::client::NativeClient; 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::Arc; @@ -60,6 +69,61 @@ fn send_abs(widget: &impl IsA, connector: &NativeClient, x: f64, y: send(connector, InputKind::MouseMoveAbs, 0, px, py, flags); } +/// The capture state shared by every input controller on the page. +struct Capture { + connector: Arc, + window: adw::ApplicationWindow, + overlay: gtk::Overlay, + hint: gtk::Label, + inhibit_shortcuts: bool, + captured: Cell, + /// VKs / GameStream button ids currently held — flushed up on release. + held_keys: RefCell>, + held_buttons: RefCell>, +} + +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::().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::().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)] pub fn new( window: &adw::ApplicationWindow, @@ -86,12 +150,31 @@ pub fn new( stats_label.set_margin_start(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(); overlay.set_child(Some(&offload)); overlay.add_overlay(&stats_label); + overlay.add_overlay(&hint); 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 fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); @@ -111,13 +194,14 @@ pub fn new( let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); 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(); window.connect_fullscreened_notify(move |w| { toolbar.set_reveal_top_bars(!w.is_fullscreen()); - }); - } + }) + }; let page = adw::NavigationPage::builder() .title(title) @@ -150,15 +234,18 @@ pub fn new( { let key = gtk::EventControllerKey::new(); key.set_propagation_phase(gtk::PropagationPhase::Capture); - let conn = connector.clone(); - let stop_k = stop.clone(); + let cap = capture.clone(); let window_k = window.clone(); key.connect_key_pressed(move |_, keyval, keycode, state| { let chord = gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK | gdk::ModifierType::SHIFT_MASK; 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; } if keyval == gdk::Key::F11 { @@ -169,113 +256,126 @@ pub fn new( } return glib::Propagation::Stop; } + if !cap.captured.get() { + return glib::Propagation::Proceed; + } if let Some(vk) = keycode .checked_sub(8) .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 }); - let conn = connector.clone(); + let cap = capture.clone(); key.connect_key_released(move |_, _keyval, keycode, _state| { if let Some(vk) = keycode .checked_sub(8) .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); } - // --- Mouse: absolute motion, buttons, wheel --- + // --- Mouse: absolute motion, buttons, wheel — forwarded only while captured --- { let motion = gtk::EventControllerMotion::new(); - let conn = connector.clone(); - let target = overlay.downgrade(); + let cap = capture.clone(); motion.connect_motion(move |_, x, y| { - if let Some(w) = target.upgrade() { - send_abs(&w, &conn, x, y); + if cap.captured.get() { + send_abs(&cap.overlay, &cap.connector, x, y); } }); overlay.add_controller(motion); } { let click = gtk::GestureClick::builder().button(0).build(); - let conn = connector.clone(); - let target = overlay.downgrade(); + let cap = capture.clone(); click.connect_pressed(move |g, _n, x, y| { - if let Some(w) = target.upgrade() { - w.grab_focus(); - send_abs(&w, &conn, x, y); + cap.overlay.grab_focus(); + if !cap.captured.get() { + 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()) { - 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| { 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); } { let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES); - let conn = connector.clone(); + let cap = capture.clone(); 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 // positive = down. Smooth fractions survive — libei's discrete scroll is // 120-based too. let vy = (-dy * 120.0) as i32; 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; if vx != 0 { - send(&conn, InputKind::MouseScroll, 1, vx, 0, 0); + send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0); } glib::Propagation::Stop }); 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| { - tracing::debug!("stream overlay mapped"); w.grab_focus(); - if inhibit_shortcuts { - if let Some(tl) = window - .surface() - .and_then(|s| s.downcast::().ok()) - { - tl.inhibit_system_shortcuts(None::<&gdk::Event>); - } - } + cap.engage(); }); } - { - let window = window.clone(); - overlay.connect_unmap(move |_| { - if let Some(tl) = window - .surface() - .and_then(|s| s.downcast::().ok()) - { - tl.restore_system_shortcuts(); + // Focus loss releases (Alt-Tab away, another window) — Swift does the same. + let active_handler = { + let cap = capture.clone(); + window.connect_is_active_notify(move |w| { + if !w.is_active() { + cap.release(); } - }); + }) + }; + { + let cap = capture.clone(); + overlay.connect_unmap(move |_| cap.release()); } // The page's `hidden` fires once navigation away completes (back button, pop on // session end) — NOT on the transient unmap/map cycle a NavigationView push performs. { let window = window.clone(); let stop_h = stop.clone(); + let handlers = RefCell::new(Some((fs_handler, active_handler))); page.connect_hidden(move |_| { tracing::debug!("stream page hidden — ending session"); + if let Some((fs, active)) = handlers.borrow_mut().take() { + window.disconnect(fs); + window.disconnect(active); + } if window.is_fullscreen() { window.unfullscreen(); } diff --git a/crates/punktfunk-host/src/encode/linux.rs b/crates/punktfunk-host/src/encode/linux.rs index 091ea66..239873c 100644 --- a/crates/punktfunk-host/src/encode/linux.rs +++ b/crates/punktfunk-host/src/encode/linux.rs @@ -173,8 +173,8 @@ impl NvencEncoder { .and_then(|s| s.parse::().ok()) .filter(|v| v.is_finite() && *v > 0.0) .unwrap_or(1.0); - let vbv_bits = - ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64).clamp(1.0, i32::MAX as f64); + let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64) + .clamp(1.0, i32::MAX as f64); unsafe { (*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32; } diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh new file mode 100644 index 0000000..ac18510 --- /dev/null +++ b/packaging/debian/build-deb.sh @@ -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- 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__.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" < %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" </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- 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" < +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