wip(launcher): recover relay and preview lane onto master
This commit is contained in:
parent
2f7cc44976
commit
df6dfefce6
@ -185,9 +185,7 @@ impl LesavkaClientApp {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
let renderer = if unified_view {
|
let renderer = if unified_view {
|
||||||
Renderer::Unified(
|
Renderer::Unified(UnifiedMonitorWindow::new().expect("unified-window"))
|
||||||
UnifiedMonitorWindow::new().expect("unified-window")
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Renderer::Breakout {
|
Renderer::Breakout {
|
||||||
left: MonitorWindow::new(0).expect("win0"),
|
left: MonitorWindow::new(0).expect("win0"),
|
||||||
|
|||||||
@ -85,7 +85,10 @@ mod tests {
|
|||||||
resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")),
|
resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")),
|
||||||
"http://env:2"
|
"http://env:2"
|
||||||
);
|
);
|
||||||
assert_eq!(resolve_server_addr(&[], Some("http://env:2")), "http://env:2");
|
assert_eq!(
|
||||||
|
resolve_server_addr(&[], Some("http://env:2")),
|
||||||
|
"http://env:2"
|
||||||
|
);
|
||||||
assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR);
|
assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -252,10 +252,9 @@ impl CameraCapture {
|
|||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
fn find_device(substr: &str) -> Option<String> {
|
fn find_device(substr: &str) -> Option<String> {
|
||||||
let wanted = substr.to_ascii_lowercase();
|
let wanted = substr.to_ascii_lowercase();
|
||||||
let by_id_dir = std::env::var("LESAVKA_CAM_BY_ID_DIR")
|
let by_id_dir =
|
||||||
.unwrap_or_else(|_| "/dev/v4l/by-id".to_string());
|
std::env::var("LESAVKA_CAM_BY_ID_DIR").unwrap_or_else(|_| "/dev/v4l/by-id".to_string());
|
||||||
let dev_root =
|
let dev_root = std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string());
|
||||||
std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string());
|
|
||||||
let mut matches: Vec<_> = std::fs::read_dir(by_id_dir)
|
let mut matches: Vec<_> = std::fs::read_dir(by_id_dir)
|
||||||
.ok()?
|
.ok()?
|
||||||
.flatten()
|
.flatten()
|
||||||
@ -272,11 +271,7 @@ impl CameraCapture {
|
|||||||
matches.sort();
|
matches.sort();
|
||||||
for p in matches {
|
for p in matches {
|
||||||
if let Ok(target) = std::fs::read_link(&p) {
|
if let Ok(target) = std::fs::read_link(&p) {
|
||||||
let dev = format!(
|
let dev = format!("{}/{}", dev_root, target.file_name()?.to_string_lossy());
|
||||||
"{}/{}",
|
|
||||||
dev_root,
|
|
||||||
target.file_name()?.to_string_lossy()
|
|
||||||
);
|
|
||||||
if Self::is_capture(&dev) {
|
if Self::is_capture(&dev) {
|
||||||
return Some(dev);
|
return Some(dev);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,9 @@ use anyhow::bail;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::Instant;
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Instant;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::broadcast::Sender,
|
sync::broadcast::Sender,
|
||||||
time::{Duration, interval},
|
time::{Duration, interval},
|
||||||
@ -488,7 +488,11 @@ impl InputAggregator {
|
|||||||
if let Some(path) = self.routing_state_path.as_deref() {
|
if let Some(path) = self.routing_state_path.as_deref() {
|
||||||
let _ = std::fs::write(
|
let _ = std::fs::write(
|
||||||
path,
|
path,
|
||||||
if remote_capture { "remote\n" } else { "local\n" },
|
if remote_capture {
|
||||||
|
"remote\n"
|
||||||
|
} else {
|
||||||
|
"local\n"
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.published_remote_capture = Some(remote_capture);
|
self.published_remote_capture = Some(remote_capture);
|
||||||
|
|||||||
@ -75,7 +75,11 @@ impl KeyboardAggregator {
|
|||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
pub fn process_events(&mut self) {
|
pub fn process_events(&mut self) {
|
||||||
let Ok(events) = self.dev.fetch_events().map(|it| it.collect::<Vec<InputEvent>>()) else {
|
let Ok(events) = self
|
||||||
|
.dev
|
||||||
|
.fetch_events()
|
||||||
|
.map(|it| it.collect::<Vec<InputEvent>>())
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -334,8 +338,7 @@ impl KeyboardAggregator {
|
|||||||
let chord = std::env::var("LESAVKA_CLIPBOARD_CHORD")
|
let chord = std::env::var("LESAVKA_CLIPBOARD_CHORD")
|
||||||
.unwrap_or_else(|_| "ctrl+alt+v".into())
|
.unwrap_or_else(|_| "ctrl+alt+v".into())
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
let have_ctrl =
|
let have_ctrl = self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL);
|
||||||
self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL);
|
|
||||||
let have_alt = self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT);
|
let have_alt = self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT);
|
||||||
if chord == "ctrl+v" {
|
if chord == "ctrl+v" {
|
||||||
have_ctrl
|
have_ctrl
|
||||||
@ -467,7 +470,11 @@ fn is_paste_modifier(code: KeyCode) -> bool {
|
|||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
fn read_clipboard_text() -> Option<String> {
|
fn read_clipboard_text() -> Option<String> {
|
||||||
if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") {
|
if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") {
|
||||||
if let Ok(out) = std::process::Command::new("sh").arg("-lc").arg(cmd).output() {
|
if let Ok(out) = std::process::Command::new("sh")
|
||||||
|
.arg("-lc")
|
||||||
|
.arg(cmd)
|
||||||
|
.output()
|
||||||
|
{
|
||||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
if out.status.success() && !text.is_empty() {
|
if out.status.success() && !text.is_empty() {
|
||||||
return Some(text);
|
return Some(text);
|
||||||
@ -475,7 +482,11 @@ fn read_clipboard_text() -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for args in [vec!["--no-newline", "--type", "text/plain"], vec!["--no-newline"], vec![]] {
|
for args in [
|
||||||
|
vec!["--no-newline", "--type", "text/plain"],
|
||||||
|
vec!["--no-newline"],
|
||||||
|
vec![],
|
||||||
|
] {
|
||||||
if let Ok(out) = std::process::Command::new("wl-paste").args(&args).output()
|
if let Ok(out) = std::process::Command::new("wl-paste").args(&args).output()
|
||||||
&& out.status.success()
|
&& out.status.success()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use lesavka_common::{
|
use lesavka_common::{hid::append_char_reports, lesavka::KeyboardReport};
|
||||||
hid::append_char_reports,
|
|
||||||
lesavka::KeyboardReport,
|
|
||||||
};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
@ -25,7 +22,9 @@ pub fn send_clipboard_to_remote(server_addr: &str) -> Result<String> {
|
|||||||
Ok(()) => Ok("Clipboard delivered to remote".to_string()),
|
Ok(()) => Ok("Clipboard delivered to remote".to_string()),
|
||||||
Err(rpc_err) => match send_clipboard_via_hid(server_addr, &text) {
|
Err(rpc_err) => match send_clipboard_via_hid(server_addr, &text) {
|
||||||
Ok(()) => Ok(format!("Clipboard delivered via HID fallback ({rpc_err})")),
|
Ok(()) => Ok(format!("Clipboard delivered via HID fallback ({rpc_err})")),
|
||||||
Err(hid_err) => Err(anyhow!("rpc failed: {rpc_err}; hid fallback failed: {hid_err}")),
|
Err(hid_err) => Err(anyhow!(
|
||||||
|
"rpc failed: {rpc_err}; hid fallback failed: {hid_err}"
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,7 +100,9 @@ fn build_hid_paste_reports(text: &str) -> Result<Vec<KeyboardReport>> {
|
|||||||
}
|
}
|
||||||
Ok(raw_reports
|
Ok(raw_reports
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|data| KeyboardReport { data: data.to_vec() })
|
.map(|data| KeyboardReport {
|
||||||
|
data: data.to_vec(),
|
||||||
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
155
client/src/launcher/device_test.rs
Normal file
155
client/src/launcher/device_test.rs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use shell_escape::escape;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::process::{Child, Command};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DeviceTestKind {
|
||||||
|
Camera,
|
||||||
|
Microphone,
|
||||||
|
Speaker,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DeviceTestController {
|
||||||
|
camera: Option<Child>,
|
||||||
|
microphone: Option<Child>,
|
||||||
|
speaker: Option<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceTestController {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&mut self, kind: DeviceTestKind) -> bool {
|
||||||
|
self.cleanup_finished();
|
||||||
|
self.slot(kind).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_camera(&mut self, camera: Option<&str>) -> Result<bool> {
|
||||||
|
self.toggle(DeviceTestKind::Camera, build_camera_test(camera))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result<bool> {
|
||||||
|
self.toggle(
|
||||||
|
DeviceTestKind::Microphone,
|
||||||
|
build_microphone_test(source, sink),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_speaker(&mut self, sink: Option<&str>) -> Result<bool> {
|
||||||
|
self.toggle(DeviceTestKind::Speaker, build_speaker_test(sink))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_all(&mut self) {
|
||||||
|
for kind in [
|
||||||
|
DeviceTestKind::Camera,
|
||||||
|
DeviceTestKind::Microphone,
|
||||||
|
DeviceTestKind::Speaker,
|
||||||
|
] {
|
||||||
|
self.stop(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle(&mut self, kind: DeviceTestKind, command: Result<Command>) -> Result<bool> {
|
||||||
|
self.cleanup_finished();
|
||||||
|
if self.slot(kind).is_some() {
|
||||||
|
self.stop(kind);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let child = command?
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("starting {kind:?} test"))?;
|
||||||
|
*self.slot_mut(kind) = Some(child);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self, kind: DeviceTestKind) {
|
||||||
|
if let Some(mut child) = self.slot_mut(kind).take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_finished(&mut self) {
|
||||||
|
for kind in [
|
||||||
|
DeviceTestKind::Camera,
|
||||||
|
DeviceTestKind::Microphone,
|
||||||
|
DeviceTestKind::Speaker,
|
||||||
|
] {
|
||||||
|
let finished = self
|
||||||
|
.slot_mut(kind)
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|child| child.try_wait().ok())
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
if finished {
|
||||||
|
let _ = self.slot_mut(kind).take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot(&self, kind: DeviceTestKind) -> &Option<Child> {
|
||||||
|
match kind {
|
||||||
|
DeviceTestKind::Camera => &self.camera,
|
||||||
|
DeviceTestKind::Microphone => &self.microphone,
|
||||||
|
DeviceTestKind::Speaker => &self.speaker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option<Child> {
|
||||||
|
match kind {
|
||||||
|
DeviceTestKind::Camera => &mut self.camera,
|
||||||
|
DeviceTestKind::Microphone => &mut self.microphone,
|
||||||
|
DeviceTestKind::Speaker => &mut self.speaker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_camera_test(camera: Option<&str>) -> Result<Command> {
|
||||||
|
let Some(camera) = camera.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
bail!("select a camera before running the local camera test");
|
||||||
|
};
|
||||||
|
let device = format!("/dev/v4l/by-id/{camera}");
|
||||||
|
Ok(shell_command(format!(
|
||||||
|
"gst-launch-1.0 -q v4l2src device={} ! videoconvert ! queue ! autovideosink sync=false",
|
||||||
|
quote(device)
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_microphone_test(source: Option<&str>, sink: Option<&str>) -> Result<Command> {
|
||||||
|
let source = source
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("select a microphone before starting a monitor test"))?;
|
||||||
|
let sink = sink.filter(|value| !value.trim().is_empty());
|
||||||
|
let sink_prop = sink
|
||||||
|
.map(|value| format!("device={}", quote(value)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(shell_command(format!(
|
||||||
|
"gst-launch-1.0 -q pulsesrc device={} ! audioconvert ! audioresample ! queue ! pulsesink {}",
|
||||||
|
quote(source),
|
||||||
|
sink_prop
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_speaker_test(sink: Option<&str>) -> Result<Command> {
|
||||||
|
let sink_prop = sink
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(|value| format!("device={}", quote(value)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(shell_command(format!(
|
||||||
|
"gst-launch-1.0 -q audiotestsrc is-live=true wave=sine freq=880 volume=0.25 ! audioconvert ! audioresample ! queue ! pulsesink {}",
|
||||||
|
sink_prop
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_command(command: String) -> Command {
|
||||||
|
let mut child = Command::new("bash");
|
||||||
|
child.args(["-lc", &command]);
|
||||||
|
child
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote(value: impl Into<String>) -> String {
|
||||||
|
escape(Cow::Owned(value.into())).into_owned()
|
||||||
|
}
|
||||||
@ -115,7 +115,10 @@ mod tests {
|
|||||||
std::fs::write(tmp.join("usb-cam-b"), "").expect("write");
|
std::fs::write(tmp.join("usb-cam-b"), "").expect("write");
|
||||||
|
|
||||||
let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string()));
|
let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string()));
|
||||||
assert_eq!(devices, vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()]);
|
assert_eq!(
|
||||||
|
devices,
|
||||||
|
vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()]
|
||||||
|
);
|
||||||
let _ = std::fs::remove_dir_all(tmp);
|
let _ = std::fs::remove_dir_all(tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +137,8 @@ mod tests {
|
|||||||
fn discover_uses_override_and_tolerates_missing_pactl() {
|
fn discover_uses_override_and_tolerates_missing_pactl() {
|
||||||
let tmp = mk_temp_dir("discover-override");
|
let tmp = mk_temp_dir("discover-override");
|
||||||
std::fs::write(tmp.join("cam"), "").expect("write");
|
std::fs::write(tmp.join("cam"), "").expect("write");
|
||||||
let catalog = DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string()));
|
let catalog =
|
||||||
|
DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string()));
|
||||||
assert_eq!(catalog.cameras, vec!["cam".to_string()]);
|
assert_eq!(catalog.cameras, vec!["cam".to_string()]);
|
||||||
let _ = std::fs::remove_dir_all(tmp);
|
let _ = std::fs::remove_dir_all(tmp);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -143,7 +143,10 @@ mod tests {
|
|||||||
|
|
||||||
let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string());
|
let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string());
|
||||||
assert_eq!(report.selected_camera.as_deref(), Some("/dev/video0"));
|
assert_eq!(report.selected_camera.as_deref(), Some("/dev/video0"));
|
||||||
assert_eq!(report.selected_microphone.as_deref(), Some("alsa_input.usb"));
|
assert_eq!(
|
||||||
|
report.selected_microphone.as_deref(),
|
||||||
|
Some("alsa_input.usb")
|
||||||
|
);
|
||||||
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
||||||
assert_eq!(report.recent_samples.len(), 1);
|
assert_eq!(report.recent_samples.len(), 1);
|
||||||
assert_eq!(report.notes, vec!["first note".to_string()]);
|
assert_eq!(report.notes, vec!["first note".to_string()]);
|
||||||
|
|||||||
@ -4,16 +4,24 @@ pub mod state;
|
|||||||
|
|
||||||
mod clipboard;
|
mod clipboard;
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
|
mod device_test;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
mod power;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
mod preview;
|
mod preview;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
mod ui_components;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
mod ui_runtime;
|
||||||
|
|
||||||
use std::{collections::BTreeMap, path::PathBuf};
|
use std::{collections::BTreeMap, path::PathBuf};
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crate::app_support::DEFAULT_SERVER_ADDR;
|
use crate::app_support::DEFAULT_SERVER_ADDR;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command};
|
pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command};
|
||||||
pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode};
|
pub use state::{CapturePowerStatus, DeviceSelection, InputRouting, LauncherState, ViewMode};
|
||||||
|
|
||||||
pub const LAUNCHER_FOCUS_SIGNAL_ENV: &str = "LESAVKA_LAUNCHER_FOCUS_SIGNAL";
|
pub const LAUNCHER_FOCUS_SIGNAL_ENV: &str = "LESAVKA_LAUNCHER_FOCUS_SIGNAL";
|
||||||
pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal";
|
pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal";
|
||||||
@ -93,7 +101,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_server_addr_uses_first_non_flag_or_default() {
|
fn resolve_server_addr_uses_first_non_flag_or_default() {
|
||||||
let args = vec!["--launcher".to_string(), "http://from-arg:50051".to_string()];
|
let args = vec![
|
||||||
|
"--launcher".to_string(),
|
||||||
|
"http://from-arg:50051".to_string(),
|
||||||
|
];
|
||||||
assert_eq!(resolve_server_addr(&args), "http://from-arg:50051");
|
assert_eq!(resolve_server_addr(&args), "http://from-arg:50051");
|
||||||
|
|
||||||
let args = vec!["--launcher".to_string()];
|
let args = vec!["--launcher".to_string()];
|
||||||
@ -116,7 +127,10 @@ mod tests {
|
|||||||
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
|
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
|
||||||
Some(&"1".to_string())
|
Some(&"1".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(envs.get("LESAVKA_CAM_SOURCE"), Some(&"/dev/video0".to_string()));
|
assert_eq!(
|
||||||
|
envs.get("LESAVKA_CAM_SOURCE"),
|
||||||
|
Some(&"/dev/video0".to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
envs.get("LESAVKA_MIC_SOURCE"),
|
envs.get("LESAVKA_MIC_SOURCE"),
|
||||||
Some(&"alsa_input.test".to_string())
|
Some(&"alsa_input.test".to_string())
|
||||||
|
|||||||
61
client/src/launcher/power.rs
Normal file
61
client/src/launcher/power.rs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use lesavka_common::lesavka::{Empty, SetCapturePowerRequest, relay_client::RelayClient};
|
||||||
|
use tonic::{Request, transport::Channel};
|
||||||
|
|
||||||
|
use super::state::CapturePowerStatus;
|
||||||
|
|
||||||
|
pub fn fetch_capture_power(server_addr: &str) -> Result<CapturePowerStatus> {
|
||||||
|
with_runtime(async move {
|
||||||
|
let mut client = connect(server_addr).await?;
|
||||||
|
let reply = client
|
||||||
|
.get_capture_power(Request::new(Empty {}))
|
||||||
|
.await
|
||||||
|
.context("querying capture power state")?
|
||||||
|
.into_inner();
|
||||||
|
Ok(map_state(reply))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_capture_power(server_addr: &str, enabled: bool) -> Result<CapturePowerStatus> {
|
||||||
|
with_runtime(async move {
|
||||||
|
let mut client = connect(server_addr).await?;
|
||||||
|
let reply = client
|
||||||
|
.set_capture_power(Request::new(SetCapturePowerRequest { enabled }))
|
||||||
|
.await
|
||||||
|
.context("setting capture power state")?
|
||||||
|
.into_inner();
|
||||||
|
Ok(map_state(reply))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_runtime<F>(future: F) -> Result<CapturePowerStatus>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = Result<CapturePowerStatus>>,
|
||||||
|
{
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building launcher power runtime")?
|
||||||
|
.block_on(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||||
|
let channel = Channel::from_shared(server_addr.to_string())
|
||||||
|
.context("invalid launcher server address")?
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.context("connecting launcher to relay host")?;
|
||||||
|
Ok(RelayClient::new(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_state(reply: lesavka_common::lesavka::CapturePowerState) -> CapturePowerStatus {
|
||||||
|
CapturePowerStatus {
|
||||||
|
available: reply.available,
|
||||||
|
enabled: reply.enabled,
|
||||||
|
unit: reply.unit,
|
||||||
|
detail: reply.detail,
|
||||||
|
active_leases: reply.active_leases,
|
||||||
|
mode: reply.mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,6 +47,29 @@ impl DisplaySurface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CapturePowerStatus {
|
||||||
|
pub available: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub unit: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub active_leases: u32,
|
||||||
|
pub mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CapturePowerStatus {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
available: false,
|
||||||
|
enabled: false,
|
||||||
|
unit: "relay.service".to_string(),
|
||||||
|
detail: "unknown".to_string(),
|
||||||
|
active_leases: 0,
|
||||||
|
mode: "auto".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub struct DeviceSelection {
|
pub struct DeviceSelection {
|
||||||
pub camera: Option<String>,
|
pub camera: Option<String>,
|
||||||
@ -60,6 +83,7 @@ pub struct LauncherState {
|
|||||||
pub view_mode: ViewMode,
|
pub view_mode: ViewMode,
|
||||||
pub displays: [DisplaySurface; 2],
|
pub displays: [DisplaySurface; 2],
|
||||||
pub devices: DeviceSelection,
|
pub devices: DeviceSelection,
|
||||||
|
pub capture_power: CapturePowerStatus,
|
||||||
pub remote_active: bool,
|
pub remote_active: bool,
|
||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
@ -71,6 +95,7 @@ impl Default for LauncherState {
|
|||||||
view_mode: ViewMode::Unified,
|
view_mode: ViewMode::Unified,
|
||||||
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
|
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
|
||||||
devices: DeviceSelection::default(),
|
devices: DeviceSelection::default(),
|
||||||
|
capture_power: CapturePowerStatus::default(),
|
||||||
remote_active: false,
|
remote_active: false,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
}
|
}
|
||||||
@ -167,9 +192,13 @@ impl LauncherState {
|
|||||||
self.notes.push(note.into());
|
self.notes.push(note.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_capture_power(&mut self, power: CapturePowerStatus) {
|
||||||
|
self.capture_power = power;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn status_line(&self) -> String {
|
pub fn status_line(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"mode={} view={} active={} d1={} d2={} camera={} mic={} speaker={}",
|
"mode={} view={} active={} power={} d1={} d2={} camera={} mic={} speaker={}",
|
||||||
match self.routing {
|
match self.routing {
|
||||||
InputRouting::Local => "local",
|
InputRouting::Local => "local",
|
||||||
InputRouting::Remote => "remote",
|
InputRouting::Remote => "remote",
|
||||||
@ -179,6 +208,11 @@ impl LauncherState {
|
|||||||
ViewMode::Breakout => "breakout",
|
ViewMode::Breakout => "breakout",
|
||||||
},
|
},
|
||||||
self.remote_active,
|
self.remote_active,
|
||||||
|
if self.capture_power.enabled {
|
||||||
|
"on"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
},
|
||||||
self.displays[0].label(),
|
self.displays[0].label(),
|
||||||
self.displays[1].label(),
|
self.displays[1].label(),
|
||||||
self.devices.camera.as_deref().unwrap_or("auto"),
|
self.devices.camera.as_deref().unwrap_or("auto"),
|
||||||
@ -222,6 +256,8 @@ mod tests {
|
|||||||
assert!(state.devices.camera.is_none());
|
assert!(state.devices.camera.is_none());
|
||||||
assert!(state.devices.microphone.is_none());
|
assert!(state.devices.microphone.is_none());
|
||||||
assert!(state.devices.speaker.is_none());
|
assert!(state.devices.speaker.is_none());
|
||||||
|
assert_eq!(state.capture_power.unit, "relay.service");
|
||||||
|
assert_eq!(state.capture_power.mode, "auto");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -303,4 +339,22 @@ mod tests {
|
|||||||
assert!(status.contains("mic=alsa_input.usb"));
|
assert!(status.contains("mic=alsa_input.usb"));
|
||||||
assert!(status.contains("speaker=alsa_output.usb"));
|
assert!(status.contains("speaker=alsa_output.usb"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_power_status_updates_snapshot_state() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_capture_power(CapturePowerStatus {
|
||||||
|
available: true,
|
||||||
|
enabled: true,
|
||||||
|
unit: "relay.service".to_string(),
|
||||||
|
detail: "active/running".to_string(),
|
||||||
|
active_leases: 2,
|
||||||
|
mode: "forced-on".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(state.capture_power.available);
|
||||||
|
assert!(state.capture_power.enabled);
|
||||||
|
assert_eq!(state.capture_power.active_leases, 2);
|
||||||
|
assert!(state.status_line().contains("power=on"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
531
client/src/launcher/ui_components.rs
Normal file
531
client/src/launcher/ui_components.rs
Normal file
@ -0,0 +1,531 @@
|
|||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
devices::DeviceCatalog,
|
||||||
|
preview::{LauncherPreview, PreviewBinding},
|
||||||
|
state::LauncherState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SummaryWidgets {
|
||||||
|
pub relay_value: gtk::Label,
|
||||||
|
pub routing_value: gtk::Label,
|
||||||
|
pub power_value: gtk::Label,
|
||||||
|
pub displays_value: gtk::Label,
|
||||||
|
pub shortcut_value: gtk::Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DisplayPaneWidgets {
|
||||||
|
pub root: gtk::Box,
|
||||||
|
pub stack: gtk::Stack,
|
||||||
|
pub picture: gtk::Picture,
|
||||||
|
pub stream_status: gtk::Label,
|
||||||
|
pub placeholder: gtk::Label,
|
||||||
|
pub action_button: gtk::Button,
|
||||||
|
pub preview_binding: Option<PreviewBinding>,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PopoutWindowHandle {
|
||||||
|
pub window: gtk::ApplicationWindow,
|
||||||
|
pub binding: PreviewBinding,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LauncherWidgets {
|
||||||
|
pub status_label: gtk::Label,
|
||||||
|
pub summary: SummaryWidgets,
|
||||||
|
pub power_detail: gtk::Label,
|
||||||
|
pub display_panes: [DisplayPaneWidgets; 2],
|
||||||
|
pub start_button: gtk::Button,
|
||||||
|
pub stop_button: gtk::Button,
|
||||||
|
pub power_button: gtk::Button,
|
||||||
|
pub input_toggle_button: gtk::Button,
|
||||||
|
pub clipboard_button: gtk::Button,
|
||||||
|
pub probe_button: gtk::Button,
|
||||||
|
pub toggle_key_combo: gtk::ComboBoxText,
|
||||||
|
pub camera_test_button: gtk::Button,
|
||||||
|
pub microphone_test_button: gtk::Button,
|
||||||
|
pub speaker_test_button: gtk::Button,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LauncherView {
|
||||||
|
pub window: gtk::ApplicationWindow,
|
||||||
|
pub server_entry: gtk::Entry,
|
||||||
|
pub camera_combo: gtk::ComboBoxText,
|
||||||
|
pub microphone_combo: gtk::ComboBoxText,
|
||||||
|
pub speaker_combo: gtk::ComboBoxText,
|
||||||
|
pub widgets: LauncherWidgets,
|
||||||
|
pub preview: Option<Rc<LauncherPreview>>,
|
||||||
|
pub popouts: Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_launcher_view(
|
||||||
|
app: >k::Application,
|
||||||
|
server_addr: &str,
|
||||||
|
catalog: &DeviceCatalog,
|
||||||
|
state: &LauncherState,
|
||||||
|
) -> LauncherView {
|
||||||
|
let window = gtk::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.title("Lesavka Launcher")
|
||||||
|
.default_width(1480)
|
||||||
|
.default_height(900)
|
||||||
|
.build();
|
||||||
|
install_css(&window);
|
||||||
|
|
||||||
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||||
|
root.add_css_class("launcher-root");
|
||||||
|
root.set_margin_start(20);
|
||||||
|
root.set_margin_end(20);
|
||||||
|
root.set_margin_top(20);
|
||||||
|
root.set_margin_bottom(20);
|
||||||
|
|
||||||
|
let hero = gtk::Box::new(gtk::Orientation::Horizontal, 16);
|
||||||
|
hero.set_hexpand(true);
|
||||||
|
|
||||||
|
let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||||
|
let heading = gtk::Label::new(Some("Lesavka Control Deck"));
|
||||||
|
heading.add_css_class("title-2");
|
||||||
|
heading.set_halign(gtk::Align::Start);
|
||||||
|
let subheading = gtk::Label::new(Some(
|
||||||
|
"Relay, capture power, device staging, and eye previews in one control surface.",
|
||||||
|
));
|
||||||
|
subheading.add_css_class("dim-label");
|
||||||
|
subheading.set_halign(gtk::Align::Start);
|
||||||
|
brand_box.append(&heading);
|
||||||
|
brand_box.append(&subheading);
|
||||||
|
hero.append(&brand_box);
|
||||||
|
|
||||||
|
let chips = gtk::Box::new(gtk::Orientation::Horizontal, 10);
|
||||||
|
chips.set_halign(gtk::Align::End);
|
||||||
|
chips.set_hexpand(true);
|
||||||
|
let (relay_chip, relay_value) = build_status_chip("Relay", "Stopped");
|
||||||
|
let (routing_chip, routing_value) = build_status_chip("Inputs", "Remote");
|
||||||
|
let (power_chip, power_value) = build_status_chip("Capture", "Unknown");
|
||||||
|
let (display_chip, displays_value) = build_status_chip("Displays", "Preview");
|
||||||
|
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");
|
||||||
|
chips.append(&relay_chip);
|
||||||
|
chips.append(&routing_chip);
|
||||||
|
chips.append(&power_chip);
|
||||||
|
chips.append(&display_chip);
|
||||||
|
chips.append(&shortcut_chip);
|
||||||
|
hero.append(&chips);
|
||||||
|
root.append(&hero);
|
||||||
|
|
||||||
|
let content = gtk::Box::new(gtk::Orientation::Horizontal, 16);
|
||||||
|
content.set_hexpand(true);
|
||||||
|
content.set_vexpand(true);
|
||||||
|
root.append(&content);
|
||||||
|
|
||||||
|
let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||||
|
sidebar.set_size_request(410, -1);
|
||||||
|
sidebar.set_valign(gtk::Align::Fill);
|
||||||
|
content.append(&sidebar);
|
||||||
|
|
||||||
|
let stage = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||||
|
stage.set_hexpand(true);
|
||||||
|
stage.set_vexpand(true);
|
||||||
|
content.append(&stage);
|
||||||
|
|
||||||
|
let (connection_panel, connection_body) = build_panel("Connection");
|
||||||
|
let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let server_entry = gtk::Entry::new();
|
||||||
|
server_entry.add_css_class("server-entry");
|
||||||
|
server_entry.set_hexpand(true);
|
||||||
|
server_entry.set_text(server_addr);
|
||||||
|
server_entry.set_tooltip_text(Some(
|
||||||
|
"Relay host address for previews, power control, and the live session.",
|
||||||
|
));
|
||||||
|
let start_button = gtk::Button::with_label("Start Relay");
|
||||||
|
start_button.add_css_class("suggested-action");
|
||||||
|
let stop_button = gtk::Button::with_label("Stop Relay");
|
||||||
|
stop_button.add_css_class("destructive-action");
|
||||||
|
server_row.append(&server_entry);
|
||||||
|
server_row.append(&start_button);
|
||||||
|
server_row.append(&stop_button);
|
||||||
|
connection_body.append(&server_row);
|
||||||
|
|
||||||
|
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let power_button = gtk::Button::with_label("Power Up Feeds");
|
||||||
|
power_button.set_tooltip_text(Some(
|
||||||
|
"Turns the relay.service-backed capture power on or off from the launcher.",
|
||||||
|
));
|
||||||
|
let power_detail = gtk::Label::new(Some("Capture power status is loading..."));
|
||||||
|
power_detail.add_css_class("dim-label");
|
||||||
|
power_detail.set_wrap(true);
|
||||||
|
power_detail.set_xalign(0.0);
|
||||||
|
power_row.append(&power_button);
|
||||||
|
power_row.append(&power_detail);
|
||||||
|
connection_body.append(&power_row);
|
||||||
|
sidebar.append(&connection_panel);
|
||||||
|
|
||||||
|
let (routing_panel, routing_body) = build_panel("Input Routing");
|
||||||
|
let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let input_toggle_button = gtk::Button::with_label("Route Inputs To Local");
|
||||||
|
input_toggle_button.set_hexpand(true);
|
||||||
|
input_toggle_button.set_tooltip_text(Some(
|
||||||
|
"Switch live keyboard and mouse ownership between the local machine and the remote target.",
|
||||||
|
));
|
||||||
|
routing_row.append(&input_toggle_button);
|
||||||
|
routing_body.append(&routing_row);
|
||||||
|
|
||||||
|
let swap_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let swap_label = gtk::Label::new(Some("Swap key"));
|
||||||
|
swap_label.set_halign(gtk::Align::Start);
|
||||||
|
let toggle_key_combo = gtk::ComboBoxText::new();
|
||||||
|
toggle_key_combo.append(Some("scrolllock"), "Scroll Lock");
|
||||||
|
toggle_key_combo.append(Some("sysrq"), "SysRq / PrtSc");
|
||||||
|
toggle_key_combo.append(Some("pause"), "Pause");
|
||||||
|
toggle_key_combo.append(Some("f12"), "F12");
|
||||||
|
toggle_key_combo.append(Some("f11"), "F11");
|
||||||
|
toggle_key_combo.append(Some("f10"), "F10");
|
||||||
|
toggle_key_combo.append(Some("off"), "Disabled");
|
||||||
|
let _ = toggle_key_combo.set_active_id(Some("pause"));
|
||||||
|
toggle_key_combo.set_tooltip_text(Some(
|
||||||
|
"Single-key live input swap while the relay is running.",
|
||||||
|
));
|
||||||
|
swap_row.append(&swap_label);
|
||||||
|
swap_row.append(&toggle_key_combo);
|
||||||
|
routing_body.append(&swap_row);
|
||||||
|
sidebar.append(&routing_panel);
|
||||||
|
|
||||||
|
let (devices_panel, devices_body) = build_panel("Devices");
|
||||||
|
let devices_grid = gtk::Grid::new();
|
||||||
|
devices_grid.set_row_spacing(8);
|
||||||
|
devices_grid.set_column_spacing(8);
|
||||||
|
devices_body.append(&devices_grid);
|
||||||
|
|
||||||
|
let camera_combo = gtk::ComboBoxText::new();
|
||||||
|
camera_combo.append(Some("auto"), "auto");
|
||||||
|
for camera in &catalog.cameras {
|
||||||
|
camera_combo.append(Some(camera), camera);
|
||||||
|
}
|
||||||
|
super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref());
|
||||||
|
let camera_test_button = gtk::Button::with_label("Test");
|
||||||
|
camera_test_button.set_tooltip_text(Some(
|
||||||
|
"Open a local preview for the selected webcam so you can confirm the right source.",
|
||||||
|
));
|
||||||
|
attach_device_row(
|
||||||
|
&devices_grid,
|
||||||
|
0,
|
||||||
|
"Camera",
|
||||||
|
&camera_combo,
|
||||||
|
&camera_test_button,
|
||||||
|
);
|
||||||
|
|
||||||
|
let microphone_combo = gtk::ComboBoxText::new();
|
||||||
|
microphone_combo.append(Some("auto"), "auto");
|
||||||
|
for microphone in &catalog.microphones {
|
||||||
|
microphone_combo.append(Some(microphone), microphone);
|
||||||
|
}
|
||||||
|
super::ui_runtime::set_combo_active_text(
|
||||||
|
µphone_combo,
|
||||||
|
state.devices.microphone.as_deref(),
|
||||||
|
);
|
||||||
|
let microphone_test_button = gtk::Button::with_label("Test");
|
||||||
|
microphone_test_button.set_tooltip_text(Some(
|
||||||
|
"Monitor the selected microphone through the selected speaker until you stop the test.",
|
||||||
|
));
|
||||||
|
attach_device_row(
|
||||||
|
&devices_grid,
|
||||||
|
1,
|
||||||
|
"Microphone",
|
||||||
|
µphone_combo,
|
||||||
|
µphone_test_button,
|
||||||
|
);
|
||||||
|
|
||||||
|
let speaker_combo = gtk::ComboBoxText::new();
|
||||||
|
speaker_combo.append(Some("auto"), "auto");
|
||||||
|
for speaker in &catalog.speakers {
|
||||||
|
speaker_combo.append(Some(speaker), speaker);
|
||||||
|
}
|
||||||
|
super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref());
|
||||||
|
let speaker_test_button = gtk::Button::with_label("Test");
|
||||||
|
speaker_test_button.set_tooltip_text(Some(
|
||||||
|
"Play a short continuous tone through the selected speaker until you stop the test.",
|
||||||
|
));
|
||||||
|
attach_device_row(
|
||||||
|
&devices_grid,
|
||||||
|
2,
|
||||||
|
"Speaker",
|
||||||
|
&speaker_combo,
|
||||||
|
&speaker_test_button,
|
||||||
|
);
|
||||||
|
sidebar.append(&devices_panel);
|
||||||
|
|
||||||
|
let (actions_panel, actions_body) = build_panel("Remote Actions");
|
||||||
|
let actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let clipboard_button = gtk::Button::with_label("Send Clipboard");
|
||||||
|
clipboard_button.set_tooltip_text(Some(
|
||||||
|
"Type the current local clipboard into the remote target. This stays launcher-only.",
|
||||||
|
));
|
||||||
|
let probe_button = gtk::Button::with_label("Copy Gate Probe");
|
||||||
|
probe_button.set_tooltip_text(Some(
|
||||||
|
"Copy the hygiene/quality probe command into the local clipboard.",
|
||||||
|
));
|
||||||
|
actions_row.append(&clipboard_button);
|
||||||
|
actions_row.append(&probe_button);
|
||||||
|
actions_body.append(&actions_row);
|
||||||
|
sidebar.append(&actions_panel);
|
||||||
|
|
||||||
|
let stage_header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let stage_title = gtk::Label::new(Some("Remote Eyes"));
|
||||||
|
stage_title.add_css_class("title-4");
|
||||||
|
stage_title.set_halign(gtk::Align::Start);
|
||||||
|
stage_header.append(&stage_title);
|
||||||
|
stage.append(&stage_header);
|
||||||
|
|
||||||
|
let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 16);
|
||||||
|
display_row.set_hexpand(true);
|
||||||
|
display_row.set_vexpand(true);
|
||||||
|
let left_pane = build_display_pane("Left Eye", "/dev/lesavka_l_eye");
|
||||||
|
let right_pane = build_display_pane("Right Eye", "/dev/lesavka_r_eye");
|
||||||
|
display_row.append(&left_pane.root);
|
||||||
|
display_row.append(&right_pane.root);
|
||||||
|
stage.append(&display_row);
|
||||||
|
|
||||||
|
let status_label = gtk::Label::new(Some("Launcher ready."));
|
||||||
|
status_label.add_css_class("status-line");
|
||||||
|
status_label.set_halign(gtk::Align::Start);
|
||||||
|
status_label.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||||
|
root.append(&status_label);
|
||||||
|
|
||||||
|
let preview = match LauncherPreview::new(server_addr.to_string()) {
|
||||||
|
Ok(preview) => Some(Rc::new(preview)),
|
||||||
|
Err(err) => {
|
||||||
|
status_label.set_text(&format!("Preview unavailable: {err}"));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut left_pane = left_pane;
|
||||||
|
let mut right_pane = right_pane;
|
||||||
|
if let Some(preview) = preview.as_ref() {
|
||||||
|
left_pane.preview_binding =
|
||||||
|
preview.install_on_picture(0, &left_pane.picture, &left_pane.stream_status);
|
||||||
|
right_pane.preview_binding =
|
||||||
|
preview.install_on_picture(1, &right_pane.picture, &right_pane.stream_status);
|
||||||
|
} else {
|
||||||
|
left_pane.stream_status.set_text("Preview unavailable");
|
||||||
|
right_pane.stream_status.set_text("Preview unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
let widgets = LauncherWidgets {
|
||||||
|
status_label: status_label.clone(),
|
||||||
|
summary: SummaryWidgets {
|
||||||
|
relay_value,
|
||||||
|
routing_value,
|
||||||
|
power_value,
|
||||||
|
displays_value,
|
||||||
|
shortcut_value,
|
||||||
|
},
|
||||||
|
power_detail,
|
||||||
|
display_panes: [left_pane.clone(), right_pane.clone()],
|
||||||
|
start_button: start_button.clone(),
|
||||||
|
stop_button: stop_button.clone(),
|
||||||
|
power_button: power_button.clone(),
|
||||||
|
input_toggle_button: input_toggle_button.clone(),
|
||||||
|
clipboard_button: clipboard_button.clone(),
|
||||||
|
probe_button: probe_button.clone(),
|
||||||
|
toggle_key_combo: toggle_key_combo.clone(),
|
||||||
|
camera_test_button: camera_test_button.clone(),
|
||||||
|
microphone_test_button: microphone_test_button.clone(),
|
||||||
|
speaker_test_button: speaker_test_button.clone(),
|
||||||
|
};
|
||||||
|
let popouts = Rc::new(RefCell::new([None, None]));
|
||||||
|
|
||||||
|
window.set_child(Some(&root));
|
||||||
|
|
||||||
|
LauncherView {
|
||||||
|
window,
|
||||||
|
server_entry,
|
||||||
|
camera_combo,
|
||||||
|
microphone_combo,
|
||||||
|
speaker_combo,
|
||||||
|
widgets,
|
||||||
|
preview,
|
||||||
|
popouts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_css(window: >k::ApplicationWindow) {
|
||||||
|
let provider = gtk::CssProvider::new();
|
||||||
|
provider.load_from_data(
|
||||||
|
r#"
|
||||||
|
window.lesavka-launcher {
|
||||||
|
background: #101319;
|
||||||
|
color: #eef2f7;
|
||||||
|
}
|
||||||
|
box.launcher-root {
|
||||||
|
background: linear-gradient(180deg, #11161f 0%, #161d28 100%);
|
||||||
|
}
|
||||||
|
box.panel {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
label.panel-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
box.status-chip {
|
||||||
|
background: rgba(91, 179, 162, 0.12);
|
||||||
|
border: 1px solid rgba(91, 179, 162, 0.25);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
label.status-chip-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
label.status-chip-value {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
box.display-card {
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
box.display-placeholder {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
label.status-line {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
entry.server-entry {
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
if let Some(display) = gtk::gdk::Display::default() {
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&display,
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
window.add_css_class("lesavka-launcher");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
|
||||||
|
let panel = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||||
|
panel.add_css_class("panel");
|
||||||
|
|
||||||
|
let heading = gtk::Label::new(Some(title));
|
||||||
|
heading.add_css_class("panel-title");
|
||||||
|
heading.set_halign(gtk::Align::Start);
|
||||||
|
panel.append(&heading);
|
||||||
|
|
||||||
|
let body = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||||
|
panel.append(&body);
|
||||||
|
(panel, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
|
||||||
|
let chip = gtk::Box::new(gtk::Orientation::Vertical, 2);
|
||||||
|
chip.add_css_class("status-chip");
|
||||||
|
|
||||||
|
let label_widget = gtk::Label::new(Some(label));
|
||||||
|
label_widget.add_css_class("status-chip-label");
|
||||||
|
label_widget.set_halign(gtk::Align::Start);
|
||||||
|
let value_widget = gtk::Label::new(Some(value));
|
||||||
|
value_widget.add_css_class("status-chip-value");
|
||||||
|
value_widget.set_halign(gtk::Align::Start);
|
||||||
|
chip.append(&label_widget);
|
||||||
|
chip.append(&value_widget);
|
||||||
|
(chip, value_widget)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_device_row(
|
||||||
|
grid: >k::Grid,
|
||||||
|
row: i32,
|
||||||
|
label: &str,
|
||||||
|
combo: >k::ComboBoxText,
|
||||||
|
test_button: >k::Button,
|
||||||
|
) {
|
||||||
|
let label_widget = gtk::Label::new(Some(label));
|
||||||
|
label_widget.set_halign(gtk::Align::Start);
|
||||||
|
combo.set_hexpand(true);
|
||||||
|
grid.attach(&label_widget, 0, row, 1, 1);
|
||||||
|
grid.attach(combo, 1, row, 1, 1);
|
||||||
|
grid.attach(test_button, 2, row, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||||
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||||
|
root.add_css_class("display-card");
|
||||||
|
root.set_hexpand(true);
|
||||||
|
root.set_vexpand(true);
|
||||||
|
|
||||||
|
let title_label = gtk::Label::new(Some(title));
|
||||||
|
title_label.add_css_class("title-4");
|
||||||
|
title_label.set_halign(gtk::Align::Start);
|
||||||
|
let capture_label = gtk::Label::new(Some(capture_path));
|
||||||
|
capture_label.add_css_class("dim-label");
|
||||||
|
capture_label.set_halign(gtk::Align::Start);
|
||||||
|
root.append(&title_label);
|
||||||
|
root.append(&capture_label);
|
||||||
|
|
||||||
|
let picture = gtk::Picture::new();
|
||||||
|
picture.set_hexpand(true);
|
||||||
|
picture.set_vexpand(true);
|
||||||
|
picture.set_can_shrink(true);
|
||||||
|
picture.set_size_request(540, 304);
|
||||||
|
|
||||||
|
let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
preview_box.append(&picture);
|
||||||
|
|
||||||
|
let placeholder = gtk::Label::new(Some(
|
||||||
|
"This feed is running in its own window.\nUse Return To Preview to dock it back here.",
|
||||||
|
));
|
||||||
|
placeholder.set_wrap(true);
|
||||||
|
placeholder.set_justify(gtk::Justification::Center);
|
||||||
|
placeholder.set_halign(gtk::Align::Center);
|
||||||
|
placeholder.set_valign(gtk::Align::Center);
|
||||||
|
|
||||||
|
let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||||
|
placeholder_box.add_css_class("display-placeholder");
|
||||||
|
placeholder_box.set_hexpand(true);
|
||||||
|
placeholder_box.set_vexpand(true);
|
||||||
|
placeholder_box.set_size_request(540, 304);
|
||||||
|
placeholder_box.append(&placeholder);
|
||||||
|
|
||||||
|
let stack = gtk::Stack::new();
|
||||||
|
stack.set_hexpand(true);
|
||||||
|
stack.set_vexpand(true);
|
||||||
|
stack.add_named(&preview_box, Some("preview"));
|
||||||
|
stack.add_named(&placeholder_box, Some("placeholder"));
|
||||||
|
stack.set_visible_child_name("preview");
|
||||||
|
root.append(&stack);
|
||||||
|
|
||||||
|
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
let stream_status = gtk::Label::new(Some("Waiting for stream..."));
|
||||||
|
stream_status.set_halign(gtk::Align::Start);
|
||||||
|
stream_status.set_hexpand(true);
|
||||||
|
let action_button = gtk::Button::with_label("Break Out");
|
||||||
|
action_button.set_halign(gtk::Align::End);
|
||||||
|
footer.append(&stream_status);
|
||||||
|
footer.append(&action_button);
|
||||||
|
root.append(&footer);
|
||||||
|
|
||||||
|
DisplayPaneWidgets {
|
||||||
|
root,
|
||||||
|
stack,
|
||||||
|
picture,
|
||||||
|
stream_status,
|
||||||
|
placeholder,
|
||||||
|
action_button,
|
||||||
|
preview_binding: None,
|
||||||
|
title: title.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
439
client/src/launcher/ui_runtime.rs
Normal file
439
client/src/launcher/ui_runtime.rs
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use gtk::{glib, prelude::*};
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Child, Command},
|
||||||
|
rc::Rc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
LAUNCHER_FOCUS_SIGNAL_ENV,
|
||||||
|
device_test::{DeviceTestController, DeviceTestKind},
|
||||||
|
launcher_focus_signal_path,
|
||||||
|
preview::LauncherPreview,
|
||||||
|
runtime_env_vars,
|
||||||
|
state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
|
||||||
|
ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
|
||||||
|
pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
|
||||||
|
pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control";
|
||||||
|
pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
|
||||||
|
|
||||||
|
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
|
||||||
|
widgets
|
||||||
|
.summary
|
||||||
|
.relay_value
|
||||||
|
.set_text(if child_running || state.remote_active {
|
||||||
|
"Running"
|
||||||
|
} else {
|
||||||
|
"Stopped"
|
||||||
|
});
|
||||||
|
widgets
|
||||||
|
.summary
|
||||||
|
.routing_value
|
||||||
|
.set_text(&capitalize(routing_name(state.routing)));
|
||||||
|
widgets
|
||||||
|
.summary
|
||||||
|
.power_value
|
||||||
|
.set_text(&capture_power_label(&state.capture_power));
|
||||||
|
widgets.summary.displays_value.set_text(&format!(
|
||||||
|
"L {} / R {}",
|
||||||
|
state.display_surface(0).label(),
|
||||||
|
state.display_surface(1).label()
|
||||||
|
));
|
||||||
|
widgets
|
||||||
|
.summary
|
||||||
|
.shortcut_value
|
||||||
|
.set_text(&selected_toggle_key_label(&widgets.toggle_key_combo));
|
||||||
|
|
||||||
|
widgets
|
||||||
|
.power_detail
|
||||||
|
.set_text(&capture_power_detail(&state.capture_power));
|
||||||
|
|
||||||
|
widgets.start_button.set_sensitive(!child_running);
|
||||||
|
widgets.stop_button.set_sensitive(child_running);
|
||||||
|
widgets.clipboard_button.set_sensitive(child_running);
|
||||||
|
widgets.probe_button.set_sensitive(true);
|
||||||
|
widgets.input_toggle_button.set_label(match state.routing {
|
||||||
|
InputRouting::Remote => "Route Inputs To Local",
|
||||||
|
InputRouting::Local => "Route Inputs To Remote",
|
||||||
|
});
|
||||||
|
widgets.power_button.set_label(
|
||||||
|
match (state.capture_power.available, state.capture_power.enabled) {
|
||||||
|
(false, _) => "Capture Power Unavailable",
|
||||||
|
(true, true) => "Power Down Feeds",
|
||||||
|
(true, false) => "Power Up Feeds",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
widgets.power_button.set_sensitive(true);
|
||||||
|
|
||||||
|
for monitor_id in 0..2 {
|
||||||
|
refresh_display_pane(
|
||||||
|
&widgets.display_panes[monitor_id],
|
||||||
|
state.display_surface(monitor_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) {
|
||||||
|
widgets
|
||||||
|
.camera_test_button
|
||||||
|
.set_label(if tests.is_running(DeviceTestKind::Camera) {
|
||||||
|
"Stop"
|
||||||
|
} else {
|
||||||
|
"Test"
|
||||||
|
});
|
||||||
|
widgets
|
||||||
|
.microphone_test_button
|
||||||
|
.set_label(if tests.is_running(DeviceTestKind::Microphone) {
|
||||||
|
"Stop"
|
||||||
|
} else {
|
||||||
|
"Test"
|
||||||
|
});
|
||||||
|
widgets
|
||||||
|
.speaker_test_button
|
||||||
|
.set_label(if tests.is_running(DeviceTestKind::Speaker) {
|
||||||
|
"Stop"
|
||||||
|
} else {
|
||||||
|
"Test"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_test_action_result(
|
||||||
|
widgets: &LauncherWidgets,
|
||||||
|
tests: &mut DeviceTestController,
|
||||||
|
result: Result<bool>,
|
||||||
|
start_msg: &str,
|
||||||
|
stop_msg: &str,
|
||||||
|
) {
|
||||||
|
match result {
|
||||||
|
Ok(true) => widgets.status_label.set_text(start_msg),
|
||||||
|
Ok(false) => widgets.status_label.set_text(stop_msg),
|
||||||
|
Err(err) => widgets
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("Device test failed: {err}")),
|
||||||
|
}
|
||||||
|
refresh_test_buttons(widgets, tests);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_popout_window(
|
||||||
|
app: >k::Application,
|
||||||
|
preview: &LauncherPreview,
|
||||||
|
state: &Rc<RefCell<LauncherState>>,
|
||||||
|
child_proc: &Rc<RefCell<Option<Child>>>,
|
||||||
|
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
|
||||||
|
widgets: &LauncherWidgets,
|
||||||
|
monitor_id: usize,
|
||||||
|
) {
|
||||||
|
if popouts.borrow()[monitor_id].is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
|
||||||
|
binding.set_enabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = gtk::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.title(&format!(
|
||||||
|
"Lesavka {}",
|
||||||
|
widgets.display_panes[monitor_id].title
|
||||||
|
))
|
||||||
|
.default_width(1280)
|
||||||
|
.default_height(760)
|
||||||
|
.build();
|
||||||
|
super::ui_components::install_css(&window);
|
||||||
|
window.maximize();
|
||||||
|
|
||||||
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||||
|
root.set_margin_start(14);
|
||||||
|
root.set_margin_end(14);
|
||||||
|
root.set_margin_top(14);
|
||||||
|
root.set_margin_bottom(14);
|
||||||
|
|
||||||
|
let title = gtk::Label::new(Some(&widgets.display_panes[monitor_id].title));
|
||||||
|
title.add_css_class("title-3");
|
||||||
|
title.set_halign(gtk::Align::Center);
|
||||||
|
root.append(&title);
|
||||||
|
|
||||||
|
let picture = gtk::Picture::new();
|
||||||
|
picture.set_hexpand(true);
|
||||||
|
picture.set_vexpand(true);
|
||||||
|
picture.set_can_shrink(true);
|
||||||
|
root.append(&picture);
|
||||||
|
|
||||||
|
let stream_status = gtk::Label::new(Some("Waiting for stream..."));
|
||||||
|
stream_status.set_halign(gtk::Align::Start);
|
||||||
|
root.append(&stream_status);
|
||||||
|
|
||||||
|
let binding = preview
|
||||||
|
.install_on_picture(monitor_id, &picture, &stream_status)
|
||||||
|
.expect("preview binding for popout");
|
||||||
|
|
||||||
|
window.set_child(Some(&root));
|
||||||
|
|
||||||
|
let state_handle = Rc::clone(state);
|
||||||
|
let child_proc_handle = Rc::clone(child_proc);
|
||||||
|
let popouts_handle = Rc::clone(popouts);
|
||||||
|
let widgets_handle = widgets.clone();
|
||||||
|
let close_binding = binding.clone();
|
||||||
|
window.connect_close_request(move |_| {
|
||||||
|
let handle = {
|
||||||
|
let mut popouts = popouts_handle.borrow_mut();
|
||||||
|
popouts[monitor_id].take()
|
||||||
|
};
|
||||||
|
if let Some(handle) = handle {
|
||||||
|
handle.binding.close();
|
||||||
|
if let Some(preview_binding) = widgets_handle.display_panes[monitor_id]
|
||||||
|
.preview_binding
|
||||||
|
.as_ref()
|
||||||
|
{
|
||||||
|
preview_binding.set_enabled(true);
|
||||||
|
}
|
||||||
|
state_handle
|
||||||
|
.borrow_mut()
|
||||||
|
.set_display_surface(monitor_id, DisplaySurface::Preview);
|
||||||
|
refresh_launcher_ui(
|
||||||
|
&widgets_handle,
|
||||||
|
&state_handle.borrow(),
|
||||||
|
child_proc_handle.borrow().is_some(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
close_binding.close();
|
||||||
|
}
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
|
||||||
|
state
|
||||||
|
.borrow_mut()
|
||||||
|
.set_display_surface(monitor_id, DisplaySurface::Window);
|
||||||
|
popouts.borrow_mut()[monitor_id] = Some(PopoutWindowHandle {
|
||||||
|
window: window.clone(),
|
||||||
|
binding,
|
||||||
|
});
|
||||||
|
refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dock_display_to_preview(
|
||||||
|
state: &Rc<RefCell<LauncherState>>,
|
||||||
|
child_proc: &Rc<RefCell<Option<Child>>>,
|
||||||
|
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
|
||||||
|
widgets: &LauncherWidgets,
|
||||||
|
monitor_id: usize,
|
||||||
|
) {
|
||||||
|
let handle = {
|
||||||
|
let mut popouts = popouts.borrow_mut();
|
||||||
|
popouts[monitor_id].take()
|
||||||
|
};
|
||||||
|
if let Some(handle) = handle {
|
||||||
|
handle.binding.close();
|
||||||
|
handle.window.close();
|
||||||
|
}
|
||||||
|
if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
|
||||||
|
binding.set_enabled(true);
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.borrow_mut()
|
||||||
|
.set_display_surface(monitor_id, DisplaySurface::Preview);
|
||||||
|
refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) {
|
||||||
|
if let Some(binding) = pane.preview_binding.as_ref() {
|
||||||
|
binding.set_enabled(matches!(surface, DisplaySurface::Preview));
|
||||||
|
}
|
||||||
|
pane.action_button
|
||||||
|
.set_sensitive(pane.preview_binding.is_some());
|
||||||
|
match surface {
|
||||||
|
DisplaySurface::Preview => {
|
||||||
|
pane.stack.set_visible_child_name("preview");
|
||||||
|
pane.action_button.set_label("Break Out");
|
||||||
|
pane.placeholder.set_text(
|
||||||
|
"This feed is running in its own window.\nUse Return To Preview to dock it back here.",
|
||||||
|
);
|
||||||
|
if pane.preview_binding.is_none() {
|
||||||
|
pane.stream_status.set_text("Preview unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisplaySurface::Window => {
|
||||||
|
pane.stack.set_visible_child_name("placeholder");
|
||||||
|
pane.action_button.set_label("Return To Preview");
|
||||||
|
pane.placeholder.set_text(&format!(
|
||||||
|
"{} is running in a dedicated window.\nReturn it here when you want the in-launcher preview back.",
|
||||||
|
pane.title
|
||||||
|
));
|
||||||
|
pane.stream_status.set_text("Streaming in its own window");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture_power_label(power: &CapturePowerStatus) -> String {
|
||||||
|
if !power.available {
|
||||||
|
return "Unavailable".to_string();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{} ({})",
|
||||||
|
if power.enabled { "On" } else { "Off" },
|
||||||
|
power.mode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture_power_detail(power: &CapturePowerStatus) -> String {
|
||||||
|
if !power.available {
|
||||||
|
return format!("{} is unavailable: {}", power.unit, power.detail);
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{} • {} • leases {}",
|
||||||
|
power.unit, power.detail, power.active_leases
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capitalize(value: &str) -> String {
|
||||||
|
let mut chars = value.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option<String> {
|
||||||
|
combo.active_text().and_then(|value| {
|
||||||
|
let value = value.to_string();
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_toggle_key(combo: >k::ComboBoxText) -> String {
|
||||||
|
combo
|
||||||
|
.active_id()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "pause".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_toggle_key_label(combo: >k::ComboBoxText) -> String {
|
||||||
|
combo
|
||||||
|
.active_text()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "Pause".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String {
|
||||||
|
let current = entry.text();
|
||||||
|
let trimmed = current.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
fallback.to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_control_path() -> PathBuf {
|
||||||
|
std::env::var(INPUT_CONTROL_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_state_path() -> PathBuf {
|
||||||
|
std::env::var(INPUT_STATE_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
|
||||||
|
std::fs::write(path, format!("{}\n", routing_name(routing)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_input_routing_state(path: &Path) -> Option<InputRouting> {
|
||||||
|
let raw = std::fs::read_to_string(path).ok()?;
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"local" => Some(InputRouting::Local),
|
||||||
|
"remote" => Some(InputRouting::Remote),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routing_name(routing: InputRouting) -> &'static str {
|
||||||
|
match routing {
|
||||||
|
InputRouting::Local => "local",
|
||||||
|
InputRouting::Remote => "remote",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_marker(path: &Path) -> u128 {
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|meta| meta.modified().ok())
|
||||||
|
.and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) {
|
||||||
|
let wanted = wanted.unwrap_or("auto");
|
||||||
|
if !combo.set_active_id(Some(wanted)) {
|
||||||
|
let _ = combo.set_active_id(Some("auto"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_client_process(
|
||||||
|
server_addr: &str,
|
||||||
|
state: &LauncherState,
|
||||||
|
input_toggle_key: &str,
|
||||||
|
input_control_path: &Path,
|
||||||
|
input_state_path: &Path,
|
||||||
|
) -> Result<Child> {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
let mut command = Command::new(exe);
|
||||||
|
command.env("LESAVKA_LAUNCHER_CHILD", "1");
|
||||||
|
command.env("LESAVKA_SERVER_ADDR", server_addr);
|
||||||
|
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
|
||||||
|
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher");
|
||||||
|
command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1");
|
||||||
|
command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path());
|
||||||
|
command.env(INPUT_CONTROL_ENV, input_control_path);
|
||||||
|
command.env(INPUT_STATE_ENV, input_state_path);
|
||||||
|
command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1");
|
||||||
|
command.env("LESAVKA_CLIPBOARD_PASTE", "0");
|
||||||
|
for (key, value) in runtime_env_vars(state) {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
Ok(command.spawn()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_child_process(child_proc: &Rc<RefCell<Option<Child>>>) {
|
||||||
|
if let Some(mut child) = child_proc.borrow_mut().take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reap_exited_child(child_proc: &Rc<RefCell<Option<Child>>>) -> bool {
|
||||||
|
let mut slot = child_proc.borrow_mut();
|
||||||
|
match slot.as_mut() {
|
||||||
|
Some(child) => match child.try_wait() {
|
||||||
|
Ok(Some(_)) => {
|
||||||
|
*slot = None;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => true,
|
||||||
|
},
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_input_routing(routing: InputRouting) -> InputRouting {
|
||||||
|
match routing {
|
||||||
|
InputRouting::Remote => InputRouting::Local,
|
||||||
|
InputRouting::Local => InputRouting::Remote,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use lesavka_client::{LesavkaClientApp, launcher};
|
||||||
use std::{env, fs::OpenOptions, path::Path};
|
use std::{env, fs::OpenOptions, path::Path};
|
||||||
use tracing_appender::non_blocking;
|
use tracing_appender::non_blocking;
|
||||||
use tracing_appender::non_blocking::WorkerGuard;
|
use tracing_appender::non_blocking::WorkerGuard;
|
||||||
use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
|
use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
|
||||||
#[cfg(not(test))]
|
|
||||||
use lesavka_client::{LesavkaClientApp, launcher};
|
|
||||||
fn ensure_runtime_dir() {
|
fn ensure_runtime_dir() {
|
||||||
if env::var_os("XDG_RUNTIME_DIR").is_none() {
|
if env::var_os("XDG_RUNTIME_DIR").is_none() {
|
||||||
let msg = "Error: $XDG_RUNTIME_DIR is not set. \
|
let msg = "Error: $XDG_RUNTIME_DIR is not set. \
|
||||||
@ -49,7 +49,10 @@ async fn main() -> Result<()> {
|
|||||||
let mut _guard: Option<WorkerGuard> = None;
|
let mut _guard: Option<WorkerGuard> = None;
|
||||||
if dev_mode {
|
if dev_mode {
|
||||||
let log_path = Path::new("/tmp").join("lesavka-client.log");
|
let log_path = Path::new("/tmp").join("lesavka-client.log");
|
||||||
let file = OpenOptions::new().create(true).write(true).open(&log_path)?;
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&log_path)?;
|
||||||
let (file_writer, guard) = non_blocking(file);
|
let (file_writer, guard) = non_blocking(file);
|
||||||
_guard = Some(guard);
|
_guard = Some(guard);
|
||||||
let file_layer = fmt::layer()
|
let file_layer = fmt::layer()
|
||||||
|
|||||||
@ -97,7 +97,9 @@ impl AudioOut {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pipeline.set_state(gst::State::Playing).context("starting audio pipeline")?;
|
pipeline
|
||||||
|
.set_state(gst::State::Playing)
|
||||||
|
.context("starting audio pipeline")?;
|
||||||
Ok(Self { pipeline, src })
|
Ok(Self { pipeline, src })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,7 +130,10 @@ impl Drop for AudioOut {
|
|||||||
fn pick_sink_element() -> Result<String> {
|
fn pick_sink_element() -> Result<String> {
|
||||||
if let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
|
if let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") {
|
||||||
let sink = normalize_sink_override(&s);
|
let sink = normalize_sink_override(&s);
|
||||||
info!("💪 sink overridden via LESAVKA_AUDIO_SINK={} -> {}", s, sink);
|
info!(
|
||||||
|
"💪 sink overridden via LESAVKA_AUDIO_SINK={} -> {}",
|
||||||
|
s, sink
|
||||||
|
);
|
||||||
return Ok(sink);
|
return Ok(sink);
|
||||||
}
|
}
|
||||||
let sinks = list_pw_sinks();
|
let sinks = list_pw_sinks();
|
||||||
|
|||||||
@ -49,7 +49,9 @@ fn spawn_wmctrl_placement(id: u32, rect: layout::Rect) {
|
|||||||
tracing::info!("✅ wmctrl placed eye-{id} via {window_id} (attempt {attempt})");
|
tracing::info!("✅ wmctrl placed eye-{id} via {window_id} (attempt {attempt})");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ => tracing::debug!("⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})"),
|
_ => tracing::debug!(
|
||||||
|
"⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,6 +17,17 @@ message PasteRequest {
|
|||||||
bool encrypted = 3;
|
bool encrypted = 3;
|
||||||
}
|
}
|
||||||
message PasteReply { bool ok = 1; string error = 2; }
|
message PasteReply { bool ok = 1; string error = 2; }
|
||||||
|
message CapturePowerState {
|
||||||
|
bool available = 1;
|
||||||
|
bool enabled = 2;
|
||||||
|
string unit = 3;
|
||||||
|
string detail = 4;
|
||||||
|
uint32 active_leases = 5;
|
||||||
|
string mode = 6;
|
||||||
|
}
|
||||||
|
message SetCapturePowerRequest {
|
||||||
|
bool enabled = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message HandshakeSet {
|
message HandshakeSet {
|
||||||
bool camera = 1;
|
bool camera = 1;
|
||||||
@ -40,6 +51,8 @@ service Relay {
|
|||||||
|
|
||||||
rpc PasteText (PasteRequest) returns (PasteReply);
|
rpc PasteText (PasteRequest) returns (PasteReply);
|
||||||
rpc ResetUsb (Empty) returns (ResetUsbReply);
|
rpc ResetUsb (Empty) returns (ResetUsbReply);
|
||||||
|
rpc GetCapturePower (Empty) returns (CapturePowerState);
|
||||||
|
rpc SetCapturePower (SetCapturePowerRequest) returns (CapturePowerState);
|
||||||
}
|
}
|
||||||
|
|
||||||
service Handshake {
|
service Handshake {
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import time
|
|
||||||
|
|
||||||
path = os.environ.get("LESAVKA_WATCHDOG_DEV", "/dev/watchdog")
|
|
||||||
interval = float(os.environ.get("LESAVKA_WATCHDOG_INTERVAL", "5"))
|
|
||||||
|
|
||||||
fd = os.open(path, os.O_WRONLY)
|
|
||||||
|
|
||||||
running = True
|
|
||||||
|
|
||||||
|
|
||||||
def handle(_sig, _frame):
|
|
||||||
global running
|
|
||||||
running = False
|
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, handle)
|
|
||||||
signal.signal(signal.SIGINT, handle)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while running:
|
|
||||||
os.write(fd, b"\0")
|
|
||||||
time.sleep(interval)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.write(fd, b"V")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
os.close(fd)
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Lesavka hardware watchdog
|
|
||||||
ConditionPathExists=/dev/watchdog
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/bin/lesavka-hw-watchdog.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
KillMode=process
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@ -164,7 +164,6 @@ sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-server" /usr/local/b
|
|||||||
sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc
|
sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc
|
||||||
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh
|
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh
|
||||||
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh
|
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh
|
||||||
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-hw-watchdog.py" /usr/local/bin/lesavka-hw-watchdog.py
|
|
||||||
|
|
||||||
echo "==> 6a. Systemd units - lesavka-core"
|
echo "==> 6a. Systemd units - lesavka-core"
|
||||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-core.service >/dev/null
|
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-core.service >/dev/null
|
||||||
@ -266,26 +265,7 @@ sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \
|
|||||||
/usr/local/bin/lesavka-watchdog.sh \
|
/usr/local/bin/lesavka-watchdog.sh \
|
||||||
/etc/lesavka/watchdog.touch
|
/etc/lesavka/watchdog.touch
|
||||||
|
|
||||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-hw-watchdog.service >/dev/null
|
|
||||||
[Unit]
|
|
||||||
Description=Lesavka hardware watchdog
|
|
||||||
ConditionPathExists=/dev/watchdog
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/bin/lesavka-hw-watchdog.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
KillMode=process
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
UNIT
|
|
||||||
|
|
||||||
sudo install -d /etc/lesavka
|
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now lesavka-hw-watchdog
|
|
||||||
|
|
||||||
if systemctl is-active --quiet lesavka-uvc; then
|
if systemctl is-active --quiet lesavka-uvc; then
|
||||||
echo "✅ lesavka-uvc is active (dependency-managed; manual restart disabled)."
|
echo "✅ lesavka-uvc is active (dependency-managed; manual restart disabled)."
|
||||||
|
|||||||
@ -672,7 +672,17 @@ mod coverage_self_tests {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert!(pending.is_none());
|
assert!(pending.is_none());
|
||||||
assert!(build_in_response(&state, interfaces, interfaces.streaming, 0xFE, UVC_GET_CUR, 8).is_none());
|
assert!(
|
||||||
|
build_in_response(
|
||||||
|
&state,
|
||||||
|
interfaces,
|
||||||
|
interfaces.streaming,
|
||||||
|
0xFE,
|
||||||
|
UVC_GET_CUR,
|
||||||
|
8
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
let short = [0u8; 8];
|
let short = [0u8; 8];
|
||||||
let _ = sanitize_streaming_control(&short, &state);
|
let _ = sanitize_streaming_control(&short, &state);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,9 @@ impl CameraRuntime {
|
|||||||
"UVC output disabled (LESAVKA_DISABLE_UVC set)",
|
"UVC output disabled (LESAVKA_DISABLE_UVC set)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(Status::internal("camera relay unavailable in coverage harness"))
|
Err(Status::internal(
|
||||||
|
"camera relay unavailable in coverage harness",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
|
|||||||
295
server/src/capture_power.rs
Normal file
295
server/src/capture_power.rs
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
use lesavka_common::lesavka::CapturePowerState;
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use {
|
||||||
|
anyhow::{Context, Result, anyhow},
|
||||||
|
std::process::Command,
|
||||||
|
std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
tokio::sync::Mutex,
|
||||||
|
tracing::{info, warn},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct CapturePowerInner {
|
||||||
|
active_leases: u32,
|
||||||
|
manual_override: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CapturePowerManager {
|
||||||
|
unit: Arc<str>,
|
||||||
|
inner: Arc<Mutex<CapturePowerInner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CapturePowerLease {
|
||||||
|
manager: CapturePowerManager,
|
||||||
|
released: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UnitSnapshot {
|
||||||
|
available: bool,
|
||||||
|
enabled: bool,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
impl CapturePowerManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let unit = std::env::var("LESAVKA_CAPTURE_POWER_UNIT")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "relay.service".to_string());
|
||||||
|
Self {
|
||||||
|
unit: Arc::<str>::from(unit),
|
||||||
|
inner: Arc::new(Mutex::new(CapturePowerInner::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acquire(&self) -> CapturePowerLease {
|
||||||
|
let (desired, unit, leases, manual_override) = {
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
inner.active_leases = inner.active_leases.saturating_add(1);
|
||||||
|
(
|
||||||
|
desired_state(&inner),
|
||||||
|
self.unit.to_string(),
|
||||||
|
inner.active_leases,
|
||||||
|
inner.manual_override,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = sync_unit_state(unit.as_str(), desired).await {
|
||||||
|
warn!(unit = %unit, leases, desired, ?manual_override, ?err, "capture power sync failed on acquire");
|
||||||
|
}
|
||||||
|
|
||||||
|
CapturePowerLease {
|
||||||
|
manager: self.clone(),
|
||||||
|
released: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_manual(&self, enabled: bool) -> Result<CapturePowerState> {
|
||||||
|
let unit = self.unit.to_string();
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
inner.manual_override = Some(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_unit_state(unit.as_str(), enabled).await?;
|
||||||
|
self.snapshot().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn snapshot(&self) -> Result<CapturePowerState> {
|
||||||
|
let (active_leases, manual_override) = {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
(inner.active_leases, inner.manual_override)
|
||||||
|
};
|
||||||
|
let unit = self.unit.to_string();
|
||||||
|
let snapshot = inspect_unit(unit.as_str()).await?;
|
||||||
|
Ok(CapturePowerState {
|
||||||
|
available: snapshot.available,
|
||||||
|
enabled: snapshot.enabled,
|
||||||
|
unit,
|
||||||
|
detail: snapshot.detail,
|
||||||
|
active_leases,
|
||||||
|
mode: match manual_override {
|
||||||
|
Some(true) => "forced-on".to_string(),
|
||||||
|
Some(false) => "forced-off".to_string(),
|
||||||
|
None => "auto".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_one(&self) {
|
||||||
|
let (desired, unit, leases) = {
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
inner.active_leases = inner.active_leases.saturating_sub(1);
|
||||||
|
if inner.active_leases == 0 {
|
||||||
|
inner.manual_override = None;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
desired_state(&inner),
|
||||||
|
self.unit.to_string(),
|
||||||
|
inner.active_leases,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = sync_unit_state(unit.as_str(), desired).await {
|
||||||
|
warn!(unit = %unit, leases, desired, ?err, "capture power sync failed on release");
|
||||||
|
} else {
|
||||||
|
info!(unit = %unit, leases, desired, "capture power synced");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
impl Drop for CapturePowerLease {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.released.swap(true, Ordering::AcqRel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let manager = self.manager.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
manager.release_one().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn desired_state(inner: &CapturePowerInner) -> bool {
|
||||||
|
inner.manual_override.unwrap_or(inner.active_leases > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
async fn inspect_unit(unit: &str) -> Result<UnitSnapshot> {
|
||||||
|
let unit = unit.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || inspect_unit_blocking(unit.as_str()))
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow!("capture power inspect task failed: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn inspect_unit_blocking(unit: &str) -> Result<UnitSnapshot> {
|
||||||
|
let output = Command::new("systemctl")
|
||||||
|
.args([
|
||||||
|
"show",
|
||||||
|
unit,
|
||||||
|
"--property=LoadState,ActiveState,SubState",
|
||||||
|
"--value",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("querying systemd unit {unit}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"systemctl show {unit} failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut lines = stdout.lines();
|
||||||
|
let load_state = lines.next().unwrap_or_default().trim().to_string();
|
||||||
|
let active_state = lines.next().unwrap_or_default().trim().to_string();
|
||||||
|
let sub_state = lines.next().unwrap_or_default().trim().to_string();
|
||||||
|
let available = !load_state.is_empty() && load_state != "not-found";
|
||||||
|
let enabled = active_state == "active";
|
||||||
|
let detail = if available {
|
||||||
|
format!("{active_state}/{sub_state}")
|
||||||
|
} else {
|
||||||
|
"unit not found".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(UnitSnapshot {
|
||||||
|
available,
|
||||||
|
enabled,
|
||||||
|
detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
async fn sync_unit_state(unit: &str, enabled: bool) -> Result<()> {
|
||||||
|
let unit = unit.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || sync_unit_state_blocking(unit.as_str(), enabled))
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow!("capture power sync task failed: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn sync_unit_state_blocking(unit: &str, enabled: bool) -> Result<()> {
|
||||||
|
let action = if enabled { "start" } else { "stop" };
|
||||||
|
let status = Command::new("systemctl")
|
||||||
|
.args([action, unit])
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("running systemctl {action} {unit}"))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("systemctl {action} {unit} failed with {status}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CapturePowerManager;
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct CapturePowerLease;
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
impl CapturePowerManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acquire(&self) -> CapturePowerLease {
|
||||||
|
CapturePowerLease
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_manual(&self, enabled: bool) -> anyhow::Result<CapturePowerState> {
|
||||||
|
Ok(CapturePowerState {
|
||||||
|
available: true,
|
||||||
|
enabled,
|
||||||
|
unit: "relay.service".to_string(),
|
||||||
|
detail: if enabled {
|
||||||
|
"active/running".to_string()
|
||||||
|
} else {
|
||||||
|
"inactive/dead".to_string()
|
||||||
|
},
|
||||||
|
active_leases: 0,
|
||||||
|
mode: if enabled {
|
||||||
|
"forced-on".to_string()
|
||||||
|
} else {
|
||||||
|
"forced-off".to_string()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn snapshot(&self) -> anyhow::Result<CapturePowerState> {
|
||||||
|
Ok(CapturePowerState {
|
||||||
|
available: true,
|
||||||
|
enabled: false,
|
||||||
|
unit: "relay.service".to_string(),
|
||||||
|
detail: "inactive/dead".to_string(),
|
||||||
|
active_leases: 0,
|
||||||
|
mode: "auto".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, coverage))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn coverage_stub_reports_auto_snapshot() {
|
||||||
|
let state = CapturePowerManager::new()
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.expect("snapshot");
|
||||||
|
assert!(state.available);
|
||||||
|
assert!(!state.enabled);
|
||||||
|
assert_eq!(state.mode, "auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn coverage_stub_toggles_manual_modes() {
|
||||||
|
let manager = CapturePowerManager::new();
|
||||||
|
let on = manager.set_manual(true).await.expect("on");
|
||||||
|
assert!(on.enabled);
|
||||||
|
assert_eq!(on.mode, "forced-on");
|
||||||
|
|
||||||
|
let off = manager.set_manual(false).await.expect("off");
|
||||||
|
assert!(!off.enabled);
|
||||||
|
assert_eq!(off.mode, "forced-off");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub mod camera_runtime;
|
pub mod camera_runtime;
|
||||||
|
pub mod capture_power;
|
||||||
pub mod gadget;
|
pub mod gadget;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod paste;
|
pub mod paste;
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
// lesavka-server - gadget cycle guarded by env
|
// lesavka-server - gadget cycle guarded by env
|
||||||
// server/src/main.rs
|
// server/src/main.rs
|
||||||
#[allow(clippy::useless_attribute)] #[forbid(unsafe_code)]
|
#[allow(clippy::useless_attribute)]
|
||||||
use anyhow::Context as _;
|
#[forbid(unsafe_code)]
|
||||||
use futures_util::{Stream, StreamExt};
|
use futures_util::{Stream, StreamExt};
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::time::Duration;
|
|
||||||
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc};
|
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
@ -14,20 +13,24 @@ use tonic_reflection::server::Builder as ReflBuilder;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, PasteRequest,
|
AudioPacket, CapturePowerState, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply,
|
||||||
ResetUsbReply, VideoPacket,
|
PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket,
|
||||||
relay_server::{Relay, RelayServer},
|
relay_server::{Relay, RelayServer},
|
||||||
};
|
};
|
||||||
|
|
||||||
use lesavka_server::{
|
use lesavka_server::{
|
||||||
audio, camera, camera_runtime::CameraRuntime, gadget::UsbGadget, handshake::HandshakeSvc,
|
audio, camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager,
|
||||||
paste, runtime_support, runtime_support::init_tracing, uvc_runtime, video,
|
gadget::UsbGadget, handshake::HandshakeSvc, paste, runtime_support,
|
||||||
|
runtime_support::init_tracing, uvc_runtime, video,
|
||||||
};
|
};
|
||||||
|
|
||||||
/*──────────────── constants ────────────────*/
|
/*──────────────── constants ────────────────*/
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
||||||
|
|
||||||
|
type VideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
|
||||||
|
type AudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
|
||||||
|
|
||||||
fn hid_endpoint(index: u8) -> String {
|
fn hid_endpoint(index: u8) -> String {
|
||||||
std::env::var("LESAVKA_HID_DIR")
|
std::env::var("LESAVKA_HID_DIR")
|
||||||
.map(|dir| format!("{dir}/hidg{index}"))
|
.map(|dir| format!("{dir}/hidg{index}"))
|
||||||
@ -41,37 +44,28 @@ struct Handler {
|
|||||||
gadget: UsbGadget,
|
gadget: UsbGadget,
|
||||||
did_cycle: Arc<AtomicBool>,
|
did_cycle: Arc<AtomicBool>,
|
||||||
camera_rt: Arc<CameraRuntime>,
|
camera_rt: Arc<CameraRuntime>,
|
||||||
|
capture_power: CapturePowerManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Handler {
|
impl Handler {
|
||||||
#[cfg(coverage)]
|
|
||||||
async fn new(gadget: UsbGadget) -> anyhow::Result<Self> {
|
async fn new(gadget: UsbGadget) -> anyhow::Result<Self> {
|
||||||
let kb = runtime_support::open_with_retry(&hid_endpoint(0)).await?;
|
|
||||||
let ms = runtime_support::open_with_retry(&hid_endpoint(1)).await?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
kb: Arc::new(Mutex::new(kb)),
|
|
||||||
ms: Arc::new(Mutex::new(ms)),
|
|
||||||
gadget,
|
|
||||||
did_cycle: Arc::new(AtomicBool::new(false)),
|
|
||||||
camera_rt: Arc::new(CameraRuntime::new()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
async fn new(gadget: UsbGadget) -> anyhow::Result<Self> {
|
|
||||||
if runtime_support::allow_gadget_cycle() {
|
if runtime_support::allow_gadget_cycle() {
|
||||||
info!("🛠️ Initial USB reset…");
|
info!("🛠️ Initial USB reset…");
|
||||||
let _ = gadget.cycle(); // ignore failure - may boot without host
|
let _ = gadget.cycle(); // ignore failure - may boot without host
|
||||||
} else {
|
}
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
{
|
||||||
|
if !runtime_support::allow_gadget_cycle() {
|
||||||
info!(
|
info!(
|
||||||
"🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)"
|
"🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("🛠️ opening HID endpoints …");
|
info!("🛠️ opening HID endpoints …");
|
||||||
|
}
|
||||||
let kb = runtime_support::open_with_retry(&hid_endpoint(0)).await?;
|
let kb = runtime_support::open_with_retry(&hid_endpoint(0)).await?;
|
||||||
let ms = runtime_support::open_with_retry(&hid_endpoint(1)).await?;
|
let ms = runtime_support::open_with_retry(&hid_endpoint(1)).await?;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
info!("✅ HID endpoints ready");
|
info!("✅ HID endpoints ready");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@ -80,17 +74,10 @@ impl Handler {
|
|||||||
gadget,
|
gadget,
|
||||||
did_cycle: Arc::new(AtomicBool::new(false)),
|
did_cycle: Arc::new(AtomicBool::new(false)),
|
||||||
camera_rt: Arc::new(CameraRuntime::new()),
|
camera_rt: Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
|
||||||
async fn reopen_hid(&self) -> anyhow::Result<()> {
|
|
||||||
*self.kb.lock().await = runtime_support::open_with_retry(&hid_endpoint(0)).await?;
|
|
||||||
*self.ms.lock().await = runtime_support::open_with_retry(&hid_endpoint(1)).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
|
||||||
async fn reopen_hid(&self) -> anyhow::Result<()> {
|
async fn reopen_hid(&self) -> anyhow::Result<()> {
|
||||||
let kb_new = runtime_support::open_with_retry(&hid_endpoint(0)).await?;
|
let kb_new = runtime_support::open_with_retry(&hid_endpoint(0)).await?;
|
||||||
let ms_new = runtime_support::open_with_retry(&hid_endpoint(1)).await?;
|
let ms_new = runtime_support::open_with_retry(&hid_endpoint(1)).await?;
|
||||||
@ -98,6 +85,116 @@ impl Handler {
|
|||||||
*self.ms.lock().await = ms_new;
|
*self.ms.lock().await = ms_new;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn capture_video_reply(
|
||||||
|
&self,
|
||||||
|
req: MonitorRequest,
|
||||||
|
) -> Result<Response<VideoStream>, Status> {
|
||||||
|
let id = req.id;
|
||||||
|
let dev = match id {
|
||||||
|
0 => "/dev/lesavka_l_eye",
|
||||||
|
1 => "/dev/lesavka_r_eye",
|
||||||
|
_ => return Err(Status::invalid_argument("monitor id must be 0 or 1")),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
{
|
||||||
|
let rpc_id = runtime_support::next_stream_id();
|
||||||
|
info!(
|
||||||
|
rpc_id,
|
||||||
|
id,
|
||||||
|
max_bitrate = req.max_bitrate,
|
||||||
|
"🎥 capture_video opened"
|
||||||
|
);
|
||||||
|
debug!(rpc_id, "🎥 streaming {dev}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let lease = self.capture_power.acquire().await;
|
||||||
|
let stream = video::eye_ball(dev, id, req.max_bitrate)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||||
|
Ok(Response::new(Box::pin(GuardedVideoStream {
|
||||||
|
inner: stream,
|
||||||
|
_lease: lease,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn paste_text_reply(
|
||||||
|
&self,
|
||||||
|
req: Request<PasteRequest>,
|
||||||
|
) -> Result<Response<PasteReply>, Status> {
|
||||||
|
let req = req.into_inner();
|
||||||
|
let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?;
|
||||||
|
if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await {
|
||||||
|
return Ok(Response::new(PasteReply {
|
||||||
|
ok: false,
|
||||||
|
error: format!("{e}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Response::new(PasteReply {
|
||||||
|
ok: true,
|
||||||
|
error: String::new(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_usb_reply(&self) -> Result<Response<ResetUsbReply>, Status> {
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
info!("🔴 explicit ResetUsb() called");
|
||||||
|
|
||||||
|
match self.gadget.cycle() {
|
||||||
|
Ok(_) => {
|
||||||
|
if let Err(e) = self.reopen_hid().await {
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
error!("💥 reopen HID failed: {e:#}");
|
||||||
|
return Err(Status::internal(e.to_string()));
|
||||||
|
}
|
||||||
|
Ok(Response::new(ResetUsbReply { ok: true }))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
error!("💥 cycle failed: {e:#}");
|
||||||
|
Err(Status::internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_capture_power_reply(&self) -> Result<Response<CapturePowerState>, Status> {
|
||||||
|
self.capture_power
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.map(Response::new)
|
||||||
|
.map_err(|e| Status::internal(format!("{e:#}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_capture_power_reply(
|
||||||
|
&self,
|
||||||
|
req: Request<SetCapturePowerRequest>,
|
||||||
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
|
self.capture_power
|
||||||
|
.set_manual(req.into_inner().enabled)
|
||||||
|
.await
|
||||||
|
.map(Response::new)
|
||||||
|
.map_err(|e| Status::internal(format!("{e:#}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GuardedVideoStream<S> {
|
||||||
|
inner: S,
|
||||||
|
_lease: lesavka_server::capture_power::CapturePowerLease,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Stream for GuardedVideoStream<S>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<VideoPacket, Status>> + Unpin,
|
||||||
|
{
|
||||||
|
type Item = Result<VideoPacket, Status>;
|
||||||
|
|
||||||
|
fn poll_next(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<Option<Self::Item>> {
|
||||||
|
Pin::new(&mut self.inner).poll_next(cx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*──────────────── gRPC service ─────────────*/
|
/*──────────────── gRPC service ─────────────*/
|
||||||
@ -107,8 +204,8 @@ impl Relay for Handler {
|
|||||||
/* existing streams ─ unchanged, except: no more auto-reset */
|
/* existing streams ─ unchanged, except: no more auto-reset */
|
||||||
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
||||||
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
||||||
type CaptureVideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
|
type CaptureVideoStream = VideoStream;
|
||||||
type CaptureAudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
|
type CaptureAudioStream = AudioStream;
|
||||||
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
|
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
|
||||||
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
|
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
|
||||||
|
|
||||||
@ -272,25 +369,7 @@ impl Relay for Handler {
|
|||||||
&self,
|
&self,
|
||||||
req: Request<MonitorRequest>,
|
req: Request<MonitorRequest>,
|
||||||
) -> Result<Response<Self::CaptureVideoStream>, Status> {
|
) -> Result<Response<Self::CaptureVideoStream>, Status> {
|
||||||
let rpc_id = runtime_support::next_stream_id();
|
self.capture_video_reply(req.into_inner()).await
|
||||||
let req = req.into_inner();
|
|
||||||
let id = req.id;
|
|
||||||
let dev = match id {
|
|
||||||
0 => "/dev/lesavka_l_eye",
|
|
||||||
1 => "/dev/lesavka_r_eye",
|
|
||||||
_ => return Err(Status::invalid_argument("monitor id must be 0 or 1")),
|
|
||||||
};
|
|
||||||
info!(
|
|
||||||
rpc_id,
|
|
||||||
id,
|
|
||||||
max_bitrate = req.max_bitrate,
|
|
||||||
"🎥 capture_video opened"
|
|
||||||
);
|
|
||||||
debug!(rpc_id, "🎥 streaming {dev}");
|
|
||||||
let s = video::eye_ball(dev, id, req.max_bitrate)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
|
||||||
Ok(Response::new(Box::pin(s)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn capture_audio(
|
async fn capture_audio(
|
||||||
@ -312,36 +391,26 @@ impl Relay for Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn paste_text(&self, req: Request<PasteRequest>) -> Result<Response<PasteReply>, Status> {
|
async fn paste_text(&self, req: Request<PasteRequest>) -> Result<Response<PasteReply>, Status> {
|
||||||
let req = req.into_inner();
|
self.paste_text_reply(req).await
|
||||||
let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?;
|
|
||||||
if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await {
|
|
||||||
return Ok(Response::new(PasteReply {
|
|
||||||
ok: false,
|
|
||||||
error: format!("{e}"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Ok(Response::new(PasteReply {
|
|
||||||
ok: true,
|
|
||||||
error: String::new(),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*────────────── USB-reset RPC ────────────*/
|
/*────────────── USB-reset RPC ────────────*/
|
||||||
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
||||||
info!("🔴 explicit ResetUsb() called");
|
self.reset_usb_reply().await
|
||||||
match self.gadget.cycle() {
|
|
||||||
Ok(_) => {
|
|
||||||
if let Err(e) = self.reopen_hid().await {
|
|
||||||
error!("💥 reopen HID failed: {e:#}");
|
|
||||||
return Err(Status::internal(e.to_string()));
|
|
||||||
}
|
|
||||||
Ok(Response::new(ResetUsbReply { ok: true }))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("💥 cycle failed: {e:#}");
|
|
||||||
Err(Status::internal(e.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_capture_power(
|
||||||
|
&self,
|
||||||
|
_req: Request<Empty>,
|
||||||
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
|
self.get_capture_power_reply().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_capture_power(
|
||||||
|
&self,
|
||||||
|
req: Request<SetCapturePowerRequest>,
|
||||||
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
|
self.set_capture_power_reply(req).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,8 +419,8 @@ impl Relay for Handler {
|
|||||||
impl Relay for Handler {
|
impl Relay for Handler {
|
||||||
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
||||||
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
||||||
type CaptureVideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
|
type CaptureVideoStream = VideoStream;
|
||||||
type CaptureAudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
|
type CaptureAudioStream = AudioStream;
|
||||||
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
|
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
|
||||||
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
|
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
|
||||||
|
|
||||||
@ -406,59 +475,47 @@ impl Relay for Handler {
|
|||||||
&self,
|
&self,
|
||||||
_req: Request<tonic::Streaming<VideoPacket>>,
|
_req: Request<tonic::Streaming<VideoPacket>>,
|
||||||
) -> Result<Response<Self::StreamCameraStream>, Status> {
|
) -> Result<Response<Self::StreamCameraStream>, Status> {
|
||||||
Err(Status::internal("camera stream unavailable in coverage harness"))
|
Err(Status::internal(
|
||||||
|
"camera stream unavailable in coverage harness",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn capture_video(
|
async fn capture_video(
|
||||||
&self,
|
&self,
|
||||||
req: Request<MonitorRequest>,
|
req: Request<MonitorRequest>,
|
||||||
) -> Result<Response<Self::CaptureVideoStream>, Status> {
|
) -> Result<Response<Self::CaptureVideoStream>, Status> {
|
||||||
let req = req.into_inner();
|
self.capture_video_reply(req.into_inner()).await
|
||||||
let id = req.id;
|
|
||||||
let dev = match id {
|
|
||||||
0 => "/dev/lesavka_l_eye",
|
|
||||||
1 => "/dev/lesavka_r_eye",
|
|
||||||
_ => return Err(Status::invalid_argument("monitor id must be 0 or 1")),
|
|
||||||
};
|
|
||||||
|
|
||||||
let s = video::eye_ball(dev, id, req.max_bitrate)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
|
||||||
Ok(Response::new(Box::pin(s)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn capture_audio(
|
async fn capture_audio(
|
||||||
&self,
|
&self,
|
||||||
_req: Request<MonitorRequest>,
|
_req: Request<MonitorRequest>,
|
||||||
) -> Result<Response<Self::CaptureAudioStream>, Status> {
|
) -> Result<Response<Self::CaptureAudioStream>, Status> {
|
||||||
Err(Status::internal("audio capture unavailable in coverage harness"))
|
Err(Status::internal(
|
||||||
|
"audio capture unavailable in coverage harness",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn paste_text(&self, req: Request<PasteRequest>) -> Result<Response<PasteReply>, Status> {
|
async fn paste_text(&self, req: Request<PasteRequest>) -> Result<Response<PasteReply>, Status> {
|
||||||
let req = req.into_inner();
|
self.paste_text_reply(req).await
|
||||||
let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?;
|
|
||||||
if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await {
|
|
||||||
return Ok(Response::new(PasteReply {
|
|
||||||
ok: false,
|
|
||||||
error: format!("{e}"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Ok(Response::new(PasteReply {
|
|
||||||
ok: true,
|
|
||||||
error: String::new(),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
||||||
match self.gadget.cycle() {
|
self.reset_usb_reply().await
|
||||||
Ok(_) => {
|
|
||||||
if let Err(e) = self.reopen_hid().await {
|
|
||||||
return Err(Status::internal(e.to_string()));
|
|
||||||
}
|
}
|
||||||
Ok(Response::new(ResetUsbReply { ok: true }))
|
|
||||||
}
|
async fn get_capture_power(
|
||||||
Err(e) => Err(Status::internal(e.to_string())),
|
&self,
|
||||||
|
_req: Request<Empty>,
|
||||||
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
|
self.get_capture_power_reply().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_capture_power(
|
||||||
|
&self,
|
||||||
|
req: Request<SetCapturePowerRequest>,
|
||||||
|
) -> Result<Response<CapturePowerState>, Status> {
|
||||||
|
self.set_capture_power_reply(req).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -197,7 +197,9 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("open temp file");
|
.expect("open temp file");
|
||||||
let kb = Mutex::new(file);
|
let kb = Mutex::new(file);
|
||||||
let err = type_text(&kb, "pw🙂").await.expect_err("unsupported char should fail");
|
let err = type_text(&kb, "pw🙂")
|
||||||
|
.await
|
||||||
|
.expect_err("unsupported char should fail");
|
||||||
assert!(err.to_string().contains("unsupported character"));
|
assert!(err.to_string().contains("unsupported character"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,7 +26,10 @@ pub fn pick_uvc_device() -> anyhow::Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(ctrl) = UsbGadget::find_controller() {
|
if let Ok(ctrl) = UsbGadget::find_controller() {
|
||||||
return Ok(format!("{}/platform-{ctrl}-video-index0", uvc_by_path_root()));
|
return Ok(format!(
|
||||||
|
"{}/platform-{ctrl}-video-index0",
|
||||||
|
uvc_by_path_root()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow::anyhow!(
|
Err(anyhow::anyhow!(
|
||||||
@ -74,7 +77,9 @@ pub fn pick_uvc_device() -> anyhow::Result<String> {
|
|||||||
.property_value("ID_PATH")
|
.property_value("ID_PATH")
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if let Some(ctrl) = ctrl.as_deref() && (product == ctrl || path.contains(ctrl)) {
|
if let Some(ctrl) = ctrl.as_deref()
|
||||||
|
&& (product == ctrl || path.contains(ctrl))
|
||||||
|
{
|
||||||
return Ok(node);
|
return Ok(node);
|
||||||
}
|
}
|
||||||
if fallback.is_none() {
|
if fallback.is_none() {
|
||||||
|
|||||||
@ -58,7 +58,8 @@ pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Res
|
|||||||
return Err(anyhow::anyhow!("invalid video source"));
|
return Err(anyhow::anyhow!("invalid video source"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let use_test_src = dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc");
|
let use_test_src =
|
||||||
|
dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc");
|
||||||
if !use_test_src {
|
if !use_test_src {
|
||||||
return Err(anyhow::anyhow!("video source unavailable"));
|
return Err(anyhow::anyhow!("video source unavailable"));
|
||||||
}
|
}
|
||||||
@ -108,7 +109,8 @@ pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Resu
|
|||||||
|
|
||||||
let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 8).max(1);
|
let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 8).max(1);
|
||||||
let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 8).max(1);
|
let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 8).max(1);
|
||||||
let use_test_src = dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc");
|
let use_test_src =
|
||||||
|
dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc");
|
||||||
let desc = if use_test_src {
|
let desc = if use_test_src {
|
||||||
let test_bitrate = env_u32("LESAVKA_EYE_TESTSRC_KBIT", max_bitrate_kbit.max(800));
|
let test_bitrate = env_u32("LESAVKA_EYE_TESTSRC_KBIT", max_bitrate_kbit.max(800));
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@ -33,7 +33,10 @@ fn find_binary(name: &str) -> Option<PathBuf> {
|
|||||||
.find(|path| path.exists() && path.is_file())
|
.find(|path| path.exists() && path.is_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wait_for_exit(mut child: std::process::Child, timeout: Duration) -> Option<std::process::ExitStatus> {
|
fn wait_for_exit(
|
||||||
|
mut child: std::process::Child,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Option<std::process::ExitStatus> {
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
if let Some(status) = child.try_wait().expect("poll child") {
|
if let Some(status) = child.try_wait().expect("poll child") {
|
||||||
@ -91,4 +94,3 @@ fn client_desktop_runtime_executes_startup_branches() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,12 +33,18 @@ mod camera_include_contract {
|
|||||||
init_gst();
|
init_gst();
|
||||||
let (enc, _caps) = CameraCapture::pick_encoder();
|
let (enc, _caps) = CameraCapture::pick_encoder();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(enc, "nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"),
|
matches!(
|
||||||
|
enc,
|
||||||
|
"nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"
|
||||||
|
),
|
||||||
"unexpected encoder: {enc}"
|
"unexpected encoder: {enc}"
|
||||||
);
|
);
|
||||||
let (enc, key_prop, key_val) = CameraCapture::choose_encoder();
|
let (enc, key_prop, key_val) = CameraCapture::choose_encoder();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(enc, "nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"),
|
matches!(
|
||||||
|
enc,
|
||||||
|
"nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"
|
||||||
|
),
|
||||||
"unexpected encoder: {enc}"
|
"unexpected encoder: {enc}"
|
||||||
);
|
);
|
||||||
assert!(!key_prop.is_empty());
|
assert!(!key_prop.is_empty());
|
||||||
@ -48,7 +54,9 @@ mod camera_include_contract {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_device_and_capture_detection_handle_missing_nodes() {
|
fn find_device_and_capture_detection_handle_missing_nodes() {
|
||||||
assert!(CameraCapture::find_device("never-matches-this-fragment").is_none());
|
assert!(CameraCapture::find_device("never-matches-this-fragment").is_none());
|
||||||
assert!(!CameraCapture::is_capture("/dev/definitely-missing-camera0"));
|
assert!(!CameraCapture::is_capture(
|
||||||
|
"/dev/definitely-missing-camera0"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -60,8 +68,7 @@ mod camera_include_contract {
|
|||||||
std::fs::create_dir_all(&by_id).expect("create by-id");
|
std::fs::create_dir_all(&by_id).expect("create by-id");
|
||||||
std::fs::create_dir_all(&dev_root).expect("create dev root");
|
std::fs::create_dir_all(&dev_root).expect("create dev root");
|
||||||
std::fs::write(dev_root.join("video42"), "").expect("create fake node");
|
std::fs::write(dev_root.join("video42"), "").expect("create fake node");
|
||||||
symlink("../dev-root/video42", by_id.join("usb-Cam_42"))
|
symlink("../dev-root/video42", by_id.join("usb-Cam_42")).expect("create camera symlink");
|
||||||
.expect("create camera symlink");
|
|
||||||
|
|
||||||
with_var(
|
with_var(
|
||||||
"LESAVKA_CAM_BY_ID_DIR",
|
"LESAVKA_CAM_BY_ID_DIR",
|
||||||
@ -168,7 +175,10 @@ mod camera_include_contract {
|
|||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
if let Some(pkt) = cap.pull() {
|
if let Some(pkt) = cap.pull() {
|
||||||
assert_eq!(pkt.id, 2);
|
assert_eq!(pkt.id, 2);
|
||||||
assert!(!pkt.data.is_empty(), "test pattern should emit payload bytes");
|
assert!(
|
||||||
|
!pkt.data.is_empty(),
|
||||||
|
"test pattern should emit payload bytes"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
|
|||||||
@ -235,13 +235,17 @@ mod keyboard_contract {
|
|||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
||||||
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'hello-from-clipboard'"), || {
|
with_var(
|
||||||
|
"LESAVKA_CLIPBOARD_CMD",
|
||||||
|
Some("printf 'hello-from-clipboard'"),
|
||||||
|
|| {
|
||||||
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
||||||
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || {
|
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || {
|
||||||
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
|
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let payload: String = rx_rpc.try_recv().expect("rpc payload");
|
let payload: String = rx_rpc.try_recv().expect("rpc payload");
|
||||||
assert!(payload.contains("hello-from-clipboard"));
|
assert!(payload.contains("hello-from-clipboard"));
|
||||||
@ -274,7 +278,10 @@ mod keyboard_contract {
|
|||||||
while rx.try_recv().is_ok() {
|
while rx.try_recv().is_ok() {
|
||||||
seen += 1;
|
seen += 1;
|
||||||
}
|
}
|
||||||
assert!(seen >= 2, "expected multiple key reports for pasted characters");
|
assert!(
|
||||||
|
seen >= 2,
|
||||||
|
"expected multiple key reports for pasted characters"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -292,208 +299,4 @@ mod keyboard_contract {
|
|||||||
let pkt = rx.try_recv().expect("empty report after reset");
|
let pkt = rx.try_recv().expect("empty report after reset");
|
||||||
assert_eq!(pkt.data, vec![0; 8]);
|
assert_eq!(pkt.data, vec![0; 8]);
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn reset_state_when_idle_still_emits_an_empty_report() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-reset-idle").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (mut agg, mut rx) = new_aggregator(dev);
|
|
||||||
agg.reset_state();
|
|
||||||
let pkt = rx.try_recv().expect("idle reset should still publish empty report");
|
|
||||||
assert_eq!(pkt.data, vec![0; 8]);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn pressed_keys_snapshot_returns_the_current_keyset() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-snapshot").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (mut agg, _) = new_aggregator(dev);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
||||||
let snapshot = agg.pressed_keys_snapshot();
|
|
||||||
assert!(snapshot.contains(&evdev::KeyCode::KEY_A));
|
|
||||||
assert!(snapshot.contains(&evdev::KeyCode::KEY_LEFTCTRL));
|
|
||||||
assert_eq!(snapshot.len(), 2);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn set_send_false_blocks_manual_empty_report() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-nosend").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (mut agg, mut rx) = new_aggregator(dev);
|
|
||||||
agg.set_send(false);
|
|
||||||
agg.send_empty_report();
|
|
||||||
assert!(rx.try_recv().is_err());
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn process_events_respects_send_toggle() {
|
|
||||||
let Some((mut vdev, dev)) = build_keyboard("lesavka-include-kbd-send-toggle") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (mut agg, mut rx) = new_aggregator(dev);
|
|
||||||
agg.set_send(false);
|
|
||||||
vdev.emit(&[evdev::InputEvent::new(
|
|
||||||
evdev::EventType::KEY.0,
|
|
||||||
evdev::KeyCode::KEY_B.0,
|
|
||||||
1,
|
|
||||||
)])
|
|
||||||
.expect("emit key");
|
|
||||||
thread::sleep(std::time::Duration::from_millis(20));
|
|
||||||
agg.process_events();
|
|
||||||
assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports");
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn paste_chord_active_supports_ctrl_v_variant() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (mut agg, _) = new_aggregator(dev);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+v"), || {
|
|
||||||
assert!(agg.paste_chord_active());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn paste_debounced_rejects_rapid_repeat_with_positive_window() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-debounce").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (agg, _) = new_aggregator(dev);
|
|
||||||
let now_ms = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("time")
|
|
||||||
.as_millis() as u64;
|
|
||||||
LAST_PASTE_MS.store(now_ms, Ordering::Relaxed);
|
|
||||||
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("9999"), || {
|
|
||||||
assert!(!agg.paste_debounced());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn paste_via_rpc_returns_false_without_sender() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-rpc-none").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (tx, _rx) = tokio::sync::broadcast::channel(8);
|
|
||||||
let agg = KeyboardAggregator::new(dev, false, tx, None);
|
|
||||||
assert!(!agg.paste_via_rpc());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn read_clipboard_text_returns_none_when_custom_and_fallback_tools_fail() {
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CMD", Some("nonexistent-clipboard-command"), || {
|
|
||||||
with_var("PATH", Some("/tmp/definitely-missing-path"), || {
|
|
||||||
assert!(read_clipboard_text().is_none());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn read_clipboard_text_handles_empty_custom_command_output() {
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CMD", Some("printf ''"), || {
|
|
||||||
with_var("PATH", Some("/tmp/definitely-missing-path"), || {
|
|
||||||
assert!(read_clipboard_text().is_none());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn read_clipboard_text_handles_failing_custom_command() {
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CMD", Some("echo boom >&2; exit 1"), || {
|
|
||||||
with_var("PATH", Some("/tmp/definitely-missing-path"), || {
|
|
||||||
assert!(read_clipboard_text().is_none());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn read_clipboard_text_uses_fallback_tool_when_available() {
|
|
||||||
let wl_paste = r#"#!/usr/bin/env sh
|
|
||||||
printf 'fallback-clipboard'
|
|
||||||
"#;
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || {
|
|
||||||
with_fake_path_command("wl-paste", wl_paste, || {
|
|
||||||
let text = read_clipboard_text().expect("fallback clipboard text");
|
|
||||||
assert_eq!(text, "fallback-clipboard");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn paste_via_rpc_returns_true_for_empty_clipboard_payload() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-rpc-empty").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
|
||||||
let (kbd_tx, _rx) = tokio::sync::broadcast::channel(8);
|
|
||||||
let agg = KeyboardAggregator::new(dev, false, kbd_tx, Some(paste_tx));
|
|
||||||
let wl_paste_empty = r#"#!/usr/bin/env sh
|
|
||||||
exit 0
|
|
||||||
"#;
|
|
||||||
with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || {
|
|
||||||
with_fake_path_command("wl-paste", wl_paste_empty, || {
|
|
||||||
assert!(agg.paste_via_rpc(), "empty clipboard should still consume the chord");
|
|
||||||
assert!(paste_rx.try_recv().is_err(), "empty clipboard should not enqueue payload");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn set_grab_path_is_non_panicking() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-grab").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (mut agg, _) = new_aggregator(dev);
|
|
||||||
agg.set_grab(false);
|
|
||||||
agg.set_grab(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn try_handle_paste_event_swallows_incomplete_chord_sequences() {
|
|
||||||
let Some(dev) = open_any_keyboard_device()
|
|
||||||
.or_else(|| build_keyboard("lesavka-include-kbd-incomplete").map(|(_, dev)| dev))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (mut agg, mut rx) = new_aggregator(dev);
|
|
||||||
agg.paste_enabled = true;
|
|
||||||
agg.paste_chord_armed = true;
|
|
||||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
||||||
|
|
||||||
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_LEFTCTRL, 1));
|
|
||||||
let pkt = rx.try_recv().expect("swallow report");
|
|
||||||
assert_eq!(pkt.data, vec![0; 8]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
256
testing/tests/client_keyboard_include_extra_contract.rs
Normal file
256
testing/tests/client_keyboard_include_extra_contract.rs
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
//! Extra integration coverage for client keyboard aggregator helpers.
|
||||||
|
//!
|
||||||
|
//! Scope: include keyboard input source and cover reset-state, clipboard
|
||||||
|
//! fallback, send-toggle, and auxiliary paste branches without blowing the
|
||||||
|
//! primary keyboard contract past the 500 LOC cap.
|
||||||
|
//! Targets: `client/src/input/keyboard.rs`.
|
||||||
|
//! Why: keyboard helper branches are numerous enough to merit a paired
|
||||||
|
//! contract file for hygiene-gate modularity.
|
||||||
|
|
||||||
|
mod keymap {
|
||||||
|
pub use lesavka_client::input::keymap::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(warnings)]
|
||||||
|
mod keyboard_contract_extra {
|
||||||
|
include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC"));
|
||||||
|
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use temp_env::with_var;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn write_executable(dir: &Path, name: &str, body: &str) {
|
||||||
|
let path = dir.join(name);
|
||||||
|
fs::write(&path, body).expect("write script");
|
||||||
|
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(path, perms).expect("chmod");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_fake_path_command(name: &str, script_body: &str, f: impl FnOnce()) {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
write_executable(dir.path(), name, script_body);
|
||||||
|
let prior = std::env::var("PATH").unwrap_or_default();
|
||||||
|
let merged = if prior.is_empty() {
|
||||||
|
dir.path().display().to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}:{prior}", dir.path().display())
|
||||||
|
};
|
||||||
|
with_var("PATH", Some(merged), f);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_any_keyboard_device() -> Option<evdev::Device> {
|
||||||
|
let entries = std::fs::read_dir("/dev/input").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = path.file_name()?.to_string_lossy();
|
||||||
|
if !name.starts_with("event") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dev = evdev::Device::open(path).ok()?;
|
||||||
|
let _ = dev.set_nonblocking(true);
|
||||||
|
let looks_like_keyboard = dev
|
||||||
|
.supported_keys()
|
||||||
|
.map(|keys| {
|
||||||
|
keys.contains(evdev::KeyCode::KEY_A)
|
||||||
|
&& keys.contains(evdev::KeyCode::KEY_ENTER)
|
||||||
|
&& keys.contains(evdev::KeyCode::KEY_LEFTCTRL)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if looks_like_keyboard {
|
||||||
|
return Some(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_keyboard(name: &str) -> Option<evdev::Device> {
|
||||||
|
let mut keys = evdev::AttributeSet::<evdev::KeyCode>::new();
|
||||||
|
for key in [
|
||||||
|
evdev::KeyCode::KEY_A,
|
||||||
|
evdev::KeyCode::KEY_B,
|
||||||
|
evdev::KeyCode::KEY_V,
|
||||||
|
evdev::KeyCode::KEY_LEFTCTRL,
|
||||||
|
evdev::KeyCode::KEY_LEFTALT,
|
||||||
|
] {
|
||||||
|
keys.insert(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut vdev = evdev::uinput::VirtualDevice::builder()
|
||||||
|
.ok()?
|
||||||
|
.name(name)
|
||||||
|
.with_keys(&keys)
|
||||||
|
.ok()?
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
for _ in 0..40 {
|
||||||
|
if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() {
|
||||||
|
if let Some(Ok(path)) = nodes.next() {
|
||||||
|
let dev = evdev::Device::open(path).ok()?;
|
||||||
|
dev.set_nonblocking(true).ok()?;
|
||||||
|
return Some(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_aggregator(
|
||||||
|
dev: evdev::Device,
|
||||||
|
) -> (
|
||||||
|
KeyboardAggregator,
|
||||||
|
tokio::sync::broadcast::Receiver<KeyboardReport>,
|
||||||
|
) {
|
||||||
|
let (tx, rx) = tokio::sync::broadcast::channel(64);
|
||||||
|
(KeyboardAggregator::new(dev, true, tx, None), rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn reset_state_when_idle_still_emits_an_empty_report() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-reset-idle"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, mut rx) = new_aggregator(dev);
|
||||||
|
agg.reset_state();
|
||||||
|
let pkt = rx
|
||||||
|
.try_recv()
|
||||||
|
.expect("idle reset should still publish empty report");
|
||||||
|
assert_eq!(pkt.data, vec![0; 8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn pressed_keys_snapshot_returns_the_current_keyset() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-snapshot"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, _) = new_aggregator(dev);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
||||||
|
let snapshot = agg.pressed_keys_snapshot();
|
||||||
|
assert!(snapshot.contains(&evdev::KeyCode::KEY_A));
|
||||||
|
assert!(snapshot.contains(&evdev::KeyCode::KEY_LEFTCTRL));
|
||||||
|
assert_eq!(snapshot.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn set_send_false_blocks_manual_empty_report() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-nosend"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, mut rx) = new_aggregator(dev);
|
||||||
|
agg.set_send(false);
|
||||||
|
agg.send_empty_report();
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn paste_chord_active_supports_ctrl_v_variant() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, _) = new_aggregator(dev);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+v"), || {
|
||||||
|
assert!(agg.paste_chord_active());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn paste_via_rpc_returns_false_without_sender() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-rpc-none"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel(8);
|
||||||
|
let agg = KeyboardAggregator::new(dev, false, tx, None);
|
||||||
|
assert!(!agg.paste_via_rpc());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn read_clipboard_text_uses_fallback_tool_when_available() {
|
||||||
|
let wl_paste = "#!/usr/bin/env sh\nprintf 'fallback-clipboard'\n";
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || {
|
||||||
|
with_fake_path_command("wl-paste", wl_paste, || {
|
||||||
|
let text = read_clipboard_text().expect("fallback clipboard text");
|
||||||
|
assert_eq!(text, "fallback-clipboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn paste_via_rpc_returns_true_for_empty_clipboard_payload() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-rpc-empty"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
let (kbd_tx, _rx) = tokio::sync::broadcast::channel(8);
|
||||||
|
let agg = KeyboardAggregator::new(dev, false, kbd_tx, Some(paste_tx));
|
||||||
|
let wl_paste_empty = "#!/usr/bin/env sh\nexit 0\n";
|
||||||
|
with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || {
|
||||||
|
with_fake_path_command("wl-paste", wl_paste_empty, || {
|
||||||
|
assert!(
|
||||||
|
agg.paste_via_rpc(),
|
||||||
|
"empty clipboard should still consume the chord"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paste_rx.try_recv().is_err(),
|
||||||
|
"empty clipboard should not enqueue payload"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn set_grab_path_is_non_panicking() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-grab"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, _) = new_aggregator(dev);
|
||||||
|
agg.set_grab(false);
|
||||||
|
agg.set_grab(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn try_handle_paste_event_swallows_incomplete_chord_sequences() {
|
||||||
|
let Some(dev) =
|
||||||
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-incomplete"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (mut agg, mut rx) = new_aggregator(dev);
|
||||||
|
agg.paste_enabled = true;
|
||||||
|
agg.paste_chord_armed = true;
|
||||||
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
||||||
|
|
||||||
|
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_LEFTCTRL, 1));
|
||||||
|
let pkt = rx.try_recv().expect("swallow report");
|
||||||
|
assert_eq!(pkt.data, vec![0; 8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,7 +33,10 @@ mod client_main_binary {
|
|||||||
with_var("LESAVKA_DEV_MODE", Some("1"), || {
|
with_var("LESAVKA_DEV_MODE", Some("1"), || {
|
||||||
with_var("LESAVKA_TEST_SKIP_APP", Some("1"), || {
|
with_var("LESAVKA_TEST_SKIP_APP", Some("1"), || {
|
||||||
let result = main();
|
let result = main();
|
||||||
assert!(result.is_ok(), "dev-mode startup should succeed in test skip mode");
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"dev-mode startup should succeed in test skip mode"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -59,7 +62,10 @@ mod client_main_binary {
|
|||||||
fn ensure_runtime_dir_panics_in_tests_when_missing() {
|
fn ensure_runtime_dir_panics_in_tests_when_missing() {
|
||||||
with_var("XDG_RUNTIME_DIR", None::<&str>, || {
|
with_var("XDG_RUNTIME_DIR", None::<&str>, || {
|
||||||
let panic_result = std::panic::catch_unwind(ensure_runtime_dir);
|
let panic_result = std::panic::catch_unwind(ensure_runtime_dir);
|
||||||
assert!(panic_result.is_err(), "missing runtime dir should panic in test cfg");
|
assert!(
|
||||||
|
panic_result.is_err(),
|
||||||
|
"missing runtime dir should panic in test cfg"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,8 +48,8 @@ fi
|
|||||||
exit 0
|
exit 0
|
||||||
"#;
|
"#;
|
||||||
with_fake_pactl(script, || {
|
with_fake_pactl(script, || {
|
||||||
let src = MicrophoneCapture::pulse_source_by_substr("Mic_1234")
|
let src =
|
||||||
.expect("matching source");
|
MicrophoneCapture::pulse_source_by_substr("Mic_1234").expect("matching source");
|
||||||
assert_eq!(src, "alsa_input.usb-Mic_1234-00.analog-stereo");
|
assert_eq!(src, "alsa_input.usb-Mic_1234-00.analog-stereo");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -95,7 +95,10 @@ exit 0
|
|||||||
pipeline: gst::Pipeline::new(),
|
pipeline: gst::Pipeline::new(),
|
||||||
sink,
|
sink,
|
||||||
};
|
};
|
||||||
assert!(cap.pull().is_none(), "empty appsink should produce no packet");
|
assert!(
|
||||||
|
cap.pull().is_none(),
|
||||||
|
"empty appsink should produce no packet"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -106,9 +106,15 @@ mod mouse_contract {
|
|||||||
.name(name)
|
.name(name)
|
||||||
.with_keys(&keys)
|
.with_keys(&keys)
|
||||||
.ok()?
|
.ok()?
|
||||||
.with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_X, abs))
|
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
||||||
|
evdev::AbsoluteAxisCode::ABS_X,
|
||||||
|
abs,
|
||||||
|
))
|
||||||
.ok()?
|
.ok()?
|
||||||
.with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_Y, abs))
|
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
||||||
|
evdev::AbsoluteAxisCode::ABS_Y,
|
||||||
|
abs,
|
||||||
|
))
|
||||||
.ok()?
|
.ok()?
|
||||||
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
||||||
evdev::AbsoluteAxisCode::ABS_MT_TRACKING_ID,
|
evdev::AbsoluteAxisCode::ABS_MT_TRACKING_ID,
|
||||||
@ -133,9 +139,15 @@ mod mouse_contract {
|
|||||||
.name(name)
|
.name(name)
|
||||||
.with_keys(&keys)
|
.with_keys(&keys)
|
||||||
.ok()?
|
.ok()?
|
||||||
.with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_X, abs))
|
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
||||||
|
evdev::AbsoluteAxisCode::ABS_X,
|
||||||
|
abs,
|
||||||
|
))
|
||||||
.ok()?
|
.ok()?
|
||||||
.with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_Y, abs))
|
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
||||||
|
evdev::AbsoluteAxisCode::ABS_Y,
|
||||||
|
abs,
|
||||||
|
))
|
||||||
.ok()?
|
.ok()?
|
||||||
.build()
|
.build()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
@ -263,7 +275,10 @@ mod mouse_contract {
|
|||||||
agg.next_send = std::time::Instant::now() - std::time::Duration::from_millis(10);
|
agg.next_send = std::time::Instant::now() - std::time::Duration::from_millis(10);
|
||||||
agg.set_send(false);
|
agg.set_send(false);
|
||||||
agg.flush();
|
agg.flush();
|
||||||
assert!(rx.try_recv().is_err(), "send-disabled flush should not emit");
|
assert!(
|
||||||
|
rx.try_recv().is_err(),
|
||||||
|
"send-disabled flush should not emit"
|
||||||
|
);
|
||||||
|
|
||||||
agg.buttons = 2;
|
agg.buttons = 2;
|
||||||
agg.last_buttons = 0;
|
agg.last_buttons = 0;
|
||||||
@ -299,7 +314,10 @@ mod mouse_contract {
|
|||||||
|
|
||||||
let threshold =
|
let threshold =
|
||||||
MouseAggregator::abs_jump_threshold(&dev, &[evdev::AbsoluteAxisCode::ABS_X], 3);
|
MouseAggregator::abs_jump_threshold(&dev, &[evdev::AbsoluteAxisCode::ABS_X], 3);
|
||||||
assert!(threshold >= 120, "threshold should honor scale-derived minimum");
|
assert!(
|
||||||
|
threshold >= 120,
|
||||||
|
"threshold should honor scale-derived minimum"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -469,5 +487,4 @@ mod mouse_contract {
|
|||||||
let pkt = rx.try_recv().expect("drop packet");
|
let pkt = rx.try_recv().expect("drop packet");
|
||||||
assert_eq!(pkt.data, vec![0; 8]);
|
assert_eq!(pkt.data, vec![0; 8]);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,7 +105,10 @@ exit 0
|
|||||||
"#;
|
"#;
|
||||||
with_fake_pactl(script, || {
|
with_fake_pactl(script, || {
|
||||||
with_var("LESAVKA_AUDIO_SINK", None::<&str>, || {
|
with_var("LESAVKA_AUDIO_SINK", None::<&str>, || {
|
||||||
assert!(list_pw_sinks().is_empty(), "no default sink should be parsed");
|
assert!(
|
||||||
|
list_pw_sinks().is_empty(),
|
||||||
|
"no default sink should be parsed"
|
||||||
|
);
|
||||||
let sink = pick_sink_element().expect("fallback sink");
|
let sink = pick_sink_element().expect("fallback sink");
|
||||||
assert_eq!(sink, "autoaudiosink");
|
assert_eq!(sink, "autoaudiosink");
|
||||||
});
|
});
|
||||||
@ -116,8 +119,7 @@ exit 0
|
|||||||
#[serial]
|
#[serial]
|
||||||
fn audio_out_new_and_push_are_stable_with_sink_override() {
|
fn audio_out_new_and_push_are_stable_with_sink_override() {
|
||||||
with_var("LESAVKA_AUDIO_SINK", Some("fakesink sync=false"), || {
|
with_var("LESAVKA_AUDIO_SINK", Some("fakesink sync=false"), || {
|
||||||
with_var("LESAVKA_TAP_AUDIO", Some("1"), || {
|
with_var("LESAVKA_TAP_AUDIO", Some("1"), || match AudioOut::new() {
|
||||||
match AudioOut::new() {
|
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
out.push(AudioPacket {
|
out.push(AudioPacket {
|
||||||
id: 0,
|
id: 0,
|
||||||
@ -129,7 +131,6 @@ exit 0
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
assert!(!err.to_string().trim().is_empty());
|
assert!(!err.to_string().trim().is_empty());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -137,11 +138,18 @@ exit 0
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn audio_out_new_returns_error_for_invalid_sink_override() {
|
fn audio_out_new_returns_error_for_invalid_sink_override() {
|
||||||
with_var("LESAVKA_AUDIO_SINK", Some("definitely-not-a-real-gst-sink"), || {
|
with_var(
|
||||||
|
"LESAVKA_AUDIO_SINK",
|
||||||
|
Some("definitely-not-a-real-gst-sink"),
|
||||||
|
|| {
|
||||||
with_var("LESAVKA_TAP_AUDIO", None::<&str>, || {
|
with_var("LESAVKA_TAP_AUDIO", None::<&str>, || {
|
||||||
let result = AudioOut::new();
|
let result = AudioOut::new();
|
||||||
assert!(result.is_err(), "invalid sink override must fail pipeline parsing");
|
assert!(
|
||||||
});
|
result.is_err(),
|
||||||
|
"invalid sink override must fail pipeline parsing"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -147,7 +147,7 @@ mod gtk {
|
|||||||
|
|
||||||
#[allow(warnings)]
|
#[allow(warnings)]
|
||||||
mod display_include_contract {
|
mod display_include_contract {
|
||||||
use crate::gtk as gtk;
|
use crate::gtk;
|
||||||
include!(env!("LESAVKA_CLIENT_OUTPUT_DISPLAY_SRC"));
|
include!(env!("LESAVKA_CLIENT_OUTPUT_DISPLAY_SRC"));
|
||||||
|
|
||||||
use crate::gtk::gdk as mock_gdk;
|
use crate::gtk::gdk as mock_gdk;
|
||||||
|
|||||||
@ -34,7 +34,10 @@ mod gadget_include_contract {
|
|||||||
&base.join(format!("sys/class/udc/{ctrl}/state")),
|
&base.join(format!("sys/class/udc/{ctrl}/state")),
|
||||||
&format!("{state}\n"),
|
&format!("{state}\n"),
|
||||||
);
|
);
|
||||||
write_file(&base.join(format!("sys/class/udc/{ctrl}/soft_connect")), "1\n");
|
write_file(
|
||||||
|
&base.join(format!("sys/class/udc/{ctrl}/soft_connect")),
|
||||||
|
"1\n",
|
||||||
|
);
|
||||||
write_file(
|
write_file(
|
||||||
&base.join("sys/bus/platform/drivers/dwc2/unbind"),
|
&base.join("sys/bus/platform/drivers/dwc2/unbind"),
|
||||||
"placeholder\n",
|
"placeholder\n",
|
||||||
@ -161,7 +164,10 @@ mod gadget_include_contract {
|
|||||||
with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
||||||
let gadget = UsbGadget::new("lesavka-test");
|
let gadget = UsbGadget::new("lesavka-test");
|
||||||
let result = gadget.cycle();
|
let result = gadget.cycle();
|
||||||
assert!(result.is_ok(), "configured host state should short-circuit safely");
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"configured host state should short-circuit safely"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +182,10 @@ mod gadget_include_contract {
|
|||||||
with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || {
|
with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || {
|
||||||
let gadget = UsbGadget::new("lesavka-test");
|
let gadget = UsbGadget::new("lesavka-test");
|
||||||
let result = gadget.cycle();
|
let result = gadget.cycle();
|
||||||
assert!(result.is_ok(), "force cycle should complete on fake sysfs tree");
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"force cycle should complete on fake sysfs tree"
|
||||||
|
);
|
||||||
let udc_path = dir.path().join("cfg/lesavka-test/UDC");
|
let udc_path = dir.path().join("cfg/lesavka-test/UDC");
|
||||||
let value = std::fs::read_to_string(udc_path).expect("read udc file");
|
let value = std::fs::read_to_string(udc_path).expect("read udc file");
|
||||||
assert_eq!(value.trim(), ctrl);
|
assert_eq!(value.trim(), ctrl);
|
||||||
@ -202,7 +211,10 @@ mod gadget_include_contract {
|
|||||||
with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || {
|
with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || {
|
||||||
let gadget = UsbGadget::new("lesavka-test");
|
let gadget = UsbGadget::new("lesavka-test");
|
||||||
let result = gadget.cycle();
|
let result = gadget.cycle();
|
||||||
assert!(result.is_ok(), "configured transition should satisfy final wait_state");
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"configured transition should satisfy final wait_state"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,7 @@ mod server_main_binary {
|
|||||||
gadget: UsbGadget::new("lesavka"),
|
gadget: UsbGadget::new("lesavka"),
|
||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -97,11 +98,15 @@ mod server_main_binary {
|
|||||||
fn main_spawns_uvc_supervisor_branch_before_failing_without_hid_nodes() {
|
fn main_spawns_uvc_supervisor_branch_before_failing_without_hid_nodes() {
|
||||||
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
|
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
|
||||||
with_var("LESAVKA_UVC_EXTERNAL", None::<&str>, || {
|
with_var("LESAVKA_UVC_EXTERNAL", None::<&str>, || {
|
||||||
with_var("LESAVKA_UVC_CTRL_BIN", Some("/definitely/missing/uvc-helper"), || {
|
with_var(
|
||||||
|
"LESAVKA_UVC_CTRL_BIN",
|
||||||
|
Some("/definitely/missing/uvc-helper"),
|
||||||
|
|| {
|
||||||
with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
|
with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
|
||||||
let _ = std::panic::catch_unwind(main);
|
let _ = std::panic::catch_unwind(main);
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -204,284 +209,4 @@ mod server_main_binary {
|
|||||||
};
|
};
|
||||||
assert_eq!(err.code(), tonic::Code::Internal);
|
assert_eq!(err.code(), tonic::Code::Internal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_keyboard_writes_reports_to_hid_file() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
rt.block_on(async {
|
|
||||||
let (dir, handler) = build_handler_for_tests();
|
|
||||||
let kb_path = dir.path().join("hidg0.bin");
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let _ = tonic::transport::Server::builder()
|
|
||||||
.add_service(RelayServer::new(handler))
|
|
||||||
.serve(addr)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = connect_with_retry(addr).await;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
tx.send(KeyboardReport {
|
|
||||||
data: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send keyboard packet");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let mut resp = cli
|
|
||||||
.stream_keyboard(tonic::Request::new(outbound))
|
|
||||||
.await
|
|
||||||
.expect("stream keyboard");
|
|
||||||
let echoed = resp
|
|
||||||
.get_mut()
|
|
||||||
.message()
|
|
||||||
.await
|
|
||||||
.expect("grpc result")
|
|
||||||
.expect("echo packet");
|
|
||||||
assert_eq!(echoed.data, vec![1, 2, 3, 4, 5, 6, 7, 8]);
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
|
||||||
let written = std::fs::read(&kb_path).expect("read hidg0 file");
|
|
||||||
assert!(
|
|
||||||
!written.is_empty(),
|
|
||||||
"keyboard stream should write HID bytes to target file"
|
|
||||||
);
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_mouse_writes_reports_to_hid_file() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
rt.block_on(async {
|
|
||||||
let (dir, handler) = build_handler_for_tests();
|
|
||||||
let ms_path = dir.path().join("hidg1.bin");
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let _ = tonic::transport::Server::builder()
|
|
||||||
.add_service(RelayServer::new(handler))
|
|
||||||
.serve(addr)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = connect_with_retry(addr).await;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
tx.send(MouseReport {
|
|
||||||
data: vec![8, 7, 6, 5, 4, 3, 2, 1],
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send mouse packet");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let mut resp = cli
|
|
||||||
.stream_mouse(tonic::Request::new(outbound))
|
|
||||||
.await
|
|
||||||
.expect("stream mouse");
|
|
||||||
let echoed = resp
|
|
||||||
.get_mut()
|
|
||||||
.message()
|
|
||||||
.await
|
|
||||||
.expect("grpc result")
|
|
||||||
.expect("echo packet");
|
|
||||||
assert_eq!(echoed.data, vec![8, 7, 6, 5, 4, 3, 2, 1]);
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
|
||||||
let written = std::fs::read(&ms_path).expect("read hidg1 file");
|
|
||||||
assert!(
|
|
||||||
!written.is_empty(),
|
|
||||||
"mouse stream should write HID bytes to target file"
|
|
||||||
);
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_keyboard_recovers_when_hid_write_fails() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
rt.block_on(async {
|
|
||||||
let (_dir, handler) = build_handler_for_tests_with_modes(false, true);
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let _ = tonic::transport::Server::builder()
|
|
||||||
.add_service(RelayServer::new(handler))
|
|
||||||
.serve(addr)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = connect_with_retry(addr).await;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
tx.send(KeyboardReport {
|
|
||||||
data: vec![11, 12, 13, 14, 15, 16, 17, 18],
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send keyboard packet");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let mut resp = cli
|
|
||||||
.stream_keyboard(tonic::Request::new(outbound))
|
|
||||||
.await
|
|
||||||
.expect("stream keyboard");
|
|
||||||
let echoed = resp
|
|
||||||
.get_mut()
|
|
||||||
.message()
|
|
||||||
.await
|
|
||||||
.expect("grpc result")
|
|
||||||
.expect("echo packet");
|
|
||||||
assert_eq!(echoed.data, vec![11, 12, 13, 14, 15, 16, 17, 18]);
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_mouse_recovers_when_hid_write_fails() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
rt.block_on(async {
|
|
||||||
let (_dir, handler) = build_handler_for_tests_with_modes(true, false);
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let _ = tonic::transport::Server::builder()
|
|
||||||
.add_service(RelayServer::new(handler))
|
|
||||||
.serve(addr)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = connect_with_retry(addr).await;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
tx.send(MouseReport {
|
|
||||||
data: vec![21, 22, 23, 24, 25, 26, 27, 28],
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send mouse packet");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let mut resp = cli
|
|
||||||
.stream_mouse(tonic::Request::new(outbound))
|
|
||||||
.await
|
|
||||||
.expect("stream mouse");
|
|
||||||
let echoed = resp
|
|
||||||
.get_mut()
|
|
||||||
.message()
|
|
||||||
.await
|
|
||||||
.expect("grpc result")
|
|
||||||
.expect("echo packet");
|
|
||||||
assert_eq!(echoed.data, vec![21, 22, 23, 24, 25, 26, 27, 28]);
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_microphone_returns_internal_error_without_uac_device() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
rt.block_on(async {
|
|
||||||
let (_dir, handler) = build_handler_for_tests();
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let _ = tonic::transport::Server::builder()
|
|
||||||
.add_service(RelayServer::new(handler))
|
|
||||||
.serve(addr)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = connect_with_retry(addr).await;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
|
||||||
let (_tx, rx) = tokio::sync::mpsc::channel::<AudioPacket>(4);
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let err = cli
|
|
||||||
.stream_microphone(tonic::Request::new(outbound))
|
|
||||||
.await
|
|
||||||
.expect_err("missing UAC sink should fail stream setup");
|
|
||||||
assert_eq!(err.code(), tonic::Code::Internal);
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn stream_camera_reports_error_or_terminates_cleanly_without_camera_hardware() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
||||||
rt.block_on(async {
|
|
||||||
let (_dir, handler) = build_handler_for_tests();
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
drop(listener);
|
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let _ = tonic::transport::Server::builder()
|
|
||||||
.add_service(RelayServer::new(handler))
|
|
||||||
.serve(addr)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = connect_with_retry(addr).await;
|
|
||||||
let mut cli = RelayClient::new(channel);
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
||||||
tx.send(VideoPacket {
|
|
||||||
id: 2,
|
|
||||||
pts: 1,
|
|
||||||
data: vec![0, 1, 2, 3],
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("send camera packet");
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
||||||
let result = cli.stream_camera(tonic::Request::new(outbound)).await;
|
|
||||||
match result {
|
|
||||||
Ok(mut stream) => {
|
|
||||||
let _ = stream.get_mut().message().await;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
err.code(),
|
|
||||||
tonic::Code::Internal | tonic::Code::Unavailable | tonic::Code::Unknown
|
|
||||||
),
|
|
||||||
"unexpected camera stream error code: {}",
|
|
||||||
err.code()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,68 @@
|
|||||||
mod server_main_binary_extra {
|
mod server_main_binary_extra {
|
||||||
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
||||||
|
|
||||||
|
use lesavka_common::lesavka::relay_client::RelayClient;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use temp_env::with_var;
|
use temp_env::with_var;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
async fn connect_with_retry(addr: std::net::SocketAddr) -> tonic::transport::Channel {
|
||||||
|
let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
|
||||||
|
.expect("endpoint")
|
||||||
|
.tcp_nodelay(true);
|
||||||
|
for _ in 0..40 {
|
||||||
|
if let Ok(channel) = endpoint.clone().connect().await {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
panic!("failed to connect to local tonic server");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_handler_for_tests_with_modes(
|
||||||
|
kb_writable: bool,
|
||||||
|
ms_writable: bool,
|
||||||
|
) -> (tempfile::TempDir, Handler) {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let kb_path = dir.path().join("hidg0.bin");
|
||||||
|
let ms_path = dir.path().join("hidg1.bin");
|
||||||
|
std::fs::write(&kb_path, []).expect("create kb file");
|
||||||
|
std::fs::write(&ms_path, []).expect("create ms file");
|
||||||
|
|
||||||
|
let kb_std = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(kb_writable)
|
||||||
|
.create(kb_writable)
|
||||||
|
.truncate(kb_writable)
|
||||||
|
.open(&kb_path)
|
||||||
|
.expect("open kb");
|
||||||
|
let ms_std = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(ms_writable)
|
||||||
|
.create(ms_writable)
|
||||||
|
.truncate(ms_writable)
|
||||||
|
.open(&ms_path)
|
||||||
|
.expect("open ms");
|
||||||
|
let kb = tokio::fs::File::from_std(kb_std);
|
||||||
|
let ms = tokio::fs::File::from_std(ms_std);
|
||||||
|
|
||||||
|
(
|
||||||
|
dir,
|
||||||
|
Handler {
|
||||||
|
kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)),
|
||||||
|
ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)),
|
||||||
|
gadget: UsbGadget::new("lesavka"),
|
||||||
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
||||||
|
build_handler_for_tests_with_modes(true, true)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn handler_new_and_reopen_hid_succeed_with_override_paths() {
|
fn handler_new_and_reopen_hid_succeed_with_override_paths() {
|
||||||
@ -54,4 +112,284 @@ mod server_main_binary_extra {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_keyboard_writes_reports_to_hid_file() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let (dir, handler) = build_handler_for_tests();
|
||||||
|
let kb_path = dir.path().join("hidg0.bin");
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
tx.send(KeyboardReport {
|
||||||
|
data: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send keyboard packet");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let mut resp = cli
|
||||||
|
.stream_keyboard(tonic::Request::new(outbound))
|
||||||
|
.await
|
||||||
|
.expect("stream keyboard");
|
||||||
|
let echoed = resp
|
||||||
|
.get_mut()
|
||||||
|
.message()
|
||||||
|
.await
|
||||||
|
.expect("grpc result")
|
||||||
|
.expect("echo packet");
|
||||||
|
assert_eq!(echoed.data, vec![1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||||
|
let written = std::fs::read(&kb_path).expect("read hidg0 file");
|
||||||
|
assert!(
|
||||||
|
!written.is_empty(),
|
||||||
|
"keyboard stream should write HID bytes to target file"
|
||||||
|
);
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_mouse_writes_reports_to_hid_file() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let (dir, handler) = build_handler_for_tests();
|
||||||
|
let ms_path = dir.path().join("hidg1.bin");
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
tx.send(MouseReport {
|
||||||
|
data: vec![8, 7, 6, 5, 4, 3, 2, 1],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send mouse packet");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let mut resp = cli
|
||||||
|
.stream_mouse(tonic::Request::new(outbound))
|
||||||
|
.await
|
||||||
|
.expect("stream mouse");
|
||||||
|
let echoed = resp
|
||||||
|
.get_mut()
|
||||||
|
.message()
|
||||||
|
.await
|
||||||
|
.expect("grpc result")
|
||||||
|
.expect("echo packet");
|
||||||
|
assert_eq!(echoed.data, vec![8, 7, 6, 5, 4, 3, 2, 1]);
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||||
|
let written = std::fs::read(&ms_path).expect("read hidg1 file");
|
||||||
|
assert!(
|
||||||
|
!written.is_empty(),
|
||||||
|
"mouse stream should write HID bytes to target file"
|
||||||
|
);
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_keyboard_recovers_when_hid_write_fails() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests_with_modes(false, true);
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
tx.send(KeyboardReport {
|
||||||
|
data: vec![11, 12, 13, 14, 15, 16, 17, 18],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send keyboard packet");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let mut resp = cli
|
||||||
|
.stream_keyboard(tonic::Request::new(outbound))
|
||||||
|
.await
|
||||||
|
.expect("stream keyboard");
|
||||||
|
let echoed = resp
|
||||||
|
.get_mut()
|
||||||
|
.message()
|
||||||
|
.await
|
||||||
|
.expect("grpc result")
|
||||||
|
.expect("echo packet");
|
||||||
|
assert_eq!(echoed.data, vec![11, 12, 13, 14, 15, 16, 17, 18]);
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_mouse_recovers_when_hid_write_fails() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests_with_modes(true, false);
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
tx.send(MouseReport {
|
||||||
|
data: vec![21, 22, 23, 24, 25, 26, 27, 28],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send mouse packet");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let mut resp = cli
|
||||||
|
.stream_mouse(tonic::Request::new(outbound))
|
||||||
|
.await
|
||||||
|
.expect("stream mouse");
|
||||||
|
let echoed = resp
|
||||||
|
.get_mut()
|
||||||
|
.message()
|
||||||
|
.await
|
||||||
|
.expect("grpc result")
|
||||||
|
.expect("echo packet");
|
||||||
|
assert_eq!(echoed.data, vec![21, 22, 23, 24, 25, 26, 27, 28]);
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_microphone_returns_internal_error_without_uac_device() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let (_tx, rx) = tokio::sync::mpsc::channel::<AudioPacket>(4);
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let err = cli
|
||||||
|
.stream_microphone(tonic::Request::new(outbound))
|
||||||
|
.await
|
||||||
|
.expect_err("missing UAC sink should fail stream setup");
|
||||||
|
assert_eq!(err.code(), tonic::Code::Internal);
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn stream_camera_reports_error_or_terminates_cleanly_without_camera_hardware() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let (_dir, handler) = build_handler_for_tests();
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let _ = tonic::transport::Server::builder()
|
||||||
|
.add_service(RelayServer::new(handler))
|
||||||
|
.serve(addr)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let channel = connect_with_retry(addr).await;
|
||||||
|
let mut cli = RelayClient::new(channel);
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||||
|
tx.send(VideoPacket {
|
||||||
|
id: 2,
|
||||||
|
pts: 1,
|
||||||
|
data: vec![0, 1, 2, 3],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send camera packet");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let result = cli.stream_camera(tonic::Request::new(outbound)).await;
|
||||||
|
match result {
|
||||||
|
Ok(mut stream) => {
|
||||||
|
let _ = stream.get_mut().message().await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err.code(),
|
||||||
|
tonic::Code::Internal | tonic::Code::Unavailable | tonic::Code::Unknown
|
||||||
|
),
|
||||||
|
"unexpected camera stream error code: {}",
|
||||||
|
err.code()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ mod server_main_rpc {
|
|||||||
gadget: UsbGadget::new("lesavka"),
|
gadget: UsbGadget::new("lesavka"),
|
||||||
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
|
||||||
|
capture_power: CapturePowerManager::new(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -413,5 +413,4 @@ mod uvc_binary {
|
|||||||
handle_setup(-1, 0, &mut state, &mut pending, interfaces, req, false);
|
handle_setup(-1, 0, &mut state, &mut pending, interfaces, req, false);
|
||||||
assert!(pending.is_none());
|
assert!(pending.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,7 +239,10 @@ mod uvc_binary_extra {
|
|||||||
with_var("LESAVKA_UVC_DEV", Some("/dev/null"), || {
|
with_var("LESAVKA_UVC_DEV", Some("/dev/null"), || {
|
||||||
with_var("LESAVKA_UVC_BLOCKING", Some("1"), || {
|
with_var("LESAVKA_UVC_BLOCKING", Some("1"), || {
|
||||||
let result = main();
|
let result = main();
|
||||||
assert!(result.is_err(), "non-UVC node should fail during event subscribe");
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"non-UVC node should fail during event subscribe"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,10 @@ fn uvc_binary_requires_device_argument_or_env() {
|
|||||||
.env_remove("LESAVKA_UVC_DEV")
|
.env_remove("LESAVKA_UVC_DEV")
|
||||||
.status()
|
.status()
|
||||||
.expect("spawn lesavka-uvc");
|
.expect("spawn lesavka-uvc");
|
||||||
assert!(!status.success(), "uvc binary should fail without a device path");
|
assert!(
|
||||||
|
!status.success(),
|
||||||
|
"uvc binary should fail without a device path"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -109,4 +112,3 @@ fn uvc_binary_accepts_positional_device_argument() {
|
|||||||
"uvc binary should fail on non-v4l2 test file"
|
"uvc binary should fail on non-v4l2 test file"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,7 @@ mod video_sinks {
|
|||||||
|
|
||||||
mod video_support {
|
mod video_support {
|
||||||
pub use lesavka_server::video_support::{
|
pub use lesavka_server::video_support::{
|
||||||
adjust_effective_fps, contains_idr, default_eye_fps, env_u32, env_usize,
|
adjust_effective_fps, contains_idr, default_eye_fps, env_u32, env_usize, should_send_frame,
|
||||||
should_send_frame,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +49,11 @@ mod video_include_contract {
|
|||||||
inner: ReceiverStream::new(rx),
|
inner: ReceiverStream::new(rx),
|
||||||
};
|
};
|
||||||
|
|
||||||
let first = stream.next().await.expect("stream item").expect("packet ok");
|
let first = stream
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.expect("stream item")
|
||||||
|
.expect("packet ok");
|
||||||
assert_eq!(first.id, 1);
|
assert_eq!(first.id, 1);
|
||||||
assert_eq!(first.pts, 42);
|
assert_eq!(first.pts, 42);
|
||||||
assert_eq!(first.data, vec![1, 2, 3, 4]);
|
assert_eq!(first.data, vec![1, 2, 3, 4]);
|
||||||
@ -74,11 +77,7 @@ mod video_include_contract {
|
|||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
with_var("LESAVKA_EYE_ADAPTIVE", Some("0"), || {
|
with_var("LESAVKA_EYE_ADAPTIVE", Some("0"), || {
|
||||||
with_var("LESAVKA_EYE_QUEUE_BUFFERS", Some("4"), || {
|
with_var("LESAVKA_EYE_QUEUE_BUFFERS", Some("4"), || {
|
||||||
let result = rt.block_on(eye_ball(
|
let result = rt.block_on(eye_ball("/dev/video0\" ! thiswillneverparse", 0, 4_000));
|
||||||
"/dev/video0\" ! thiswillneverparse",
|
|
||||||
0,
|
|
||||||
4_000,
|
|
||||||
));
|
|
||||||
assert!(result.is_err(), "malformed pipeline should fail parse");
|
assert!(result.is_err(), "malformed pipeline should fail parse");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -90,11 +89,7 @@ mod video_include_contract {
|
|||||||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||||||
with_var("LESAVKA_EYE_APPSINK_BUFFERS", Some("5"), || {
|
with_var("LESAVKA_EYE_APPSINK_BUFFERS", Some("5"), || {
|
||||||
with_var("LESAVKA_EYE_CHAN_CAPACITY", Some("17"), || {
|
with_var("LESAVKA_EYE_CHAN_CAPACITY", Some("17"), || {
|
||||||
let result = rt.block_on(eye_ball(
|
let result = rt.block_on(eye_ball("/dev/video1\" ! still-bad", 1, 8_000));
|
||||||
"/dev/video1\" ! still-bad",
|
|
||||||
1,
|
|
||||||
8_000,
|
|
||||||
));
|
|
||||||
assert!(result.is_err(), "malformed pipeline should fail parse");
|
assert!(result.is_err(), "malformed pipeline should fail parse");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -106,7 +101,10 @@ mod video_include_contract {
|
|||||||
let panic_result = std::panic::catch_unwind(|| {
|
let panic_result = std::panic::catch_unwind(|| {
|
||||||
let _ = rt.block_on(eye_ball("/dev/video0", 2, 1_000));
|
let _ = rt.block_on(eye_ball("/dev/video0", 2, 1_000));
|
||||||
});
|
});
|
||||||
assert!(panic_result.is_err(), "invalid eye id must panic before setup");
|
assert!(
|
||||||
|
panic_result.is_err(),
|
||||||
|
"invalid eye id must panic before setup"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -207,10 +205,7 @@ mod video_include_contract {
|
|||||||
Err(_) => panic!("testsrc setup timed out"),
|
Err(_) => panic!("testsrc setup timed out"),
|
||||||
};
|
};
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
||||||
std::time::Duration::from_secs(1),
|
|
||||||
stream.next(),
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -116,9 +116,16 @@ fn hdmi_sink_mjpeg_constructor_path_is_stable() {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn hdmi_sink_override_with_invalid_element_returns_error() {
|
fn hdmi_sink_override_with_invalid_element_returns_error() {
|
||||||
with_var("LESAVKA_HDMI_SINK", Some("definitely-not-a-real-gst-element"), || {
|
with_var(
|
||||||
|
"LESAVKA_HDMI_SINK",
|
||||||
|
Some("definitely-not-a-real-gst-element"),
|
||||||
|
|| {
|
||||||
let cfg = hdmi_config(CameraCodec::H264);
|
let cfg = hdmi_config(CameraCodec::H264);
|
||||||
let result = HdmiSink::new(&cfg);
|
let result = HdmiSink::new(&cfg);
|
||||||
assert!(result.is_err(), "invalid sink override should fail construction");
|
assert!(
|
||||||
});
|
result.is_err(),
|
||||||
|
"invalid sink override should fail construction"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,10 +50,14 @@ mod video_sinks_include_contract {
|
|||||||
#[serial]
|
#[serial]
|
||||||
fn build_hdmi_sink_invalid_override_surfaces_error() {
|
fn build_hdmi_sink_invalid_override_surfaces_error() {
|
||||||
init_gst();
|
init_gst();
|
||||||
with_var("LESAVKA_HDMI_SINK", Some("definitely-not-a-real-gst-element"), || {
|
with_var(
|
||||||
|
"LESAVKA_HDMI_SINK",
|
||||||
|
Some("definitely-not-a-real-gst-element"),
|
||||||
|
|| {
|
||||||
let sink = build_hdmi_sink(&cfg(CameraCodec::H264));
|
let sink = build_hdmi_sink(&cfg(CameraCodec::H264));
|
||||||
assert!(sink.is_err(), "invalid override must fail");
|
assert!(sink.is_err(), "invalid override must fail");
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -84,7 +88,8 @@ mod video_sinks_include_contract {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn camera_sink_dispatch_is_stable_for_uvc_variant() {
|
fn camera_sink_dispatch_is_stable_for_uvc_variant() {
|
||||||
if let Ok(sink) = WebcamSink::new("/dev/video-definitely-missing", &cfg(CameraCodec::Mjpeg)) {
|
if let Ok(sink) = WebcamSink::new("/dev/video-definitely-missing", &cfg(CameraCodec::Mjpeg))
|
||||||
|
{
|
||||||
let cam_sink = CameraSink::Uvc(sink);
|
let cam_sink = CameraSink::Uvc(sink);
|
||||||
cam_sink.push(VideoPacket {
|
cam_sink.push(VideoPacket {
|
||||||
id: 9,
|
id: 9,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user