core: permissions change, client: weyland window support

This commit is contained in:
Brad Stein 2025-11-30 23:07:31 -03:00
parent 4f69556235
commit 3cdf5c0718
6 changed files with 151 additions and 30 deletions

View File

@ -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"

View File

@ -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<Self> {
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() })
}
/// Fuzzymatch devices under `/dev/v4l/by-id`
/// Fuzzymatch devices under `/dev/v4l/by-id`, preferring capture nodes
fn find_device(substr: &str) -> Option<String> {
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 webcam is disabled
pub fn new_stub() -> Self {
let pipeline = gst::Pipeline::new();

View File

@ -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",
)
});

View File

@ -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::<VideoOverlay>() {
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<dyn Fn(&str) -> std::io::Result<ExitStatus> + 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})"
),
}
}
});
}
}
}
}

View File

@ -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

View File

@ -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]