client: headless mode and kmssink guard

This commit is contained in:
Brad Stein 2026-02-02 15:15:45 -03:00
parent 57adce2696
commit 4f898ddee7
3 changed files with 108 additions and 70 deletions

View File

@ -32,6 +32,7 @@ pub struct LesavkaClientApp {
aggregator: Option<InputAggregator>, aggregator: Option<InputAggregator>,
server_addr: String, server_addr: String,
dev_mode: bool, dev_mode: bool,
headless: bool,
kbd_tx: broadcast::Sender<KeyboardReport>, kbd_tx: broadcast::Sender<KeyboardReport>,
mou_tx: broadcast::Sender<MouseReport>, mou_tx: broadcast::Sender<MouseReport>,
} }
@ -39,6 +40,7 @@ pub struct LesavkaClientApp {
impl LesavkaClientApp { impl LesavkaClientApp {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok(); let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok();
let headless = std::env::var("LESAVKA_HEADLESS").is_ok();
let server_addr = std::env::args() let server_addr = std::env::args()
.nth(1) .nth(1)
.or_else(|| std::env::var("LESAVKA_SERVER_ADDR").ok()) .or_else(|| std::env::var("LESAVKA_SERVER_ADDR").ok())
@ -47,12 +49,17 @@ impl LesavkaClientApp {
let (kbd_tx, _) = broadcast::channel(1024); let (kbd_tx, _) = broadcast::channel(1024);
let (mou_tx, _) = broadcast::channel(4096); let (mou_tx, _) = broadcast::channel(4096);
let agg = InputAggregator::new(dev_mode, kbd_tx.clone(), mou_tx.clone()); let agg = if headless {
None
} else {
Some(InputAggregator::new(dev_mode, kbd_tx.clone(), mou_tx.clone()))
};
Ok(Self { Ok(Self {
aggregator: Some(agg), aggregator: agg,
server_addr, server_addr,
dev_mode, dev_mode,
headless,
kbd_tx, kbd_tx,
mou_tx, mou_tx,
}) })
@ -98,18 +105,25 @@ impl LesavkaClientApp {
.tcp_nodelay(true) .tcp_nodelay(true)
.connect_lazy(); .connect_lazy();
/*────────── input aggregator task (grab after handshake) ─────────────*/ let mut agg_task = None;
let mut aggregator = self.aggregator.take().expect("InputAggregator present"); let mut kbd_loop = None;
info!("⌛ grabbing input devices…"); let mut mou_loop = None;
aggregator.init()?; // grab devices now that handshake succeeded if !self.headless {
let agg_task = tokio::spawn(async move { /*────────── input aggregator task (grab after handshake) ─────────────*/
let mut a = aggregator; let mut aggregator = self.aggregator.take().expect("InputAggregator present");
a.run().await info!("⌛ grabbing input devices…");
}); aggregator.init()?; // grab devices now that handshake succeeded
agg_task = Some(tokio::spawn(async move {
let mut a = aggregator;
a.run().await
}));
/*────────── HID streams (never return) ────────*/ /*────────── HID streams (never return) ────────*/
let kbd_loop = self.stream_loop_keyboard(hid_ep.clone()); kbd_loop = Some(self.stream_loop_keyboard(hid_ep.clone()));
let mou_loop = self.stream_loop_mouse(hid_ep.clone()); mou_loop = Some(self.stream_loop_mouse(hid_ep.clone()));
} else {
info!("🧪 headless mode: skipping HID input capture");
}
/*───────── optional 300s auto-exit in dev mode */ /*───────── optional 300s auto-exit in dev mode */
let suicide = async { let suicide = async {
@ -122,57 +136,61 @@ impl LesavkaClientApp {
} }
}; };
/*────────── video rendering thread (winit) ────*/ if !self.headless {
let (video_tx, mut video_rx) = tokio::sync::mpsc::unbounded_channel::<VideoPacket>(); /*────────── video rendering thread (winit) ────*/
let (video_tx, mut video_rx) = tokio::sync::mpsc::unbounded_channel::<VideoPacket>();
std::thread::spawn(move || { std::thread::spawn(move || {
gtk::init().expect("GTK initialisation failed"); gtk::init().expect("GTK initialisation failed");
let el = EventLoopBuilder::<()>::new() let el = EventLoopBuilder::<()>::new()
.with_any_thread(true) .with_any_thread(true)
.build() .build()
.unwrap(); .unwrap();
let win0 = MonitorWindow::new(0).expect("win0"); let win0 = MonitorWindow::new(0).expect("win0");
let win1 = MonitorWindow::new(1).expect("win1"); let win1 = MonitorWindow::new(1).expect("win1");
let _ = el.run(move |_: Event<()>, _elwt| { let _ = el.run(move |_: Event<()>, _elwt| {
_elwt.set_control_flow(ControlFlow::WaitUntil( _elwt.set_control_flow(ControlFlow::WaitUntil(
std::time::Instant::now() + std::time::Duration::from_millis(16), std::time::Instant::now() + std::time::Duration::from_millis(16),
)); ));
static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static DUMP_CNT: std::sync::atomic::AtomicU32 = static DUMP_CNT: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0); std::sync::atomic::AtomicU32::new(0);
while let Ok(pkt) = video_rx.try_recv() { while let Ok(pkt) = video_rx.try_recv() {
CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 { if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 {
debug!( debug!(
"🎥 received {} video packets", "🎥 received {} video packets",
CNT.load(std::sync::atomic::Ordering::Relaxed) CNT.load(std::sync::atomic::Ordering::Relaxed)
); );
}
let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if n % 120 == 0 {
let eye = if pkt.id == 0 { "l" } else { "r" };
let path = format!("/tmp/eye{eye}-cli-{n:05}.h264");
std::fs::write(&path, &pkt.data).ok();
}
match pkt.id {
0 => win0.push_packet(pkt),
1 => win1.push_packet(pkt),
_ => {}
}
} }
let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); });
if n % 120 == 0 {
let eye = if pkt.id == 0 { "l" } else { "r" };
let path = format!("/tmp/eye{eye}-cli-{n:05}.h264");
std::fs::write(&path, &pkt.data).ok();
}
match pkt.id {
0 => win0.push_packet(pkt),
1 => win1.push_packet(pkt),
_ => {}
}
}
}); });
});
/*────────── start video gRPC pullers ──────────*/ /*────────── start video gRPC pullers ──────────*/
let ep_video = vid_ep.clone(); let ep_video = vid_ep.clone();
tokio::spawn(Self::video_loop(ep_video, video_tx)); tokio::spawn(Self::video_loop(ep_video, video_tx));
/*────────── audio renderer & puller ───────────*/ /*────────── audio renderer & puller ───────────*/
let audio_out = AudioOut::new()?; let audio_out = AudioOut::new()?;
let ep_audio = vid_ep.clone(); let ep_audio = vid_ep.clone();
tokio::spawn(Self::audio_loop(ep_audio, audio_out)); tokio::spawn(Self::audio_loop(ep_audio, audio_out));
} else {
info!("🧪 headless mode: skipping video/audio renderers");
}
/*────────── camera & mic tasks (gated by caps) ───────────*/ /*────────── camera & mic tasks (gated by caps) ───────────*/
if caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err() { if caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err() {
if let Some(cfg) = camera_cfg { if let Some(cfg) = camera_cfg {
@ -190,23 +208,32 @@ impl LesavkaClientApp {
)?); )?);
tokio::spawn(Self::cam_loop(vid_ep.clone(), cam)); tokio::spawn(Self::cam_loop(vid_ep.clone(), cam));
} }
if caps.microphone { if caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err() {
let mic = Arc::new(MicrophoneCapture::new()?); let mic = Arc::new(MicrophoneCapture::new()?);
tokio::spawn(Self::voice_loop(vid_ep.clone(), mic)); // renamed tokio::spawn(Self::voice_loop(vid_ep.clone(), mic)); // renamed
} }
/*────────── central reactor ───────────────────*/ /*────────── central reactor ───────────────────*/
tokio::select! { if self.headless {
_ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); }, tokio::select! {
_ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); }, _ = suicide => { /* handled above */ },
_ = suicide => { /* handled above */ }, }
r = agg_task => { } else {
match r { let kbd_loop = kbd_loop.expect("kbd_loop");
Ok(Ok(())) => warn!("input aggregator terminated cleanly"), let mou_loop = mou_loop.expect("mou_loop");
Ok(Err(e)) => error!("input aggregator error: {e:?}"), let agg_task = agg_task.expect("agg_task");
Err(join_err) => error!("aggregator task panicked: {join_err:?}"), tokio::select! {
_ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); },
_ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); },
_ = suicide => { /* handled above */ },
r = agg_task => {
match r {
Ok(Ok(())) => warn!("input aggregator terminated cleanly"),
Ok(Err(e)) => error!("input aggregator error: {e:?}"),
Err(join_err) => error!("aggregator task panicked: {join_err:?}"),
}
std::process::exit(1);
} }
std::process::exit(1);
} }
} }

View File

@ -23,7 +23,10 @@ fn ensure_runtime_dir() {
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { async fn main() -> Result<()> {
ensure_runtime_dir(); let headless = env::var("LESAVKA_HEADLESS").is_ok();
if !headless {
ensure_runtime_dir();
}
/*------------- common filter & stderr layer ------------------------*/ /*------------- common filter & stderr layer ------------------------*/
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {

View File

@ -546,7 +546,15 @@ fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
if gst::ElementFactory::find("kmssink").is_some() { if gst::ElementFactory::find("kmssink").is_some() {
let sink = gst::ElementFactory::make("kmssink").build()?; let sink = gst::ElementFactory::make("kmssink").build()?;
if let Some(connector) = cfg.hdmi.as_ref().and_then(|h| h.id) { if let Some(connector) = cfg.hdmi.as_ref().and_then(|h| h.id) {
sink.set_property("connector-id", &connector); if sink.has_property("connector-id", None) {
sink.set_property("connector-id", &(connector as i32));
} else {
tracing::warn!(
target: "lesavka_server::video",
%connector,
"kmssink does not expose connector-id property; using default connector"
);
}
} }
sink.set_property("sync", &false); sink.set_property("sync", &false);
return Ok(sink); return Ok(sink);