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:
@@ -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"] }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
|||||||
¶ms.host,
|
¶ms.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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(>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);
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(>k::StringList::new(
|
.model(>k::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(>k::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(>k::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(>k::StringList::new(&["Auto", "Xbox 360", "DualSense"]))
|
.model(>k::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));
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user