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"
|
||||
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"] }
|
||||
|
||||
@@ -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<RefCell<Settings>>,
|
||||
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<bool>,
|
||||
}
|
||||
@@ -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<App>, 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<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]>) {
|
||||
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<App>, 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<App>, 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);
|
||||
|
||||
@@ -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<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 —
|
||||
//! 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<NativeClient>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Option<std::thread::JoinHandle<()>> {
|
||||
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<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) {
|
||||
@@ -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
|
||||
// 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<sdl3::gamepad::Gamepad> = None;
|
||||
let pad_id = |p: &Option<sdl3::gamepad::Gamepad>| -> Option<u32> {
|
||||
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<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() {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn Fn(ConnectRequest)>,
|
||||
on_settings: Rc<dyn Fn()>,
|
||||
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
@@ -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<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 stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||
let res_names: Vec<String> = 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<gtk::Widget>, settings: Rc<RefCell<Settings>>) {
|
||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.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()
|
||||
.title("Refresh rate")
|
||||
.model(>k::StringList::new(
|
||||
&REFRESH
|
||||
.iter()
|
||||
.map(|r| format!("{r} Hz"))
|
||||
.collect::<Vec<_>>()
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.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::<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()
|
||||
.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<gtk::Widget>, settings: Rc<RefCell<Settings>>) {
|
||||
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<gtk::Widget>, settings: Rc<RefCell<Settings>>) {
|
||||
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));
|
||||
|
||||
@@ -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<gtk::Widget>, 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<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)]
|
||||
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::<gdk::Toplevel>().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::<gdk::Toplevel>().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();
|
||||
}
|
||||
|
||||
@@ -173,8 +173,8 @@ impl NvencEncoder {
|
||||
.and_then(|s| s.parse::<f32>().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;
|
||||
}
|
||||
|
||||
@@ -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