diff --git a/client/Cargo.toml b/client/Cargo.toml index 5141e67..2fef19e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -29,6 +29,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" async-stream = "0.3" shell-escape = "0.1" +v4l = "0.14" [build-dependencies] prost-build = "0.13" diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 6eb7188..d0f8f92 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -7,6 +7,7 @@ use gstreamer as gst; use gstreamer_app as gst_app; use gst::prelude::*; use lesavka_common::lesavka::VideoPacket; +use v4l::capability::Capabilities; pub struct CameraCapture { pipeline: gst::Pipeline, @@ -17,10 +18,13 @@ impl CameraCapture { pub fn new(device_fragment: Option<&str>) -> anyhow::Result { gst::init().ok(); - // Pick device - let dev = device_fragment - .and_then(Self::find_device) - .unwrap_or_else(|| "/dev/video0".into()); + // Pick device (prefers V4L2 nodes with capture capability) + let dev = match device_fragment { + Some(path) if path.starts_with("/dev/") => path.to_string(), + Some(fragment) => Self::find_device(fragment) + .unwrap_or_else(|| "/dev/video0".into()), + None => "/dev/video0".into(), + }; // let (enc, raw_caps) = Self::pick_encoder(); // (NVIDIA → VA-API → software x264). @@ -91,20 +95,51 @@ impl CameraCapture { Some(VideoPacket { id: 2, pts, data: map.as_slice().to_vec() }) } - /// Fuzzy‑match devices under `/dev/v4l/by-id` + /// Fuzzy‑match devices under `/dev/v4l/by-id`, preferring capture nodes fn find_device(substr: &str) -> Option { - let dir = std::fs::read_dir("/dev/v4l/by-id").ok()?; - for e in dir.flatten() { - let p = e.path(); - if p.file_name()?.to_string_lossy().contains(substr) { - if let Ok(target) = std::fs::read_link(&p) { - return Some(format!("/dev/{}", target.file_name()?.to_string_lossy())); + let wanted = substr.to_ascii_lowercase(); + let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id") + .ok()? + .flatten() + .filter_map(|e| { + let p = e.path(); + let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); + if name.contains(&wanted) { + Some(p) + } else { + None + } + }) + .collect(); + + // deterministic order + matches.sort(); + + for p in matches { + if let Ok(target) = std::fs::read_link(&p) { + let dev = format!("/dev/{}", target.file_name()?.to_string_lossy()); + if Self::is_capture(&dev) { + return Some(dev); } } } None } + fn is_capture(dev: &str) -> bool { + match v4l::Device::with_path(dev) { + Ok(d) => d + .query_caps() + .map(|caps| { + let c = caps.capabilities; + c.contains(Capabilities::VIDEO_CAPTURE) + || c.contains(Capabilities::VIDEO_CAPTURE_MPLANE) + }) + .unwrap_or(false), + Err(_) => false, + } + } + /// Cheap stub used when the web‑cam is disabled pub fn new_stub() -> Self { let pipeline = gst::Pipeline::new(); diff --git a/client/src/main.rs b/client/src/main.rs index fe485e7..9273772 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -28,11 +28,14 @@ async fn main() -> Result<()> { /*------------- common filter & stderr layer ------------------------*/ let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new( - "lesavka_client=trace,\ - lesavka_server=trace,\ - tonic=debug,\ - h2=debug,\ - tower=debug", + "warn,\ + lesavka_client::app=info,\ + lesavka_client::input::camera=debug,\ + lesavka_client::output::video=info,\ + lesavka_client::output::audio=info,\ + tonic=warn,\ + h2=warn,\ + tower=warn", ) }); diff --git a/client/src/output/video.rs b/client/src/output/video.rs index ec8170e..6e4ea44 100644 --- a/client/src/output/video.rs +++ b/client/src/output/video.rs @@ -62,6 +62,9 @@ impl MonitorWindow { /* -------- move/resize overlay ---------------------------------- */ if let Some(sink_elem) = pipeline.by_name("sink") { + // give the native window a predictable title for wmctrl/placement + sink_elem.set_property("window-title", &format!("Lesavka-eye-{id}")).ok(); + if let Ok(overlay) = sink_elem.dynamic_cast::() { if let Some(r) = rects.get(id as usize) { // 1. Tell glimagesink how to crop the texture in its own window @@ -84,6 +87,11 @@ impl MonitorWindow { run: Arc std::io::Result + Send + Sync>, } + let is_kde = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_ascii_lowercase() + .contains("kde"); + let placer = if Command::new("swaymsg").arg("-t").arg("get_tree") .output().is_ok() { @@ -100,6 +108,30 @@ impl MonitorWindow { .status() }), } + } else if is_kde { + // KWin DBus script: move/resize by title + Placer { + name: "kwin", + run: Arc::new(|script| { + let dbus = if Command::new("qdbus6") + .arg("--version") + .status() + .is_ok() + { + "qdbus6" + } else { + "qdbus" + }; + Command::new(dbus) + .args([ + "org.kde.KWin", + "/KWin", + "org.kde.KWin.evaluateScript", + script, + ]) + .status() + }), + } } else { Placer { name: "noop", @@ -113,24 +145,40 @@ impl MonitorWindow { }; if placer.name != "noop" { - // Criteria string that works for i3-/sway-compatible IPC - let criteria = format!( - r#"[title="^Lesavka-eye-{id}$"] \ - resize set {w} {h}; \ - move absolute position {x} {y}"#, - w = r.w, - h = r.h, - x = r.x, - y = r.y, - ); - + let cmd = match placer.name { + // Criteria string that works for i3-/sway-compatible IPC + "swaymsg" | "hyprctl" => format!( + r#"[title="^Lesavka-eye-{id}$"] \ + resize set {w} {h}; \ + move absolute position {x} {y}"#, + w = r.w, + h = r.h, + x = r.x, + y = r.y, + ), + // KWin JS via DBus + "kwin" => format!( + "var clients = workspace.clientList(); \ + clients.forEach(function(c) {{ \ + if (c.caption === \"Lesavka-eye-{id}\") {{ \ + c.geometry = {{x:{x}, y:{y}, width:{w}, height:{h}}}; \ + }} \ + }});", + x = r.x, + y = r.y, + w = r.w, + h = r.h, + ), + _ => String::new(), + }; + // Retry in a detached thread - avoids blocking GStreamer let placename = placer.name; let runner = placer.run.clone(); thread::spawn(move || { for attempt in 1..=10 { thread::sleep(Duration::from_millis(300)); - match runner(&criteria) { + match runner(&cmd) { Ok(st) if st.success() => { tracing::info!( "✅ {placename}: placed eye-{id} (attempt {attempt})" @@ -145,6 +193,38 @@ impl MonitorWindow { }); } } + // 3. X11 placement (kwin/i3/etc.) via wmctrl + else if std::env::var_os("DISPLAY").is_some() { + let title = format!("Lesavka-eye-{id}"); + let w = r.w; + let h = r.h; + let x = r.x; + let y = r.y; + std::thread::spawn(move || { + for attempt in 1..=10 { + std::thread::sleep(std::time::Duration::from_millis(300)); + let status = Command::new("wmctrl") + .args([ + "-r", + &title, + "-e", + &format!("0,{x},{y},{w},{h}"), + ]) + .status(); + match status { + Ok(st) if st.success() => { + tracing::info!( + "✅ wmctrl placed eye-{id} (attempt {attempt})" + ); + break; + } + _ => tracing::debug!( + "⌛ wmctrl: eye-{id} not mapped yet (attempt {attempt})" + ), + } + } + }); + } } } } diff --git a/scripts/install/client.sh b/scripts/install/client.sh index 8fb8de7..e6ab188 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -8,7 +8,8 @@ ORIG_USER=${SUDO_USER:-$(id -un)} sudo pacman -Syq --needed --noconfirm \ git rustup protobuf gcc evtest base-devel \ gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \ - pipewire pipewire-pulse + pipewire pipewire-pulse \ + wmctrl qt6-tools # 1b. yay for AUR bits (run as the invoking user, never root) if ! command -v yay >/dev/null 2>&1; then diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 9592e7a..122ed5e 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -147,7 +147,8 @@ Requires=sys-kernel-config.mount Type=oneshot ExecStart=/usr/local/bin/lesavka-core.sh RemainAfterExit=yes -CapabilityBoundingSet=CAP_SYS_ADMIN +CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_MODULE +AmbientCapabilities=CAP_SYS_MODULE MountFlags=slave [Install]