diff --git a/client/Cargo.toml b/client/Cargo.toml
index e6e4fb7..8dd28aa 100644
--- a/client/Cargo.toml
+++ b/client/Cargo.toml
@@ -36,6 +36,10 @@ base64 = "0.22"
[build-dependencies]
prost-build = "0.13"
+[dev-dependencies]
+temp-env = { workspace = true }
+tempfile = { workspace = true }
+
[lib]
name = "lesavka_client"
path = "src/lib.rs"
diff --git a/client/assets/icons/hicolor/1024x1024/apps/dev.lesavka.launcher.png b/client/assets/icons/hicolor/1024x1024/apps/dev.lesavka.launcher.png
new file mode 100644
index 0000000..94a0a6f
Binary files /dev/null and b/client/assets/icons/hicolor/1024x1024/apps/dev.lesavka.launcher.png differ
diff --git a/client/assets/icons/hicolor/1024x1024/apps/lesavka.png b/client/assets/icons/hicolor/1024x1024/apps/lesavka.png
new file mode 100644
index 0000000..94a0a6f
Binary files /dev/null and b/client/assets/icons/hicolor/1024x1024/apps/lesavka.png differ
diff --git a/client/assets/icons/hicolor/scalable/apps/dev.lesavka.launcher.svg b/client/assets/icons/hicolor/scalable/apps/dev.lesavka.launcher.svg
new file mode 100644
index 0000000..2e5fd19
--- /dev/null
+++ b/client/assets/icons/hicolor/scalable/apps/dev.lesavka.launcher.svg
@@ -0,0 +1,3 @@
+
diff --git a/client/assets/icons/hicolor/scalable/apps/lesavka.svg b/client/assets/icons/hicolor/scalable/apps/lesavka.svg
new file mode 100644
index 0000000..2e5fd19
--- /dev/null
+++ b/client/assets/icons/hicolor/scalable/apps/lesavka.svg
@@ -0,0 +1,3 @@
+
diff --git a/client/assets/linux/lesavka.desktop b/client/assets/linux/lesavka.desktop
new file mode 100644
index 0000000..8222e6a
--- /dev/null
+++ b/client/assets/linux/lesavka.desktop
@@ -0,0 +1,11 @@
+[Desktop Entry]
+Version=1.0
+Type=Application
+Name=Lesavka
+Comment=Relay capture, input routing, and preview control deck
+Exec=/usr/local/bin/lesavka
+Icon=lesavka
+Terminal=false
+Categories=Utility;GTK;
+Keywords=relay;remote;capture;kvm;preview;
+StartupNotify=true
diff --git a/client/src/app.rs b/client/src/app.rs
index bb8dcd5..7bdbc74 100644
--- a/client/src/app.rs
+++ b/client/src/app.rs
@@ -5,7 +5,10 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{broadcast, mpsc};
-use tokio_stream::{StreamExt, wrappers::BroadcastStream};
+use tokio_stream::{
+ StreamExt,
+ wrappers::{BroadcastStream, errors::BroadcastStreamRecvError},
+};
use tonic::{Request, transport::Channel};
use tracing::{debug, error, info, trace, warn};
use winit::{
@@ -235,10 +238,13 @@ impl LesavkaClientApp {
}
/*────────── audio renderer & puller ───────────*/
- let audio_out = AudioOut::new()?;
- let ep_audio = vid_ep.clone();
-
- tokio::spawn(Self::audio_loop(ep_audio, audio_out));
+ if std::env::var("LESAVKA_AUDIO_DISABLE").is_err() {
+ let audio_out = AudioOut::new()?;
+ let ep_audio = vid_ep.clone();
+ tokio::spawn(Self::audio_loop(ep_audio, audio_out));
+ } else {
+ info!("🔇 remote audio disabled for this relay session");
+ }
} else {
info!("🧪 headless mode: skipping video/audio renderers");
}
@@ -335,7 +341,8 @@ impl LesavkaClientApp {
info!("⌨️🤙 Keyboard dial {}", self.server_addr);
let mut cli = RelayClient::new(ep.clone());
- let outbound = BroadcastStream::new(self.kbd_tx.subscribe()).filter_map(|r| r.ok());
+ let outbound =
+ BroadcastStream::new(self.kbd_tx.subscribe()).filter_map(keyboard_stream_report);
match cli.stream_keyboard(Request::new(outbound)).await {
Ok(mut resp) => {
@@ -359,7 +366,8 @@ impl LesavkaClientApp {
info!("🖱️🤙 Mouse dial {}", self.server_addr);
let mut cli = RelayClient::new(ep.clone());
- let outbound = BroadcastStream::new(self.mou_tx.subscribe()).filter_map(|r| r.ok());
+ let outbound =
+ BroadcastStream::new(self.mou_tx.subscribe()).filter_map(mouse_stream_report);
match cli.stream_mouse(Request::new(outbound)).await {
Ok(mut resp) => {
@@ -392,6 +400,9 @@ impl LesavkaClientApp {
let req = MonitorRequest {
id: monitor_id,
max_bitrate,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
};
match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => {
@@ -432,6 +443,9 @@ impl LesavkaClientApp {
let req = MonitorRequest {
id: 0,
max_bitrate: 0,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
};
match cli.capture_audio(Request::new(req)).await {
Ok(mut stream) => {
@@ -544,3 +558,33 @@ impl LesavkaClientApp {
}
}
}
+
+pub(crate) fn keyboard_stream_report(
+ report: Result,
+) -> Option {
+ match report {
+ Ok(report) => Some(report),
+ Err(BroadcastStreamRecvError::Lagged(skipped)) => {
+ warn!(
+ skipped,
+ "⌨️ live keyboard stream lagged; sending a clean reset report before continuing"
+ );
+ Some(KeyboardReport { data: vec![0; 8] })
+ }
+ }
+}
+
+pub(crate) fn mouse_stream_report(
+ report: Result,
+) -> Option {
+ match report {
+ Ok(report) => Some(report),
+ Err(BroadcastStreamRecvError::Lagged(skipped)) => {
+ warn!(
+ skipped,
+ "🖱️ live mouse stream lagged; sending a neutral report before continuing"
+ );
+ Some(MouseReport { data: vec![0; 4] })
+ }
+ }
+}
diff --git a/client/src/app_support.rs b/client/src/app_support.rs
index 246c787..defbf95 100644
--- a/client/src/app_support.rs
+++ b/client/src/app_support.rs
@@ -102,6 +102,9 @@ mod tests {
camera_width: Some(1280),
camera_height: Some(720),
camera_fps: Some(25),
+ eye_width: None,
+ eye_height: None,
+ eye_fps: None,
};
let config = camera_config_from_caps(&caps).expect("complete caps should map");
diff --git a/client/src/handshake.rs b/client/src/handshake.rs
index d1ad629..159f154 100644
--- a/client/src/handshake.rs
+++ b/client/src/handshake.rs
@@ -17,6 +17,9 @@ pub struct PeerCaps {
pub camera_width: Option,
pub camera_height: Option,
pub camera_fps: Option,
+ pub eye_width: Option,
+ pub eye_height: Option,
+ pub eye_fps: Option,
}
fn likely_port_typo_hint(uri: &str) -> Option<&'static str> {
@@ -66,6 +69,9 @@ pub async fn negotiate(uri: &str) -> PeerCaps {
camera_width: (rsp.camera_width != 0).then_some(rsp.camera_width),
camera_height: (rsp.camera_height != 0).then_some(rsp.camera_height),
camera_fps: (rsp.camera_fps != 0).then_some(rsp.camera_fps),
+ eye_width: (rsp.eye_width != 0).then_some(rsp.eye_width),
+ eye_height: (rsp.eye_height != 0).then_some(rsp.eye_height),
+ eye_fps: (rsp.eye_fps != 0).then_some(rsp.eye_fps),
}
}
Ok(Err(e)) if e.code() == Code::Unimplemented => PeerCaps::default(),
@@ -144,6 +150,21 @@ pub async fn negotiate(uri: &str) -> PeerCaps {
} else {
Some(rsp.camera_fps)
},
+ eye_width: if rsp.eye_width == 0 {
+ None
+ } else {
+ Some(rsp.eye_width)
+ },
+ eye_height: if rsp.eye_height == 0 {
+ None
+ } else {
+ Some(rsp.eye_height)
+ },
+ eye_fps: if rsp.eye_fps == 0 {
+ None
+ } else {
+ Some(rsp.eye_fps)
+ },
};
info!(?caps, "🤝 handshake ok");
caps
diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs
index fc47134..fb5e728 100644
--- a/client/src/input/camera.rs
+++ b/client/src/input/camera.rs
@@ -89,9 +89,9 @@ impl CameraCapture {
tracing::info!("📸 using MJPG source with software encode");
}
let _enc_opts = if enc == "x264enc" {
- let bitrate_kbit = env_u32("LESAVKA_CAM_H264_KBIT", 2500);
+ let bitrate_kbit = env_u32("LESAVKA_CAM_H264_KBIT", 4500);
format!(
- "{enc} tune=zerolatency speed-preset=veryfast bitrate={bitrate_kbit} {kf_prop}={kf_val}"
+ "{enc} tune=zerolatency speed-preset=faster bitrate={bitrate_kbit} {kf_prop}={kf_val}"
)
} else {
format!("{enc} {kf_prop}={kf_val}")
diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs
index 43e3ba2..8b9753a 100644
--- a/client/src/input/inputs.rs
+++ b/client/src/input/inputs.rs
@@ -32,6 +32,8 @@ pub struct InputAggregator {
paste_tx: Option>,
keyboards: Vec,
mice: Vec,
+ selected_keyboard_path: Option,
+ selected_mouse_path: Option,
capture_remote_boot: bool,
quick_toggle_key: Option,
quick_toggle_down: bool,
@@ -42,6 +44,10 @@ pub struct InputAggregator {
#[cfg(not(coverage))]
routing_control_marker: u128,
#[cfg(not(coverage))]
+ clipboard_control_path: Option,
+ #[cfg(not(coverage))]
+ clipboard_control_marker: u128,
+ #[cfg(not(coverage))]
routing_state_path: Option,
#[cfg(not(coverage))]
published_remote_capture: Option,
@@ -69,6 +75,9 @@ impl InputAggregator {
let routing_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_CONTROL");
#[cfg(not(coverage))]
let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE");
+ #[cfg(not(coverage))]
+ let clipboard_control_path =
+ launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
Self {
kbd_tx,
mou_tx,
@@ -81,6 +90,8 @@ impl InputAggregator {
paste_tx,
keyboards: Vec::new(),
mice: Vec::new(),
+ selected_keyboard_path: input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"),
+ selected_mouse_path: input_device_override_from_env("LESAVKA_MOUSE_DEVICE"),
capture_remote_boot,
quick_toggle_key,
quick_toggle_down: false,
@@ -94,6 +105,13 @@ impl InputAggregator {
#[cfg(not(coverage))]
routing_control_path,
#[cfg(not(coverage))]
+ clipboard_control_marker: clipboard_control_path
+ .as_deref()
+ .map(path_marker)
+ .unwrap_or_default(),
+ #[cfg(not(coverage))]
+ clipboard_control_path,
+ #[cfg(not(coverage))]
routing_state_path,
#[cfg(not(coverage))]
published_remote_capture: None,
@@ -115,6 +133,12 @@ impl InputAggregator {
let _ = dev.set_nonblocking(true);
match classify_device(&dev) {
DeviceKind::Keyboard => {
+ if !matches_selected_input_device(
+ &path,
+ self.selected_keyboard_path.as_deref(),
+ ) {
+ continue;
+ }
let mut aggregator = KeyboardAggregator::new(
dev,
self.dev_mode,
@@ -128,6 +152,12 @@ impl InputAggregator {
self.keyboards.push(aggregator);
}
DeviceKind::Mouse => {
+ if !matches_selected_input_device(
+ &path,
+ self.selected_mouse_path.as_deref(),
+ ) {
+ continue;
+ }
let mut aggregator =
MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
aggregator.set_send(self.capture_remote_boot);
@@ -173,6 +203,10 @@ impl InputAggregator {
match classify_device(&dev) {
DeviceKind::Keyboard => {
+ if !matches_selected_input_device(&path, self.selected_keyboard_path.as_deref())
+ {
+ continue;
+ }
if self.capture_remote_boot {
dev.grab()
.with_context(|| format!("grabbing keyboard {path:?}"))?;
@@ -202,6 +236,9 @@ impl InputAggregator {
continue;
}
DeviceKind::Mouse => {
+ if !matches_selected_input_device(&path, self.selected_mouse_path.as_deref()) {
+ continue;
+ }
if self.capture_remote_boot {
dev.grab()
.with_context(|| format!("grabbing mouse {path:?}"))?;
@@ -300,6 +337,7 @@ impl InputAggregator {
want_kill |= kbd.magic_kill();
}
self.poll_launcher_routing_request();
+ self.poll_launcher_clipboard_request();
let quick_toggle_now = self.quick_toggle_active();
self.observe_quick_toggle(quick_toggle_now);
let magic_now = self.keyboards.iter().any(|k| k.magic_grab());
@@ -428,9 +466,12 @@ impl InputAggregator {
}
}
- fn quick_toggle_active(&self) -> bool {
- self.quick_toggle_key
- .is_some_and(|key| self.keyboards.iter().any(|kbd| kbd.has_key(key)))
+ fn quick_toggle_active(&mut self) -> bool {
+ self.quick_toggle_key.is_some_and(|key| {
+ self.keyboards
+ .iter_mut()
+ .any(|kbd| kbd.take_key_activation(key))
+ })
}
fn observe_quick_toggle(&mut self, quick_toggle_now: bool) {
@@ -479,6 +520,24 @@ impl InputAggregator {
}
}
+ #[cfg(not(coverage))]
+ fn poll_launcher_clipboard_request(&mut self) {
+ let Some(path) = self.clipboard_control_path.as_deref() else {
+ return;
+ };
+ let marker = path_marker(path);
+ if marker <= self.clipboard_control_marker {
+ return;
+ }
+ self.clipboard_control_marker = marker;
+ let Some(keyboard) = self.keyboards.first_mut() else {
+ warn!("📋 launcher requested clipboard paste, but no keyboard is available");
+ return;
+ };
+ info!("📋 launcher requested clipboard paste on the live relay session");
+ keyboard.trigger_clipboard_paste();
+ }
+
#[cfg(not(coverage))]
fn publish_routing_state_if_changed(&mut self) {
let remote_capture = !self.released;
@@ -731,8 +790,8 @@ fn focus_launcher_on_local_if_enabled() {
.unwrap_or_default()
),
);
- let title = std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE")
- .unwrap_or_else(|_| "Lesavka Launcher".to_string());
+ let title =
+ std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE").unwrap_or_else(|_| "Lesavka".to_string());
let _ = std::process::Command::new("wmctrl")
.args(["-a", &title])
.status();
@@ -746,6 +805,17 @@ fn launcher_routing_path_from_env(key: &str) -> Option {
.filter(|path| !path.as_os_str().is_empty())
}
+fn input_device_override_from_env(key: &str) -> Option {
+ std::env::var(key)
+ .ok()
+ .map(|raw| raw.trim().to_string())
+ .filter(|raw| !raw.is_empty() && !raw.eq_ignore_ascii_case("all"))
+}
+
+fn matches_selected_input_device(path: &std::path::Path, selected: Option<&str>) -> bool {
+ selected.is_none_or(|selected| path.to_string_lossy() == selected)
+}
+
#[cfg(not(coverage))]
fn read_launcher_routing_request(path: &Path) -> Option {
let raw = std::fs::read_to_string(path).ok()?;
diff --git a/client/src/input/keyboard.rs b/client/src/input/keyboard.rs
index ae5cc2d..b7e001b 100644
--- a/client/src/input/keyboard.rs
+++ b/client/src/input/keyboard.rs
@@ -26,6 +26,7 @@ pub struct KeyboardAggregator {
paste_chord_armed: bool,
paste_chord_consumed: bool,
pressed_keys: HashSet,
+ recent_key_presses: HashSet,
}
/*───────── helpers ───────────────────────────────────────────────────*/
@@ -34,10 +35,10 @@ static SEQ: AtomicU32 = AtomicU32::new(0);
static LAST_PASTE_MS: AtomicU64 = AtomicU64::new(0);
fn update_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) {
- if value == 1 {
- pressed_keys.insert(code);
- } else {
+ if value == 0 {
pressed_keys.remove(&code);
+ } else if value > 0 {
+ pressed_keys.insert(code);
}
}
@@ -73,6 +74,7 @@ impl KeyboardAggregator {
paste_chord_armed: false,
paste_chord_consumed: false,
pressed_keys: HashSet::new(),
+ recent_key_presses: HashSet::new(),
}
}
@@ -94,6 +96,7 @@ impl KeyboardAggregator {
#[cfg(coverage)]
pub fn process_events(&mut self) {
+ self.recent_key_presses.clear();
let Ok(events) = self
.dev
.fetch_events()
@@ -109,18 +112,21 @@ impl KeyboardAggregator {
let code = KeyCode::new(ev.code());
let value = ev.value();
update_pressed_keys(&mut self.pressed_keys, code, value);
+ if value == 1 {
+ self.recent_key_presses.insert(code);
+ }
let swallowed = self.try_handle_paste_event(code, value);
- if !swallowed && !self.sending_disabled {
- let _ = self.tx.send(KeyboardReport {
- data: self.build_report().to_vec(),
- });
+ if !swallowed {
+ let report = self.build_report();
+ self.emit_live_report(code, value, report);
}
}
}
#[cfg(not(coverage))]
pub fn process_events(&mut self) {
+ self.recent_key_presses.clear();
// --- first fetch, then log (avoids aliasing borrow) ---
let events: Vec = match self.dev.fetch_events() {
Ok(it) => it.collect(),
@@ -148,6 +154,9 @@ impl KeyboardAggregator {
let code = KeyCode::new(ev.code());
let value = ev.value();
update_pressed_keys(&mut self.pressed_keys, code, value);
+ if value == 1 {
+ self.recent_key_presses.insert(code);
+ }
if self.try_handle_paste_event(code, value) {
continue;
@@ -159,11 +168,7 @@ impl KeyboardAggregator {
if self.dev_mode {
debug!(seq = id, ?report, "kbd");
}
- if !self.sending_disabled {
- let _ = self.tx.send(KeyboardReport {
- data: report.to_vec(),
- });
- }
+ self.emit_live_report(code, value, report);
}
}
@@ -193,6 +198,10 @@ impl KeyboardAggregator {
self.pressed_keys.contains(&kc)
}
+ pub fn take_key_activation(&mut self, kc: KeyCode) -> bool {
+ self.has_key(kc) || self.recent_key_presses.remove(&kc)
+ }
+
pub fn pressed_keys_snapshot(&self) -> Vec {
self.pressed_keys.iter().copied().collect()
}
@@ -221,10 +230,12 @@ impl KeyboardAggregator {
pub fn reset_state(&mut self) {
if self.pressed_keys.is_empty() {
+ self.recent_key_presses.clear();
self.send_empty_report();
return;
}
self.pressed_keys.clear();
+ self.recent_key_presses.clear();
self.send_empty_report();
}
@@ -237,6 +248,17 @@ impl KeyboardAggregator {
});
}
+ fn emit_live_report(&self, code: KeyCode, value: i32, report: [u8; 8]) {
+ if should_stage_modifier_report(code, value, report) {
+ self.send_report(modifier_only_report(report[0]));
+ let delay = live_modifier_delay();
+ if !delay.is_zero() {
+ std::thread::sleep(delay);
+ }
+ }
+ self.send_report(report);
+ }
+
#[cfg(coverage)]
fn try_handle_paste_event(&mut self, code: KeyCode, value: i32) -> bool {
if self.paste_chord_consumed {
@@ -406,7 +428,11 @@ impl KeyboardAggregator {
.unwrap_or(8);
let delay = Duration::from_millis(delay_ms);
- tracing::info!("📋 pasting {} chars", text.chars().count().min(max));
+ tracing::info!(
+ "📋 pasting {} chars over HID with {}ms inter-report delay",
+ text.chars().count().min(max),
+ delay_ms
+ );
for c in text.chars().take(max) {
let mut reports = Vec::with_capacity(4);
@@ -433,21 +459,34 @@ impl KeyboardAggregator {
};
tx.send(text).is_ok()
}
+
+ pub fn trigger_clipboard_paste(&mut self) {
+ if !self.paste_enabled {
+ tracing::warn!(
+ "📋 launcher requested clipboard paste, but clipboard paste is disabled"
+ );
+ return;
+ }
+ self.paste_chord_armed = false;
+ self.paste_chord_consumed = false;
+ if self.paste_rpc_enabled && self.paste_via_rpc() {
+ tracing::info!("📋 clipboard paste forwarded through paste RPC");
+ return;
+ }
+ tracing::info!("📋 clipboard paste falling back to live HID typing");
+ self.paste_clipboard();
+ }
}
fn paste_rpc_enabled_from_env() -> bool {
let rpc_enabled = std::env::var("LESAVKA_PASTE_RPC")
.map(|v| v != "0")
.unwrap_or(true);
- let have_key = std::env::var("LESAVKA_PASTE_KEY")
- .map(|v| !v.trim().is_empty())
- .unwrap_or(false);
+ let have_key = paste_key_available_from_env();
let enabled = paste_rpc_enabled(rpc_enabled, have_key);
#[cfg(not(coverage))]
if rpc_enabled && !have_key {
- tracing::info!(
- "📋 LESAVKA_PASTE_KEY missing; disabling paste RPC and using HID paste fallback"
- );
+ tracing::info!("📋 paste key missing; disabling paste RPC and using HID paste fallback");
}
enabled
}
@@ -456,6 +495,23 @@ fn paste_rpc_enabled(rpc_enabled: bool, have_key: bool) -> bool {
rpc_enabled && have_key
}
+fn paste_key_available_from_env() -> bool {
+ if std::env::var("LESAVKA_PASTE_KEY")
+ .map(|value| !value.trim().is_empty())
+ .unwrap_or(false)
+ {
+ return true;
+ }
+ if let Ok(path) = std::env::var("LESAVKA_PASTE_KEY_FILE") {
+ return std::path::Path::new(path.trim()).is_file();
+ }
+ std::env::var_os("HOME").is_some_and(|home| {
+ let mut path = std::path::PathBuf::from(home);
+ path.push(".config/lesavka/paste-key");
+ path.is_file()
+ })
+}
+
fn is_paste_modifier(code: KeyCode) -> bool {
matches!(
code,
@@ -466,6 +522,25 @@ fn is_paste_modifier(code: KeyCode) -> bool {
)
}
+fn should_stage_modifier_report(code: KeyCode, value: i32, report: [u8; 8]) -> bool {
+ value == 1
+ && is_modifier(code).is_none()
+ && report[0] != 0
+ && report[2..].iter().any(|b| *b != 0)
+}
+
+fn modifier_only_report(modifiers: u8) -> [u8; 8] {
+ [modifiers, 0, 0, 0, 0, 0, 0, 0]
+}
+
+fn live_modifier_delay() -> Duration {
+ std::env::var("LESAVKA_LIVE_MODIFIER_DELAY_MS")
+ .ok()
+ .and_then(|value| value.parse::().ok())
+ .map(Duration::from_millis)
+ .unwrap_or_else(|| Duration::from_millis(24))
+}
+
#[cfg(coverage)]
fn read_clipboard_text() -> Option {
if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") {
@@ -549,8 +624,9 @@ impl Drop for KeyboardAggregator {
#[cfg(test)]
mod tests {
- use super::{is_paste_modifier, paste_rpc_enabled};
+ use super::{is_paste_modifier, paste_key_available_from_env, paste_rpc_enabled};
use evdev::KeyCode;
+ use tempfile::tempdir;
#[test]
fn paste_rpc_disabled_when_env_off() {
@@ -568,6 +644,26 @@ mod tests {
assert!(paste_rpc_enabled(true, true));
}
+ #[test]
+ fn paste_key_detection_accepts_explicit_key_file() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("paste-key");
+ std::fs::write(
+ &path,
+ "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
+ )
+ .expect("write key file");
+ temp_env::with_vars(
+ [
+ ("LESAVKA_PASTE_KEY", None::<&str>),
+ ("LESAVKA_PASTE_KEY_FILE", path.to_str()),
+ ],
+ || {
+ assert!(paste_key_available_from_env());
+ },
+ );
+ }
+
#[test]
fn paste_modifier_recognizes_ctrl_alt_only() {
assert!(is_paste_modifier(KeyCode::KEY_LEFTCTRL));
diff --git a/client/src/launcher/clipboard.rs b/client/src/launcher/clipboard.rs
index b4dd396..3551e4f 100644
--- a/client/src/launcher/clipboard.rs
+++ b/client/src/launcher/clipboard.rs
@@ -7,17 +7,14 @@ use {
crate::paste,
async_stream::stream,
lesavka_common::lesavka::relay_client::RelayClient,
- std::process::Command,
tokio::runtime::Builder as RuntimeBuilder,
tonic::{Request, transport::Channel},
};
#[cfg(not(coverage))]
-/// Deliver the local clipboard to the remote side, preferring the encrypted
-/// paste RPC and falling back to direct HID keyboard reports when the shared
-/// key is unavailable.
-pub fn send_clipboard_to_remote(server_addr: &str) -> Result {
- let text = read_clipboard_text().ok_or_else(|| anyhow!("clipboard is empty or unavailable"))?;
+/// Deliver already-captured clipboard text to the remote side, preferring the
+/// encrypted paste RPC and falling back to direct HID keyboard reports.
+pub fn send_clipboard_text_to_remote(server_addr: &str, text: &str) -> Result {
match send_clipboard_via_rpc(server_addr, &text) {
Ok(()) => Ok("Clipboard delivered to remote".to_string()),
Err(rpc_err) => match send_clipboard_via_hid(server_addr, &text) {
@@ -34,13 +31,19 @@ pub fn send_clipboard_to_remote(server_addr: &str) -> Result {
/// configured for encrypted clipboard delivery.
fn send_clipboard_via_rpc(server_addr: &str, text: &str) -> Result<()> {
let req = paste::build_paste_request(text)?;
+ let timeout = clipboard_transport_timeout();
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
rt.block_on(async {
- let channel = Channel::from_shared(server_addr.to_string())?
- .connect()
- .await?;
+ let channel = tokio::time::timeout(
+ timeout,
+ Channel::from_shared(server_addr.to_string())?.connect(),
+ )
+ .await
+ .map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??;
let mut cli = RelayClient::new(channel);
- let reply = cli.paste_text(Request::new(req)).await?;
+ let reply = tokio::time::timeout(timeout, cli.paste_text(Request::new(req)))
+ .await
+ .map_err(|_| anyhow!("timed out waiting for paste RPC reply after {:?}", timeout))??;
if reply.get_ref().ok {
Ok(())
} else {
@@ -56,11 +59,12 @@ fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> {
let reports = build_hid_paste_reports(text)?;
let delay = clipboard_hid_delay();
let report_count = reports.len();
+ let timeout = clipboard_transport_timeout();
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
rt.block_on(async {
- let channel = Channel::from_shared(server_addr.to_string())?
- .connect()
- .await?;
+ let channel = tokio::time::timeout(timeout, Channel::from_shared(server_addr.to_string())?.connect())
+ .await
+ .map_err(|_| anyhow!("timed out connecting keyboard fallback stream after {:?}", timeout))??;
let mut cli = RelayClient::new(channel);
let outbound = stream! {
for report in reports {
@@ -70,13 +74,30 @@ fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> {
}
}
};
- let mut resp = cli.stream_keyboard(Request::new(outbound)).await?;
+ let mut resp = tokio::time::timeout(timeout, cli.stream_keyboard(Request::new(outbound)))
+ .await
+ .map_err(|_| anyhow!("timed out opening keyboard fallback stream after {:?}", timeout))??;
let mut echoed = 0usize;
- while let Some(item) = resp.get_mut().message().await.transpose() {
- item?;
- echoed += 1;
- if echoed >= report_count {
- break;
+ let deadline = tokio::time::Instant::now() + timeout;
+ while echoed < report_count {
+ let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
+ if remaining.is_zero() {
+ anyhow::bail!(
+ "timed out waiting for keyboard fallback acknowledgement after {echoed}/{report_count} reports"
+ );
+ }
+ match tokio::time::timeout(remaining, resp.get_mut().message()).await {
+ Ok(Ok(Some(item))) => {
+ let _ = item;
+ echoed += 1;
+ }
+ Ok(Ok(None)) => break,
+ Ok(Err(err)) => return Err(err.into()),
+ Err(_) => {
+ anyhow::bail!(
+ "timed out waiting for keyboard fallback acknowledgement after {echoed}/{report_count} reports"
+ );
+ }
}
}
Ok(())
@@ -116,44 +137,19 @@ fn clipboard_hid_delay() -> Duration {
Duration::from_millis(delay_ms)
}
-/// Read the local clipboard and drop trailing file-copy newlines so password
-/// pastes do not accidentally submit an extra Enter.
-#[cfg(not(coverage))]
-fn read_clipboard_text() -> Option {
- if let Ok(out) = Command::new("sh")
- .arg("-lc")
- .arg(std::env::var("LESAVKA_CLIPBOARD_CMD").unwrap_or_else(
- |_| "wl-paste --no-newline --type text/plain || xclip -selection clipboard -o || xsel -b -o".to_string(),
- ))
- .output()
- && out.status.success()
- {
- let text = trim_clipboard_text(String::from_utf8_lossy(&out.stdout).to_string());
- if !text.is_empty() {
- return Some(text);
- }
- }
- None
-}
-
-/// Trim trailing clipboard newlines so pasted passwords do not gain a
-/// synthetic submit keystroke.
-fn trim_clipboard_text(text: String) -> String {
- text.trim_end_matches(['\r', '\n']).to_string()
+fn clipboard_transport_timeout() -> Duration {
+ let timeout_ms = std::env::var("LESAVKA_CLIPBOARD_TIMEOUT_MS")
+ .ok()
+ .and_then(|value| value.parse::().ok())
+ .unwrap_or(6_000);
+ Duration::from_millis(timeout_ms)
}
#[cfg(test)]
mod tests {
- use super::{build_hid_paste_reports, clipboard_hid_delay, trim_clipboard_text};
+ use super::{build_hid_paste_reports, clipboard_hid_delay, clipboard_transport_timeout};
use std::time::Duration;
- #[test]
- fn trim_clipboard_text_strips_trailing_newlines() {
- assert_eq!(trim_clipboard_text("secret\n".to_string()), "secret");
- assert_eq!(trim_clipboard_text("secret\r\n".to_string()), "secret");
- assert_eq!(trim_clipboard_text("secret".to_string()), "secret");
- }
-
#[test]
fn build_hid_paste_reports_emits_press_and_release_pairs() {
let reports = build_hid_paste_reports("Az").expect("hid reports");
@@ -174,4 +170,9 @@ mod tests {
fn clipboard_hid_delay_has_stable_default() {
assert_eq!(clipboard_hid_delay(), Duration::from_millis(18));
}
+
+ #[test]
+ fn clipboard_transport_timeout_has_stable_default() {
+ assert_eq!(clipboard_transport_timeout(), Duration::from_millis(6_000));
+ }
}
diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs
index 890f887..03013a7 100644
--- a/client/src/launcher/device_test.rs
+++ b/client/src/launcher/device_test.rs
@@ -5,6 +5,7 @@ use gstreamer_app as gst_app;
use gtk::{gdk, glib};
use shell_escape::escape;
use std::borrow::Cow;
+use std::fs;
use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@ -13,20 +14,46 @@ use std::time::Duration;
const CAMERA_PREVIEW_WIDTH: i32 = 360;
const CAMERA_PREVIEW_HEIGHT: i32 = 202;
const CAMERA_PREVIEW_IDLE: &str = "Select a camera and click Start Preview.";
+const MIC_MONITOR_RATE: i32 = 16_000;
+const MIC_MONITOR_CHANNELS: i32 = 1;
+const MIC_MONITOR_SAMPLE_BYTES: usize = 2;
+const MIC_REPLAY_SECONDS: usize = 3;
+const MIC_REPLAY_PATH: &str = "/tmp/lesavka-mic-replay.wav";
+const MIC_REPLAY_MAX_BYTES: usize = MIC_MONITOR_RATE as usize
+ * MIC_MONITOR_CHANNELS as usize
+ * MIC_MONITOR_SAMPLE_BYTES
+ * MIC_REPLAY_SECONDS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceTestKind {
Camera,
Microphone,
+ MicrophoneReplay,
Speaker,
}
-#[derive(Default)]
pub struct DeviceTestController {
camera: Option,
selected_camera: Option,
- microphone: Option,
+ microphone: Option,
speaker: Option,
+ microphone_replay: Option,
+ microphone_buffer: Arc>>,
+ microphone_level: Arc>,
+}
+
+impl Default for DeviceTestController {
+ fn default() -> Self {
+ Self {
+ camera: None,
+ selected_camera: None,
+ microphone: None,
+ speaker: None,
+ microphone_replay: None,
+ microphone_buffer: Arc::new(Mutex::new(Vec::new())),
+ microphone_level: Arc::new(Mutex::new(0.0)),
+ }
+ }
}
impl DeviceTestController {
@@ -55,7 +82,11 @@ impl DeviceTestController {
.camera
.as_ref()
.is_some_and(LocalCameraPreview::is_running),
- DeviceTestKind::Microphone => self.microphone.is_some(),
+ DeviceTestKind::Microphone => self
+ .microphone
+ .as_ref()
+ .is_some_and(LocalMicrophoneMonitor::is_running),
+ DeviceTestKind::MicrophoneReplay => self.microphone_replay.is_some(),
DeviceTestKind::Speaker => self.speaker.is_some(),
}
}
@@ -77,26 +108,72 @@ impl DeviceTestController {
}
pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result {
- self.toggle(
- DeviceTestKind::Microphone,
- build_microphone_test(source, sink),
- )
+ self.cleanup_finished();
+ if self.microphone.is_some() {
+ self.stop(DeviceTestKind::Microphone);
+ return Ok(false);
+ }
+
+ let monitor = LocalMicrophoneMonitor::start(
+ source,
+ sink,
+ Arc::clone(&self.microphone_buffer),
+ Arc::clone(&self.microphone_level),
+ )?;
+ self.microphone = Some(monitor);
+ Ok(true)
}
pub fn toggle_speaker(&mut self, sink: Option<&str>) -> Result {
- self.toggle(DeviceTestKind::Speaker, build_speaker_test(sink))
+ self.toggle_child(DeviceTestKind::Speaker, build_speaker_test(sink))
+ }
+
+ pub fn toggle_microphone_replay(&mut self, sink: Option<&str>) -> Result {
+ self.cleanup_finished();
+ if self.microphone_replay.is_some() {
+ self.stop(DeviceTestKind::MicrophoneReplay);
+ return Ok(false);
+ }
+
+ let wav_bytes = self.replay_wav_bytes()?;
+ fs::write(MIC_REPLAY_PATH, wav_bytes).context("writing microphone replay clip")?;
+ let child = build_microphone_replay_test(MIC_REPLAY_PATH, sink)?
+ .spawn()
+ .context("starting microphone replay")?;
+ self.microphone_replay = Some(child);
+ Ok(true)
+ }
+
+ pub fn microphone_level_fraction(&mut self) -> f64 {
+ self.cleanup_finished();
+ self.microphone_level
+ .lock()
+ .map(|value| (*value).clamp(0.0, 1.0))
+ .unwrap_or(0.0)
+ }
+
+ pub fn microphone_replay_ready(&mut self) -> bool {
+ self.cleanup_finished();
+ self.microphone_buffer
+ .lock()
+ .map(|buffer| !buffer.is_empty())
+ .unwrap_or(false)
}
pub fn stop_all(&mut self) {
if let Some(camera) = self.camera.as_mut() {
camera.stop();
}
- for kind in [DeviceTestKind::Microphone, DeviceTestKind::Speaker] {
+ for kind in [
+ DeviceTestKind::Microphone,
+ DeviceTestKind::MicrophoneReplay,
+ DeviceTestKind::Speaker,
+ ] {
self.stop(kind);
}
}
- fn toggle(&mut self, kind: DeviceTestKind, command: Result) -> Result {
+ fn toggle_child(&mut self, kind: DeviceTestKind, command: Result) -> Result {
self.cleanup_finished();
if self.slot(kind).is_some() {
self.stop(kind);
@@ -110,14 +187,34 @@ impl DeviceTestController {
}
fn stop(&mut self, kind: DeviceTestKind) {
- if let Some(mut child) = self.slot_mut(kind).take() {
- let _ = child.kill();
- let _ = child.wait();
+ match kind {
+ DeviceTestKind::Camera => panic!("camera preview is not stopped through this path"),
+ DeviceTestKind::Microphone => {
+ if let Some(mut monitor) = self.microphone.take() {
+ monitor.stop();
+ }
+ if let Ok(mut level) = self.microphone_level.lock() {
+ *level = 0.0;
+ }
+ }
+ DeviceTestKind::MicrophoneReplay | DeviceTestKind::Speaker => {
+ 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::Microphone, DeviceTestKind::Speaker] {
+ if self
+ .microphone
+ .as_mut()
+ .is_some_and(|monitor| !monitor.is_running())
+ {
+ self.microphone = None;
+ }
+ for kind in [DeviceTestKind::MicrophoneReplay, DeviceTestKind::Speaker] {
let finished = self
.slot_mut(kind)
.as_mut()
@@ -132,19 +229,42 @@ impl DeviceTestController {
fn slot(&self, kind: DeviceTestKind) -> &Option {
match kind {
- DeviceTestKind::Camera => panic!("camera preview is not an external child process"),
- DeviceTestKind::Microphone => &self.microphone,
+ DeviceTestKind::Camera | DeviceTestKind::Microphone => {
+ panic!("this device test is not an external child process")
+ }
+ DeviceTestKind::MicrophoneReplay => &self.microphone_replay,
DeviceTestKind::Speaker => &self.speaker,
}
}
fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option {
match kind {
- DeviceTestKind::Camera => panic!("camera preview is not an external child process"),
- DeviceTestKind::Microphone => &mut self.microphone,
+ DeviceTestKind::Camera | DeviceTestKind::Microphone => {
+ panic!("this device test is not an external child process")
+ }
+ DeviceTestKind::MicrophoneReplay => &mut self.microphone_replay,
DeviceTestKind::Speaker => &mut self.speaker,
}
}
+
+ fn replay_wav_bytes(&self) -> Result> {
+ let audio = self
+ .microphone_buffer
+ .lock()
+ .map_err(|_| anyhow!("microphone replay buffer is unavailable right now"))?
+ .clone();
+ if audio.is_empty() {
+ return Err(anyhow!(
+ "Monitor Mic long enough to capture audio before replaying the last 3 seconds."
+ ));
+ }
+ Ok(build_wav_bytes(
+ &audio,
+ MIC_MONITOR_RATE as u32,
+ MIC_MONITOR_CHANNELS as u16,
+ 16,
+ ))
+ }
}
struct LocalCameraPreview {
@@ -155,6 +275,11 @@ struct LocalCameraPreview {
selected_device: Option,
}
+struct LocalMicrophoneMonitor {
+ running: Arc,
+ generation: Arc,
+}
+
struct PreviewFrame {
width: i32,
height: i32,
@@ -169,6 +294,8 @@ impl LocalCameraPreview {
let generation = Arc::new(AtomicU64::new(0));
let running = Arc::new(AtomicBool::new(false));
+ picture.set_paintable(Some(&blank_camera_preview_texture()));
+
{
let picture = picture.clone();
let status_label = status_label.clone();
@@ -290,6 +417,74 @@ impl LocalCameraPreview {
}
}
+fn blank_camera_preview_texture() -> gdk::MemoryTexture {
+ let rgba = vec![12_u8; (CAMERA_PREVIEW_WIDTH * CAMERA_PREVIEW_HEIGHT * 4) as usize];
+ let bytes = glib::Bytes::from_owned(rgba);
+ gdk::MemoryTexture::new(
+ CAMERA_PREVIEW_WIDTH,
+ CAMERA_PREVIEW_HEIGHT,
+ gdk::MemoryFormat::R8g8b8a8,
+ &bytes,
+ (CAMERA_PREVIEW_WIDTH * 4) as usize,
+ )
+}
+
+impl LocalMicrophoneMonitor {
+ fn start(
+ source: Option<&str>,
+ sink: Option<&str>,
+ recent_audio: Arc>>,
+ level: Arc>,
+ ) -> Result {
+ gst::init().context("initialising microphone preview")?;
+ let source = source
+ .filter(|value| !value.trim().is_empty())
+ .ok_or_else(|| anyhow!("select a microphone before starting Monitor Mic"))?
+ .to_string();
+ let sink = sink
+ .filter(|value| !value.trim().is_empty())
+ .map(ToOwned::to_owned);
+ if let Ok(mut buffer) = recent_audio.lock() {
+ buffer.clear();
+ }
+ if let Ok(mut meter) = level.lock() {
+ *meter = 0.0;
+ }
+
+ let running = Arc::new(AtomicBool::new(true));
+ let generation = Arc::new(AtomicU64::new(1));
+ let running_handle = Arc::clone(&running);
+ let generation_handle = Arc::clone(&generation);
+ let token = generation.load(Ordering::Acquire);
+
+ std::thread::spawn(move || {
+ let _ = run_microphone_monitor_feed(
+ &source,
+ sink.as_deref(),
+ token,
+ recent_audio,
+ level,
+ generation_handle,
+ running_handle,
+ );
+ });
+
+ Ok(Self {
+ running,
+ generation,
+ })
+ }
+
+ fn is_running(&self) -> bool {
+ self.running.load(Ordering::Acquire)
+ }
+
+ fn stop(&mut self) {
+ self.running.store(false, Ordering::Release);
+ self.generation.fetch_add(1, Ordering::AcqRel);
+ }
+}
+
fn normalize_camera_selection(camera: Option<&str>) -> Option {
camera
.map(str::trim)
@@ -305,6 +500,42 @@ fn resolve_camera_device(camera: &str) -> String {
}
}
+fn run_microphone_monitor_feed(
+ source: &str,
+ sink: Option<&str>,
+ token: u64,
+ recent_audio: Arc>>,
+ level: Arc>,
+ generation: Arc,
+ running: Arc,
+) -> Result<()> {
+ let (pipeline, appsink) = build_microphone_monitor_pipeline(source, sink)?;
+ pipeline
+ .set_state(gst::State::Playing)
+ .context("starting microphone preview pipeline")?;
+
+ while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
+ if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) {
+ if let Some(buffer) = sample.buffer()
+ && let Ok(map) = buffer.map_readable()
+ {
+ let bytes = map.as_slice();
+ push_recent_audio(&recent_audio, bytes);
+ update_microphone_level(&level, bytes);
+ }
+ } else if let Ok(mut meter) = level.lock() {
+ *meter = (*meter * 0.8).clamp(0.0, 1.0);
+ }
+ }
+
+ let _ = pipeline.set_state(gst::State::Null);
+ if let Ok(mut meter) = level.lock() {
+ *meter = 0.0;
+ }
+ running.store(false, Ordering::Release);
+ Ok(())
+}
+
fn run_camera_preview_feed(
selected: String,
device: String,
@@ -356,6 +587,29 @@ fn build_camera_preview_pipeline(device: &str) -> Result<(gst::Pipeline, gst_app
Ok((pipeline, appsink))
}
+fn build_microphone_monitor_pipeline(
+ source: &str,
+ sink: Option<&str>,
+) -> Result<(gst::Pipeline, gst_app::AppSink)> {
+ let desc = microphone_monitor_pipeline_desc(source, sink);
+ let pipeline = gst::parse::launch(&desc)?
+ .downcast::()
+ .expect("microphone monitor pipeline");
+ let appsink = pipeline
+ .by_name("mic_preview_sink")
+ .context("missing microphone preview appsink")?
+ .downcast::()
+ .expect("microphone preview appsink");
+ appsink.set_caps(Some(
+ &gst::Caps::builder("audio/x-raw")
+ .field("format", "S16LE")
+ .field("rate", MIC_MONITOR_RATE)
+ .field("channels", MIC_MONITOR_CHANNELS)
+ .build(),
+ ));
+ Ok((pipeline, appsink))
+}
+
fn camera_preview_pipeline_desc(device: &str) -> String {
let device = gst_quote(device);
format!(
@@ -366,6 +620,22 @@ fn camera_preview_pipeline_desc(device: &str) -> String {
)
}
+fn microphone_monitor_pipeline_desc(source: &str, sink: Option<&str>) -> String {
+ let source = gst_quote(source);
+ let sink_prop = sink
+ .map(gst_quote)
+ .map(|value| format!(" device=\"{value}\""))
+ .unwrap_or_default();
+ format!(
+ "pulsesrc device=\"{source}\" ! \
+ audioconvert ! audioresample ! \
+ audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \
+ tee name=t \
+ t. ! queue ! pulsesink{sink_prop} \
+ t. ! queue ! appsink name=mic_preview_sink emit-signals=false sync=false max-buffers=8 drop=true"
+ )
+}
+
fn sample_to_frame(sample: &gst::Sample) -> Option {
let caps = sample.caps()?;
let structure = caps.structure(0)?;
@@ -387,21 +657,6 @@ fn gst_quote(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
-fn build_microphone_test(source: Option<&str>, sink: Option<&str>) -> Result {
- let source = source
- .filter(|value| !value.trim().is_empty())
- .ok_or_else(|| 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 {
let sink_prop = sink
.filter(|value| !value.trim().is_empty())
@@ -413,6 +668,18 @@ fn build_speaker_test(sink: Option<&str>) -> Result {
)))
}
+fn build_microphone_replay_test(path: &str, sink: Option<&str>) -> Result {
+ 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 filesrc location={} ! wavparse ! audioconvert ! audioresample ! queue ! pulsesink {}",
+ quote(path),
+ sink_prop
+ )))
+}
+
fn shell_command(command: String) -> Command {
let mut child = Command::new("bash");
child.args(["-lc", &command]);
@@ -423,9 +690,58 @@ fn quote(value: impl Into) -> String {
escape(Cow::Owned(value.into())).into_owned()
}
+fn push_recent_audio(buffer: &Arc>>, bytes: &[u8]) {
+ if let Ok(mut ring) = buffer.lock() {
+ ring.extend_from_slice(bytes);
+ if ring.len() > MIC_REPLAY_MAX_BYTES {
+ let overflow = ring.len() - MIC_REPLAY_MAX_BYTES;
+ ring.drain(0..overflow);
+ }
+ }
+}
+
+fn update_microphone_level(level: &Arc>, bytes: &[u8]) {
+ let peak = bytes
+ .chunks_exact(2)
+ .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]).unsigned_abs() as f64)
+ .fold(0.0, f64::max)
+ / i16::MAX as f64;
+ if let Ok(mut meter) = level.lock() {
+ *meter = peak.clamp(0.0, 1.0);
+ }
+}
+
+fn build_wav_bytes(audio: &[u8], sample_rate: u32, channels: u16, bits_per_sample: u16) -> Vec {
+ let block_align = channels * (bits_per_sample / 8);
+ let byte_rate = sample_rate * block_align as u32;
+ let data_len = audio.len() as u32;
+ let riff_len = 36 + data_len;
+
+ let mut wav = Vec::with_capacity(44 + audio.len());
+ wav.extend_from_slice(b"RIFF");
+ wav.extend_from_slice(&riff_len.to_le_bytes());
+ wav.extend_from_slice(b"WAVE");
+ wav.extend_from_slice(b"fmt ");
+ wav.extend_from_slice(&16u32.to_le_bytes());
+ wav.extend_from_slice(&1u16.to_le_bytes());
+ wav.extend_from_slice(&channels.to_le_bytes());
+ wav.extend_from_slice(&sample_rate.to_le_bytes());
+ wav.extend_from_slice(&byte_rate.to_le_bytes());
+ wav.extend_from_slice(&block_align.to_le_bytes());
+ wav.extend_from_slice(&bits_per_sample.to_le_bytes());
+ wav.extend_from_slice(b"data");
+ wav.extend_from_slice(&data_len.to_le_bytes());
+ wav.extend_from_slice(audio);
+ wav
+}
+
#[cfg(test)]
mod tests {
- use super::{camera_preview_pipeline_desc, normalize_camera_selection, resolve_camera_device};
+ use super::{
+ MIC_REPLAY_MAX_BYTES, build_wav_bytes, camera_preview_pipeline_desc,
+ normalize_camera_selection, push_recent_audio, resolve_camera_device,
+ };
+ use std::sync::{Arc, Mutex};
#[test]
fn resolve_camera_device_accepts_explicit_paths_and_catalog_names() {
@@ -454,4 +770,24 @@ mod tests {
assert!(desc.contains("videoconvert ! videoscale ! videorate !"));
assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,"));
}
+
+ #[test]
+ fn push_recent_audio_keeps_only_last_three_seconds() {
+ let buffer = Arc::new(Mutex::new(Vec::new()));
+ push_recent_audio(&buffer, &vec![1u8; MIC_REPLAY_MAX_BYTES / 2]);
+ push_recent_audio(&buffer, &vec![2u8; MIC_REPLAY_MAX_BYTES]);
+ let stored = buffer.lock().expect("buffer").clone();
+ assert_eq!(stored.len(), MIC_REPLAY_MAX_BYTES);
+ assert!(stored.iter().any(|byte| *byte == 2));
+ }
+
+ #[test]
+ fn build_wav_bytes_writes_a_valid_riff_header() {
+ let audio = vec![0u8; 32];
+ let wav = build_wav_bytes(&audio, 16_000, 1, 16);
+ assert!(wav.starts_with(b"RIFF"));
+ assert_eq!(&wav[8..12], b"WAVE");
+ assert_eq!(&wav[36..40], b"data");
+ assert_eq!(wav.len(), 44 + audio.len());
+ }
}
diff --git a/client/src/launcher/devices.rs b/client/src/launcher/devices.rs
index 1ba528f..da4b6c2 100644
--- a/client/src/launcher/devices.rs
+++ b/client/src/launcher/devices.rs
@@ -1,10 +1,14 @@
use std::collections::BTreeSet;
+use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeviceCatalog {
pub cameras: Vec,
pub microphones: Vec,
pub speakers: Vec,
+ pub keyboards: Vec,
+ pub mice: Vec,
}
impl DeviceCatalog {
@@ -13,17 +17,25 @@ impl DeviceCatalog {
}
pub fn is_empty(&self) -> bool {
- self.cameras.is_empty() && self.microphones.is_empty() && self.speakers.is_empty()
+ self.cameras.is_empty()
+ && self.microphones.is_empty()
+ && self.speakers.is_empty()
+ && self.keyboards.is_empty()
+ && self.mice.is_empty()
}
fn discover_with_camera_override(override_dir: Option) -> Self {
let cameras = discover_camera_devices(override_dir);
let microphones = discover_pactl_devices("sources");
let speakers = discover_pactl_devices("sinks");
+ let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
+ let mice = discover_input_devices(InputDeviceKind::Mouse);
Self {
cameras,
microphones,
speakers,
+ keyboards,
+ mice,
}
}
}
@@ -72,6 +84,70 @@ pub fn parse_pactl_short(stdout: &str) -> Vec {
set.into_iter().collect()
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum InputDeviceKind {
+ Keyboard,
+ Mouse,
+}
+
+fn discover_input_devices(kind: InputDeviceKind) -> Vec {
+ let Ok(iter) = std::fs::read_dir("/dev/input") else {
+ return Vec::new();
+ };
+
+ let mut set = BTreeSet::new();
+ for entry in iter.flatten() {
+ let path = entry.path();
+ if !path
+ .file_name()
+ .is_some_and(|name| name.to_string_lossy().starts_with("event"))
+ {
+ continue;
+ }
+ let Ok(device) = Device::open(&path) else {
+ continue;
+ };
+ if classify_input_device(&device) == Some(kind) {
+ set.insert(path.to_string_lossy().to_string());
+ }
+ }
+
+ set.into_iter().collect()
+}
+
+fn classify_input_device(device: &Device) -> Option {
+ let events = device.supported_events();
+
+ if events.contains(EventType::KEY)
+ && device
+ .supported_keys()
+ .is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER))
+ {
+ return Some(InputDeviceKind::Keyboard);
+ }
+
+ if events.contains(EventType::RELATIVE)
+ && let (Some(rel), Some(keys)) = (device.supported_relative_axes(), device.supported_keys())
+ && rel.contains(RelativeAxisCode::REL_X)
+ && rel.contains(RelativeAxisCode::REL_Y)
+ && (keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT))
+ {
+ return Some(InputDeviceKind::Mouse);
+ }
+
+ if events.contains(EventType::ABSOLUTE)
+ && let (Some(abs), Some(keys)) = (device.supported_absolute_axes(), device.supported_keys())
+ && ((abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y))
+ || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X)
+ && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y)))
+ && (keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT))
+ {
+ return Some(InputDeviceKind::Mouse);
+ }
+
+ None
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs
index fcd4b48..8677ce9 100644
--- a/client/src/launcher/diagnostics.rs
+++ b/client/src/launcher/diagnostics.rs
@@ -135,6 +135,8 @@ mod tests {
camera: Some("/dev/video0".to_string()),
microphone: Some("alsa_input.usb".to_string()),
speaker: Some("alsa_output.usb".to_string()),
+ keyboard: Some("/dev/input/event10".to_string()),
+ mouse: Some("/dev/input/event11".to_string()),
};
state.push_note("first note");
diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs
index 060606d..79fd734 100644
--- a/client/src/launcher/mod.rs
+++ b/client/src/launcher/mod.rs
@@ -25,6 +25,8 @@ pub use state::{CapturePowerStatus, DeviceSelection, InputRouting, LauncherState
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 LAUNCHER_CLIPBOARD_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_CLIPBOARD_CONTROL";
+pub const DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH: &str = "/tmp/lesavka-launcher-clipboard.control";
pub fn maybe_run_launcher(args: &[String]) -> Result {
if should_run_launcher(args) {
@@ -50,6 +52,9 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap {
"LESAVKA_VIEW_MODE".to_string(),
state.view_mode.as_env().to_string(),
);
+ envs.insert("LESAVKA_AUDIO_DISABLE".to_string(), "1".to_string());
+ envs.insert("LESAVKA_MIC_DISABLE".to_string(), "1".to_string());
+ envs.insert("LESAVKA_CLIPBOARD_DELAY_MS".to_string(), "18".to_string());
if matches!(state.view_mode, ViewMode::Unified) {
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
}
@@ -62,6 +67,27 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap {
if let Some(speaker) = state.devices.speaker.as_ref() {
envs.insert("LESAVKA_AUDIO_SINK".to_string(), speaker.clone());
}
+ if let Some(keyboard) = state.devices.keyboard.as_ref() {
+ envs.insert("LESAVKA_KEYBOARD_DEVICE".to_string(), keyboard.clone());
+ }
+ if let Some(mouse) = state.devices.mouse.as_ref() {
+ envs.insert("LESAVKA_MOUSE_DEVICE".to_string(), mouse.clone());
+ }
+ for key in [
+ "LESAVKA_PASTE_KEY",
+ "LESAVKA_PASTE_KEY_FILE",
+ "LESAVKA_PASTE_RPC",
+ "LESAVKA_PASTE_MAX",
+ "LESAVKA_PASTE_DELAY_MS",
+ "LESAVKA_CLIPBOARD_CMD",
+ "LESAVKA_CLIPBOARD_TIMEOUT_MS",
+ ] {
+ if let Ok(value) = std::env::var(key)
+ && !value.trim().is_empty()
+ {
+ envs.insert(key.to_string(), value);
+ }
+ }
envs
}
@@ -71,6 +97,12 @@ pub fn launcher_focus_signal_path() -> PathBuf {
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH))
}
+pub fn launcher_clipboard_control_path() -> PathBuf {
+ std::env::var(LAUNCHER_CLIPBOARD_CONTROL_ENV)
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from(DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH))
+}
+
fn resolve_server_addr(args: &[String]) -> String {
for window in args.windows(2) {
if window[0] == "--server" {
@@ -119,10 +151,18 @@ mod tests {
state.select_camera(Some("/dev/video0".to_string()));
state.select_microphone(Some("alsa_input.test".to_string()));
state.select_speaker(Some("alsa_output.test".to_string()));
+ state.select_keyboard(Some("/dev/input/event10".to_string()));
+ state.select_mouse(Some("/dev/input/event11".to_string()));
let envs = runtime_env_vars(&state);
assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string()));
assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string()));
+ assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string()));
+ assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string()));
+ assert_eq!(
+ envs.get("LESAVKA_CLIPBOARD_DELAY_MS"),
+ Some(&"18".to_string())
+ );
assert_eq!(
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
Some(&"1".to_string())
@@ -139,6 +179,39 @@ mod tests {
envs.get("LESAVKA_AUDIO_SINK"),
Some(&"alsa_output.test".to_string())
);
+ assert_eq!(
+ envs.get("LESAVKA_KEYBOARD_DEVICE"),
+ Some(&"/dev/input/event10".to_string())
+ );
+ assert_eq!(
+ envs.get("LESAVKA_MOUSE_DEVICE"),
+ Some(&"/dev/input/event11".to_string())
+ );
+ assert!(!envs.contains_key("LESAVKA_PASTE_KEY_FILE"));
+ }
+
+ #[test]
+ fn runtime_env_vars_passes_through_clipboard_transport_env() {
+ temp_env::with_vars(
+ [
+ ("LESAVKA_PASTE_KEY_FILE", Some("/tmp/paste-key")),
+ ("LESAVKA_PASTE_RPC", Some("1")),
+ ("LESAVKA_CLIPBOARD_CMD", Some("cat /tmp/secret")),
+ ],
+ || {
+ let state = LauncherState::new();
+ let envs = runtime_env_vars(&state);
+ assert_eq!(
+ envs.get("LESAVKA_PASTE_KEY_FILE"),
+ Some(&"/tmp/paste-key".to_string())
+ );
+ assert_eq!(envs.get("LESAVKA_PASTE_RPC"), Some(&"1".to_string()));
+ assert_eq!(
+ envs.get("LESAVKA_CLIPBOARD_CMD"),
+ Some(&"cat /tmp/secret".to_string())
+ );
+ },
+ );
}
#[test]
diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs
index 516a91a..44793c7 100644
--- a/client/src/launcher/preview.rs
+++ b/client/src/launcher/preview.rs
@@ -7,6 +7,8 @@ use gstreamer::prelude::{Cast, ElementExt, GstBinExt};
#[cfg(not(coverage))]
use gstreamer_app as gst_app;
#[cfg(not(coverage))]
+use gtk::prelude::WidgetExt;
+#[cfg(not(coverage))]
use gtk::{gdk, glib};
#[cfg(not(coverage))]
use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient};
@@ -26,13 +28,18 @@ const PREVIEW_WIDTH: i32 = 640;
#[cfg(not(coverage))]
const PREVIEW_HEIGHT: i32 = 360;
#[cfg(not(coverage))]
+const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920;
+#[cfg(not(coverage))]
+const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080;
+#[cfg(not(coverage))]
const PREVIEW_IDLE_STATUS: &str = "Connect relay to preview.";
#[cfg(not(coverage))]
pub struct LauncherPreview {
server_addr: Arc>,
- inline_feeds: [PreviewFeed; 2],
- window_feeds: [PreviewFeed; 2],
+ log_sink: Arc>>>,
+ inline_feeds: Arc>,
+ window_feeds: Arc>,
}
#[cfg(not(coverage))]
@@ -53,8 +60,11 @@ pub enum PreviewSurface {
#[cfg(not(coverage))]
#[derive(Clone, Copy, Debug)]
struct PreviewProfile {
- width: i32,
- height: i32,
+ display_width: i32,
+ display_height: i32,
+ requested_width: i32,
+ requested_height: i32,
+ requested_fps: u32,
max_bitrate_kbit: u32,
}
@@ -63,14 +73,32 @@ impl PreviewSurface {
fn profile(self) -> PreviewProfile {
match self {
Self::Inline => PreviewProfile {
- width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH),
- height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT),
- max_bitrate_kbit: preview_bitrate("LESAVKA_PREVIEW_MAX_KBIT", 2_500),
+ display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH),
+ display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT),
+ requested_width: preview_dimension(
+ "LESAVKA_PREVIEW_REQUEST_WIDTH",
+ DEFAULT_EYE_SOURCE_WIDTH,
+ ),
+ requested_height: preview_dimension(
+ "LESAVKA_PREVIEW_REQUEST_HEIGHT",
+ DEFAULT_EYE_SOURCE_HEIGHT,
+ ),
+ requested_fps: preview_bitrate("LESAVKA_PREVIEW_REQUEST_FPS", 30),
+ max_bitrate_kbit: preview_bitrate("LESAVKA_PREVIEW_MAX_KBIT", 12_000),
},
Self::Window => PreviewProfile {
- width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280),
- height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720),
- max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 8_000),
+ display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280),
+ display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720),
+ requested_width: preview_dimension(
+ "LESAVKA_BREAKOUT_REQUEST_WIDTH",
+ DEFAULT_EYE_SOURCE_WIDTH,
+ ),
+ requested_height: preview_dimension(
+ "LESAVKA_BREAKOUT_REQUEST_HEIGHT",
+ DEFAULT_EYE_SOURCE_HEIGHT,
+ ),
+ requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30),
+ max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 12_000),
},
}
}
@@ -81,19 +109,49 @@ impl LauncherPreview {
pub fn new(server_addr: String) -> Result {
gst::init().context("initialising preview gstreamer")?;
let server_addr = Arc::new(Mutex::new(server_addr));
+ let log_sink = Arc::new(Mutex::new(None));
+ let inline_feeds = Arc::new(Mutex::new([
+ PreviewFeed::spawn(
+ Arc::clone(&server_addr),
+ 0,
+ PreviewSurface::Inline.profile(),
+ Arc::clone(&log_sink),
+ )?,
+ PreviewFeed::spawn(
+ Arc::clone(&server_addr),
+ 1,
+ PreviewSurface::Inline.profile(),
+ Arc::clone(&log_sink),
+ )?,
+ ]));
+ let window_feeds = Arc::new(Mutex::new([
+ PreviewFeed::spawn(
+ Arc::clone(&server_addr),
+ 0,
+ PreviewSurface::Window.profile(),
+ Arc::clone(&log_sink),
+ )?,
+ PreviewFeed::spawn(
+ Arc::clone(&server_addr),
+ 1,
+ PreviewSurface::Window.profile(),
+ Arc::clone(&log_sink),
+ )?,
+ ]));
Ok(Self {
server_addr: Arc::clone(&server_addr),
- inline_feeds: [
- PreviewFeed::spawn(Arc::clone(&server_addr), 0, PreviewSurface::Inline.profile())?,
- PreviewFeed::spawn(server_addr.clone(), 1, PreviewSurface::Inline.profile())?,
- ],
- window_feeds: [
- PreviewFeed::spawn(Arc::clone(&server_addr), 0, PreviewSurface::Window.profile())?,
- PreviewFeed::spawn(server_addr, 1, PreviewSurface::Window.profile())?,
- ],
+ log_sink: Arc::clone(&log_sink),
+ inline_feeds,
+ window_feeds,
})
}
+ pub fn set_log_sink(&self, tx: std::sync::mpsc::Sender) {
+ if let Ok(mut slot) = self.log_sink.lock() {
+ *slot = Some(tx);
+ }
+ }
+
pub fn set_server_addr(&self, server_addr: String) {
if let Ok(mut slot) = self.server_addr.lock() {
*slot = server_addr;
@@ -101,12 +159,15 @@ impl LauncherPreview {
}
pub fn set_session_active(&self, active: bool) {
- for feed in self
- .inline_feeds
- .iter()
- .chain(self.window_feeds.iter())
- {
- feed.set_active(active);
+ if let Ok(feeds) = self.inline_feeds.lock() {
+ for feed in feeds.iter() {
+ feed.set_active(active);
+ }
+ }
+ if let Ok(feeds) = self.window_feeds.lock() {
+ for feed in feeds.iter() {
+ feed.set_active(active);
+ }
}
}
@@ -117,15 +178,101 @@ impl LauncherPreview {
picture: >k::Picture,
status_label: >k::Label,
) -> Option {
- self.feeds_for_surface(surface)
- .get(monitor_id)
- .map(|feed| feed.install_on_picture(picture, status_label))
+ match surface {
+ PreviewSurface::Inline => self
+ .inline_feeds
+ .lock()
+ .ok()
+ .and_then(|feeds| feeds.get(monitor_id).cloned())
+ .map(|feed| feed.install_on_picture(picture, status_label)),
+ PreviewSurface::Window => self
+ .window_feeds
+ .lock()
+ .ok()
+ .and_then(|feeds| feeds.get(monitor_id).cloned())
+ .map(|feed| feed.install_on_picture(picture, status_label)),
+ }
}
- fn feeds_for_surface(&self, surface: PreviewSurface) -> &[PreviewFeed; 2] {
- match surface {
- PreviewSurface::Inline => &self.inline_feeds,
- PreviewSurface::Window => &self.window_feeds,
+ pub fn set_capture_profile(
+ &self,
+ monitor_id: usize,
+ requested_width: i32,
+ requested_height: i32,
+ requested_fps: u32,
+ max_bitrate_kbit: u32,
+ ) {
+ self.rebuild_feed(
+ &self.inline_feeds,
+ monitor_id,
+ Some((
+ requested_width,
+ requested_height,
+ requested_fps,
+ max_bitrate_kbit,
+ )),
+ None,
+ );
+ self.rebuild_feed(
+ &self.window_feeds,
+ monitor_id,
+ Some((
+ requested_width,
+ requested_height,
+ requested_fps,
+ max_bitrate_kbit,
+ )),
+ None,
+ );
+ }
+
+ pub fn set_breakout_profile(&self, monitor_id: usize, width: i32, height: i32) {
+ self.rebuild_feed(&self.window_feeds, monitor_id, None, Some((width, height)));
+ }
+
+ fn rebuild_feed(
+ &self,
+ feeds: &Arc>,
+ monitor_id: usize,
+ requested: Option<(i32, i32, u32, u32)>,
+ display: Option<(i32, i32)>,
+ ) {
+ let Ok(mut feeds) = feeds.lock() else {
+ return;
+ };
+ let Some(existing) = feeds.get(monitor_id).cloned() else {
+ return;
+ };
+ let was_active = existing.is_active();
+ let mut profile = existing.profile();
+ if let Some((requested_width, requested_height, requested_fps, max_bitrate_kbit)) =
+ requested
+ {
+ profile.requested_width = requested_width.max(2);
+ profile.requested_height = requested_height.max(2);
+ profile.requested_fps = requested_fps.max(1);
+ profile.max_bitrate_kbit = max_bitrate_kbit.max(800);
+ }
+ if let Some((display_width, display_height)) = display {
+ profile.display_width = display_width.max(2);
+ profile.display_height = display_height.max(2);
+ }
+ match PreviewFeed::spawn(
+ Arc::clone(&self.server_addr),
+ monitor_id as u32,
+ profile,
+ Arc::clone(&self.log_sink),
+ ) {
+ Ok(feed) => {
+ if was_active {
+ feed.set_active(true);
+ }
+ existing.shutdown();
+ feeds[monitor_id] = feed;
+ }
+ Err(err) => {
+ warn!(monitor_id, ?err, "could not rebuild preview feed");
+ }
}
}
}
@@ -153,13 +300,25 @@ impl PreviewBinding {
self.active_bindings.fetch_sub(1, Ordering::AcqRel);
}
}
+
+ #[cfg(test)]
+ pub(crate) fn test_stub() -> Self {
+ Self {
+ enabled: Arc::new(AtomicBool::new(true)),
+ alive: Arc::new(AtomicBool::new(true)),
+ active_bindings: Arc::new(AtomicUsize::new(1)),
+ }
+ }
}
#[cfg(not(coverage))]
+#[derive(Clone)]
struct PreviewFeed {
shared: Arc>,
session_active: Arc,
active_bindings: Arc,
+ running: Arc,
+ profile: PreviewProfile,
}
#[cfg(not(coverage))]
@@ -168,6 +327,8 @@ struct SharedPreviewState {
status: String,
generation: u64,
clear_picture: bool,
+ last_logged_error: Option,
+ last_logged_status: Option,
}
#[cfg(not(coverage))]
@@ -178,17 +339,22 @@ impl SharedPreviewState {
status: PREVIEW_IDLE_STATUS.to_string(),
generation: 1,
clear_picture: true,
+ last_logged_error: None,
+ last_logged_status: None,
}
}
fn set_status(&mut self, status: impl Into, clear_picture: bool) {
let status = status.into();
let changed = self.status != status || clear_picture;
- self.status = status;
+ self.status = status.clone();
if clear_picture {
self.latest = None;
self.clear_picture = true;
}
+ if !looks_like_preview_problem(&status) {
+ self.last_logged_error = None;
+ }
if changed {
self.generation = self.generation.saturating_add(1);
}
@@ -197,6 +363,7 @@ impl SharedPreviewState {
fn push_frame(&mut self, frame: PreviewFrame) {
self.latest = Some(frame);
self.clear_picture = false;
+ self.last_logged_error = None;
if self.status != "Live" {
self.status = "Live".to_string();
self.generation = self.generation.saturating_add(1);
@@ -210,13 +377,16 @@ impl PreviewFeed {
server_addr: Arc>,
monitor_id: u32,
profile: PreviewProfile,
+ log_sink: Arc>>>,
) -> Result {
let shared = Arc::new(Mutex::new(SharedPreviewState::new()));
let session_active = Arc::new(AtomicBool::new(false));
let active_bindings = Arc::new(AtomicUsize::new(0));
+ let running = Arc::new(AtomicBool::new(true));
let shared_state = Arc::clone(&shared);
let session_active_flag = Arc::clone(&session_active);
let active_bindings_flag = Arc::clone(&active_bindings);
+ let running_flag = Arc::clone(&running);
std::thread::spawn(move || {
if let Err(err) = run_preview_feed(
server_addr,
@@ -224,7 +394,9 @@ impl PreviewFeed {
profile,
session_active_flag,
active_bindings_flag,
+ running_flag,
shared_state,
+ log_sink,
) {
warn!(monitor_id, ?err, "launcher preview feed exited");
}
@@ -233,9 +405,19 @@ impl PreviewFeed {
shared,
session_active,
active_bindings,
+ running,
+ profile,
})
}
+ fn profile(&self) -> PreviewProfile {
+ self.profile
+ }
+
+ fn is_active(&self) -> bool {
+ self.session_active.load(Ordering::Relaxed)
+ }
+
fn set_active(&self, active: bool) {
self.session_active.store(active, Ordering::Relaxed);
if !active {
@@ -243,6 +425,11 @@ impl PreviewFeed {
}
}
+ fn shutdown(&self) {
+ self.running.store(false, Ordering::Relaxed);
+ self.replace_status(PREVIEW_IDLE_STATUS, true);
+ }
+
fn replace_status(&self, status: impl Into, clear_picture: bool) {
if let Ok(mut shared) = self.shared.lock() {
shared.set_status(status, clear_picture);
@@ -300,6 +487,7 @@ impl PreviewFeed {
}
if generation != last_generation {
status_label.set_text(&status);
+ status_label.set_tooltip_text(Some(&status));
last_generation = generation;
}
glib::ControlFlow::Continue
@@ -327,7 +515,9 @@ fn run_preview_feed(
profile: PreviewProfile,
session_active: Arc,
active_bindings: Arc,
+ running: Arc,
shared: Arc>,
+ log_sink: Arc>>>,
) -> Result<()> {
let (pipeline, appsrc, appsink) = build_preview_pipeline(profile)?;
pipeline
@@ -337,8 +527,12 @@ fn run_preview_feed(
{
let shared = Arc::clone(&shared);
let appsink = appsink.clone();
+ let running = Arc::clone(&running);
std::thread::spawn(move || {
loop {
+ if !running.load(Ordering::Relaxed) {
+ break;
+ }
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) {
if let Some(frame) = sample_to_frame(&sample) {
if let Ok(mut slot) = shared.lock() {
@@ -359,27 +553,48 @@ fn run_preview_feed(
let mut was_active = false;
let mut retry_delay = Duration::from_millis(750);
loop {
+ if !running.load(Ordering::Relaxed) {
+ break;
+ }
let active_now = session_active.load(Ordering::Relaxed)
&& active_bindings.load(Ordering::Relaxed) > 0;
if !active_now {
was_active = false;
retry_delay = Duration::from_millis(750);
- set_shared_status(&shared, PREVIEW_IDLE_STATUS, true);
+ set_shared_status(&shared, &log_sink, monitor_id, PREVIEW_IDLE_STATUS, true);
tokio::time::sleep(Duration::from_millis(150)).await;
continue;
}
if !was_active {
was_active = true;
- set_shared_status(&shared, "Waking relay preview...", true);
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Waking relay preview...",
+ true,
+ );
tokio::time::sleep(Duration::from_millis(350)).await;
}
- set_shared_status(&shared, "Connecting relay preview...", true);
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Connecting relay preview...",
+ true,
+ );
let current_addr = match server_addr.lock() {
Ok(value) => value.clone(),
Err(_) => {
- set_shared_status(&shared, "Preview address is unavailable.", true);
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Preview address is unavailable.",
+ true,
+ );
tokio::time::sleep(Duration::from_millis(750)).await;
continue;
}
@@ -390,9 +605,17 @@ fn run_preview_feed(
Ok(channel) => channel,
Err(err) => {
warn!(monitor_id, ?err, "launcher preview connect failed");
+ log_preview_issue(
+ &shared,
+ &log_sink,
+ monitor_id,
+ &format!("Preview host is unavailable: {err}"),
+ );
set_shared_status(
&shared,
- format!("Preview host is unavailable: {err}"),
+ &log_sink,
+ monitor_id,
+ "Preview host is unavailable.",
true,
);
tokio::time::sleep(retry_delay).await;
@@ -401,7 +624,19 @@ fn run_preview_feed(
},
Err(err) => {
warn!(monitor_id, ?err, "launcher preview endpoint invalid");
- set_shared_status(&shared, format!("Preview address is invalid: {err}"), true);
+ log_preview_issue(
+ &shared,
+ &log_sink,
+ monitor_id,
+ &format!("Preview address is invalid: {err}"),
+ );
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Preview address is invalid.",
+ true,
+ );
tokio::time::sleep(retry_delay).await;
continue;
}
@@ -411,14 +646,24 @@ fn run_preview_feed(
let req = MonitorRequest {
id: monitor_id,
max_bitrate: profile.max_bitrate_kbit,
+ requested_width: profile.requested_width.max(0) as u32,
+ requested_height: profile.requested_height.max(0) as u32,
+ requested_fps: profile.requested_fps,
};
match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => {
retry_delay = Duration::from_millis(750);
debug!(monitor_id, "launcher preview connected");
- set_shared_status(&shared, "Waiting for stream...", true);
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Waiting for stream...",
+ true,
+ );
loop {
if !session_active.load(Ordering::Relaxed)
+ || !running.load(Ordering::Relaxed)
|| active_bindings.load(Ordering::Relaxed) == 0
{
break;
@@ -431,18 +676,33 @@ fn run_preview_feed(
{
Ok(Ok(Some(pkt))) => push_preview_packet(&appsrc, pkt),
Ok(Ok(None)) => {
- set_shared_status(&shared, "Preview stream ended.", true);
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Preview stream ended.",
+ true,
+ );
retry_delay = Duration::from_millis(1_500);
break;
}
Ok(Err(err)) => {
warn!(monitor_id, ?err, "launcher preview stream error");
+ log_preview_issue(
+ &shared,
+ &log_sink,
+ monitor_id,
+ &format!("Preview stream error: {err}"),
+ );
set_shared_status(
&shared,
- format!("Preview stream error: {err}"),
+ &log_sink,
+ monitor_id,
+ "Preview stream error. See session log.",
true,
);
- retry_delay = preview_retry_delay(retry_delay, Some(&err.to_string()));
+ retry_delay =
+ preview_retry_delay(retry_delay, Some(&err.to_string()));
break;
}
Err(_) => continue,
@@ -456,11 +716,35 @@ fn run_preview_feed(
?err,
"launcher preview waiting for capture pipeline"
);
- set_shared_status(&shared, "Waiting for capture pipeline...", true);
+ log_preview_issue(
+ &shared,
+ &log_sink,
+ monitor_id,
+ &format!("Waiting for capture pipeline: {err}"),
+ );
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Waiting for capture pipeline...",
+ true,
+ );
retry_delay = preview_retry_delay(retry_delay, Some(err.message()));
} else {
warn!(monitor_id, ?err, "launcher preview rpc failed");
- set_shared_status(&shared, format!("Preview RPC failed: {err}"), true);
+ log_preview_issue(
+ &shared,
+ &log_sink,
+ monitor_id,
+ &format!("Preview RPC failed: {err}"),
+ );
+ set_shared_status(
+ &shared,
+ &log_sink,
+ monitor_id,
+ "Preview RPC failed. See session log.",
+ true,
+ );
retry_delay = preview_retry_delay(retry_delay, Some(err.message()));
}
}
@@ -471,6 +755,8 @@ fn run_preview_feed(
Ok::<(), anyhow::Error>(())
});
+ let _ = pipeline.set_state(gst::State::Null);
+
Ok(())
}
@@ -482,7 +768,9 @@ fn preview_startup_condition(err: &tonic::Status) -> bool {
|| message.contains("failed to change its state")
|| message.contains("resource busy")
|| message.contains("device or resource busy")
- || message.contains("no signal"))
+ || message.contains("no signal")
+ || message.contains("was not ready")
+ || message.contains("no such file or directory"))
}
#[cfg(not(coverage))]
@@ -509,14 +797,119 @@ fn preview_retry_delay(current: Duration, message: Option<&str>) -> Duration {
#[cfg(not(coverage))]
fn set_shared_status(
shared: &Arc>,
+ log_sink: &Arc>>>,
+ monitor_id: u32,
status: impl Into,
clear: bool,
) {
- if let Ok(mut slot) = shared.lock() {
- slot.set_status(status, clear);
+ let status = status.into();
+ let should_log = if let Ok(mut slot) = shared.lock() {
+ let should_log = slot.last_logged_status.as_deref() != Some(status.as_str());
+ if should_log {
+ slot.last_logged_status = Some(status.clone());
+ }
+ slot.set_status(status.clone(), clear);
+ should_log
+ } else {
+ false
+ };
+ if should_log {
+ log_preview_status(log_sink, monitor_id, &status);
}
}
+#[cfg(not(coverage))]
+fn log_preview_issue(
+ shared: &Arc>,
+ log_sink: &Arc>>>,
+ monitor_id: u32,
+ message: &str,
+) {
+ let should_log = if let Ok(mut slot) = shared.lock() {
+ if slot.last_logged_error.as_deref() == Some(message) {
+ false
+ } else {
+ slot.last_logged_error = Some(message.to_string());
+ true
+ }
+ } else {
+ false
+ };
+ if !should_log {
+ return;
+ }
+ if let Ok(slot) = log_sink.lock()
+ && let Some(tx) = slot.as_ref()
+ {
+ let _ = tx.send(format!(
+ "[preview:{}] {message}",
+ preview_eye_label(monitor_id)
+ ));
+ }
+}
+
+#[cfg(not(coverage))]
+fn log_preview_status(
+ log_sink: &Arc>>>,
+ monitor_id: u32,
+ status: &str,
+) {
+ if status == PREVIEW_IDLE_STATUS {
+ return;
+ }
+ let eye = preview_eye_label(monitor_id);
+ let message = match status {
+ "Waking relay preview..." => format!("🪄 {eye} eye is waking the preview spell."),
+ "Connecting relay preview..." => format!("🛰️ dialing the {eye} eye feed."),
+ "Waiting for stream..." => {
+ format!("👀 {eye} eye is connected and waiting for the first frame.")
+ }
+ "Preview stream ended." => format!("🌙 {eye} eye preview stream ended."),
+ "Preview host is unavailable." => format!("💔 {eye} eye cannot reach the preview host."),
+ "Preview address is unavailable." => {
+ format!("🧭 {eye} eye does not have a usable preview address yet.")
+ }
+ "Preview address is invalid." => format!("🧭 {eye} eye was given a bad preview address."),
+ "Waiting for capture pipeline..." => {
+ format!("⏳ {eye} eye is waiting for the capture pipeline to wake up.")
+ }
+ "Preview stream error. See session log." => {
+ format!("💥 {eye} eye hit a preview stream error. See the log spellbook for detail.")
+ }
+ "Preview RPC failed. See session log." => {
+ format!("💥 {eye} eye preview RPC fizzled. See the log spellbook for detail.")
+ }
+ other => format!("🎥 {eye} eye: {other}"),
+ };
+ if let Ok(slot) = log_sink.lock()
+ && let Some(tx) = slot.as_ref()
+ {
+ let _ = tx.send(format!(
+ "[preview:{}] {message}",
+ preview_eye_label(monitor_id)
+ ));
+ }
+}
+
+#[cfg(not(coverage))]
+fn preview_eye_label(monitor_id: u32) -> &'static str {
+ match monitor_id {
+ 0 => "left",
+ 1 => "right",
+ _ => "eye",
+ }
+}
+
+#[cfg(not(coverage))]
+fn looks_like_preview_problem(status: &str) -> bool {
+ let lower = status.to_ascii_lowercase();
+ lower.contains("unavailable")
+ || lower.contains("invalid")
+ || lower.contains("failed")
+ || lower.contains("waiting for capture pipeline")
+ || lower.contains("error")
+}
+
#[cfg(not(coverage))]
fn build_preview_pipeline(
profile: PreviewProfile,
@@ -526,10 +919,8 @@ fn build_preview_pipeline(
queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
h264parse disable-passthrough=true ! avdec_h264 ! videoconvert ! videoscale ! \
video/x-raw,format=RGBA,width={},height={},pixel-aspect-ratio=1/1 ! \
- appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true"
- ,
- profile.width,
- profile.height
+ appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true",
+ profile.display_width, profile.display_height
);
let pipeline = gst::parse::launch(&desc)?
.downcast::()
@@ -556,8 +947,8 @@ fn build_preview_pipeline(
appsink.set_caps(Some(
&gst::Caps::builder("video/x-raw")
.field("format", &"RGBA")
- .field("width", &profile.width)
- .field("height", &profile.height)
+ .field("width", &profile.display_width)
+ .field("height", &profile.display_height)
.build(),
));
@@ -610,21 +1001,30 @@ fn preview_dimension(var: &str, default: i32) -> i32 {
#[cfg(test)]
mod tests {
- use super::{PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface};
+ use super::{
+ DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, PREVIEW_HEIGHT, PREVIEW_WIDTH,
+ PreviewSurface,
+ };
#[test]
fn inline_preview_profile_uses_existing_defaults() {
let profile = PreviewSurface::Inline.profile();
- assert_eq!(profile.width, PREVIEW_WIDTH);
- assert_eq!(profile.height, PREVIEW_HEIGHT);
- assert_eq!(profile.max_bitrate_kbit, 2_500);
+ assert_eq!(profile.display_width, PREVIEW_WIDTH);
+ assert_eq!(profile.display_height, PREVIEW_HEIGHT);
+ assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH);
+ assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT);
+ assert_eq!(profile.requested_fps, 30);
+ assert_eq!(profile.max_bitrate_kbit, 12_000);
}
#[test]
fn breakout_preview_profile_defaults_to_higher_quality() {
let profile = PreviewSurface::Window.profile();
- assert_eq!(profile.width, 1280);
- assert_eq!(profile.height, 720);
- assert_eq!(profile.max_bitrate_kbit, 8_000);
+ assert_eq!(profile.display_width, 1280);
+ assert_eq!(profile.display_height, 720);
+ assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH);
+ assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT);
+ assert_eq!(profile.requested_fps, 30);
+ assert_eq!(profile.max_bitrate_kbit, 12_000);
}
}
diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs
index ecd348f..b3fb141 100644
--- a/client/src/launcher/state.rs
+++ b/client/src/launcher/state.rs
@@ -47,6 +47,112 @@ impl DisplaySurface {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum BreakoutSizePreset {
+ P540,
+ P720,
+ P900,
+ P1080,
+ P1440,
+ Source,
+ FillDisplay,
+}
+
+impl BreakoutSizePreset {
+ pub fn as_id(self) -> &'static str {
+ match self {
+ Self::P540 => "540p",
+ Self::P720 => "720p",
+ Self::P900 => "900p",
+ Self::P1080 => "1080p",
+ Self::P1440 => "1440p",
+ Self::Source => "source",
+ Self::FillDisplay => "fill",
+ }
+ }
+
+ pub fn from_id(raw: &str) -> Option {
+ match raw {
+ "540p" => Some(Self::P540),
+ "720p" => Some(Self::P720),
+ "900p" => Some(Self::P900),
+ "1080p" => Some(Self::P1080),
+ "1440p" => Some(Self::P1440),
+ "source" => Some(Self::Source),
+ "fill" => Some(Self::FillDisplay),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CaptureSizePreset {
+ P540,
+ P720,
+ P900,
+ P1080,
+ P1440,
+ Source,
+}
+
+impl CaptureSizePreset {
+ pub fn as_id(self) -> &'static str {
+ match self {
+ Self::P540 => "540p",
+ Self::P720 => "720p",
+ Self::P900 => "900p",
+ Self::P1080 => "1080p",
+ Self::P1440 => "1440p",
+ Self::Source => "source",
+ }
+ }
+
+ pub fn from_id(raw: &str) -> Option {
+ match raw {
+ "540p" => Some(Self::P540),
+ "720p" => Some(Self::P720),
+ "900p" => Some(Self::P900),
+ "1080p" => Some(Self::P1080),
+ "1440p" => Some(Self::P1440),
+ "source" => Some(Self::Source),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PreviewSourceSize {
+ pub width: u32,
+ pub height: u32,
+ pub fps: u32,
+}
+
+impl Default for PreviewSourceSize {
+ fn default() -> Self {
+ Self {
+ width: 1920,
+ height: 1080,
+ fps: 30,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct BreakoutSizeChoice {
+ pub preset: BreakoutSizePreset,
+ pub width: i32,
+ pub height: i32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct CaptureSizeChoice {
+ pub preset: CaptureSizePreset,
+ pub width: i32,
+ pub height: i32,
+ pub fps: u32,
+ pub max_bitrate_kbit: u32,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapturePowerStatus {
pub available: bool,
@@ -75,16 +181,25 @@ pub struct DeviceSelection {
pub camera: Option,
pub microphone: Option,
pub speaker: Option,
+ pub keyboard: Option,
+ pub mouse: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherState {
+ pub server_available: bool,
pub routing: InputRouting,
pub view_mode: ViewMode,
pub displays: [DisplaySurface; 2],
+ pub preview_source: PreviewSourceSize,
+ pub breakout_limit: PreviewSourceSize,
+ pub breakout_display: PreviewSourceSize,
+ pub capture_sizes: [CaptureSizePreset; 2],
+ pub breakout_sizes: [BreakoutSizePreset; 2],
pub devices: DeviceSelection,
pub swap_key: String,
pub swap_key_binding: bool,
+ pub swap_key_binding_token: u64,
pub capture_power: CapturePowerStatus,
pub remote_active: bool,
pub notes: Vec,
@@ -93,12 +208,19 @@ pub struct LauncherState {
impl Default for LauncherState {
fn default() -> Self {
Self {
+ server_available: false,
routing: InputRouting::Remote,
view_mode: ViewMode::Unified,
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
+ preview_source: PreviewSourceSize::default(),
+ breakout_limit: PreviewSourceSize::default(),
+ breakout_display: PreviewSourceSize::default(),
+ capture_sizes: [CaptureSizePreset::Source, CaptureSizePreset::Source],
+ breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
devices: DeviceSelection::default(),
swap_key: "pause".to_string(),
swap_key_binding: false,
+ swap_key_binding_token: 0,
capture_power: CapturePowerStatus::default(),
remote_active: false,
notes: Vec::new(),
@@ -115,6 +237,10 @@ impl LauncherState {
self.routing = routing;
}
+ pub fn set_server_available(&mut self, available: bool) {
+ self.server_available = available;
+ }
+
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
self.view_mode = view_mode;
self.displays = match view_mode {
@@ -152,6 +278,102 @@ impl LauncherState {
.count()
}
+ pub fn preview_source_size(&self) -> PreviewSourceSize {
+ self.preview_source
+ }
+
+ pub fn set_preview_source_profile(&mut self, width: u32, height: u32, fps: u32) {
+ if width == 0 || height == 0 {
+ return;
+ }
+ self.preview_source = PreviewSourceSize {
+ width,
+ height,
+ fps: fps.max(1),
+ };
+ }
+
+ pub fn breakout_limit_size(&self) -> PreviewSourceSize {
+ self.breakout_limit
+ }
+
+ pub fn set_breakout_limit_size(&mut self, width: u32, height: u32) {
+ if width == 0 || height == 0 {
+ return;
+ }
+ self.breakout_limit = PreviewSourceSize {
+ width,
+ height,
+ fps: self.breakout_limit.fps.max(1),
+ };
+ }
+
+ pub fn breakout_display_size(&self) -> PreviewSourceSize {
+ self.breakout_display
+ }
+
+ pub fn set_breakout_display_size(&mut self, width: u32, height: u32) {
+ if width == 0 || height == 0 {
+ return;
+ }
+ self.breakout_display = PreviewSourceSize {
+ width,
+ height,
+ fps: self.breakout_display.fps.max(1),
+ };
+ }
+
+ pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset {
+ self.capture_sizes
+ .get(monitor_id)
+ .copied()
+ .unwrap_or(CaptureSizePreset::Source)
+ }
+
+ pub fn set_capture_size_preset(&mut self, monitor_id: usize, preset: CaptureSizePreset) {
+ if let Some(slot) = self.capture_sizes.get_mut(monitor_id) {
+ *slot = preset;
+ }
+ }
+
+ pub fn capture_size_choice(&self, monitor_id: usize) -> CaptureSizeChoice {
+ capture_size_choice(self.preview_source, self.capture_size_preset(monitor_id))
+ }
+
+ pub fn capture_size_options(&self) -> Vec {
+ capture_size_options(self.preview_source)
+ }
+
+ pub fn breakout_size_preset(&self, monitor_id: usize) -> BreakoutSizePreset {
+ self.breakout_sizes
+ .get(monitor_id)
+ .copied()
+ .unwrap_or(BreakoutSizePreset::Source)
+ }
+
+ pub fn set_breakout_size_preset(&mut self, monitor_id: usize, preset: BreakoutSizePreset) {
+ if let Some(slot) = self.breakout_sizes.get_mut(monitor_id) {
+ *slot = preset;
+ }
+ }
+
+ pub fn breakout_size_choice(&self, monitor_id: usize) -> BreakoutSizeChoice {
+ breakout_size_choice(
+ self.breakout_limit,
+ self.breakout_display,
+ self.preview_source,
+ self.breakout_size_preset(monitor_id),
+ )
+ }
+
+ pub fn breakout_size_options(&self) -> Vec {
+ breakout_size_options(
+ self.breakout_limit,
+ self.breakout_display,
+ self.preview_source,
+ )
+ }
+
pub fn select_camera(&mut self, camera: Option) {
self.devices.camera = normalize_selection(camera);
}
@@ -164,6 +386,14 @@ impl LauncherState {
self.devices.speaker = normalize_selection(speaker);
}
+ pub fn select_keyboard(&mut self, keyboard: Option) {
+ self.devices.keyboard = normalize_selection(keyboard);
+ }
+
+ pub fn select_mouse(&mut self, mouse: Option) {
+ self.devices.mouse = normalize_selection(mouse);
+ }
+
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
if self.devices.camera.is_none() {
self.devices.camera = catalog.cameras.first().cloned();
@@ -180,14 +410,30 @@ impl LauncherState {
self.swap_key = normalize_swap_key(swap_key.into());
}
- pub fn begin_swap_key_binding(&mut self) {
+ pub fn begin_swap_key_binding(&mut self) -> u64 {
self.swap_key_binding = true;
+ self.swap_key_binding_token = self.swap_key_binding_token.wrapping_add(1);
+ self.swap_key_binding_token
}
pub fn finish_swap_key_binding(&mut self) {
self.swap_key_binding = false;
}
+ pub fn cancel_swap_key_binding(&mut self, token: u64) -> bool {
+ if self.swap_key_binding && self.swap_key_binding_token == token {
+ self.swap_key_binding = false;
+ true
+ } else {
+ false
+ }
+ }
+
+ pub fn complete_swap_key_binding(&mut self, swap_key: impl Into) {
+ self.set_swap_key(swap_key);
+ self.finish_swap_key_binding();
+ }
+
pub fn start_remote(&mut self) -> bool {
if self.remote_active {
return false;
@@ -214,7 +460,8 @@ impl LauncherState {
pub fn status_line(&self) -> String {
format!(
- "mode={} view={} active={} power={} d1={} d2={} camera={} mic={} speaker={} swap={}",
+ "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}",
+ self.server_available,
match self.routing {
InputRouting::Local => "local",
InputRouting::Remote => "remote",
@@ -229,16 +476,193 @@ impl LauncherState {
} else {
"off"
},
+ self.preview_source.width,
+ self.preview_source.height,
self.displays[0].label(),
self.displays[1].label(),
self.devices.camera.as_deref().unwrap_or("auto"),
self.devices.microphone.as_deref().unwrap_or("auto"),
self.devices.speaker.as_deref().unwrap_or("auto"),
+ self.devices.keyboard.as_deref().unwrap_or("all"),
+ self.devices.mouse.as_deref().unwrap_or("all"),
self.swap_key,
)
}
}
+fn breakout_size_choice(
+ physical_limit: PreviewSourceSize,
+ display_fill: PreviewSourceSize,
+ source: PreviewSourceSize,
+ preset: BreakoutSizePreset,
+) -> BreakoutSizeChoice {
+ let physical_width = physical_limit.width.max(1) as i32;
+ let physical_height = physical_limit.height.max(1) as i32;
+ let display_width = display_fill.width.max(1) as i32;
+ let display_height = display_fill.height.max(1) as i32;
+ let (width, height) = match preset {
+ BreakoutSizePreset::P540 => {
+ fit_standard_dimensions(physical_width, physical_height, 960, 540)
+ }
+ BreakoutSizePreset::P720 => {
+ fit_standard_dimensions(physical_width, physical_height, 1280, 720)
+ }
+ BreakoutSizePreset::P900 => {
+ fit_standard_dimensions(physical_width, physical_height, 1600, 900)
+ }
+ BreakoutSizePreset::P1080 => {
+ fit_standard_dimensions(physical_width, physical_height, 1920, 1080)
+ }
+ BreakoutSizePreset::P1440 => {
+ fit_standard_dimensions(physical_width, physical_height, 2560, 1440)
+ }
+ BreakoutSizePreset::Source => fit_standard_dimensions(
+ physical_width,
+ physical_height,
+ source.width.max(1) as i32,
+ source.height.max(1) as i32,
+ ),
+ BreakoutSizePreset::FillDisplay => (display_width, display_height),
+ };
+ BreakoutSizeChoice {
+ preset,
+ width,
+ height,
+ }
+}
+
+fn breakout_size_options(
+ physical_limit: PreviewSourceSize,
+ display_fill: PreviewSourceSize,
+ source: PreviewSourceSize,
+) -> Vec {
+ let mut options = Vec::new();
+ for preset in [
+ BreakoutSizePreset::Source,
+ BreakoutSizePreset::P540,
+ BreakoutSizePreset::P720,
+ BreakoutSizePreset::P900,
+ BreakoutSizePreset::P1080,
+ BreakoutSizePreset::P1440,
+ BreakoutSizePreset::FillDisplay,
+ ] {
+ let choice = breakout_size_choice(physical_limit, display_fill, source, preset);
+ let allow_duplicate_label = matches!(
+ preset,
+ BreakoutSizePreset::Source | BreakoutSizePreset::FillDisplay
+ );
+ if !allow_duplicate_label
+ && options.iter().any(|existing: &BreakoutSizeChoice| {
+ existing.width == choice.width && existing.height == choice.height
+ })
+ {
+ continue;
+ }
+ options.push(choice);
+ }
+ options
+}
+
+fn capture_size_choice(source: PreviewSourceSize, preset: CaptureSizePreset) -> CaptureSizeChoice {
+ let source_width = source.width.max(1) as i32;
+ let source_height = source.height.max(1) as i32;
+ let source_fps = source.fps.max(1);
+ let (width, height, fps, max_bitrate_kbit) = match preset {
+ CaptureSizePreset::P540 => {
+ let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360);
+ (width, height, source_fps.min(15), 2_500)
+ }
+ CaptureSizePreset::P720 => {
+ let (width, height) = fit_standard_dimensions(source_width, source_height, 1280, 720);
+ (width, height, source_fps.min(24), 6_000)
+ }
+ CaptureSizePreset::P900 => {
+ let (width, height) = fit_standard_dimensions(source_width, source_height, 1600, 900);
+ (width, height, source_fps.min(30), 8_500)
+ }
+ CaptureSizePreset::P1080 => {
+ let (width, height) = fit_standard_dimensions(source_width, source_height, 1920, 1080);
+ (width, height, source_fps.min(30), 12_000)
+ }
+ CaptureSizePreset::P1440 => {
+ let (width, height) = fit_standard_dimensions(source_width, source_height, 2560, 1440);
+ (width, height, source_fps.min(30), 18_000)
+ }
+ CaptureSizePreset::Source => (
+ source_width,
+ source_height,
+ source_fps,
+ estimate_source_bitrate_kbit(source_width, source_height, source_fps),
+ ),
+ };
+ CaptureSizeChoice {
+ preset,
+ width,
+ height,
+ fps,
+ max_bitrate_kbit,
+ }
+}
+
+fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 {
+ let pixels_per_second = width.max(1) as u64 * height.max(1) as u64 * fps.max(1) as u64;
+ match pixels_per_second {
+ p if p >= 1920_u64 * 1080 * 50 => 18_000,
+ p if p >= 1920_u64 * 1080 * 24 => 12_000,
+ p if p >= 1280_u64 * 720 * 24 => 6_000,
+ _ => 2_500,
+ }
+}
+
+fn capture_size_options(source: PreviewSourceSize) -> Vec {
+ let mut options = Vec::new();
+ for preset in [
+ CaptureSizePreset::Source,
+ CaptureSizePreset::P540,
+ CaptureSizePreset::P720,
+ CaptureSizePreset::P900,
+ CaptureSizePreset::P1080,
+ CaptureSizePreset::P1440,
+ ] {
+ let choice = capture_size_choice(source, preset);
+ if options.iter().any(|existing: &CaptureSizeChoice| {
+ existing.width == choice.width
+ && existing.height == choice.height
+ && existing.fps == choice.fps
+ && existing.max_bitrate_kbit == choice.max_bitrate_kbit
+ }) {
+ continue;
+ }
+ options.push(choice);
+ }
+ options
+}
+
+fn fit_standard_dimensions(
+ limit_width: i32,
+ limit_height: i32,
+ wanted_width: i32,
+ wanted_height: i32,
+) -> (i32, i32) {
+ let width = wanted_width.min(limit_width).max(2);
+ let height = wanted_height.min(limit_height).max(2);
+ if width == limit_width && height == limit_height {
+ return (width, height);
+ }
+ let width_from_height = round_down_even((height * 16) / 9);
+ if width_from_height <= limit_width {
+ (round_down_even(width_from_height), round_down_even(height))
+ } else {
+ let height_from_width = round_down_even((width * 9) / 16);
+ (round_down_even(width), round_down_even(height_from_width))
+ }
+}
+
+fn round_down_even(value: i32) -> i32 {
+ let rounded = value.max(2);
+ rounded - (rounded % 2)
+}
+
fn normalize_selection(value: Option) -> Option {
value.and_then(|v| {
let trimmed = v.trim();
@@ -278,10 +702,17 @@ mod tests {
assert_eq!(state.view_mode, ViewMode::Unified);
assert_eq!(state.display_surface(0), DisplaySurface::Preview);
assert_eq!(state.display_surface(1), DisplaySurface::Preview);
+ assert_eq!(state.preview_source_size(), PreviewSourceSize::default());
+ assert_eq!(state.breakout_limit_size(), PreviewSourceSize::default());
+ assert_eq!(state.capture_size_preset(0), CaptureSizePreset::Source);
+ assert_eq!(state.breakout_size_preset(0), BreakoutSizePreset::Source);
+ assert!(!state.server_available);
assert!(!state.remote_active);
assert!(state.devices.camera.is_none());
assert!(state.devices.microphone.is_none());
assert!(state.devices.speaker.is_none());
+ assert!(state.devices.keyboard.is_none());
+ assert!(state.devices.mouse.is_none());
assert_eq!(state.capture_power.unit, "relay.service");
assert_eq!(state.capture_power.mode, "auto");
}
@@ -324,6 +755,8 @@ mod tests {
cameras: vec!["/dev/video0".to_string()],
microphones: vec!["alsa_input.usb".to_string()],
speakers: vec!["alsa_output.usb".to_string()],
+ keyboards: vec!["/dev/input/event10".to_string()],
+ mice: vec!["/dev/input/event11".to_string()],
};
state.apply_catalog_defaults(&catalog);
@@ -348,22 +781,30 @@ mod tests {
#[test]
fn status_line_mentions_all_user_visible_controls() {
let mut state = LauncherState::new();
+ state.set_server_available(true);
state.set_routing(InputRouting::Local);
state.set_view_mode(ViewMode::Unified);
state.select_camera(Some("/dev/video0".to_string()));
state.select_microphone(Some("alsa_input.usb".to_string()));
state.select_speaker(Some("alsa_output.usb".to_string()));
+ state.select_keyboard(Some("/dev/input/event-kbd".to_string()));
+ state.select_mouse(Some("/dev/input/event-mouse".to_string()));
+ state.set_preview_source_profile(1920, 1080, 30);
state.start_remote();
let status = state.status_line();
assert!(status.contains("mode=local"));
+ assert!(status.contains("server=true"));
assert!(status.contains("view=unified"));
assert!(status.contains("active=true"));
+ assert!(status.contains("source=1920x1080"));
assert!(status.contains("d1=preview"));
assert!(status.contains("d2=preview"));
assert!(status.contains("camera=/dev/video0"));
assert!(status.contains("mic=alsa_input.usb"));
assert!(status.contains("speaker=alsa_output.usb"));
+ assert!(status.contains("kbd=/dev/input/event-kbd"));
+ assert!(status.contains("mouse=/dev/input/event-mouse"));
}
#[test]
@@ -384,14 +825,68 @@ mod tests {
assert!(state.status_line().contains("power=on"));
}
+ #[test]
+ fn server_availability_tracks_reachability() {
+ let mut state = LauncherState::new();
+ assert!(!state.server_available);
+ state.set_server_available(true);
+ assert!(state.server_available);
+ }
+
+ #[test]
+ fn breakout_size_choices_track_the_negotiated_source_size() {
+ let mut state = LauncherState::new();
+ state.set_preview_source_profile(1920, 1080, 30);
+ state.set_breakout_limit_size(2560, 1440);
+
+ let source = state.capture_size_choice(0);
+ assert_eq!(source.width, 1920);
+ assert_eq!(source.height, 1080);
+ assert_eq!(source.fps, 30);
+ assert_eq!(source.max_bitrate_kbit, 12_000);
+
+ state.set_capture_size_preset(0, CaptureSizePreset::P540);
+ let compact_capture = state.capture_size_choice(0);
+ assert_eq!(compact_capture.width, 640);
+ assert_eq!(compact_capture.height, 360);
+ assert_eq!(compact_capture.fps, 15);
+ assert_eq!(compact_capture.max_bitrate_kbit, 2_500);
+
+ let display = state.breakout_size_choice(0);
+ assert_eq!(display.width, 1920);
+ assert_eq!(display.height, 1080);
+
+ state.set_breakout_size_preset(0, BreakoutSizePreset::P540);
+ let compact = state.breakout_size_choice(0);
+ assert_eq!(compact.width, 960);
+ assert_eq!(compact.height, 540);
+
+ let capture_options = state.capture_size_options();
+ assert!(capture_options.len() >= 5);
+ assert!(capture_options.iter().any(|choice| {
+ choice.preset == CaptureSizePreset::Source
+ && choice.width == 1920
+ && choice.height == 1080
+ }));
+
+ let breakout_options = state.breakout_size_options();
+ assert!(breakout_options.len() >= 5);
+ assert!(breakout_options.iter().any(|choice| {
+ choice.preset == BreakoutSizePreset::Source
+ && choice.width == 1920
+ && choice.height == 1080
+ }));
+ }
+
#[test]
fn swap_key_binding_tracks_selected_key_and_binding_mode() {
let mut state = LauncherState::new();
assert_eq!(state.swap_key, "pause");
assert!(!state.swap_key_binding);
- state.begin_swap_key_binding();
+ let token = state.begin_swap_key_binding();
assert!(state.swap_key_binding);
+ assert_eq!(token, state.swap_key_binding_token);
state.set_swap_key("F8");
assert_eq!(state.swap_key, "f8");
@@ -403,6 +898,29 @@ mod tests {
assert!(!state.swap_key_binding);
}
+ #[test]
+ fn swap_key_binding_timeout_only_cancels_matching_attempt() {
+ let mut state = LauncherState::new();
+ let first = state.begin_swap_key_binding();
+ let second = state.begin_swap_key_binding();
+
+ assert!(!state.cancel_swap_key_binding(first));
+ assert!(state.swap_key_binding);
+ assert!(state.cancel_swap_key_binding(second));
+ assert!(!state.swap_key_binding);
+ }
+
+ #[test]
+ fn complete_swap_key_binding_updates_value_and_ends_binding() {
+ let mut state = LauncherState::new();
+ state.begin_swap_key_binding();
+
+ state.complete_swap_key_binding("F12");
+
+ assert_eq!(state.swap_key, "f12");
+ assert!(!state.swap_key_binding);
+ }
+
#[test]
fn push_note_accumulates_operator_context() {
let mut state = LauncherState::new();
@@ -411,4 +929,23 @@ mod tests {
assert_eq!(state.notes, vec!["preview warm", "relay linked"]);
}
+
+ #[test]
+ fn source_capture_profile_uses_source_fps_and_scaled_profiles_cap_it() {
+ let mut state = LauncherState::new();
+ state.set_preview_source_profile(1920, 1080, 60);
+ let source = state.capture_size_choice(0);
+ assert_eq!(source.width, 1920);
+ assert_eq!(source.height, 1080);
+ assert_eq!(source.fps, 60);
+ assert!(source.max_bitrate_kbit >= 12_000);
+
+ state.set_capture_size_preset(0, CaptureSizePreset::P720);
+ let hd = state.capture_size_choice(0);
+ assert_eq!(hd.fps, 24);
+
+ state.set_capture_size_preset(0, CaptureSizePreset::P540);
+ let compact = state.capture_size_choice(0);
+ assert_eq!(compact.fps, 15);
+ }
}
diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs
index 78a0729..498a4d0 100644
--- a/client/src/launcher/ui.rs
+++ b/client/src/launcher/ui.rs
@@ -2,26 +2,34 @@ use anyhow::Result;
#[cfg(not(coverage))]
use {
- super::clipboard::send_clipboard_to_remote,
+ super::clipboard::send_clipboard_text_to_remote,
super::device_test::{DeviceTestController, DeviceTestKind},
super::devices::DeviceCatalog,
super::diagnostics::quality_probe_command,
+ super::launcher_clipboard_control_path,
super::launcher_focus_signal_path,
super::power::{fetch_capture_power, set_capture_power_mode},
- super::state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
+ super::state::{
+ BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, InputRouting,
+ LauncherState,
+ },
super::ui_components::build_launcher_view,
super::ui_runtime::{
- RelayChild, capture_swap_key, dock_display_to_preview, input_control_path,
- input_state_path, next_input_routing, open_popout_window, path_marker,
- read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons,
- routing_name, selected_combo_value, selected_server_addr, spawn_client_process,
- stop_child_process, toggle_key_label, update_test_action_result,
- write_input_routing_request,
+ RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
+ capture_swap_key, copy_session_log, dock_all_displays_to_preview, dock_display_to_preview,
+ input_control_path, input_state_path, next_input_routing, open_popout_window,
+ open_session_log_popout, path_marker, present_popout_windows, read_input_routing_state,
+ reap_exited_child, refresh_launcher_ui, refresh_test_buttons, routing_name,
+ selected_combo_value, selected_server_addr, spawn_client_process, stop_child_process,
+ toggle_key_label, update_test_action_result, write_input_routing_request,
},
+ crate::handshake::{PeerCaps, negotiate},
+ crate::output::display::enumerate_monitors,
gtk::glib,
gtk::prelude::*,
lesavka_common::lesavka::CapturePowerCommand,
std::cell::{Cell, RefCell},
+ std::process::Command,
std::rc::Rc,
std::time::Duration,
};
@@ -37,6 +45,16 @@ enum RelayMessage {
Spawned(std::result::Result),
}
+#[cfg(not(coverage))]
+enum CapsMessage {
+ Refresh(PeerCaps),
+}
+
+#[cfg(not(coverage))]
+enum ClipboardMessage {
+ Finished(std::result::Result),
+}
+
#[cfg(not(coverage))]
fn request_capture_power_refresh(
power_tx: std::sync::mpsc::Sender,
@@ -52,6 +70,203 @@ fn request_capture_power_refresh(
});
}
+#[cfg(not(coverage))]
+fn request_capture_power_command(
+ power_tx: std::sync::mpsc::Sender,
+ server_addr: String,
+ command: CapturePowerCommand,
+) {
+ std::thread::spawn(move || {
+ let result = set_capture_power_mode(&server_addr, command).map_err(|err| err.to_string());
+ let _ = power_tx.send(PowerMessage::Command(result));
+ });
+}
+
+#[cfg(not(coverage))]
+fn request_handshake_caps(
+ caps_tx: std::sync::mpsc::Sender,
+ server_addr: String,
+ delay: Duration,
+) {
+ std::thread::spawn(move || {
+ if !delay.is_zero() {
+ std::thread::sleep(delay);
+ }
+ let runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build();
+ let caps = match runtime {
+ Ok(runtime) => runtime.block_on(negotiate(&server_addr)),
+ Err(_) => PeerCaps::default(),
+ };
+ let _ = caps_tx.send(CapsMessage::Refresh(caps));
+ });
+}
+
+#[cfg(not(coverage))]
+fn unavailable_capture_power(detail: String) -> CapturePowerStatus {
+ CapturePowerStatus {
+ available: false,
+ enabled: false,
+ unit: "relay.service".to_string(),
+ detail,
+ active_leases: 0,
+ mode: "auto".to_string(),
+ }
+}
+
+#[cfg(not(coverage))]
+fn refresh_eye_feed_controls(
+ widgets: &super::ui_components::LauncherWidgets,
+ state: &LauncherState,
+) {
+ for monitor_id in 0..2 {
+ super::ui_components::sync_capture_size_combo(
+ &widgets.display_panes[monitor_id].capture_combo,
+ state.capture_size_options(),
+ state.capture_size_preset(monitor_id),
+ );
+ super::ui_components::sync_breakout_size_combo(
+ &widgets.display_panes[monitor_id].breakout_combo,
+ state.breakout_size_options(),
+ state.breakout_size_preset(monitor_id),
+ );
+ }
+}
+
+#[cfg(not(coverage))]
+fn largest_monitor_size() -> (u32, u32) {
+ let (width, height) = enumerate_monitors()
+ .into_iter()
+ .max_by_key(|monitor| {
+ effective_monitor_width(&monitor) as u64 * effective_monitor_height(&monitor) as u64
+ })
+ .map(|monitor| {
+ (
+ effective_monitor_width(&monitor),
+ effective_monitor_height(&monitor),
+ )
+ })
+ .unwrap_or((1920, 1080));
+ (width.max(2), height.max(2))
+}
+
+#[cfg(not(coverage))]
+fn largest_monitor_physical_size() -> (u32, u32) {
+ if let Some((width, height)) = probe_kscreen_display_size() {
+ return (width, height);
+ }
+ normalize_breakout_limit(largest_monitor_size().0, largest_monitor_size().1)
+}
+
+#[cfg(not(coverage))]
+fn probe_kscreen_display_size() -> Option<(u32, u32)> {
+ let output = Command::new("kscreen-doctor").arg("-o").output().ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ let text = String::from_utf8(output.stdout).ok()?;
+ let mut best = None;
+ for line in text.lines() {
+ if !line.contains("Modes:") {
+ continue;
+ }
+ let active = line
+ .split_whitespace()
+ .find(|token| token.contains('*') && token.contains('x'))?;
+ let dims = active
+ .trim_matches(|ch: char| ch == '*' || ch == '!')
+ .split('@')
+ .next()?;
+ let (width, height) = dims.split_once('x')?;
+ let width = width.parse::().ok()?;
+ let height = height.parse::().ok()?;
+ if best
+ .map(|(best_w, best_h)| width as u64 * height as u64 > best_w as u64 * best_h as u64)
+ .unwrap_or(true)
+ {
+ best = Some((width, height));
+ }
+ }
+ best
+}
+
+#[cfg(not(coverage))]
+fn effective_monitor_width(monitor: &crate::output::display::MonitorInfo) -> u32 {
+ let scale = monitor.scale_factor.max(1) as u32;
+ (monitor.geometry.width().max(1) as u32).saturating_mul(scale)
+}
+
+#[cfg(not(coverage))]
+fn effective_monitor_height(monitor: &crate::output::display::MonitorInfo) -> u32 {
+ let scale = monitor.scale_factor.max(1) as u32;
+ (monitor.geometry.height().max(1) as u32).saturating_mul(scale)
+}
+
+#[cfg(not(coverage))]
+fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) {
+ const STANDARD_SIZES: &[(u32, u32)] = &[
+ (3840, 2160),
+ (2560, 1440),
+ (1920, 1080),
+ (1600, 900),
+ (1366, 768),
+ (1280, 720),
+ (960, 540),
+ ];
+
+ STANDARD_SIZES
+ .iter()
+ .copied()
+ .find(|(candidate_w, candidate_h)| *candidate_w <= width && *candidate_h <= height)
+ .unwrap_or((width.max(2), height.max(2)))
+}
+
+#[cfg(not(coverage))]
+fn rebind_inline_preview(
+ preview: &super::preview::LauncherPreview,
+ widgets: &super::ui_components::LauncherWidgets,
+ monitor_id: usize,
+) {
+ if let Some(binding) = widgets.display_panes[monitor_id]
+ .preview_binding
+ .borrow_mut()
+ .take()
+ {
+ binding.close();
+ }
+ let binding = preview.install_on_picture(
+ monitor_id,
+ super::preview::PreviewSurface::Inline,
+ &widgets.display_panes[monitor_id].picture,
+ &widgets.display_panes[monitor_id].stream_status,
+ );
+ *widgets.display_panes[monitor_id]
+ .preview_binding
+ .borrow_mut() = binding;
+}
+
+#[cfg(not(coverage))]
+fn rebind_popout_preview(
+ preview: &super::preview::LauncherPreview,
+ popouts: &Rc; 2]>>,
+ monitor_id: usize,
+) {
+ let mut popouts = popouts.borrow_mut();
+ let Some(handle) = popouts.get_mut(monitor_id).and_then(|slot| slot.as_mut()) else {
+ return;
+ };
+ handle.binding.close();
+ if let Some(binding) = preview.install_on_picture(
+ monitor_id,
+ super::preview::PreviewSurface::Window,
+ &handle.picture,
+ &handle.status_label,
+ ) {
+ handle.binding = binding;
+ }
+}
+
#[cfg(not(coverage))]
fn disconnected_capture_note(mode: &str) -> &'static str {
match mode {
@@ -65,6 +280,14 @@ fn disconnected_capture_note(mode: &str) -> &'static str {
}
}
+/// Keeps remote eye previews tied to a live session while respecting forced-off staging.
+fn session_preview_active(
+ state: &crate::launcher::state::LauncherState,
+ child_running: bool,
+) -> bool {
+ (child_running || state.remote_active) && state.capture_power.mode != "forced-off"
+}
+
#[cfg(not(coverage))]
pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let app = gtk::Application::builder()
@@ -77,15 +300,18 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let tests = Rc::new(RefCell::new(DeviceTestController::new()));
let server_addr = Rc::new(server_addr);
let focus_signal_path = Rc::new(launcher_focus_signal_path());
+ let clipboard_control_path = Rc::new(launcher_clipboard_control_path());
let input_control_path = Rc::new(input_control_path());
let input_state_path = Rc::new(input_state_path());
let _ = std::fs::remove_file(focus_signal_path.as_path());
+ let _ = std::fs::remove_file(clipboard_control_path.as_path());
let _ = std::fs::remove_file(input_control_path.as_path());
let _ = std::fs::remove_file(input_state_path.as_path());
{
let child_proc = Rc::clone(&child_proc);
let focus_signal_path = Rc::clone(&focus_signal_path);
+ let clipboard_control_path = Rc::clone(&clipboard_control_path);
let input_control_path = Rc::clone(&input_control_path);
let input_state_path = Rc::clone(&input_state_path);
let tests = Rc::clone(&tests);
@@ -93,6 +319,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
stop_child_process(&child_proc);
tests.borrow_mut().stop_all();
let _ = std::fs::remove_file(focus_signal_path.as_path());
+ let _ = std::fs::remove_file(clipboard_control_path.as_path());
let _ = std::fs::remove_file(input_control_path.as_path());
let _ = std::fs::remove_file(input_state_path.as_path());
});
@@ -109,15 +336,25 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let input_state_path = Rc::clone(&input_state_path);
app.connect_activate(move |app| {
+ let (display_width, display_height) = largest_monitor_size();
+ let (physical_width, physical_height) = largest_monitor_physical_size();
+ {
+ let mut state = state.borrow_mut();
+ state.set_breakout_display_size(display_width, display_height);
+ state.set_breakout_limit_size(physical_width, physical_height);
+ }
let view = build_launcher_view(app, server_addr.as_ref(), &catalog, &state.borrow());
let window = view.window.clone();
let server_entry = view.server_entry.clone();
let camera_combo = view.camera_combo.clone();
let microphone_combo = view.microphone_combo.clone();
let speaker_combo = view.speaker_combo.clone();
+ let keyboard_combo = view.keyboard_combo.clone();
+ let mouse_combo = view.mouse_combo.clone();
let widgets = view.widgets.clone();
let preview = view.preview.clone();
let popouts = Rc::clone(&view.popouts);
+ let log_popout = Rc::clone(&view.log_popout);
{
let mut tests = tests.borrow_mut();
@@ -145,6 +382,13 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let power_request_in_flight = Rc::new(Cell::new(false));
let (relay_tx, relay_rx) = std::sync::mpsc::channel::();
let relay_request_in_flight = Rc::new(Cell::new(false));
+ let (caps_tx, caps_rx) = std::sync::mpsc::channel::();
+ let (clipboard_tx, clipboard_rx) = std::sync::mpsc::channel::();
+ let (log_tx, log_rx) = std::sync::mpsc::channel::();
+
+ if let Some(preview) = preview.as_ref() {
+ preview.set_log_sink(log_tx.clone());
+ }
{
let state = Rc::clone(&state);
@@ -173,6 +417,49 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
});
}
+ {
+ let state = Rc::clone(&state);
+ let widgets = widgets.clone();
+ let child_proc = Rc::clone(&child_proc);
+ let keyboard_combo = keyboard_combo.clone();
+ let keyboard_combo_read = keyboard_combo.clone();
+ keyboard_combo.connect_changed(move |_| {
+ let selected = selected_combo_value(&keyboard_combo_read);
+ state.borrow_mut().select_keyboard(selected.clone());
+ let message = match selected.as_deref() {
+ Some(path) => {
+ format!("The next relay launch will listen only to keyboard {path}.")
+ }
+ None => "The next relay launch will listen to all keyboards.".to_string(),
+ };
+ widgets.status_label.set_text(&message);
+ refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
+ });
+ }
+
+ {
+ let state = Rc::clone(&state);
+ let widgets = widgets.clone();
+ let child_proc = Rc::clone(&child_proc);
+ let mouse_combo = mouse_combo.clone();
+ let mouse_combo_read = mouse_combo.clone();
+ mouse_combo.connect_changed(move |_| {
+ let selected = selected_combo_value(&mouse_combo_read);
+ state.borrow_mut().select_mouse(selected.clone());
+ let message = match selected.as_deref() {
+ Some(path) => {
+ format!("The next relay launch will listen only to pointer {path}.")
+ }
+ None => {
+ "The next relay launch will listen to all pointer devices."
+ .to_string()
+ }
+ };
+ widgets.status_label.set_text(&message);
+ refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
+ });
+ }
+
if let Some(preview) = preview.as_ref() {
preview.set_session_active(false);
}
@@ -181,6 +468,124 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
selected_server_addr(&server_entry, server_addr.as_ref()),
Duration::ZERO,
);
+ request_handshake_caps(
+ caps_tx.clone(),
+ selected_server_addr(&server_entry, server_addr.as_ref()),
+ Duration::ZERO,
+ );
+
+ {
+ let state = Rc::clone(&state);
+ let child_proc = Rc::clone(&child_proc);
+ let widgets = widgets.clone();
+ let server_entry = server_entry.clone();
+ let server_entry_read = server_entry.clone();
+ let server_addr_fallback = Rc::clone(&server_addr);
+ let preview = preview.clone();
+ let power_tx = power_tx.clone();
+ let caps_tx = caps_tx.clone();
+ server_entry.connect_changed(move |_| {
+ let server_addr =
+ selected_server_addr(&server_entry_read, server_addr_fallback.as_ref());
+ state.borrow_mut().set_server_available(false);
+ if let Some(preview) = preview.as_ref() {
+ preview.set_server_addr(server_addr.clone());
+ }
+ refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
+ request_capture_power_refresh(
+ power_tx.clone(),
+ server_addr.clone(),
+ Duration::from_millis(150),
+ );
+ request_handshake_caps(caps_tx.clone(), server_addr, Duration::from_millis(150));
+ });
+ }
+
+ for monitor_id in 0..2 {
+ let state = Rc::clone(&state);
+ let widgets = widgets.clone();
+ let popouts = Rc::clone(&popouts);
+ let child_proc = Rc::clone(&child_proc);
+ let preview = preview.clone();
+ let capture_combo = widgets.display_panes[monitor_id].capture_combo.clone();
+ capture_combo.connect_changed(move |combo| {
+ let Some(active_id) = combo.active_id() else {
+ return;
+ };
+ let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else {
+ return;
+ };
+ if state.borrow().capture_size_preset(monitor_id) == preset {
+ return;
+ }
+ {
+ let mut state = state.borrow_mut();
+ state.set_capture_size_preset(monitor_id, preset);
+ }
+ if let Some(preview) = preview.as_ref() {
+ let choice = state.borrow().capture_size_choice(monitor_id);
+ preview.set_capture_profile(
+ monitor_id,
+ choice.width,
+ choice.height,
+ choice.fps,
+ choice.max_bitrate_kbit,
+ );
+ rebind_inline_preview(preview, &widgets, monitor_id);
+ rebind_popout_preview(preview, &popouts, monitor_id);
+ }
+ refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
+ });
+ }
+
+ for monitor_id in 0..2 {
+ let state = Rc::clone(&state);
+ let widgets = widgets.clone();
+ let popouts = Rc::clone(&popouts);
+ let child_proc = Rc::clone(&child_proc);
+ let preview = preview.clone();
+ let breakout_combo = widgets.display_panes[monitor_id].breakout_combo.clone();
+ breakout_combo.connect_changed(move |combo| {
+ let Some(active_id) = combo.active_id() else {
+ return;
+ };
+ let Some(preset) = BreakoutSizePreset::from_id(active_id.as_str()) else {
+ return;
+ };
+ if state.borrow().breakout_size_preset(monitor_id) == preset {
+ return;
+ }
+ {
+ let mut state = state.borrow_mut();
+ state.set_breakout_size_preset(monitor_id, preset);
+ }
+ let size = state.borrow().breakout_size_choice(monitor_id);
+ if let Some(preview) = preview.as_ref() {
+ preview.set_breakout_profile(monitor_id, size.width, size.height);
+ }
+ let popout_open = {
+ popouts
+ .borrow()
+ .get(monitor_id)
+ .and_then(|slot| slot.as_ref())
+ .is_some()
+ };
+ if popout_open {
+ if let Some(preview) = preview.as_ref() {
+ rebind_popout_preview(preview, &popouts, monitor_id);
+ }
+ if let Some(handle) = popouts
+ .borrow()
+ .get(monitor_id)
+ .and_then(|slot| slot.as_ref())
+ {
+ let display_limit = state.borrow().breakout_display_size();
+ apply_popout_window_size(handle, size, display_limit);
+ }
+ }
+ refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
+ });
+ }
{
let state = Rc::clone(&state);
@@ -242,6 +647,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let power_tx = power_tx.clone();
let relay_tx = relay_tx.clone();
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
+ let popouts = Rc::clone(&popouts);
+ let window = window.clone();
let start_button = widgets.start_button.clone();
let widgets_handle = widgets.clone();
start_button.connect_clicked(move |_| {
@@ -257,13 +664,31 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let _ = state.stop_remote();
state.capture_power.mode.clone()
};
+ dock_all_displays_to_preview(
+ &state,
+ &child_proc,
+ &popouts,
+ &widgets_handle,
+ );
+ window.present();
if let Some(preview) = preview.as_ref() {
preview.set_server_addr(server_addr.clone());
preview.set_session_active(false);
}
- widgets_handle
- .status_label
- .set_text(disconnected_capture_note(&power_mode));
+ if power_mode != "auto" {
+ widgets_handle.status_label.set_text(
+ "Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.",
+ );
+ request_capture_power_command(
+ power_tx.clone(),
+ server_addr.clone(),
+ CapturePowerCommand::Auto,
+ );
+ } else {
+ widgets_handle
+ .status_label
+ .set_text(disconnected_capture_note(&power_mode));
+ }
request_capture_power_refresh(
power_tx.clone(),
server_addr.clone(),
@@ -282,6 +707,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
state.select_camera(selected_combo_value(&camera_combo));
state.select_microphone(selected_combo_value(µphone_combo));
state.select_speaker(selected_combo_value(&speaker_combo));
+ state.select_keyboard(selected_combo_value(&keyboard_combo));
+ state.select_mouse(selected_combo_value(&mouse_combo));
}
let _ = std::fs::remove_file(input_control_path.as_path());
let _ = std::fs::remove_file(input_state_path.as_path());
@@ -319,6 +746,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let child_proc = Rc::clone(&child_proc);
let widgets = widgets.clone();
let input_control_path = Rc::clone(&input_control_path);
+ let popouts = Rc::clone(&popouts);
+ let window = window.clone();
let input_toggle_button = widgets.input_toggle_button.clone();
let widgets_handle = widgets.clone();
input_toggle_button.connect_clicked(move |_| {
@@ -346,6 +775,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
state.borrow_mut().set_routing(next);
refresh_launcher_ui(&widgets_handle, &state.borrow(), child_running);
+ if matches!(next, InputRouting::Remote) {
+ present_popout_windows(&popouts);
+ } else {
+ window.present();
+ }
});
}
@@ -355,11 +789,26 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let widgets = widgets.clone();
let swap_key_button = widgets.swap_key_button.clone();
swap_key_button.connect_clicked(move |_| {
- state.borrow_mut().begin_swap_key_binding();
+ let token = state.borrow_mut().begin_swap_key_binding();
widgets
.status_label
- .set_text("Press a single key now to make it the swap shortcut.");
+ .set_text("Press a single key within 3 seconds to make it the swap shortcut.");
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
+ let state = Rc::clone(&state);
+ let child_proc = Rc::clone(&child_proc);
+ let widgets = widgets.clone();
+ glib::timeout_add_local_once(Duration::from_secs(3), move || {
+ if state.borrow_mut().cancel_swap_key_binding(token) {
+ widgets.status_label.set_text(
+ "Swap-key capture timed out. The previous shortcut is still in place.",
+ );
+ refresh_launcher_ui(
+ &widgets,
+ &state.borrow(),
+ child_proc.borrow().is_some(),
+ );
+ }
+ });
});
}
@@ -368,6 +817,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let widgets = widgets.clone();
let server_entry = server_entry.clone();
let server_addr_fallback = Rc::clone(&server_addr);
+ let clipboard_tx = clipboard_tx.clone();
widgets.clipboard_button.connect_clicked(move |_| {
if child_proc.borrow().is_none() {
widgets
@@ -377,32 +827,44 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
+ let Some(display) = gtk::gdk::Display::default() else {
+ widgets
+ .status_label
+ .set_text("No desktop clipboard is available in this session.");
+ return;
+ };
widgets
.status_label
- .set_text("Sending clipboard to the remote target...");
- let (result_tx, result_rx) = std::sync::mpsc::channel::();
- std::thread::spawn(move || {
- let message = match send_clipboard_to_remote(&server_addr) {
- Ok(mode) => mode,
- Err(err) => format!("Clipboard send failed: {err}"),
- };
- let _ = result_tx.send(message);
- });
-
- let status_label = widgets.status_label.clone();
- glib::timeout_add_local(Duration::from_millis(100), move || {
- match result_rx.try_recv() {
- Ok(message) => {
- status_label.set_text(&message);
- glib::ControlFlow::Break
+ .set_text("Reading the local clipboard and packing a remote paste spell...");
+ let clipboard = display.clipboard();
+ let clipboard_tx = clipboard_tx.clone();
+ clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| {
+ match result {
+ Ok(Some(text)) => {
+ let text = text.trim_end_matches(['\r', '\n']).to_string();
+ if text.is_empty() {
+ let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
+ "clipboard is empty".to_string(),
+ )));
+ return;
+ }
+ let clipboard_tx = clipboard_tx.clone();
+ std::thread::spawn(move || {
+ let result = send_clipboard_text_to_remote(&server_addr, &text)
+ .map_err(|err| err.to_string());
+ let _ = clipboard_tx
+ .send(ClipboardMessage::Finished(result));
+ });
}
- Err(std::sync::mpsc::TryRecvError::Empty) => {
- glib::ControlFlow::Continue
+ Ok(None) => {
+ let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
+ "clipboard is empty".to_string(),
+ )));
}
- Err(std::sync::mpsc::TryRecvError::Disconnected) => {
- status_label
- .set_text("Clipboard send failed: launcher worker exited.");
- glib::ControlFlow::Break
+ Err(err) => {
+ let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
+ format!("clipboard read failed: {err}"),
+ )));
}
}
});
@@ -426,6 +888,33 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
});
}
+ {
+ let widgets = widgets.clone();
+ widgets.console_copy_button.connect_clicked(move |_| {
+ if let Err(err) = copy_session_log(&widgets.session_log_buffer) {
+ widgets
+ .status_label
+ .set_text(&format!("Could not copy the session log: {err}"));
+ } else {
+ widgets
+ .status_label
+ .set_text("Session log copied to the local clipboard.");
+ }
+ });
+ }
+
+ {
+ let app = app.clone();
+ let widgets = widgets.clone();
+ let log_popout = Rc::clone(&log_popout);
+ widgets.console_popout_button.connect_clicked(move |_| {
+ open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer);
+ widgets
+ .status_label
+ .set_text("Session log moved into its own window.");
+ });
+ }
+
{
let widgets = widgets.clone();
let tests = Rc::clone(&tests);
@@ -472,6 +961,26 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
});
}
+ {
+ let widgets = widgets.clone();
+ let tests = Rc::clone(&tests);
+ let speaker_combo = speaker_combo.clone();
+ let microphone_replay_button = widgets.microphone_replay_button.clone();
+ let widgets_handle = widgets.clone();
+ microphone_replay_button.connect_clicked(move |_| {
+ let result = tests
+ .borrow_mut()
+ .toggle_microphone_replay(selected_combo_value(&speaker_combo).as_deref());
+ update_test_action_result(
+ &widgets_handle,
+ &mut tests.borrow_mut(),
+ result,
+ "Replaying the latest local mic capture through the selected speaker.",
+ "Mic replay stopped.",
+ );
+ });
+ }
+
{
let widgets = widgets.clone();
let tests = Rc::clone(&tests);
@@ -509,13 +1018,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
widgets_handle
.status_label
.set_text("Returning capture feeds to automatic mode...");
- let tx = power_tx.clone();
- std::thread::spawn(move || {
- let result =
- set_capture_power_mode(&server_addr, CapturePowerCommand::Auto)
- .map_err(|err| err.to_string());
- let _ = tx.send(PowerMessage::Command(result));
- });
+ request_capture_power_command(
+ power_tx.clone(),
+ server_addr,
+ CapturePowerCommand::Auto,
+ );
});
}
@@ -535,13 +1042,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
widgets_handle
.status_label
.set_text("Forcing capture feeds on for staging...");
- let tx = power_tx.clone();
- std::thread::spawn(move || {
- let result =
- set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOn)
- .map_err(|err| err.to_string());
- let _ = tx.send(PowerMessage::Command(result));
- });
+ request_capture_power_command(
+ power_tx.clone(),
+ server_addr,
+ CapturePowerCommand::ForceOn,
+ );
});
}
@@ -561,13 +1066,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
widgets_handle
.status_label
.set_text("Forcing capture feeds off for staging...");
- let tx = power_tx.clone();
- std::thread::spawn(move || {
- let result =
- set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOff)
- .map_err(|err| err.to_string());
- let _ = tx.send(PowerMessage::Command(result));
- });
+ request_capture_power_command(
+ power_tx.clone(),
+ server_addr,
+ CapturePowerCommand::ForceOff,
+ );
});
}
@@ -586,7 +1089,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
.set_text("Preview is unavailable for breakout windows.");
return;
};
- match state.borrow().display_surface(monitor_id) {
+ let surface = {
+ let state = state.borrow();
+ state.display_surface(monitor_id)
+ };
+ match surface {
DisplaySurface::Preview => {
open_popout_window(
&app,
@@ -644,8 +1151,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let relay_live = child_proc.borrow().is_some() || state.borrow().remote_active;
{
let mut state = state.borrow_mut();
- state.set_swap_key(swap_key.clone());
- state.finish_swap_key_binding();
+ state.complete_swap_key_binding(swap_key.clone());
}
let status_message = if relay_live {
format!(
@@ -683,6 +1189,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
let preview = preview.clone();
let power_tx = power_tx.clone();
+ let log_tx = log_tx.clone();
glib::timeout_add_local(Duration::from_millis(180), move || {
let child_running = reap_exited_child(&child_proc);
if !child_running && state.borrow().remote_active {
@@ -691,14 +1198,27 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let _ = state.stop_remote();
state.capture_power.mode.clone()
};
+ dock_all_displays_to_preview(&state, &child_proc, &popouts, &widgets);
+ window.present();
if let Some(preview) = preview.as_ref() {
preview.set_session_active(false);
}
- widgets
- .status_label
- .set_text(disconnected_capture_note(&power_mode));
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
+ if power_mode != "auto" {
+ widgets.status_label.set_text(
+ "Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.",
+ );
+ request_capture_power_command(
+ power_tx.clone(),
+ server_addr.clone(),
+ CapturePowerCommand::Auto,
+ );
+ } else {
+ widgets
+ .status_label
+ .set_text(disconnected_capture_note(&power_mode));
+ }
request_capture_power_refresh(
power_tx.clone(),
server_addr.clone(),
@@ -719,6 +1239,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{
state.borrow_mut().set_routing(routing);
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
+ if matches!(routing, InputRouting::Remote) {
+ present_popout_windows(&popouts);
+ } else {
+ window.present();
+ }
}
}
@@ -737,14 +1262,22 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
while let Ok(message) = relay_rx.try_recv() {
relay_request_in_flight.set(false);
match message {
- RelayMessage::Spawned(Ok(child)) => {
+ RelayMessage::Spawned(Ok(mut child)) => {
+ attach_child_log_streams(&mut child, log_tx.clone());
*child_proc.borrow_mut() = Some(child);
- let _ = state.borrow_mut().start_remote();
+ {
+ let mut state = state.borrow_mut();
+ state.set_server_available(true);
+ let _ = state.start_remote();
+ }
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
if let Some(preview) = preview.as_ref() {
preview.set_server_addr(server_addr.clone());
- preview.set_session_active(true);
+ preview.set_session_active(session_preview_active(
+ &state.borrow(),
+ child_proc.borrow().is_some(),
+ ));
}
let routing = routing_name(state.borrow().routing);
let power_mode = state.borrow().capture_power.mode.clone();
@@ -763,6 +1296,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
),
};
widgets.status_label.set_text(&message);
+ if matches!(state.borrow().routing, InputRouting::Remote) {
+ present_popout_windows(&popouts);
+ }
request_capture_power_refresh(
power_tx.clone(),
server_addr.clone(),
@@ -775,6 +1311,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
);
}
RelayMessage::Spawned(Err(err)) => {
+ state.borrow_mut().set_server_available(false);
if let Some(preview) = preview.as_ref() {
preview.set_session_active(false);
}
@@ -785,25 +1322,68 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
}
+ while let Ok(line) = log_rx.try_recv() {
+ append_session_log(&widgets.session_log_buffer, &line);
+ let mut end = widgets.session_log_buffer.end_iter();
+ widgets
+ .session_log_view
+ .scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0);
+ }
+
while let Ok(message) = power_rx.try_recv() {
power_request_in_flight.set(false);
match message {
PowerMessage::Refresh(Ok(power)) => {
- state.borrow_mut().set_capture_power(power);
+ {
+ let mut state = state.borrow_mut();
+ state.set_server_available(true);
+ state.set_capture_power(power);
+ }
+ if let Some(preview) = preview.as_ref() {
+ let preview_active = {
+ let state = state.borrow();
+ session_preview_active(
+ &state,
+ child_proc.borrow().is_some(),
+ )
+ };
+ preview.set_session_active(preview_active);
+ }
}
PowerMessage::Refresh(Err(err)) => {
- state.borrow_mut().set_capture_power(CapturePowerStatus {
- available: false,
- enabled: false,
- unit: "relay.service".to_string(),
- detail: err,
- active_leases: 0,
- mode: "auto".to_string(),
- });
+ {
+ let mut state = state.borrow_mut();
+ state.set_server_available(false);
+ state.set_capture_power(unavailable_capture_power(err));
+ }
+ if let Some(preview) = preview.as_ref() {
+ let preview_active = {
+ let state = state.borrow();
+ session_preview_active(
+ &state,
+ child_proc.borrow().is_some(),
+ )
+ };
+ preview.set_session_active(preview_active);
+ }
}
PowerMessage::Command(Ok(power)) => {
let mode = power.mode.clone();
- state.borrow_mut().set_capture_power(power);
+ {
+ let mut state = state.borrow_mut();
+ state.set_server_available(true);
+ state.set_capture_power(power);
+ }
+ if let Some(preview) = preview.as_ref() {
+ let preview_active = {
+ let state = state.borrow();
+ session_preview_active(
+ &state,
+ child_proc.borrow().is_some(),
+ )
+ };
+ preview.set_session_active(preview_active);
+ }
widgets.status_label.set_text(match mode.as_str() {
"forced-on" => "Capture feeds forced on. Remote eyes stay awake even if previews or the relay stop.",
"forced-off" => "Capture feeds forced off. Remote eye previews and session video stay dark until you switch back.",
@@ -811,6 +1391,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
});
}
PowerMessage::Command(Err(err)) => {
+ let mut state = state.borrow_mut();
+ state.set_server_available(false);
+ state.set_capture_power(unavailable_capture_power(err.clone()));
widgets
.status_label
.set_text(&format!("Capture power update failed: {err}"));
@@ -818,6 +1401,59 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
}
+ while let Ok(message) = caps_rx.try_recv() {
+ match message {
+ CapsMessage::Refresh(caps) => {
+ if let (Some(width), Some(height)) =
+ (caps.eye_width, caps.eye_height)
+ {
+ let fps = caps.eye_fps.unwrap_or(30);
+ {
+ let mut state = state.borrow_mut();
+ state.set_preview_source_profile(width, height, fps);
+ }
+ if let Some(preview) = preview.as_ref() {
+ for monitor_id in 0..2 {
+ let capture = state.borrow().capture_size_choice(monitor_id);
+ let breakout = state.borrow().breakout_size_choice(monitor_id);
+ preview.set_capture_profile(
+ monitor_id,
+ capture.width,
+ capture.height,
+ capture.fps,
+ capture.max_bitrate_kbit,
+ );
+ preview.set_breakout_profile(
+ monitor_id,
+ breakout.width,
+ breakout.height,
+ );
+ rebind_inline_preview(preview, &widgets, monitor_id);
+ rebind_popout_preview(preview, &popouts, monitor_id);
+ }
+ }
+ refresh_eye_feed_controls(&widgets, &state.borrow());
+ } else {
+ refresh_eye_feed_controls(&widgets, &state.borrow());
+ }
+ }
+ }
+ }
+
+ while let Ok(message) = clipboard_rx.try_recv() {
+ match message {
+ ClipboardMessage::Finished(Ok(detail)) => {
+ widgets.status_label.set_text(&format!("✨ {detail}"));
+ }
+ ClipboardMessage::Finished(Err(err)) => {
+ widgets
+ .status_label
+ .set_text(&format!("Clipboard send failed: {err}"));
+ }
+ }
+ }
+
+ let child_running = child_proc.borrow().is_some();
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
glib::ControlFlow::Continue
@@ -839,10 +1475,27 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
#[cfg(all(test, coverage))]
mod tests {
- use super::run_gui_launcher;
+ use super::{run_gui_launcher, session_preview_active};
+ use crate::launcher::state::{CapturePowerStatus, LauncherState};
#[test]
fn coverage_stub_returns_ok() {
assert!(run_gui_launcher("http://127.0.0.1:50051".to_string()).is_ok());
}
+
+ #[test]
+ fn session_preview_stays_idle_when_capture_is_forced_off() {
+ let mut state = LauncherState::new();
+ state.start_remote();
+ state.set_capture_power(CapturePowerStatus {
+ available: true,
+ enabled: false,
+ unit: "relay.service".to_string(),
+ detail: "inactive/dead".to_string(),
+ active_leases: 1,
+ mode: "forced-off".to_string(),
+ });
+
+ assert!(!session_preview_active(&state, true));
+ }
}
diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs
index 8a502a3..479e0f9 100644
--- a/client/src/launcher/ui_components.rs
+++ b/client/src/launcher/ui_components.rs
@@ -1,19 +1,24 @@
use std::{cell::RefCell, rc::Rc};
-use gtk::prelude::*;
+use evdev::Device;
+use gtk::{pango, prelude::*};
use super::{
devices::DeviceCatalog,
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
- state::LauncherState,
+ state::{
+ BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset, LauncherState,
+ },
};
#[derive(Clone)]
pub struct SummaryWidgets {
+ pub relay_light: gtk::Box,
pub relay_value: gtk::Label,
+ pub routing_light: gtk::Box,
pub routing_value: gtk::Label,
- pub power_value: gtk::Label,
- pub displays_value: gtk::Label,
+ pub gpio_light: gtk::Box,
+ pub gpio_value: gtk::Label,
pub shortcut_value: gtk::Label,
}
@@ -24,25 +29,29 @@ pub struct DisplayPaneWidgets {
pub picture: gtk::Picture,
pub stream_status: gtk::Label,
pub placeholder: gtk::Label,
+ pub capture_combo: gtk::ComboBoxText,
+ pub breakout_combo: gtk::ComboBoxText,
pub action_button: gtk::Button,
- pub preview_binding: Option,
+ pub preview_binding: Rc>>,
pub title: String,
}
pub struct PopoutWindowHandle {
pub window: gtk::ApplicationWindow,
+ pub picture: gtk::Picture,
+ pub status_label: gtk::Label,
pub binding: PreviewBinding,
}
#[derive(Clone)]
pub struct LauncherWidgets {
pub status_label: gtk::Label,
+ pub session_log_buffer: gtk::TextBuffer,
+ pub session_log_view: gtk::TextView,
pub summary: SummaryWidgets,
pub power_detail: gtk::Label,
- pub launch_plan_title: gtk::Label,
- pub launch_plan_summary: gtk::Label,
- pub launch_plan_detail: gtk::Label,
- pub local_test_detail: gtk::Label,
+ pub audio_check_detail: gtk::Label,
+ pub audio_check_meter: gtk::ProgressBar,
pub display_panes: [DisplayPaneWidgets; 2],
pub start_button: gtk::Button,
pub power_auto_button: gtk::Button,
@@ -54,7 +63,10 @@ pub struct LauncherWidgets {
pub swap_key_button: gtk::Button,
pub camera_test_button: gtk::Button,
pub microphone_test_button: gtk::Button,
+ pub microphone_replay_button: gtk::Button,
pub speaker_test_button: gtk::Button,
+ pub console_copy_button: gtk::Button,
+ pub console_popout_button: gtk::Button,
}
#[derive(Clone)]
@@ -69,12 +81,24 @@ pub struct LauncherView {
pub camera_combo: gtk::ComboBoxText,
pub microphone_combo: gtk::ComboBoxText,
pub speaker_combo: gtk::ComboBoxText,
+ pub keyboard_combo: gtk::ComboBoxText,
+ pub mouse_combo: gtk::ComboBoxText,
pub device_stage: DeviceStageWidgets,
pub widgets: LauncherWidgets,
pub preview: Option>,
pub popouts: Rc; 2]>>,
+ pub log_popout: Rc>>,
}
+pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
+const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
+const LAUNCHER_DEFAULT_WIDTH: i32 = 1510;
+const LAUNCHER_DEFAULT_HEIGHT: i32 = 930;
+const OPERATIONS_RAIL_WIDTH: i32 = 304;
+const STAGING_COMBO_WIDTH: i32 = 690;
+const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 178;
+const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 316;
+
pub fn build_launcher_view(
app: >k::Application,
server_addr: &str,
@@ -83,235 +107,66 @@ pub fn build_launcher_view(
) -> LauncherView {
let window = gtk::ApplicationWindow::builder()
.application(app)
- .title("Lesavka Launcher")
- .default_width(1480)
- .default_height(900)
+ .title("Lesavka")
+ .default_width(LAUNCHER_DEFAULT_WIDTH)
+ .default_height(LAUNCHER_DEFAULT_HEIGHT)
.build();
install_css(&window);
+ install_window_icon(&window);
- let root = gtk::Box::new(gtk::Orientation::Vertical, 16);
+ let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
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);
+ root.set_margin_start(10);
+ root.set_margin_end(10);
+ root.set_margin_top(10);
+ root.set_margin_bottom(10);
- let hero = gtk::Box::new(gtk::Orientation::Horizontal, 16);
+ let hero = gtk::Box::new(gtk::Orientation::Horizontal, 8);
hero.set_hexpand(true);
- let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 4);
- let heading = gtk::Label::new(Some("Lesavka Control Deck"));
+ let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
+ let heading = gtk::Label::new(Some("Lesavka"));
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);
+ let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6);
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 (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", "Offline");
+ let (routing_chip, routing_light, routing_value) =
+ build_status_chip_with_light("Inputs", "Local");
+ let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown");
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");
+ stabilize_chip(&relay_chip, 84);
+ stabilize_chip(&routing_chip, 84);
+ stabilize_chip(&gpio_chip, 84);
+ stabilize_chip(&shortcut_chip, 88);
chips.append(&relay_chip);
chips.append(&routing_chip);
- chips.append(&power_chip);
- chips.append(&display_chip);
+ chips.append(&gpio_chip);
chips.append(&shortcut_chip);
hero.append(&chips);
root.append(&hero);
- let content = gtk::Box::new(gtk::Orientation::Horizontal, 16);
+ let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
content.set_hexpand(true);
content.set_vexpand(true);
root.append(&content);
- let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 12);
- sidebar.set_size_request(420, -1);
- sidebar.set_valign(gtk::Align::Fill);
- content.append(&sidebar);
+ let workspace = gtk::Box::new(gtk::Orientation::Vertical, 8);
+ workspace.set_hexpand(true);
+ workspace.set_vexpand(true);
+ content.append(&workspace);
- let stage = gtk::Box::new(gtk::Orientation::Vertical, 12);
- stage.set_hexpand(true);
- stage.set_vexpand(true);
- content.append(&stage);
+ let operations = gtk::Box::new(gtk::Orientation::Vertical, 8);
+ operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1);
+ operations.set_hexpand(false);
+ operations.set_vexpand(true);
+ content.append(&operations);
- let (connection_panel, connection_body) = build_panel("Session");
- 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.",
- ));
- connection_body.append(&server_entry);
-
- let relay_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
- let start_button = gtk::Button::with_label("Connect Relay");
- start_button.add_css_class("suggested-action");
- start_button.set_hexpand(true);
- start_button.set_tooltip_text(Some(
- "Connect to the relay host, bring the staged session online, and start the eye previews.",
- ));
- relay_actions_row.append(&start_button);
- connection_body.append(&relay_actions_row);
-
- let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
- let clipboard_button = gtk::Button::with_label("Send Clipboard");
- clipboard_button.set_hexpand(true);
- 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_hexpand(true);
- probe_button.set_tooltip_text(Some(
- "Copy the hygiene/quality probe command into the local clipboard.",
- ));
- live_actions_row.append(&clipboard_button);
- live_actions_row.append(&probe_button);
- connection_body.append(&live_actions_row);
-
- let power_intro = gtk::Label::new(Some(
- "Capture power can stay automatic or be forced on/off while you stage a session.",
- ));
- power_intro.add_css_class("dim-label");
- power_intro.set_wrap(true);
- power_intro.set_xalign(0.0);
- connection_body.append(&power_intro);
-
- let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
- let power_auto_button = gtk::Button::with_label("Auto");
- power_auto_button.add_css_class("pill-toggle");
- power_auto_button.set_tooltip_text(Some(
- "Automatic mode follows the active remote preview and relay stream leases.",
- ));
- let power_on_button = gtk::Button::with_label("Force On");
- power_on_button.add_css_class("pill-toggle");
- power_on_button.set_tooltip_text(Some(
- "Keep the capture feeds powered even when no preview or session stream is active.",
- ));
- let power_off_button = gtk::Button::with_label("Force Off");
- power_off_button.add_css_class("pill-toggle");
- power_off_button.set_tooltip_text(Some(
- "Hold the capture feeds down even if previews or clients ask for them.",
- ));
- 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_auto_button);
- power_row.append(&power_on_button);
- power_row.append(&power_off_button);
- connection_body.append(&power_row);
- connection_body.append(&power_detail);
- 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.",
- ));
- let swap_key_button = gtk::Button::with_label(&format!(
- "Set Swap Key ({})",
- super::ui_runtime::toggle_key_label(&state.swap_key)
- ));
- swap_key_button.set_tooltip_text(Some(
- "Press this, then hit one keyboard key to make it the live local/remote input swap shortcut.",
- ));
- routing_row.append(&input_toggle_button);
- routing_row.append(&swap_key_button);
- routing_body.append(&routing_row);
- sidebar.append(&routing_panel);
-
- let (devices_panel, devices_body) = build_panel("Device Staging");
- let devices_intro = gtk::Label::new(Some(
- "Choose the exact local camera, microphone, and speaker the next relay launch should inherit.",
- ));
- devices_intro.add_css_class("dim-label");
- devices_intro.set_wrap(true);
- devices_intro.set_xalign(0.0);
- devices_body.append(&devices_intro);
- 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("Start Preview");
- 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("Monitor Mic");
- 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("Play Tone");
- 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 stage_header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
- let stage_title = gtk::Label::new(Some("Remote Eye Feeds"));
- 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);
+ let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
display_row.set_hexpand(true);
display_row.set_vexpand(true);
display_row.set_homogeneous(true);
@@ -319,78 +174,305 @@ pub fn build_launcher_view(
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);
+ workspace.append(&display_row);
- let workspace_row = gtk::Box::new(gtk::Orientation::Horizontal, 16);
- workspace_row.set_hexpand(true);
- workspace_row.set_vexpand(true);
- stage.append(&workspace_row);
+ let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ staging_row.set_hexpand(true);
+ staging_row.set_vexpand(false);
+ workspace.append(&staging_row);
+
+ let (devices_panel, devices_body) = build_panel("Device Staging");
+ devices_panel.set_hexpand(true);
+ devices_panel.set_vexpand(false);
+ devices_body.set_spacing(8);
+
+ let control_group = build_subgroup("Control Inputs");
+ let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
+ control_row.set_homogeneous(true);
+ control_group.append(&control_row);
+
+ let camera_combo = gtk::ComboBoxText::new();
+ camera_combo.append(Some("auto"), "auto");
+ for camera in &catalog.cameras {
+ append_stage_choice(&camera_combo, camera);
+ }
+ super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref());
+ let camera_test_button = gtk::Button::with_label("Start Preview");
+ stabilize_button(&camera_test_button, 118);
+ camera_test_button.set_tooltip_text(Some(
+ "Open a local preview for the selected webcam so you can confirm the right source.",
+ ));
+
+ let speaker_combo = gtk::ComboBoxText::new();
+ speaker_combo.append(Some("auto"), "auto");
+ for speaker in &catalog.speakers {
+ append_stage_choice(&speaker_combo, speaker);
+ }
+ super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref());
+ let speaker_test_button = gtk::Button::with_label("Play Tone");
+ stabilize_button(&speaker_test_button, 118);
+ speaker_test_button.set_tooltip_text(Some(
+ "Play a short continuous tone through the selected speaker until you stop the test.",
+ ));
+
+ let keyboard_combo = gtk::ComboBoxText::new();
+ keyboard_combo.append(Some("all"), "all keyboards");
+ for keyboard in &catalog.keyboards {
+ append_input_choice(&keyboard_combo, keyboard);
+ }
+ super::ui_runtime::set_combo_active_text(&keyboard_combo, state.devices.keyboard.as_deref());
+ keyboard_combo.set_tooltip_text(Some(
+ "Leave this on all keyboards to relay every keyboard, or pick one specific device.",
+ ));
+ let keyboard_block = build_selector_block("Keyboard", &keyboard_combo);
+ control_row.append(&keyboard_block);
+
+ let mouse_combo = gtk::ComboBoxText::new();
+ mouse_combo.append(Some("all"), "all mice");
+ for mouse in &catalog.mice {
+ append_input_choice(&mouse_combo, mouse);
+ }
+ super::ui_runtime::set_combo_active_text(&mouse_combo, state.devices.mouse.as_deref());
+ mouse_combo.set_tooltip_text(Some(
+ "Leave this on all mice to relay every pointer, or pick one specific device.",
+ ));
+ let mouse_block = build_selector_block("Mouse", &mouse_combo);
+ control_row.append(&mouse_block);
+ devices_body.append(&control_group);
+
+ let media_group = build_subgroup("Media Controls");
+ let media_grid = gtk::Grid::new();
+ media_grid.set_row_spacing(10);
+ media_grid.set_column_spacing(8);
+ media_group.append(&media_grid);
+ camera_combo.set_size_request(STAGING_COMBO_WIDTH, -1);
+ speaker_combo.set_size_request(STAGING_COMBO_WIDTH, -1);
+ attach_device_row(&media_grid, 0, "Camera", &camera_combo, &camera_test_button);
+ attach_device_row(
+ &media_grid,
+ 1,
+ "Speaker",
+ &speaker_combo,
+ &speaker_test_button,
+ );
+
+ let microphone_combo = gtk::ComboBoxText::new();
+ microphone_combo.append(Some("auto"), "auto");
+ for microphone in &catalog.microphones {
+ append_stage_choice(µphone_combo, microphone);
+ }
+ super::ui_runtime::set_combo_active_text(
+ µphone_combo,
+ state.devices.microphone.as_deref(),
+ );
+ let microphone_test_button = gtk::Button::with_label("Monitor Mic");
+ stabilize_button(µphone_test_button, 118);
+ microphone_test_button.set_tooltip_text(Some(
+ "Monitor the selected microphone through the selected speaker until you stop the test.",
+ ));
+ microphone_combo.set_size_request(STAGING_COMBO_WIDTH, -1);
+ attach_device_row(
+ &media_grid,
+ 2,
+ "Microphone",
+ µphone_combo,
+ µphone_test_button,
+ );
+
+ let audio_check_detail = gtk::Label::new(Some(
+ "Monitor Mic listens locally, Replay Last 3s replays the latest captured mic audio, and Play Tone verifies the speaker path.",
+ ));
+ audio_check_detail.add_css_class("dim-label");
+ audio_check_detail.set_wrap(true);
+ audio_check_detail.set_xalign(0.0);
+ let audio_check_meter = gtk::ProgressBar::new();
+ audio_check_meter.add_css_class("audio-check-meter");
+ audio_check_meter.set_show_text(false);
+ devices_body.append(&media_group);
+ staging_row.append(&devices_panel);
let (preview_panel, preview_body) = build_panel("Selected Camera Preview");
preview_panel.set_hexpand(true);
- preview_panel.set_vexpand(true);
- let preview_note = gtk::Label::new(Some(
- "Verify the chosen webcam here before you launch. Audio device tests still stay local.",
- ));
- preview_note.add_css_class("dim-label");
- preview_note.set_wrap(true);
- preview_note.set_xalign(0.0);
+ preview_panel.set_vexpand(false);
+ preview_body.set_spacing(6);
let camera_preview = gtk::Picture::new();
- camera_preview.set_can_shrink(true);
+ camera_preview.set_can_shrink(false);
camera_preview.set_hexpand(true);
camera_preview.set_vexpand(true);
- camera_preview.set_size_request(420, 210);
+ camera_preview.set_size_request(
+ CAMERA_PREVIEW_VIEWPORT_WIDTH,
+ CAMERA_PREVIEW_VIEWPORT_HEIGHT,
+ );
camera_preview.set_keep_aspect_ratio(true);
camera_preview.add_css_class("camera-preview-frame");
let camera_status = gtk::Label::new(Some("Select a camera and click Start Preview."));
camera_status.add_css_class("dim-label");
camera_status.set_wrap(true);
camera_status.set_xalign(0.0);
- preview_body.append(&preview_note);
- preview_body.append(&camera_preview);
- preview_body.append(&camera_status);
- workspace_row.append(&preview_panel);
+ let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
+ camera_preview_shell.set_hexpand(true);
+ camera_preview_shell.set_vexpand(false);
+ camera_preview_shell.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT);
+ let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false);
+ camera_preview_frame.set_hexpand(true);
+ camera_preview_frame.set_vexpand(false);
+ camera_preview_frame.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT);
+ camera_preview_frame.set_child(Some(&camera_preview));
+ camera_preview_shell.append(&camera_preview_frame);
+ preview_body.append(&camera_preview_shell);
- let operations_column = gtk::Box::new(gtk::Orientation::Vertical, 12);
- operations_column.set_size_request(340, -1);
- workspace_row.append(&operations_column);
+ let playback_group = build_subgroup("Mic Playback");
+ let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 8);
+ let playback_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ playback_row.set_homogeneous(true);
+ let microphone_replay_button = gtk::Button::with_label("Replay Last 3s");
+ stabilize_button(µphone_replay_button, 124);
+ let audio_preview_heading = gtk::Label::new(Some("Local Playback / Activity"));
+ audio_preview_heading.add_css_class("subgroup-title");
+ audio_preview_heading.set_hexpand(true);
+ audio_preview_heading.set_halign(gtk::Align::Start);
+ playback_row.append(µphone_replay_button);
+ playback_row.append(&audio_preview_heading);
+ playback_body.append(&playback_row);
+ playback_body.append(&audio_check_meter);
+ playback_group.append(&playback_body);
+ preview_body.append(&playback_group);
+ staging_row.append(&preview_panel);
- let (plan_panel, plan_body) = build_panel("Launch Plan");
- let launch_plan_title = gtk::Label::new(Some("Stage locally, then start the relay."));
- launch_plan_title.add_css_class("title-4");
- launch_plan_title.set_halign(gtk::Align::Start);
- launch_plan_title.set_wrap(true);
- let launch_plan_summary =
- gtk::Label::new(Some("Camera: auto\nMicrophone: auto\nSpeaker: auto"));
- launch_plan_summary.add_css_class("launch-plan-summary");
- launch_plan_summary.set_halign(gtk::Align::Start);
- launch_plan_summary.set_xalign(0.0);
- launch_plan_summary.set_wrap(true);
- let local_test_detail = gtk::Label::new(Some(
- "Local checks are idle. Use Start Preview, Monitor Mic, or Play Tone before you launch.",
+ let (connection_panel, connection_body) = build_panel("Session");
+ let server_entry = gtk::Entry::new();
+ server_entry.add_css_class("server-entry");
+ server_entry.set_hexpand(true);
+ server_entry.set_width_chars(18);
+ server_entry.set_text(server_addr);
+ server_entry.set_tooltip_text(Some(
+ "Relay host address for previews, power control, and the live session.",
));
- local_test_detail.add_css_class("dim-label");
- local_test_detail.set_halign(gtk::Align::Start);
- local_test_detail.set_xalign(0.0);
- local_test_detail.set_wrap(true);
- let launch_plan_detail = gtk::Label::new(Some(
- "Automatic capture mode will wake the remote feeds when previews or the live relay ask for them.",
- ));
- launch_plan_detail.add_css_class("dim-label");
- launch_plan_detail.set_halign(gtk::Align::Start);
- launch_plan_detail.set_xalign(0.0);
- launch_plan_detail.set_wrap(true);
- plan_body.append(&launch_plan_title);
- plan_body.append(&launch_plan_summary);
- plan_body.append(&local_test_detail);
- plan_body.append(&launch_plan_detail);
- operations_column.append(&plan_panel);
+ connection_body.append(&server_entry);
- let status_label = gtk::Label::new(Some("Launcher ready."));
+ let relay_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ relay_actions_row.set_homogeneous(true);
+ let start_button = gtk::Button::with_label("Connect Relay");
+ start_button.add_css_class("suggested-action");
+ start_button.set_hexpand(true);
+ stabilize_button(&start_button, 180);
+ relay_actions_row.append(&start_button);
+ connection_body.append(&relay_actions_row);
+
+ let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ live_actions_row.set_homogeneous(true);
+ let clipboard_button = gtk::Button::with_label("Send Clipboard");
+ clipboard_button.set_hexpand(true);
+ stabilize_button(&clipboard_button, 108);
+ 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_hexpand(true);
+ stabilize_button(&probe_button, 108);
+ probe_button.set_tooltip_text(Some(
+ "Copy the hygiene/quality probe command into the local clipboard.",
+ ));
+ live_actions_row.append(&clipboard_button);
+ live_actions_row.append(&probe_button);
+ connection_body.append(&live_actions_row);
+
+ connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
+ let power_heading = gtk::Label::new(Some("GPIO Power"));
+ power_heading.add_css_class("subgroup-title");
+ power_heading.set_halign(gtk::Align::Start);
+ connection_body.append(&power_heading);
+
+ let power_shell = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ power_shell.set_halign(gtk::Align::Center);
+ let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ let power_on_button = gtk::Button::with_label("On");
+ stabilize_button(&power_on_button, 64);
+ power_on_button.add_css_class("pill-toggle");
+ let power_auto_button = gtk::Button::with_label("Auto");
+ stabilize_button(&power_auto_button, 64);
+ power_auto_button.add_css_class("pill-toggle");
+ let power_off_button = gtk::Button::with_label("Off");
+ stabilize_button(&power_off_button, 64);
+ power_off_button.add_css_class("pill-toggle");
+ 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_on_button);
+ power_row.append(&power_auto_button);
+ power_row.append(&power_off_button);
+ power_shell.append(&power_row);
+ connection_body.append(&power_shell);
+ let routing_heading = gtk::Label::new(Some("Input Routing"));
+ routing_heading.add_css_class("subgroup-title");
+ routing_heading.set_halign(gtk::Align::Start);
+ connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
+ connection_body.append(&routing_heading);
+
+ let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ routing_row.set_homogeneous(true);
+ let input_toggle_button = gtk::Button::with_label("Change Routing");
+ input_toggle_button.set_hexpand(true);
+ stabilize_button(&input_toggle_button, 128);
+ input_toggle_button.set_tooltip_text(Some(
+ "Change live keyboard and mouse ownership between this machine and the remote target.",
+ ));
+ let swap_key_button = gtk::Button::with_label("Set Swap Key");
+ stabilize_button(&swap_key_button, 128);
+ routing_row.append(&input_toggle_button);
+ routing_row.append(&swap_key_button);
+ connection_body.append(&routing_row);
+ operations.append(&connection_panel);
+
+ let (console_panel, console_body) = build_panel("Session Console");
+ console_panel.set_vexpand(true);
+ let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ console_toolbar.set_homogeneous(true);
+ let console_copy_button = gtk::Button::with_label("Copy Log");
+ stabilize_button(&console_copy_button, 104);
+ let console_popout_button = gtk::Button::with_label("Break Out Log");
+ stabilize_button(&console_popout_button, 104);
+ console_toolbar.append(&console_copy_button);
+ console_toolbar.append(&console_popout_button);
+ let status_label = gtk::Label::new(Some("Session log 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);
+ status_label.set_wrap(true);
+ status_label.set_xalign(0.0);
+ let session_log_buffer = gtk::TextBuffer::new(None);
+ session_log_buffer.create_tag(Some("log-launcher"), &[("foreground", &"#8bd5ca")]);
+ session_log_buffer.create_tag(Some("log-relay"), &[("foreground", &"#89b4fa")]);
+ session_log_buffer.create_tag(Some("log-preview"), &[("foreground", &"#cba6f7")]);
+ session_log_buffer.create_tag(Some("log-stderr"), &[("foreground", &"#f9e2af")]);
+ session_log_buffer.create_tag(Some("log-warn"), &[("foreground", &"#fab387")]);
+ session_log_buffer.create_tag(Some("log-error"), &[("foreground", &"#f38ba8")]);
+ super::ui_runtime::append_session_log(&session_log_buffer, "[launcher] Session log ready.");
+ let session_log_view = gtk::TextView::with_buffer(&session_log_buffer);
+ session_log_view.add_css_class("status-log");
+ session_log_view.set_editable(false);
+ session_log_view.set_cursor_visible(false);
+ session_log_view.set_monospace(true);
+ session_log_view.set_wrap_mode(gtk::WrapMode::WordChar);
+ let log_scroll = gtk::ScrolledWindow::builder()
+ .hexpand(true)
+ .vexpand(true)
+ .min_content_height(220)
+ .child(&session_log_view)
+ .build();
+ console_body.append(&console_toolbar);
+ console_body.append(&log_scroll);
+ operations.append(&console_panel);
+
+ {
+ let buffer = session_log_buffer.clone();
+ let view = session_log_view.clone();
+ status_label.connect_notify_local(Some("label"), move |label, _| {
+ super::ui_runtime::append_session_log(&buffer, &format!("[launcher] {}", label.text()));
+ let mut end = buffer.end_iter();
+ view.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0);
+ });
+ }
let preview = match LauncherPreview::new(server_addr.to_string()) {
Ok(preview) => Some(Rc::new(preview)),
@@ -400,16 +482,16 @@ pub fn build_launcher_view(
}
};
- let mut left_pane = left_pane;
- let mut right_pane = right_pane;
+ let left_pane = left_pane;
+ let right_pane = right_pane;
if let Some(preview) = preview.as_ref() {
- left_pane.preview_binding = preview.install_on_picture(
+ *left_pane.preview_binding.borrow_mut() = preview.install_on_picture(
0,
PreviewSurface::Inline,
&left_pane.picture,
&left_pane.stream_status,
);
- right_pane.preview_binding = preview.install_on_picture(
+ *right_pane.preview_binding.borrow_mut() = preview.install_on_picture(
1,
PreviewSurface::Inline,
&right_pane.picture,
@@ -419,21 +501,43 @@ pub fn build_launcher_view(
left_pane.stream_status.set_text("Preview unavailable");
right_pane.stream_status.set_text("Preview unavailable");
}
+ sync_capture_size_combo(
+ &left_pane.capture_combo,
+ state.capture_size_options(),
+ state.capture_size_preset(0),
+ );
+ sync_capture_size_combo(
+ &right_pane.capture_combo,
+ state.capture_size_options(),
+ state.capture_size_preset(1),
+ );
+ sync_breakout_size_combo(
+ &left_pane.breakout_combo,
+ state.breakout_size_options(),
+ state.breakout_size_preset(0),
+ );
+ sync_breakout_size_combo(
+ &right_pane.breakout_combo,
+ state.breakout_size_options(),
+ state.breakout_size_preset(1),
+ );
let widgets = LauncherWidgets {
status_label: status_label.clone(),
+ session_log_buffer: session_log_buffer.clone(),
+ session_log_view: session_log_view.clone(),
summary: SummaryWidgets {
+ relay_light,
relay_value,
+ routing_light,
routing_value,
- power_value,
- displays_value,
+ gpio_light,
+ gpio_value,
shortcut_value,
},
power_detail,
- launch_plan_title,
- launch_plan_summary,
- launch_plan_detail,
- local_test_detail,
+ audio_check_detail,
+ audio_check_meter,
display_panes: [left_pane.clone(), right_pane.clone()],
start_button: start_button.clone(),
power_auto_button: power_auto_button.clone(),
@@ -445,9 +549,13 @@ pub fn build_launcher_view(
swap_key_button: swap_key_button.clone(),
camera_test_button: camera_test_button.clone(),
microphone_test_button: microphone_test_button.clone(),
+ microphone_replay_button: microphone_replay_button.clone(),
speaker_test_button: speaker_test_button.clone(),
+ console_copy_button: console_copy_button.clone(),
+ console_popout_button: console_popout_button.clone(),
};
let popouts = Rc::new(RefCell::new([None, None]));
+ let log_popout = Rc::new(RefCell::new(None));
window.set_child(Some(&root));
@@ -457,6 +565,8 @@ pub fn build_launcher_view(
camera_combo,
microphone_combo,
speaker_combo,
+ keyboard_combo,
+ mouse_combo,
device_stage: DeviceStageWidgets {
camera_preview,
camera_status,
@@ -464,6 +574,7 @@ pub fn build_launcher_view(
widgets,
preview,
popouts,
+ log_popout,
}
}
@@ -471,7 +582,7 @@ pub fn install_css(window: >k::ApplicationWindow) {
let provider = gtk::CssProvider::new();
provider.load_from_data(
r#"
- window.lesavka-launcher {
+ window.lesavka {
background: #101319;
color: #eef2f7;
}
@@ -482,18 +593,40 @@ pub fn install_css(window: >k::ApplicationWindow) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
- padding: 14px;
+ padding: 10px;
+ }
+ box.subgroup {
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 14px;
+ padding: 8px;
}
label.panel-title {
font-weight: 700;
font-size: 1.05rem;
margin-bottom: 4px;
}
+ label.subgroup-title {
+ font-weight: 700;
+ opacity: 0.92;
+ }
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;
+ padding: 7px 10px;
+ }
+ box.status-light {
+ min-width: 10px;
+ min-height: 10px;
+ border-radius: 999px;
+ background: rgba(214, 81, 81, 0.92);
+ }
+ box.status-light-live {
+ background: rgba(96, 214, 126, 0.95);
+ }
+ box.status-light-idle {
+ background: rgba(214, 81, 81, 0.92);
}
label.status-chip-label {
font-size: 0.78rem;
@@ -520,14 +653,22 @@ pub fn install_css(window: >k::ApplicationWindow) {
border-radius: 14px;
}
label.status-line {
- opacity: 0.88;
+ opacity: 0.9;
}
- label.launch-plan-summary {
+ textview.status-log {
font-family: monospace;
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(0, 0, 0, 0.22);
border-radius: 14px;
- padding: 12px;
+ padding: 10px;
+ }
+ progressbar.audio-check-meter trough {
+ min-height: 10px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ }
+ progressbar.audio-check-meter progress {
+ border-radius: 999px;
+ background: rgba(91, 179, 162, 0.88);
}
entry.server-entry {
min-height: 38px;
@@ -550,11 +691,20 @@ pub fn install_css(window: >k::ApplicationWindow) {
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
- window.add_css_class("lesavka-launcher");
+ window.add_css_class("lesavka");
+}
+
+pub fn install_window_icon(window: &impl IsA) {
+ if let Some(display) = gtk::gdk::Display::default() {
+ let theme = gtk::IconTheme::for_display(&display);
+ theme.add_search_path(LESAVKA_ICON_SEARCH_PATH);
+ }
+ gtk::Window::set_default_icon_name(LESAVKA_ICON_NAME);
+ window.as_ref().set_icon_name(Some(LESAVKA_ICON_NAME));
}
fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
- let panel = gtk::Box::new(gtk::Orientation::Vertical, 10);
+ let panel = gtk::Box::new(gtk::Orientation::Vertical, 8);
panel.add_css_class("panel");
let heading = gtk::Label::new(Some(title));
@@ -562,13 +712,23 @@ fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
heading.set_halign(gtk::Align::Start);
panel.append(&heading);
- let body = gtk::Box::new(gtk::Orientation::Vertical, 10);
+ let body = gtk::Box::new(gtk::Orientation::Vertical, 8);
panel.append(&body);
(panel, body)
}
+fn build_subgroup(title: &str) -> gtk::Box {
+ let group = gtk::Box::new(gtk::Orientation::Vertical, 8);
+ group.add_css_class("subgroup");
+ let heading = gtk::Label::new(Some(title));
+ heading.add_css_class("subgroup-title");
+ heading.set_halign(gtk::Align::Start);
+ group.append(&heading);
+ group
+}
+
fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
- let chip = gtk::Box::new(gtk::Orientation::Vertical, 2);
+ let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
chip.add_css_class("status-chip");
let label_widget = gtk::Label::new(Some(label));
@@ -582,6 +742,75 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
(chip, value_widget)
}
+fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) {
+ let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
+ chip.add_css_class("status-chip");
+
+ let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6);
+ meta.add_css_class("status-chip-meta");
+ let light = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ light.add_css_class("status-light");
+ light.add_css_class("status-light-idle");
+ let label_widget = gtk::Label::new(Some(label));
+ label_widget.add_css_class("status-chip-label");
+ label_widget.set_halign(gtk::Align::Start);
+ meta.append(&light);
+ meta.append(&label_widget);
+ 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(&meta);
+ chip.append(&value_widget);
+ (chip, light, value_widget)
+}
+
+fn stabilize_chip(chip: >k::Box, width: i32) {
+ chip.set_size_request(width, -1);
+}
+
+pub fn sync_capture_size_combo(
+ combo: >k::ComboBoxText,
+ options: Vec,
+ selected: CaptureSizePreset,
+) {
+ combo.remove_all();
+ for option in options {
+ let label = match option.preset {
+ CaptureSizePreset::Source => format!(
+ "{}x{} @ {} fps • {} kbit (Source Size)",
+ option.width, option.height, option.fps, option.max_bitrate_kbit
+ ),
+ _ => format!(
+ "{}x{} @ {} fps • {} kbit",
+ option.width, option.height, option.fps, option.max_bitrate_kbit
+ ),
+ };
+ combo.append(Some(option.preset.as_id()), &label);
+ }
+ combo.set_active_id(Some(selected.as_id()));
+}
+
+pub fn sync_breakout_size_combo(
+ combo: >k::ComboBoxText,
+ options: Vec,
+ selected: BreakoutSizePreset,
+) {
+ combo.remove_all();
+ for option in options {
+ let label = match option.preset {
+ BreakoutSizePreset::Source => {
+ format!("{}x{} (Source Size)", option.width, option.height)
+ }
+ BreakoutSizePreset::FillDisplay => {
+ format!("{}x{} (Display Size)", option.width, option.height)
+ }
+ _ => format!("{}x{}", option.width, option.height),
+ };
+ combo.append(Some(option.preset.as_id()), &label);
+ }
+ combo.set_active_id(Some(selected.as_id()));
+}
+
fn attach_device_row(
grid: >k::Grid,
row: i32,
@@ -597,6 +826,61 @@ fn attach_device_row(
grid.attach(test_button, 2, row, 1, 1);
}
+fn build_selector_block(label: &str, combo: >k::ComboBoxText) -> gtk::Box {
+ let block = gtk::Box::new(gtk::Orientation::Vertical, 6);
+ let label_widget = gtk::Label::new(Some(label));
+ label_widget.set_halign(gtk::Align::Start);
+ combo.set_hexpand(true);
+ combo.set_size_request(0, -1);
+ block.append(&label_widget);
+ block.append(combo);
+ block
+}
+
+fn append_input_choice(combo: >k::ComboBoxText, value: &str) {
+ let short = value.rsplit('/').next().unwrap_or(value);
+ let label = Device::open(value)
+ .ok()
+ .and_then(|device| device.name().map(|name| format!("{name} • {short}")))
+ .unwrap_or_else(|| short.to_string());
+ combo.append(Some(value), &label);
+}
+
+fn append_stage_choice(combo: >k::ComboBoxText, value: &str) {
+ combo.append(Some(value), &compact_stage_label(value));
+}
+
+fn compact_stage_label(value: &str) -> String {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return "auto".to_string();
+ }
+ if let Some(short) = trimmed.rsplit('/').next()
+ && short != trimmed
+ {
+ return shorten_label(short);
+ }
+ if let Some(rest) = trimmed
+ .strip_prefix("alsa_input.")
+ .or_else(|| trimmed.strip_prefix("alsa_output."))
+ {
+ return shorten_label(rest);
+ }
+ shorten_label(trimmed)
+}
+
+fn shorten_label(value: &str) -> String {
+ const MAX: usize = 44;
+ let compact = value.replace('_', " ");
+ let mut chars = compact.chars();
+ let preview: String = chars.by_ref().take(MAX).collect();
+ if chars.next().is_some() {
+ format!("{preview}…")
+ } else {
+ preview
+ }
+}
+
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");
@@ -616,7 +900,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
picture.set_hexpand(true);
picture.set_vexpand(true);
picture.set_can_shrink(true);
- picture.set_size_request(540, 240);
+ picture.set_size_request(220, 124);
let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
preview_box.append(&picture);
@@ -633,7 +917,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
placeholder_box.add_css_class("display-placeholder");
placeholder_box.set_hexpand(true);
placeholder_box.set_vexpand(true);
- placeholder_box.set_size_request(540, 240);
+ placeholder_box.set_size_request(220, 124);
placeholder_box.append(&placeholder);
let stack = gtk::Stack::new();
@@ -648,9 +932,25 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
let stream_status = gtk::Label::new(Some("Connect relay to preview."));
stream_status.set_halign(gtk::Align::Start);
stream_status.set_hexpand(true);
+ stream_status.set_ellipsize(pango::EllipsizeMode::End);
+ stream_status.set_single_line_mode(true);
+ stream_status.set_max_width_chars(24);
+ stream_status.set_tooltip_text(Some("Connect relay to preview."));
+ let capture_combo = gtk::ComboBoxText::new();
+ capture_combo.set_tooltip_text(Some(
+ "Choose the server-side capture profile for this eye feed: resolution, target fps, and bitrate.",
+ ));
+ capture_combo.set_size_request(272, -1);
+ let breakout_combo = gtk::ComboBoxText::new();
+ breakout_combo.set_tooltip_text(Some(
+ "Choose the client-side breakout window size for this eye feed.",
+ ));
+ breakout_combo.set_size_request(180, -1);
let action_button = gtk::Button::with_label("Break Out");
+ stabilize_button(&action_button, 104);
action_button.set_halign(gtk::Align::End);
- footer.append(&stream_status);
+ footer.append(&capture_combo);
+ footer.append(&breakout_combo);
footer.append(&action_button);
root.append(&footer);
@@ -660,8 +960,14 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
picture,
stream_status,
placeholder,
+ capture_combo,
+ breakout_combo,
action_button,
- preview_binding: None,
+ preview_binding: Rc::new(RefCell::new(None)),
title: title.to_string(),
}
}
+
+fn stabilize_button(button: >k::Button, width: i32) {
+ button.set_size_request(width, 36);
+}
diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs
index e0cf40c..412054b 100644
--- a/client/src/launcher/ui_runtime.rs
+++ b/client/src/launcher/ui_runtime.rs
@@ -1,21 +1,21 @@
use anyhow::Result;
-use gtk::{glib, prelude::*};
+use gtk::{gdk, glib, prelude::*};
use std::{
cell::RefCell,
+ io::{BufRead, BufReader},
path::{Path, PathBuf},
- process::{Child, Command},
+ process::{Child, Command, Stdio},
rc::Rc,
+ sync::mpsc::Sender,
};
use super::{
- LAUNCHER_CLIPBOARD_CONTROL_ENV,
- LAUNCHER_FOCUS_SIGNAL_ENV,
+ LAUNCHER_CLIPBOARD_CONTROL_ENV, LAUNCHER_FOCUS_SIGNAL_ENV,
device_test::{DeviceTestController, DeviceTestKind},
- launcher_clipboard_control_path,
- launcher_focus_signal_path,
+ launcher_clipboard_control_path, launcher_focus_signal_path,
preview::{LauncherPreview, PreviewSurface},
runtime_env_vars,
- state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
+ state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle},
};
@@ -28,23 +28,31 @@ pub type RelayChild = Child;
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
let relay_live = child_running || state.remote_active;
+ set_status_light(&widgets.summary.relay_light, state.server_available);
widgets
.summary
.relay_value
- .set_text(if relay_live { "Running" } else { "Stopped" });
+ .set_text(if state.server_available {
+ "Online"
+ } else {
+ "Offline"
+ });
+ set_status_light(
+ &widgets.summary.routing_light,
+ matches!(state.routing, InputRouting::Remote),
+ );
widgets
.summary
.routing_value
.set_text(&capitalize(routing_name(state.routing)));
+ set_status_light(
+ &widgets.summary.gpio_light,
+ state.capture_power.available && state.capture_power.enabled,
+ );
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()
- ));
+ .gpio_value
+ .set_text(&gpio_power_label(&state.capture_power));
widgets
.summary
.shortcut_value
@@ -53,16 +61,6 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets
.power_detail
.set_text(&capture_power_detail(&state.capture_power));
- widgets
- .launch_plan_title
- .set_text(&launch_plan_title(state, child_running));
- widgets
- .launch_plan_summary
- .set_text(&launch_plan_summary(state));
- widgets
- .launch_plan_detail
- .set_text(&launch_plan_detail(state, child_running));
-
widgets.start_button.set_label(if relay_live {
"Disconnect Relay"
} else {
@@ -76,16 +74,23 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
}));
widgets.clipboard_button.set_sensitive(relay_live);
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",
- });
- let swap_key_label = if state.swap_key_binding {
- "Press Any Key…".to_string()
+ widgets.input_toggle_button.set_label("Change Routing");
+ widgets
+ .input_toggle_button
+ .set_tooltip_text(Some(match state.routing {
+ InputRouting::Remote => {
+ "Inputs are currently going to the remote session. Click to bring them back local."
+ }
+ InputRouting::Local => {
+ "Inputs are currently staying local. Click to hand them to the remote session."
+ }
+ }));
+ widgets.swap_key_button.set_label("Set Swap Key");
+ widgets.swap_key_button.set_tooltip_text(Some(if state.swap_key_binding {
+ "Waiting for the next key press. The top chip still shows the current live shortcut."
} else {
- format!("Set Swap Key ({})", toggle_key_label(&state.swap_key))
- };
- widgets.swap_key_button.set_label(&swap_key_label);
+ "Capture the next key you press and make it the swap shortcut. The current shortcut is shown in the top chip."
+ }));
let power_available = state.capture_power.available;
widgets
.power_auto_button
@@ -109,6 +114,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) {
let camera_running = tests.is_running(DeviceTestKind::Camera);
let microphone_running = tests.is_running(DeviceTestKind::Microphone);
+ let microphone_replay_running = tests.is_running(DeviceTestKind::MicrophoneReplay);
let speaker_running = tests.is_running(DeviceTestKind::Speaker);
widgets.camera_test_button.set_label(if camera_running {
@@ -123,16 +129,36 @@ pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestCon
} else {
"Monitor Mic"
});
+ widgets
+ .microphone_replay_button
+ .set_label(if microphone_replay_running {
+ "Stop Replay"
+ } else {
+ "Replay Last 3s"
+ });
+ widgets
+ .microphone_replay_button
+ .set_sensitive(microphone_replay_running || tests.microphone_replay_ready());
widgets.speaker_test_button.set_label(if speaker_running {
"Stop Tone"
} else {
"Play Tone"
});
- widgets.local_test_detail.set_text(&local_test_detail(
+ widgets.audio_check_detail.set_text(&local_test_detail(
camera_running,
microphone_running,
speaker_running,
+ microphone_replay_running,
));
+ if microphone_running {
+ widgets
+ .audio_check_meter
+ .set_fraction(tests.microphone_level_fraction());
+ } else if speaker_running || microphone_replay_running {
+ widgets.audio_check_meter.pulse();
+ } else {
+ widgets.audio_check_meter.set_fraction(0.0);
+ }
}
pub fn update_test_action_result(
@@ -169,53 +195,54 @@ pub fn open_popout_window(
return;
}
- if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
+ if let Some(binding) = widgets.display_panes[monitor_id]
+ .preview_binding
+ .borrow()
+ .as_ref()
+ {
binding.set_enabled(false);
}
+ let (breakout_size, breakout_limit) = {
+ let state = state.borrow();
+ (
+ state.breakout_size_choice(monitor_id),
+ state.breakout_display_size(),
+ )
+ };
let window = gtk::ApplicationWindow::builder()
.application(app)
.title(format!(
"Lesavka {}",
widgets.display_panes[monitor_id].title
))
- .default_width(1280)
- .default_height(760)
+ .default_width(breakout_size.width)
+ .default_height(breakout_size.height)
.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);
+ super::ui_components::install_window_icon(&window);
+ window.set_decorated(false);
+ window.set_resizable(false);
let picture = gtk::Picture::new();
picture.set_hexpand(true);
picture.set_vexpand(true);
- picture.set_can_shrink(true);
+ picture.set_can_shrink(false);
+ picture.set_keep_aspect_ratio(true);
+ picture.set_size_request(breakout_size.width, breakout_size.height);
+ let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
+ root.set_size_request(breakout_size.width, breakout_size.height);
root.append(&picture);
- let stream_status = gtk::Label::new(Some("Connect relay to preview."));
- stream_status.set_halign(gtk::Align::Start);
- root.append(&stream_status);
+ let stream_status = gtk::Label::new(Some(""));
let binding = preview
- .install_on_picture(
- monitor_id,
- PreviewSurface::Window,
- &picture,
- &stream_status,
- )
+ .install_on_picture(monitor_id, PreviewSurface::Window, &picture, &stream_status)
.expect("preview binding for popout");
window.set_child(Some(&root));
+ install_popout_drag(&window, &picture);
+ apply_popout_window_geometry(&window, &root, &picture, breakout_size, breakout_limit);
let state_handle = Rc::clone(state);
let child_proc_handle = Rc::clone(child_proc);
@@ -231,6 +258,7 @@ pub fn open_popout_window(
handle.binding.close();
if let Some(preview_binding) = widgets_handle.display_panes[monitor_id]
.preview_binding
+ .borrow()
.as_ref()
{
preview_binding.set_enabled(true);
@@ -241,11 +269,7 @@ pub fn open_popout_window(
}
let child_running = child_proc_handle.borrow().is_some();
let state_snapshot = state_handle.borrow().clone();
- refresh_launcher_ui(
- &widgets_handle,
- &state_snapshot,
- child_running,
- );
+ refresh_launcher_ui(&widgets_handle, &state_snapshot, child_running);
} else {
close_binding.close();
}
@@ -260,6 +284,8 @@ pub fn open_popout_window(
let mut popouts = popouts.borrow_mut();
popouts[monitor_id] = Some(PopoutWindowHandle {
window: window.clone(),
+ picture: picture.clone(),
+ status_label: stream_status.clone(),
binding,
});
}
@@ -267,6 +293,36 @@ pub fn open_popout_window(
let state_snapshot = state.borrow().clone();
refresh_launcher_ui(widgets, &state_snapshot, child_running);
window.present();
+ schedule_popout_window_geometry(
+ window.clone(),
+ root.clone(),
+ picture.clone(),
+ breakout_size,
+ breakout_limit,
+ );
+}
+
+pub fn apply_popout_window_size(
+ handle: &PopoutWindowHandle,
+ size: BreakoutSizeChoice,
+ display_limit: super::state::PreviewSourceSize,
+) {
+ let Some(root) = handle
+ .picture
+ .parent()
+ .and_then(|widget| widget.downcast::().ok())
+ else {
+ return;
+ };
+ apply_popout_window_geometry(&handle.window, &root, &handle.picture, size, display_limit);
+ handle.window.present();
+ schedule_popout_window_geometry(
+ handle.window.clone(),
+ root.clone(),
+ handle.picture.clone(),
+ size,
+ display_limit,
+ );
}
pub fn dock_display_to_preview(
@@ -284,7 +340,11 @@ pub fn dock_display_to_preview(
handle.binding.close();
handle.window.close();
}
- if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
+ if let Some(binding) = widgets.display_panes[monitor_id]
+ .preview_binding
+ .borrow()
+ .as_ref()
+ {
binding.set_enabled(true);
}
{
@@ -296,12 +356,54 @@ pub fn dock_display_to_preview(
refresh_launcher_ui(widgets, &state_snapshot, child_running);
}
+pub fn dock_all_displays_to_preview(
+ state: &Rc>,
+ child_proc: &Rc>>,
+ popouts: &Rc; 2]>>,
+ widgets: &LauncherWidgets,
+) {
+ let mut handles = Vec::new();
+ {
+ let mut popouts = popouts.borrow_mut();
+ for monitor_id in 0..2 {
+ if let Some(handle) = popouts[monitor_id].take() {
+ handles.push(handle);
+ }
+ }
+ }
+ for handle in handles {
+ handle.binding.close();
+ handle.window.close();
+ }
+
+ for monitor_id in 0..2 {
+ if let Some(binding) = widgets.display_panes[monitor_id]
+ .preview_binding
+ .borrow()
+ .as_ref()
+ {
+ binding.set_enabled(true);
+ }
+ }
+
+ {
+ let mut state = state.borrow_mut();
+ for monitor_id in 0..2 {
+ state.set_display_surface(monitor_id, DisplaySurface::Preview);
+ }
+ }
+
+ let child_running = child_proc.borrow().is_some();
+ let state_snapshot = state.borrow().clone();
+ refresh_launcher_ui(widgets, &state_snapshot, child_running);
+}
+
pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) {
- if let Some(binding) = pane.preview_binding.as_ref() {
+ if let Some(binding) = pane.preview_binding.borrow().as_ref() {
binding.set_enabled(matches!(surface, DisplaySurface::Preview));
}
pane.action_button
- .set_sensitive(pane.preview_binding.is_some());
+ .set_sensitive(pane.preview_binding.borrow().is_some());
match surface {
DisplaySurface::Preview => {
pane.stack.set_visible_child_name("preview");
@@ -309,7 +411,7 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface)
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() {
+ if pane.preview_binding.borrow().is_none() {
pane.stream_status.set_text("Preview unavailable");
}
}
@@ -325,20 +427,14 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface)
}
}
-pub fn capture_power_label(power: &CapturePowerStatus) -> String {
+pub fn gpio_power_label(power: &CapturePowerStatus) -> String {
if !power.available {
return "Unavailable".to_string();
}
- match power.mode.as_str() {
- "forced-on" => "Forced On".to_string(),
- "forced-off" => "Forced Off".to_string(),
- _ => {
- if power.enabled {
- "Auto • Live".to_string()
- } else {
- "Auto • Standby".to_string()
- }
- }
+ if power.enabled {
+ "Power On".to_string()
+ } else {
+ "Power Off".to_string()
}
}
@@ -348,15 +444,15 @@ pub fn capture_power_detail(power: &CapturePowerStatus) -> String {
}
match power.mode.as_str() {
"forced-on" => format!(
- "{} • manual override holding feeds up • {} • leases {}",
+ "{} • awake • {} • leases {}",
power.unit, power.detail, power.active_leases
),
"forced-off" => format!(
- "{} • manual override holding feeds down • {} • leases {}",
+ "{} • dark • {} • leases {}",
power.unit, power.detail, power.active_leases
),
_ => format!(
- "{} • automatic mode follows live previews and session demand • {} • leases {}",
+ "{} • auto • {} • leases {}",
power.unit, power.detail, power.active_leases
),
}
@@ -380,80 +476,12 @@ fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) {
}
}
-/// Summarizes the current staging state as a short operator-facing heading.
-fn launch_plan_title(state: &LauncherState, child_running: bool) -> String {
- if child_running || state.remote_active {
- return match state.capture_power.mode.as_str() {
- "forced-off" => "Relay live, but capture is intentionally dark.".to_string(),
- "forced-on" => "Relay live with capture held awake.".to_string(),
- _ => "Relay live with previews tied to the session.".to_string(),
- };
- }
-
- match state.capture_power.mode.as_str() {
- "forced-off" => "Staging mode is holding capture off.".to_string(),
- "forced-on" => "Capture is pre-warmed for staging.".to_string(),
- _ => "Stage locally, then start the relay.".to_string(),
- }
-}
-
-/// Shows the exact devices the next relay launch will inherit.
-fn launch_plan_summary(state: &LauncherState) -> String {
- format!(
- "Camera: {}\nMicrophone: {}\nSpeaker: {}",
- selected_device_label(state.devices.camera.as_deref()),
- selected_device_label(state.devices.microphone.as_deref()),
- selected_device_label(state.devices.speaker.as_deref())
- )
-}
-
-/// Explains the consequence of the current capture and session state.
-fn launch_plan_detail(state: &LauncherState, child_running: bool) -> String {
- if child_running || state.remote_active {
- return match state.capture_power.mode.as_str() {
- "forced-off" => format!(
- "Inputs are routed to {}. The session is connected, but capture is intentionally dark until you return to Auto or Force On.",
- capitalize(routing_name(state.routing))
- ),
- "forced-on" => format!(
- "Inputs are routed to {}. The relay host is holding capture awake, so the eye previews stay ready even between bursts of activity.",
- capitalize(routing_name(state.routing))
- ),
- _ => format!(
- "Inputs are routed to {}. Connecting the relay also brings the eye previews online, and the server keeps capture awake for the live session.",
- capitalize(routing_name(state.routing))
- ),
- };
- }
-
- if !state.capture_power.available {
- return format!(
- "Capture power status from {} is unavailable right now. You can still stage devices locally before you try the relay host again.",
- state.capture_power.unit
- );
- }
-
- match state.capture_power.mode.as_str() {
- "forced-off" => {
- "Remote eye previews and the next relay session will stay dark until you return to Auto or Force On."
- .to_string()
- }
- "forced-on" => {
- "The relay host is already holding capture awake, which is useful when you want the eye feeds ready the moment you connect."
- .to_string()
- }
- _ => {
- "When you connect the relay, the eye previews come up with it. Disconnecting returns capture to the server-side grace/standby path."
- .to_string()
- }
- }
-}
-
/// Reports which local staging checks are active right now.
fn local_test_detail(
camera_running: bool,
microphone_running: bool,
speaker_running: bool,
+ microphone_replay_running: bool,
) -> String {
let mut active = Vec::new();
if camera_running {
@@ -465,9 +493,12 @@ fn local_test_detail(
if speaker_running {
active.push("speaker tone");
}
+ if microphone_replay_running {
+ active.push("mic replay");
+ }
if active.is_empty() {
- "Local checks are idle. Use Start Preview, Monitor Mic, or Play Tone before you launch."
+ "Local checks are idle. Use Start Preview, Monitor Mic, Replay Last 3s, or Play Tone before you launch."
.to_string()
} else {
format!(
@@ -477,13 +508,104 @@ fn local_test_detail(
}
}
-/// Formats a selected device for the launch-plan summary.
-fn selected_device_label(value: Option<&str>) -> String {
- value
- .map(compact_device_name)
- .unwrap_or_else(|| "auto".to_string())
+fn install_popout_drag(window: >k::ApplicationWindow, widget: &impl IsA) {
+ let drag = gtk::GestureClick::new();
+ drag.set_button(0);
+ let native = window.clone();
+ drag.connect_pressed(move |gesture, _press, x, y| {
+ let Some(device) = gesture.current_event_device() else {
+ return;
+ };
+ let Some(surface) = native.surface() else {
+ return;
+ };
+ let Some(toplevel) = surface.dynamic_cast_ref::() else {
+ return;
+ };
+ let timestamp = gesture
+ .current_event()
+ .map(|event| event.time())
+ .unwrap_or(0);
+ toplevel.begin_move(&device, 1, x, y, timestamp);
+ });
+ widget.add_controller(drag);
}
+fn apply_popout_window_geometry(
+ window: >k::ApplicationWindow,
+ root: >k::Box,
+ picture: >k::Picture,
+ size: BreakoutSizeChoice,
+ display_limit: super::state::PreviewSourceSize,
+) {
+ picture.set_size_request(size.width, size.height);
+ root.set_size_request(size.width, size.height);
+ window.set_default_size(size.width, size.height);
+ if should_cover_display(size, display_limit) {
+ fullscreen_on_largest_monitor(window);
+ } else {
+ window.unfullscreen();
+ }
+}
+
+fn schedule_popout_window_geometry(
+ window: gtk::ApplicationWindow,
+ root: gtk::Box,
+ picture: gtk::Picture,
+ size: BreakoutSizeChoice,
+ display_limit: super::state::PreviewSourceSize,
+) {
+ for delay_ms in [0_u64, 25, 150] {
+ let window = window.clone();
+ let root = root.clone();
+ let picture = picture.clone();
+ glib::timeout_add_local_once(std::time::Duration::from_millis(delay_ms), move || {
+ apply_popout_window_geometry(&window, &root, &picture, size, display_limit);
+ window.present();
+ });
+ }
+}
+
+fn fullscreen_on_largest_monitor(window: >k::ApplicationWindow) {
+ let Some(display) = gdk::Display::default() else {
+ window.fullscreen();
+ return;
+ };
+ let monitors = display.monitors();
+ let monitor = (0..monitors.n_items())
+ .filter_map(|idx| monitors.item(idx))
+ .filter_map(|obj| obj.downcast::().ok())
+ .max_by_key(|monitor| {
+ let geometry = monitor.geometry();
+ let scale = monitor.scale_factor().max(1);
+ geometry.width().max(1) as i64
+ * scale as i64
+ * geometry.height().max(1) as i64
+ * scale as i64
+ });
+ if let Some(monitor) = monitor.as_ref() {
+ window.fullscreen_on_monitor(monitor);
+ } else {
+ window.fullscreen();
+ }
+}
+
+fn should_cover_display(
+ size: BreakoutSizeChoice,
+ display_limit: super::state::PreviewSourceSize,
+) -> bool {
+ matches!(size.preset, super::state::BreakoutSizePreset::FillDisplay)
+ || (size.width >= display_limit.width.max(1) as i32
+ && size.height >= display_limit.height.max(1) as i32)
+}
+
+pub fn present_popout_windows(popouts: &Rc; 2]>>) {
+ for handle in popouts.borrow().iter().flatten() {
+ handle.window.present();
+ }
+}
+
+#[cfg(test)]
/// Prefer the basename for `/dev/...` entries while keeping Pulse names intact.
fn compact_device_name(value: &str) -> String {
let trimmed = value.trim();
@@ -502,15 +624,22 @@ pub fn capitalize(value: &str) -> String {
}
pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option {
- 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())
- }
- })
+ combo
+ .active_id()
+ .map(|value| value.to_string())
+ .or_else(|| combo.active_text().map(|value| value.to_string()))
+ .and_then(|value| {
+ let value = value.to_string();
+ let trimmed = value.trim();
+ if trimmed.is_empty()
+ || trimmed.eq_ignore_ascii_case("auto")
+ || trimmed.eq_ignore_ascii_case("all")
+ {
+ None
+ } else {
+ Some(trimmed.to_string())
+ }
+ })
}
pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String {
@@ -535,19 +664,6 @@ pub fn input_state_path() -> PathBuf {
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH))
}
-pub fn write_clipboard_control_request(path: &Path) -> Result<()> {
- std::fs::write(
- path,
- format!(
- "{}\n",
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)?
- .as_millis()
- ),
- )?;
- Ok(())
-}
-
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
std::fs::write(path, format!("{}\n", routing_name(routing)))?;
Ok(())
@@ -580,9 +696,13 @@ pub fn path_marker(path: &Path) -> u128 {
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"));
+ if combo.set_active_id(Some(wanted)) {
+ return;
}
+ if combo.set_active_id(Some("auto")) {
+ return;
+ }
+ let _ = combo.set_active_id(Some("all"));
}
pub fn toggle_key_label(raw: &str) -> String {
@@ -669,10 +789,12 @@ pub fn spawn_client_process(
let exe = std::env::current_exe()?;
let mut command = Command::new(exe);
command.arg("--no-launcher");
+ command.stdout(Stdio::piped());
+ command.stderr(Stdio::piped());
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_LAUNCHER_WINDOW_TITLE", "Lesavka");
command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1");
command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path());
command.env(
@@ -689,6 +811,121 @@ pub fn spawn_client_process(
Ok(command.spawn()?)
}
+pub fn attach_child_log_streams(child: &mut RelayChild, tx: Sender) {
+ if let Some(stdout) = child.stdout.take() {
+ spawn_log_reader(stdout, "[relay] ", tx.clone());
+ }
+ if let Some(stderr) = child.stderr.take() {
+ spawn_log_reader(stderr, "[relay:stderr] ", tx);
+ }
+}
+
+fn spawn_log_reader(reader: R, prefix: &'static str, tx: Sender)
+where
+ R: std::io::Read + Send + 'static,
+{
+ std::thread::spawn(move || {
+ for line in BufReader::new(reader)
+ .lines()
+ .map_while(std::result::Result::ok)
+ {
+ let trimmed = line.trim();
+ if !trimmed.is_empty() {
+ let _ = tx.send(format!("{prefix}{trimmed}"));
+ }
+ }
+ });
+}
+
+pub fn append_session_log(buffer: >k::TextBuffer, message: &str) {
+ let cleaned = strip_ansi_sequences(message);
+ let trimmed = cleaned.trim();
+ if trimmed.is_empty() {
+ return;
+ }
+ let mut end = buffer.end_iter();
+ let tags = classify_log_tags(trimmed);
+ if tags.is_empty() {
+ buffer.insert(&mut end, &format!("{trimmed}\n"));
+ } else {
+ buffer.insert_with_tags_by_name(&mut end, &format!("{trimmed}\n"), &tags);
+ }
+}
+
+pub fn copy_session_log(buffer: >k::TextBuffer) -> Result<()> {
+ let text = buffer
+ .text(&buffer.start_iter(), &buffer.end_iter(), false)
+ .to_string();
+ let display = gtk::gdk::Display::default()
+ .ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?;
+ display.clipboard().set_text(&text);
+ Ok(())
+}
+
+pub fn open_session_log_popout(
+ app: >k::Application,
+ handle: &Rc>>,
+ buffer: >k::TextBuffer,
+) {
+ if let Some(window) = handle.borrow().as_ref() {
+ window.present();
+ return;
+ }
+
+ let window = gtk::ApplicationWindow::builder()
+ .application(app)
+ .title("Lesavka Log")
+ .default_width(980)
+ .default_height(680)
+ .build();
+ super::ui_components::install_css(&window);
+ super::ui_components::install_window_icon(&window);
+
+ 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 toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
+ let copy_button = gtk::Button::with_label("Copy Log");
+ toolbar.append(©_button);
+ root.append(&toolbar);
+
+ let view = gtk::TextView::with_buffer(buffer);
+ view.add_css_class("status-log");
+ view.set_editable(false);
+ view.set_cursor_visible(false);
+ view.set_monospace(true);
+ view.set_wrap_mode(gtk::WrapMode::WordChar);
+ let scroll = gtk::ScrolledWindow::builder()
+ .hexpand(true)
+ .vexpand(true)
+ .child(&view)
+ .build();
+ root.append(&scroll);
+ window.set_child(Some(&root));
+ window.maximize();
+
+ {
+ let buffer = buffer.clone();
+ copy_button.connect_clicked(move |_| {
+ let _ = copy_session_log(&buffer);
+ });
+ }
+
+ {
+ let handle = Rc::clone(handle);
+ window.connect_close_request(move |_| {
+ handle.borrow_mut().take();
+ glib::Propagation::Proceed
+ });
+ }
+
+ *handle.borrow_mut() = Some(window.clone());
+ window.present();
+}
+
pub fn stop_child_process(child_proc: &Rc>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
@@ -717,47 +954,94 @@ pub fn next_input_routing(routing: InputRouting) -> InputRouting {
}
}
+fn set_status_light(light: >k::Box, active: bool) {
+ light.remove_css_class("status-light-live");
+ light.remove_css_class("status-light-idle");
+ light.add_css_class(if active {
+ "status-light-live"
+ } else {
+ "status-light-idle"
+ });
+}
+
+fn classify_log_tags(message: &str) -> Vec<&'static str> {
+ let mut tags = Vec::new();
+ if message.starts_with("[launcher]") {
+ tags.push("log-launcher");
+ } else if message.starts_with("[relay:stderr]") {
+ tags.push("log-stderr");
+ } else if message.starts_with("[relay]") {
+ tags.push("log-relay");
+ } else if message.starts_with("[preview:") {
+ tags.push("log-preview");
+ } else {
+ tags.push("log-launcher");
+ }
+
+ let uppercase = message.to_ascii_uppercase();
+ if uppercase.contains(" ERROR ")
+ || uppercase.contains("FAILED")
+ || uppercase.contains("PANIC")
+ || uppercase.contains(" RPC FAILED")
+ {
+ tags.push("log-error");
+ } else if uppercase.contains(" WARN ")
+ || uppercase.contains("UNAVAILABLE")
+ || uppercase.contains("WAITING FOR CAPTURE PIPELINE")
+ {
+ tags.push("log-warn");
+ }
+ tags
+}
+
+fn strip_ansi_sequences(input: &str) -> String {
+ let mut output = String::with_capacity(input.len());
+ let mut chars = input.chars().peekable();
+ while let Some(ch) = chars.next() {
+ if ch == '\u{1b}' {
+ match chars.peek().copied() {
+ Some('[') => {
+ let _ = chars.next();
+ while let Some(next) = chars.next() {
+ if ('@'..='~').contains(&next) {
+ break;
+ }
+ }
+ }
+ Some(']') => {
+ let _ = chars.next();
+ while let Some(next) = chars.next() {
+ if next == '\u{7}' {
+ break;
+ }
+ if next == '\u{1b}' && matches!(chars.peek(), Some('\\')) {
+ let _ = chars.next();
+ break;
+ }
+ }
+ }
+ _ => {}
+ }
+ continue;
+ }
+ output.push(ch);
+ }
+ output
+}
+
#[cfg(test)]
mod tests {
use super::*;
-
- #[test]
- fn launch_plan_summary_compacts_selected_devices() {
- let mut state = LauncherState::new();
- state.select_camera(Some(
- "/dev/v4l/by-id/usb-Logitech_C920-video-index0".to_string(),
- ));
- state.select_microphone(Some("alsa_input.usb-focusrite".to_string()));
- state.select_speaker(Some("alsa_output.studio".to_string()));
-
- let summary = launch_plan_summary(&state);
- assert!(summary.contains("usb-Logitech_C920-video-index0"));
- assert!(summary.contains("alsa_input.usb-focusrite"));
- assert!(summary.contains("alsa_output.studio"));
- assert!(!summary.contains("/dev/v4l/by-id/"));
- }
-
- #[test]
- fn launch_plan_detail_calls_out_forced_off_sessions() {
- let mut state = LauncherState::new();
- state.set_capture_power(CapturePowerStatus {
- available: true,
- enabled: false,
- unit: "relay.service".to_string(),
- detail: "inactive/dead".to_string(),
- active_leases: 0,
- mode: "forced-off".to_string(),
- });
- state.start_remote();
-
- let detail = launch_plan_detail(&state, true);
- assert!(detail.contains("intentionally dark"));
- }
+ use crate::launcher::{
+ devices::DeviceCatalog, preview::PreviewBinding, state::LauncherState,
+ ui_components::build_launcher_view,
+ };
+ use std::{cell::RefCell, rc::Rc};
#[test]
fn local_test_detail_mentions_idle_and_running_modes() {
- assert!(local_test_detail(false, false, false).contains("idle"));
- let running = local_test_detail(true, true, false);
+ assert!(local_test_detail(false, false, false, false).contains("idle"));
+ let running = local_test_detail(true, true, false, false);
assert!(running.contains("camera preview"));
assert!(running.contains("mic monitor"));
}
@@ -767,4 +1051,127 @@ mod tests {
assert_eq!(compact_device_name("/dev/video0"), "video0");
assert_eq!(compact_device_name("alsa_input.usb"), "alsa_input.usb");
}
+
+ #[test]
+ fn strip_ansi_sequences_removes_terminal_codes() {
+ let raw = "\u{1b}[32mINFO\u{1b}[0m hello";
+ assert_eq!(strip_ansi_sequences(raw), "INFO hello");
+ }
+
+ #[test]
+ fn classify_log_tags_assigns_prefix_and_severity_colors() {
+ let tags = classify_log_tags("[relay] WARN pipeline failed");
+ assert!(tags.contains(&"log-relay"));
+ assert!(tags.contains(&"log-error") || tags.contains(&"log-warn"));
+ }
+
+ #[test]
+ fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {
+ if gtk::init().is_err() || gtk::gdk::Display::default().is_none() {
+ return;
+ }
+
+ let app = gtk::Application::builder()
+ .application_id("dev.lesavka.test-dock")
+ .build();
+ let _ = app.register(None::<>k::gio::Cancellable>);
+
+ let state = Rc::new(RefCell::new(LauncherState::new()));
+ state
+ .borrow_mut()
+ .set_display_surface(0, DisplaySurface::Window);
+ state
+ .borrow_mut()
+ .set_display_surface(1, DisplaySurface::Window);
+ let state_snapshot = state.borrow().clone();
+ let view = build_launcher_view(
+ &app,
+ "http://127.0.0.1:50051",
+ &DeviceCatalog::default(),
+ &state_snapshot,
+ );
+ let child_proc = Rc::new(RefCell::new(None::));
+
+ let left_binding = PreviewBinding::test_stub();
+ let right_binding = PreviewBinding::test_stub();
+ {
+ let mut popouts = view.popouts.borrow_mut();
+ popouts[0] = Some(PopoutWindowHandle {
+ window: gtk::ApplicationWindow::builder()
+ .application(&app)
+ .title("Left")
+ .build(),
+ picture: gtk::Picture::new(),
+ status_label: gtk::Label::new(None),
+ binding: left_binding,
+ });
+ popouts[1] = Some(PopoutWindowHandle {
+ window: gtk::ApplicationWindow::builder()
+ .application(&app)
+ .title("Right")
+ .build(),
+ picture: gtk::Picture::new(),
+ status_label: gtk::Label::new(None),
+ binding: right_binding,
+ });
+ }
+
+ dock_all_displays_to_preview(&state, &child_proc, &view.popouts, &view.widgets);
+
+ assert!(view.popouts.borrow().iter().all(|handle| handle.is_none()));
+ assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview);
+ assert_eq!(state.borrow().display_surface(1), DisplaySurface::Preview);
+ }
+
+ #[test]
+ fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() {
+ if gtk::init().is_err() || gtk::gdk::Display::default().is_none() {
+ return;
+ }
+
+ let app = gtk::Application::builder()
+ .application_id("dev.lesavka.test-reentrant-dock")
+ .build();
+ let _ = app.register(None::<>k::gio::Cancellable>);
+
+ let state = Rc::new(RefCell::new(LauncherState::new()));
+ state
+ .borrow_mut()
+ .set_display_surface(0, DisplaySurface::Window);
+ let state_snapshot = state.borrow().clone();
+ let view = build_launcher_view(
+ &app,
+ "http://127.0.0.1:50051",
+ &DeviceCatalog::default(),
+ &state_snapshot,
+ );
+ let child_proc = Rc::new(RefCell::new(None::));
+
+ let popouts = Rc::clone(&view.popouts);
+ let window = gtk::ApplicationWindow::builder()
+ .application(&app)
+ .title("Reentrant")
+ .build();
+ {
+ let popouts = Rc::clone(&popouts);
+ window.connect_close_request(move |_| {
+ let _ = popouts.borrow_mut()[0].take();
+ glib::Propagation::Proceed
+ });
+ }
+ {
+ let mut slot = popouts.borrow_mut();
+ slot[0] = Some(PopoutWindowHandle {
+ window,
+ picture: gtk::Picture::new(),
+ status_label: gtk::Label::new(None),
+ binding: PreviewBinding::test_stub(),
+ });
+ }
+
+ dock_all_displays_to_preview(&state, &child_proc, &popouts, &view.widgets);
+
+ assert!(popouts.borrow().iter().all(|handle| handle.is_none()));
+ assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview);
+ }
}
diff --git a/client/src/paste.rs b/client/src/paste.rs
index d603a17..4b5eb02 100644
--- a/client/src/paste.rs
+++ b/client/src/paste.rs
@@ -4,6 +4,7 @@
use anyhow::{Context, Result};
use chacha20poly1305::aead::{Aead, KeyInit, OsRng, rand_core::RngCore};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
+use std::path::PathBuf;
use lesavka_common::lesavka::PasteRequest;
use lesavka_common::paste::{decode_shared_key, truncate_text};
@@ -40,7 +41,42 @@ pub fn build_paste_request(text: &str) -> Result {
}
fn load_key() -> Result<[u8; 32]> {
- let raw = std::env::var("LESAVKA_PASTE_KEY")
- .context("LESAVKA_PASTE_KEY not set (required for PasteText RPC)")?;
+ let raw = load_key_material()?;
decode_shared_key(&raw)
}
+
+fn load_key_material() -> Result {
+ if let Some(raw) = std::env::var("LESAVKA_PASTE_KEY")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ {
+ return Ok(raw);
+ }
+
+ if let Some(path) = std::env::var("LESAVKA_PASTE_KEY_FILE")
+ .ok()
+ .map(PathBuf::from)
+ .or_else(default_paste_key_path)
+ {
+ let raw = std::fs::read_to_string(&path)
+ .with_context(|| format!("reading paste key file {}", path.display()))?;
+ let trimmed = raw.trim().to_string();
+ if !trimmed.is_empty() {
+ return Ok(trimmed);
+ }
+ anyhow::bail!("paste key file {} is empty", path.display());
+ }
+
+ anyhow::bail!(
+ "LESAVKA_PASTE_KEY not set (or no paste key file present at ~/.config/lesavka/paste-key)"
+ )
+}
+
+fn default_paste_key_path() -> Option {
+ std::env::var_os("HOME").map(|home| {
+ let mut path = PathBuf::from(home);
+ path.push(".config/lesavka/paste-key");
+ path
+ })
+}
diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto
index 17f510f..bd02ade 100644
--- a/common/proto/lesavka.proto
+++ b/common/proto/lesavka.proto
@@ -5,7 +5,13 @@ package lesavka;
message KeyboardReport { bytes data = 1; }
message MouseReport { bytes data = 1; }
-message MonitorRequest { uint32 id = 1; uint32 max_bitrate = 2; }
+message MonitorRequest {
+ uint32 id = 1;
+ uint32 max_bitrate = 2;
+ uint32 requested_width = 3;
+ uint32 requested_height = 4;
+ uint32 requested_fps = 5;
+}
message VideoPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; }
message AudioPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; }
@@ -44,6 +50,9 @@ message HandshakeSet {
uint32 camera_width = 5;
uint32 camera_height = 6;
uint32 camera_fps = 7;
+ uint32 eye_width = 8;
+ uint32 eye_height = 9;
+ uint32 eye_fps = 10;
}
message Empty {}
diff --git a/common/src/paste.rs b/common/src/paste.rs
index a8635e1..5a933a3 100644
--- a/common/src/paste.rs
+++ b/common/src/paste.rs
@@ -4,25 +4,37 @@ use anyhow::{Context, Result};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
-/// Decode the shared paste key from either hex or base64.
+/// Decode the shared paste key from raw bytes, hex, or base64.
///
-/// Inputs: the raw operator-supplied secret, optionally prefixed with `hex:`.
+/// Inputs: the raw operator-supplied secret, optionally prefixed with `hex:` or
+/// `raw:`.
/// Outputs: a 32-byte key suitable for ChaCha20-Poly1305.
/// # Errors
///
/// Returns an error when the input is not valid base64/hex or does not decode
-/// to exactly 32 bytes.
+/// to exactly 32 bytes, and is not already an exact 32-byte secret.
/// Why: both the client and server enforce the same secret format, so this
/// logic lives in one place instead of drifting across crates.
pub fn decode_shared_key(raw: &str) -> Result<[u8; 32]> {
let trimmed = raw.trim();
- let payload = trimmed.strip_prefix("hex:").unwrap_or(trimmed);
- let bytes = if payload.len() == 64 && payload.chars().all(|c| c.is_ascii_hexdigit()) {
- hex_to_bytes(payload)?
+ let bytes = if let Some(payload) = trimmed.strip_prefix("raw:") {
+ payload.as_bytes().to_vec()
} else {
- STANDARD
- .decode(payload.as_bytes())
- .context("LESAVKA_PASTE_KEY must be 32-byte base64 or 64-char hex")?
+ let payload = trimmed.strip_prefix("hex:").unwrap_or(trimmed);
+ if payload.len() == 64 && payload.chars().all(|c| c.is_ascii_hexdigit()) {
+ hex_to_bytes(payload)?
+ } else {
+ match STANDARD.decode(payload.as_bytes()) {
+ Ok(decoded) if decoded.len() == 32 => decoded,
+ Ok(_) | Err(_) if payload.as_bytes().len() == 32 => payload.as_bytes().to_vec(),
+ Ok(_) => anyhow::bail!("LESAVKA_PASTE_KEY must decode to 32 bytes"),
+ Err(err) => {
+ return Err(err).context(
+ "LESAVKA_PASTE_KEY must be a 32-byte raw secret, 32-byte base64, or 64-char hex",
+ );
+ }
+ }
+ }
};
if bytes.len() != 32 {
@@ -70,6 +82,7 @@ mod tests {
const HEX_KEY: &str = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
const B64_KEY: &str = "ABEiM0RVZneImaq7zN3u/wARIjNEVWZ3iJmqu8zd7v8=";
+ const RAW_KEY: &str = "0123456789abcdef0123456789abcdef";
#[test]
fn decode_shared_key_accepts_hex_and_base64() {
@@ -80,12 +93,36 @@ mod tests {
assert_eq!(hex[31], 0xff);
}
+ #[test]
+ fn decode_shared_key_accepts_raw_32_byte_secret() {
+ let raw = decode_shared_key(RAW_KEY).expect("raw key should decode");
+ let explicit = decode_shared_key("raw:0123456789abcdef0123456789abcdef")
+ .expect("raw-prefixed key should decode");
+ assert_eq!(raw, explicit);
+ assert_eq!(raw, *RAW_KEY.as_bytes());
+ }
+
#[test]
fn decode_shared_key_rejects_short_input() {
let error = decode_shared_key("Zm9v").expect_err("short key must fail");
assert!(error.to_string().contains("32 bytes"));
}
+ #[test]
+ fn decode_shared_key_rejects_invalid_hex_payload() {
+ let error = decode_shared_key(
+ "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeefg",
+ )
+ .expect_err("invalid hex key must fail");
+ assert!(error.to_string().contains("32 bytes"));
+ }
+
+ #[test]
+ fn decode_shared_key_rejects_wrong_length_raw_prefix() {
+ let error = decode_shared_key("raw:too-short").expect_err("short raw key must fail");
+ assert!(error.to_string().contains("32 bytes"));
+ }
+
#[test]
fn truncate_text_preserves_unicode_boundaries() {
assert_eq!(truncate_text("abc", 10), "abc");
diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json
index 487da55..38da86f 100644
--- a/scripts/ci/hygiene_gate_baseline.json
+++ b/scripts/ci/hygiene_gate_baseline.json
@@ -2,18 +2,18 @@
"files": {
"client/src/app.rs": {
"clippy_warnings": 42,
- "doc_debt": 10,
- "loc": 546
+ "doc_debt": 12,
+ "loc": 590
},
"client/src/app_support.rs": {
"clippy_warnings": 0,
"doc_debt": 3,
- "loc": 128
+ "loc": 131
},
"client/src/handshake.rs": {
- "clippy_warnings": 0,
+ "clippy_warnings": 2,
"doc_debt": 3,
- "loc": 194
+ "loc": 215
},
"client/src/input/camera.rs": {
"clippy_warnings": 38,
@@ -22,13 +22,13 @@
},
"client/src/input/inputs.rs": {
"clippy_warnings": 42,
- "doc_debt": 19,
- "loc": 801
+ "doc_debt": 20,
+ "loc": 871
},
"client/src/input/keyboard.rs": {
- "clippy_warnings": 24,
- "doc_debt": 18,
- "loc": 580
+ "clippy_warnings": 26,
+ "doc_debt": 22,
+ "loc": 676
},
"client/src/input/keymap.rs": {
"clippy_warnings": 8,
@@ -51,29 +51,29 @@
"loc": 317
},
"client/src/launcher/clipboard.rs": {
- "clippy_warnings": 2,
- "doc_debt": 1,
- "loc": 177
+ "clippy_warnings": 12,
+ "doc_debt": 0,
+ "loc": 178
},
"client/src/launcher/device_test.rs": {
- "clippy_warnings": 22,
- "doc_debt": 20,
- "loc": 457
+ "clippy_warnings": 43,
+ "doc_debt": 29,
+ "loc": 793
},
"client/src/launcher/devices.rs": {
"clippy_warnings": 6,
- "doc_debt": 3,
- "loc": 158
+ "doc_debt": 6,
+ "loc": 234
},
"client/src/launcher/diagnostics.rs": {
"clippy_warnings": 17,
"doc_debt": 3,
- "loc": 175
+ "loc": 177
},
"client/src/launcher/mod.rs": {
- "clippy_warnings": 6,
- "doc_debt": 4,
- "loc": 195
+ "clippy_warnings": 8,
+ "doc_debt": 5,
+ "loc": 268
},
"client/src/launcher/power.rs": {
"clippy_warnings": 0,
@@ -81,29 +81,29 @@
"loc": 69
},
"client/src/launcher/preview.rs": {
- "clippy_warnings": 24,
- "doc_debt": 13,
- "loc": 442
+ "clippy_warnings": 36,
+ "doc_debt": 26,
+ "loc": 1030
},
"client/src/launcher/state.rs": {
- "clippy_warnings": 16,
- "doc_debt": 18,
- "loc": 414
+ "clippy_warnings": 64,
+ "doc_debt": 36,
+ "loc": 951
},
"client/src/launcher/ui.rs": {
- "clippy_warnings": 10,
- "doc_debt": 3,
- "loc": 848
+ "clippy_warnings": 42,
+ "doc_debt": 12,
+ "loc": 1501
},
"client/src/launcher/ui_components.rs": {
- "clippy_warnings": 8,
- "doc_debt": 4,
- "loc": 689
+ "clippy_warnings": 6,
+ "doc_debt": 10,
+ "loc": 973
},
"client/src/launcher/ui_runtime.rs": {
- "clippy_warnings": 10,
- "doc_debt": 22,
- "loc": 730
+ "clippy_warnings": 36,
+ "doc_debt": 35,
+ "loc": 1177
},
"client/src/layout.rs": {
"clippy_warnings": 6,
@@ -148,7 +148,7 @@
"client/src/paste.rs": {
"clippy_warnings": 2,
"doc_debt": 1,
- "loc": 46
+ "loc": 82
},
"common/src/bin/cli.rs": {
"clippy_warnings": 0,
@@ -171,9 +171,9 @@
"loc": 22
},
"common/src/paste.rs": {
- "clippy_warnings": 0,
+ "clippy_warnings": 2,
"doc_debt": 2,
- "loc": 95
+ "loc": 132
},
"server/src/audio.rs": {
"clippy_warnings": 37,
@@ -181,7 +181,8 @@
"loc": 397
},
"server/src/bin/lesavka-uvc.real.inc": {
- "clippy_warnings": 31
+ "clippy_warnings": 31,
+ "doc_debt": 0
},
"server/src/bin/lesavka-uvc.rs": {
"clippy_warnings": 0,
@@ -211,7 +212,7 @@
"server/src/handshake.rs": {
"clippy_warnings": 2,
"doc_debt": 1,
- "loc": 40
+ "loc": 44
},
"server/src/lib.rs": {
"clippy_warnings": 0,
@@ -221,17 +222,17 @@
"server/src/main.rs": {
"clippy_warnings": 10,
"doc_debt": 13,
- "loc": 576
+ "loc": 586
},
"server/src/paste.rs": {
- "clippy_warnings": 6,
- "doc_debt": 3,
- "loc": 207
+ "clippy_warnings": 8,
+ "doc_debt": 4,
+ "loc": 255
},
"server/src/runtime_support.rs": {
"clippy_warnings": 14,
"doc_debt": 8,
- "loc": 387
+ "loc": 397
},
"server/src/uvc_control/model.rs": {
"clippy_warnings": 0,
@@ -249,9 +250,9 @@
"loc": 241
},
"server/src/video.rs": {
- "clippy_warnings": 25,
- "doc_debt": 2,
- "loc": 343
+ "clippy_warnings": 33,
+ "doc_debt": 8,
+ "loc": 589
},
"server/src/video_sinks.rs": {
"clippy_warnings": 78,
@@ -268,21 +269,5 @@
"doc_debt": 0,
"loc": 10
}
- },
- "client/src/input/inputs.rs": {
- "loc": 801,
- "doc_debt": 19
- },
- "client/src/launcher/state.rs": {
- "loc": 414,
- "clippy_warnings": 16,
- "doc_debt": 18
- },
- "client/src/launcher/ui.rs": {
- "loc": 848
- },
- "client/src/launcher/ui_runtime.rs": {
- "loc": 730,
- "doc_debt": 22
}
}
diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json
index b67aed7..7c5c73a 100644
--- a/scripts/ci/quality_gate_baseline.json
+++ b/scripts/ci/quality_gate_baseline.json
@@ -1,28 +1,28 @@
{
"files": {
"client/src/app.rs": {
- "line_percent": 95.1219512195122,
- "loc": 546
+ "line_percent": 96.61016949152543,
+ "loc": 590
},
"client/src/app_support.rs": {
"line_percent": 100.0,
- "loc": 128
+ "loc": 131
},
"client/src/handshake.rs": {
- "line_percent": 96.15384615384616,
- "loc": 194
+ "line_percent": 96.36363636363636,
+ "loc": 215
},
"client/src/input/camera.rs": {
"line_percent": 98.42931937172776,
"loc": 372
},
"client/src/input/inputs.rs": {
- "line_percent": 98.27089337175792,
- "loc": 801
+ "line_percent": 97.12793733681463,
+ "loc": 871
},
"client/src/input/keyboard.rs": {
- "line_percent": 95.9409594095941,
- "loc": 580
+ "line_percent": 91.76136363636364,
+ "loc": 676
},
"client/src/input/keymap.rs": {
"line_percent": 100.0,
@@ -37,28 +37,28 @@
"loc": 317
},
"client/src/launcher/clipboard.rs": {
- "line_percent": 98.0,
- "loc": 177
+ "line_percent": 96.22641509433963,
+ "loc": 178
},
"client/src/launcher/devices.rs": {
- "line_percent": 98.13084112149532,
- "loc": 158
+ "line_percent": 96.25,
+ "loc": 234
},
"client/src/launcher/diagnostics.rs": {
- "line_percent": 97.14285714285714,
- "loc": 175
+ "line_percent": 97.19626168224299,
+ "loc": 177
},
"client/src/launcher/mod.rs": {
- "line_percent": 95.23809523809523,
- "loc": 195
+ "line_percent": 93.61702127659576,
+ "loc": 268
},
"client/src/launcher/state.rs": {
- "line_percent": 98.51851851851852,
- "loc": 414
+ "line_percent": 90.42553191489363,
+ "loc": 951
},
"client/src/launcher/ui.rs": {
"line_percent": 100.0,
- "loc": 848
+ "loc": 1501
},
"client/src/layout.rs": {
"line_percent": 97.72727272727273,
@@ -85,8 +85,8 @@
"loc": 547
},
"client/src/paste.rs": {
- "line_percent": 96.29629629629629,
- "loc": 46
+ "line_percent": 98.27586206896551,
+ "loc": 82
},
"common/src/bin/cli.rs": {
"line_percent": 100.0,
@@ -105,11 +105,11 @@
"loc": 22
},
"common/src/paste.rs": {
- "line_percent": 100.0,
- "loc": 95
+ "line_percent": 97.05882352941178,
+ "loc": 132
},
"server/src/audio.rs": {
- "line_percent": 98.97,
+ "line_percent": 98.96907216494846,
"loc": 397
},
"server/src/bin/lesavka-uvc.rs": {
@@ -134,27 +134,27 @@
},
"server/src/handshake.rs": {
"line_percent": 100.0,
- "loc": 40
+ "loc": 44
},
"server/src/main.rs": {
- "line_percent": 95.33333333333334,
- "loc": 576
+ "line_percent": 95.54140127388536,
+ "loc": 586
},
"server/src/paste.rs": {
- "line_percent": 97.12230215827337,
- "loc": 207
+ "line_percent": 96.21621621621622,
+ "loc": 255
},
"server/src/runtime_support.rs": {
"line_percent": 96.42857142857143,
- "loc": 387
+ "loc": 397
},
"server/src/uvc_runtime.rs": {
"line_percent": 97.14285714285714,
"loc": 241
},
"server/src/video.rs": {
- "line_percent": 100.0,
- "loc": 343
+ "line_percent": 79.16666666666666,
+ "loc": 589
},
"server/src/video_sinks.rs": {
"line_percent": 100.0,
@@ -164,17 +164,5 @@
"line_percent": 96.03174603174604,
"loc": 236
}
- },
- "client/src/input/inputs.rs": {
- "loc": 801,
- "line_percent": 98.27089337175792
- },
- "client/src/launcher/state.rs": {
- "loc": 414,
- "line_percent": 98.51851851851852
- },
- "client/src/launcher/ui.rs": {
- "loc": 848,
- "line_percent": 100.0
}
}
diff --git a/scripts/install/client.sh b/scripts/install/client.sh
index 1838803..c3d46bf 100755
--- a/scripts/install/client.sh
+++ b/scripts/install/client.sh
@@ -3,8 +3,9 @@
set -euo pipefail
ORIG_USER=${SUDO_USER:-$(id -un)}
-SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
-REPO_ROOT=$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel 2>/dev/null || true)
+REF=${LESAVKA_REF:-master}
+REPO_URL=${LESAVKA_REPO_URL:-ssh://git@scm.bstein.dev:2242/bstein/lesavka.git}
+SRC=/var/src/lesavka
log() {
printf '==> %s\n' "$*"
@@ -15,7 +16,7 @@ sudo pacman -Syq --needed --noconfirm \
git rustup protobuf gcc clang evtest base-devel \
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
pipewire pipewire-pulse \
- wmctrl qt6-tools wl-clipboard xclip xsel
+ wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils
ensure_yay() {
if command -v yay >/dev/null 2>&1; then
@@ -49,19 +50,21 @@ log "2. Ensuring Rust toolchain"
sudo rustup default stable
sudo -u "$ORIG_USER" rustup default stable
-# 3. clone / update into a user-writable dir (or use local repo if present)
-USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
-if [[ -n ${REPO_ROOT:-} && -d $REPO_ROOT/.git ]]; then
- SRC="$REPO_ROOT"
- echo "==> 3. Using local repo at $SRC"
+# 3. clone / update into a canonical workspace checkout
+log "3. Syncing source checkout for ref ${REF}"
+if [[ ! -d /var/src ]]; then
+ sudo mkdir -p /var/src
+fi
+sudo chown "$ORIG_USER":"$ORIG_USER" /var/src
+if [[ -d $SRC/.git ]]; then
+ sudo -u "$ORIG_USER" git -C "$SRC" fetch --all --tags --prune
else
- SRC="$USER_HOME/.local/src/lesavka"
- sudo -u "$ORIG_USER" mkdir -p "$(dirname "$SRC")"
- if [[ -d $SRC/.git ]]; then
- sudo -u "$ORIG_USER" git -C "$SRC" pull --ff-only
- else
- sudo -u "$ORIG_USER" git clone "$PWD" "$SRC"
- fi
+ sudo -u "$ORIG_USER" git clone "$REPO_URL" "$SRC"
+fi
+if sudo -u "$ORIG_USER" git -C "$SRC" rev-parse --verify --quiet "origin/$REF" >/dev/null; then
+ sudo -u "$ORIG_USER" git -C "$SRC" checkout -B "$REF" "origin/$REF"
+else
+ sudo -u "$ORIG_USER" git -C "$SRC" checkout --force "$REF"
fi
# 4. build
@@ -69,45 +72,36 @@ log "4. Building client release binary"
sudo -u "$ORIG_USER" bash -c "cd '$SRC/client' && cargo clean && cargo build --release"
# 5. install binary
-log "5. Installing /usr/local/bin/lesavka-client"
-sudo install -Dm755 "$SRC/client/target/release/lesavka-client" /usr/local/bin/lesavka-client
+log "5. Installing launchable client binaries"
+sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client
+sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka
-# 6. systemd service for system scope: /etc/systemd/system/lesavka-client.service
-sudo tee /etc/systemd/system/lesavka-client.service >/dev/null <<'EOF'
-[Unit]
-Description=Lesavka Client
-After=network-online.target
-Wants=network-online.target
+log "6. Registering desktop application"
+sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \
+ /usr/share/icons/hicolor/1024x1024/apps/lesavka.png
+sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \
+ /usr/share/pixmaps/lesavka.png
+sudo install -Dm644 "$SRC/client/assets/linux/lesavka.desktop" \
+ /usr/share/applications/lesavka.desktop
+if command -v update-desktop-database >/dev/null 2>&1; then
+ sudo update-desktop-database /usr/share/applications
+fi
+if command -v gtk-update-icon-cache >/dev/null 2>&1; then
+ sudo gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
+fi
-[Service]
-Type=simple
-User=root
-Group=root
-
-Environment=RUST_LOG=debug
-Environment=LESAVKA_DEV_MODE=1
-Environment=LESAVKA_SERVER_ADDR=http://38.28.125.112:50051
-
-ExecStart=/usr/local/bin/lesavka-client
-Restart=no
-
-[Install]
-WantedBy=default.target
-EOF
-
-# 7. Call the *user* instance inside the caller’s session
-log "7. Reloading/starting service"
+log "7. Removing legacy auto-start service"
+sudo systemctl disable --now lesavka-client.service >/dev/null 2>&1 || true
+sudo rm -f /etc/systemd/system/lesavka-client.service
sudo systemctl daemon-reload
-sudo systemctl enable --now lesavka-client.service
-sudo systemctl restart lesavka-client || true
echo
echo "✅ lesavka-client install complete"
echo " Binary: /usr/local/bin/lesavka-client"
-echo " Build source: $SRC/client/target/release/lesavka-client"
-echo " Service: systemctl status lesavka-client --no-pager"
+echo " Launch alias: /usr/local/bin/lesavka"
+echo " Desktop entry: /usr/share/applications/lesavka.desktop"
+echo " Build source: $SRC/target/release/lesavka-client"
echo
-echo "Fish quick start:"
-echo " set -gx LESAVKA_SERVER_ADDR http://:50051"
-echo " set -gx LESAVKA_VIDEO_MAX_KBIT 4000"
-echo " /usr/local/bin/lesavka-client"
+echo "Quick start:"
+echo " KDE menu: search for Lesavka"
+echo " Terminal: /usr/local/bin/lesavka"
diff --git a/scripts/install/server.sh b/scripts/install/server.sh
index 1e2e072..81de01d 100755
--- a/scripts/install/server.sh
+++ b/scripts/install/server.sh
@@ -160,8 +160,8 @@ echo "==> 4c. Source build"
sudo -u "$ORIG_USER" bash -c "cd '$SRC_DIR/server' && cargo clean && cargo build --release --bins"
echo "==> 5. Install binaries"
-sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-server" /usr/local/bin/lesavka-server
-sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc
+sudo install -Dm755 "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server
+sudo install -Dm755 "$SRC_DIR/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-uvc.sh" /usr/local/bin/lesavka-uvc.sh
diff --git a/server/src/capture_power.rs b/server/src/capture_power.rs
index 703c1ce..c1b2a8d 100644
--- a/server/src/capture_power.rs
+++ b/server/src/capture_power.rs
@@ -2,11 +2,11 @@ use lesavka_common::lesavka::CapturePowerState;
#[cfg(not(coverage))]
use {
- anyhow::{anyhow, Context, Result},
+ anyhow::{Context, Result, anyhow},
std::process::Command,
std::sync::{
- atomic::{AtomicBool, Ordering},
Arc,
+ atomic::{AtomicBool, Ordering},
},
tokio::{
sync::Mutex,
diff --git a/server/src/handshake.rs b/server/src/handshake.rs
index 5b54dbd..bd3ef52 100644
--- a/server/src/handshake.rs
+++ b/server/src/handshake.rs
@@ -16,6 +16,7 @@ impl Handshake for HandshakeSvc {
_req: Request,
) -> Result, Status> {
let cfg = camera::update_camera_config();
+ let (eye_width, eye_height, eye_fps) = crate::video::eye_source_profile();
let camera_enabled = match cfg.output {
camera::CameraOutput::Uvc => std::env::var("LESAVKA_DISABLE_UVC").is_err(),
camera::CameraOutput::Hdmi => true,
@@ -29,6 +30,9 @@ impl Handshake for HandshakeSvc {
camera_width: cfg.width,
camera_height: cfg.height,
camera_fps: cfg.fps,
+ eye_width,
+ eye_height,
+ eye_fps,
}))
}
}
diff --git a/server/src/main.rs b/server/src/main.rs
index 8c98f40..797b107 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -13,9 +13,9 @@ use tonic_reflection::server::Builder as ReflBuilder;
use tracing::{debug, error, info, warn};
use lesavka_common::lesavka::{
- relay_server::{Relay, RelayServer},
AudioPacket, CapturePowerCommand, CapturePowerState, Empty, KeyboardReport, MonitorRequest,
MouseReport, PasteReply, PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket,
+ relay_server::{Relay, RelayServer},
};
use lesavka_server::{
@@ -104,15 +104,25 @@ impl Handler {
rpc_id,
id,
max_bitrate = req.max_bitrate,
+ requested_width = req.requested_width,
+ requested_height = req.requested_height,
+ requested_fps = req.requested_fps,
"🎥 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:#}")))?;
+ let stream = video::eye_ball_with_request(
+ dev,
+ id,
+ req.max_bitrate,
+ req.requested_width,
+ req.requested_height,
+ req.requested_fps,
+ )
+ .await
+ .map_err(|e| Status::internal(format!("{e:#}")))?;
Ok(Response::new(Box::pin(GuardedVideoStream {
inner: stream,
_lease: lease,
diff --git a/server/src/paste.rs b/server/src/paste.rs
index 5d73209..0836b82 100644
--- a/server/src/paste.rs
+++ b/server/src/paste.rs
@@ -4,6 +4,7 @@
use anyhow::{Context, Result};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
+use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
@@ -94,11 +95,32 @@ fn unsupported_chars(chars: impl Iterator- ) -> (usize, String) {
}
fn load_key() -> Result<[u8; 32]> {
- let raw = std::env::var("LESAVKA_PASTE_KEY")
- .context("LESAVKA_PASTE_KEY not set (required for PasteText RPC)")?;
+ let raw = load_key_material()?;
decode_shared_key(&raw)
}
+fn load_key_material() -> Result {
+ if let Some(raw) = std::env::var("LESAVKA_PASTE_KEY")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ {
+ return Ok(raw);
+ }
+
+ let path = std::env::var("LESAVKA_PASTE_KEY_FILE")
+ .ok()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("/etc/lesavka/paste-key"));
+ let raw = std::fs::read_to_string(&path)
+ .with_context(|| format!("reading paste key file {}", path.display()))?;
+ let trimmed = raw.trim().to_string();
+ if trimmed.is_empty() {
+ anyhow::bail!("paste key file {} is empty", path.display());
+ }
+ Ok(trimmed)
+}
+
#[cfg(test)]
mod tests {
use super::{decrypt, type_text, unsupported_chars};
@@ -204,4 +226,30 @@ mod tests {
});
});
}
+
+ #[test]
+ #[serial]
+ fn decrypt_loads_key_from_file() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("paste-key");
+ let key = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
+ std::fs::write(&path, key).expect("write key file");
+ with_var("LESAVKA_PASTE_KEY", None::<&str>, || {
+ with_var("LESAVKA_PASTE_KEY_FILE", Some(path.as_os_str()), || {
+ let raw_key = lesavka_common::paste::decode_shared_key(key).expect("decode key");
+ let cipher = ChaCha20Poly1305::new(Key::from_slice(&raw_key));
+ let nonce_bytes = [0x22u8; 12];
+ let nonce = Nonce::from_slice(&nonce_bytes);
+ let data = cipher
+ .encrypt(nonce, b"file backed secret".as_ref())
+ .expect("encrypt");
+ let req = PasteRequest {
+ nonce: nonce_bytes.to_vec(),
+ data,
+ encrypted: true,
+ };
+ assert_eq!(decrypt(&req).expect("decrypt"), "file backed secret");
+ });
+ });
+ }
}
diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs
index f763308..286f714 100644
--- a/server/src/runtime_support.rs
+++ b/server/src/runtime_support.rs
@@ -288,8 +288,18 @@ pub async fn write_hid_report(
dev: &Arc>,
data: &[u8],
) -> std::io::Result<()> {
+ let attempts = std::env::var("LESAVKA_HID_WRITE_RETRIES")
+ .ok()
+ .and_then(|value| value.parse::().ok())
+ .unwrap_or(24)
+ .max(1);
+ let base_delay_ms = std::env::var("LESAVKA_HID_WRITE_RETRY_DELAY_MS")
+ .ok()
+ .and_then(|value| value.parse::().ok())
+ .unwrap_or(2)
+ .max(1);
let mut last_error: Option = None;
- for attempt in 0..5 {
+ for attempt in 0..attempts {
let mut file = dev.lock().await;
match file.write_all(data).await {
Ok(()) => return Ok(()),
@@ -302,7 +312,7 @@ pub async fn write_hid_report(
Err(error) => return Err(error),
}
drop(file);
- tokio::time::sleep(Duration::from_millis((attempt as u64 + 1) * 2)).await;
+ tokio::time::sleep(Duration::from_millis((attempt as u64 + 1) * base_delay_ms)).await;
}
Err(last_error.unwrap_or_else(|| std::io::Error::from_raw_os_error(libc::EAGAIN)))
diff --git a/server/src/video.rs b/server/src/video.rs
index 4c843c8..b8bbe50 100644
--- a/server/src/video.rs
+++ b/server/src/video.rs
@@ -154,7 +154,6 @@ fn start_eye_pipeline(pipeline: &gst::Pipeline, bus: &gst::Bus, eye: &str) -> an
Ok(())
}
-#[cfg(not(coverage))]
fn eye_device_wait_timeout() -> Duration {
Duration::from_millis(
std::env::var("LESAVKA_EYE_DEVICE_WAIT_MS")
@@ -164,7 +163,6 @@ fn eye_device_wait_timeout() -> Duration {
)
}
-#[cfg(not(coverage))]
fn eye_device_wait_poll() -> Duration {
Duration::from_millis(
std::env::var("LESAVKA_EYE_DEVICE_POLL_MS")
@@ -175,6 +173,62 @@ fn eye_device_wait_poll() -> Duration {
)
}
+pub fn eye_source_profile() -> (u32, u32, u32) {
+ let width = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_WIDTH", 1920).max(320));
+ let height = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_HEIGHT", 1080).max(180));
+ let fps = env_u32("LESAVKA_EYE_SOURCE_FPS", 30).max(1);
+ (width, height, fps)
+}
+
+fn round_down_even_u32(value: u32) -> u32 {
+ let rounded = value.max(2);
+ rounded - (rounded % 2)
+}
+
+#[derive(Clone, Copy, Debug)]
+struct EyeCaptureRequest {
+ source_width: u32,
+ source_height: u32,
+ requested_width: u32,
+ requested_height: u32,
+ requested_fps: u32,
+ max_bitrate_kbit: u32,
+ downscale: bool,
+}
+
+fn normalize_eye_capture_request(
+ requested_width: u32,
+ requested_height: u32,
+ requested_fps: u32,
+ max_bitrate_kbit: u32,
+) -> EyeCaptureRequest {
+ let (source_width, source_height, source_fps) = eye_source_profile();
+ let requested_width = if requested_width == 0 {
+ source_width
+ } else {
+ round_down_even_u32(requested_width.min(source_width).max(320))
+ };
+ let requested_height = if requested_height == 0 {
+ source_height
+ } else {
+ round_down_even_u32(requested_height.min(source_height).max(180))
+ };
+ let requested_fps = if requested_fps == 0 {
+ source_fps.max(1)
+ } else {
+ requested_fps.max(1).min(source_fps.max(1))
+ };
+ EyeCaptureRequest {
+ source_width,
+ source_height,
+ requested_width,
+ requested_height,
+ requested_fps,
+ max_bitrate_kbit: max_bitrate_kbit.max(800),
+ downscale: requested_width < source_width || requested_height < source_height,
+ }
+}
+
#[cfg(not(coverage))]
async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> {
let timeout = eye_device_wait_timeout();
@@ -201,6 +255,32 @@ async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> {
))
}
+#[cfg(coverage)]
+async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> {
+ let timeout = eye_device_wait_timeout();
+ let poll = eye_device_wait_poll();
+ let deadline = Instant::now() + timeout;
+ let last_detail = loop {
+ let detail = match tokio::fs::metadata(dev).await {
+ Ok(metadata) if metadata.file_type().is_char_device() => return Ok(()),
+ Ok(metadata) => format!("device exists but is not a character device ({metadata:?})"),
+ Err(err) => err.to_string(),
+ };
+
+ if Instant::now() >= deadline {
+ break detail;
+ }
+
+ sleep(poll).await;
+ };
+
+ Err(anyhow::anyhow!(
+ "🎥 eye-{eye} device {dev} was not ready within {} ms: {}",
+ timeout.as_millis(),
+ last_detail
+ ))
+}
+
/// Capture one eye stream from the local V4L2 gadget and expose it as a gRPC stream.
///
/// Inputs: the V4L2 device node, logical eye id, and negotiated bitrate cap.
@@ -209,6 +289,18 @@ async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> {
/// frames before they build up in gRPC queues and destabilize downstream playback.
#[cfg(coverage)]
pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result {
+ eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0).await
+}
+
+#[cfg(coverage)]
+pub async fn eye_ball_with_request(
+ dev: &str,
+ id: u32,
+ _max_bitrate_kbit: u32,
+ _requested_width: u32,
+ _requested_height: u32,
+ _requested_fps: u32,
+) -> anyhow::Result {
let _ = EYE_ID[id as usize];
if dev.contains('"') {
return Err(anyhow::anyhow!("invalid video source"));
@@ -242,10 +334,32 @@ pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Res
#[cfg(not(coverage))]
pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result {
+ eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0).await
+}
+
+#[cfg(not(coverage))]
+pub async fn eye_ball_with_request(
+ dev: &str,
+ id: u32,
+ max_bitrate_kbit: u32,
+ requested_width: u32,
+ requested_height: u32,
+ requested_fps: u32,
+) -> anyhow::Result {
let eye = EYE_ID[id as usize];
gst::init().context("gst init")?;
- let target_fps = env_u32("LESAVKA_EYE_FPS", default_eye_fps(max_bitrate_kbit)).max(1);
+ let request = normalize_eye_capture_request(
+ requested_width,
+ requested_height,
+ requested_fps,
+ max_bitrate_kbit,
+ );
+ let target_fps = if requested_fps > 0 {
+ request.requested_fps
+ } else {
+ env_u32("LESAVKA_EYE_FPS", default_eye_fps(max_bitrate_kbit)).max(1)
+ };
let min_fps = env_u32("LESAVKA_EYE_MIN_FPS", 12).clamp(1, target_fps);
let adaptive = std::env::var("LESAVKA_EYE_ADAPTIVE")
.map(|value| value != "0")
@@ -254,6 +368,11 @@ pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Resu
target: "lesavka_server::video",
eye = %eye,
max_bitrate_kbit,
+ source_width = request.source_width,
+ source_height = request.source_height,
+ requested_width = request.requested_width,
+ requested_height = request.requested_height,
+ requested_fps = request.requested_fps,
target_fps,
min_fps,
adaptive,
@@ -275,15 +394,32 @@ pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Resu
wait_for_eye_device(dev, eye).await?;
}
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", request.max_bitrate_kbit);
format!(
"videotestsrc name=cam_{eye} is-live=true pattern=smpte ! \
- video/x-raw,width=640,height=360,framerate={target_fps}/1 ! \
+ video/x-raw,width={},height={},framerate={}/1 ! \
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
- x264enc tune=zerolatency speed-preset=ultrafast bitrate={test_bitrate} key-int-max=30 ! \
+ x264enc tune=zerolatency speed-preset=veryfast bitrate={test_bitrate} key-int-max=30 ! \
h264parse disable-passthrough=true config-interval=-1 ! \
video/x-h264,stream-format=byte-stream,alignment=au ! \
- appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true"
+ appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
+ request.requested_width, request.requested_height, request.requested_fps,
+ )
+ } else if request.downscale {
+ format!(
+ "v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
+ queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
+ h264parse disable-passthrough=true config-interval=-1 ! avdec_h264 ! videoconvert ! \
+ videoscale ! videorate ! video/x-raw,width={},height={},framerate={}/1,pixel-aspect-ratio=1/1 ! \
+ x264enc tune=zerolatency speed-preset=faster bitrate={} key-int-max={} ! \
+ h264parse disable-passthrough=true config-interval=-1 ! \
+ video/x-h264,stream-format=byte-stream,alignment=au ! \
+ appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
+ request.requested_width,
+ request.requested_height,
+ request.requested_fps,
+ request.max_bitrate_kbit,
+ request.requested_fps.max(1),
)
} else {
format!(
diff --git a/testing/tests/client_app_include_contract.rs b/testing/tests/client_app_include_contract.rs
index 515fcc1..ce06698 100644
--- a/testing/tests/client_app_include_contract.rs
+++ b/testing/tests/client_app_include_contract.rs
@@ -206,9 +206,13 @@ mod paste {
mod app_include_contract;
mod tests {
- use super::app_include_contract::LesavkaClientApp;
+ use super::app_include_contract::{
+ LesavkaClientApp, keyboard_stream_report, mouse_stream_report,
+ };
+ use lesavka_common::lesavka::{KeyboardReport, MouseReport};
use serial_test::serial;
use temp_env::with_var;
+ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
#[test]
#[serial]
@@ -258,4 +262,30 @@ mod tests {
});
});
}
+
+ #[test]
+ fn keyboard_stream_report_turns_lag_into_a_clean_reset() {
+ let pkt = keyboard_stream_report(Err(BroadcastStreamRecvError::Lagged(3)))
+ .expect("lagged keyboard item should produce reset");
+ assert_eq!(pkt.data, vec![0; 8]);
+
+ let passthrough = keyboard_stream_report(Ok(KeyboardReport {
+ data: vec![1, 2, 3],
+ }))
+ .expect("ok keyboard item should pass through");
+ assert_eq!(passthrough.data, vec![1, 2, 3]);
+ }
+
+ #[test]
+ fn mouse_stream_report_turns_lag_into_a_neutral_packet() {
+ let pkt = mouse_stream_report(Err(BroadcastStreamRecvError::Lagged(5)))
+ .expect("lagged mouse item should produce neutral packet");
+ assert_eq!(pkt.data, vec![0; 4]);
+
+ let passthrough = mouse_stream_report(Ok(MouseReport {
+ data: vec![9, 8, 7, 6],
+ }))
+ .expect("ok mouse item should pass through");
+ assert_eq!(passthrough.data, vec![9, 8, 7, 6]);
+ }
}
diff --git a/testing/tests/client_app_process_contract.rs b/testing/tests/client_app_process_contract.rs
index b39a7a4..0f2737c 100644
--- a/testing/tests/client_app_process_contract.rs
+++ b/testing/tests/client_app_process_contract.rs
@@ -59,6 +59,7 @@ fn client_headless_runtime_enters_main_loop() {
};
let child = Command::new(Path::new(&bin))
+ .arg("--no-launcher")
.env("LESAVKA_HEADLESS", "1")
.env("LESAVKA_SERVER_ADDR", "http://127.0.0.1:9")
.spawn()
@@ -81,6 +82,7 @@ fn client_desktop_runtime_executes_startup_branches() {
let runtime_dir = tempdir().expect("runtime dir");
let child = Command::new(Path::new(&bin))
+ .arg("--no-launcher")
.env("XDG_RUNTIME_DIR", runtime_dir.path())
.env_remove("LESAVKA_HEADLESS")
.env("LESAVKA_SERVER_ADDR", "not a uri")
diff --git a/testing/tests/client_inputs_extra_contract.rs b/testing/tests/client_inputs_extra_contract.rs
new file mode 100644
index 0000000..e37853c
--- /dev/null
+++ b/testing/tests/client_inputs_extra_contract.rs
@@ -0,0 +1,95 @@
+//! Extra include-based coverage for input aggregator edge cases.
+//!
+//! Scope: keep additional quick-toggle regression checks in a separate file so
+//! each testing module stays under the 500 LOC contract.
+//! Targets: `client/src/input/inputs.rs`.
+//! Why: quick swap-key taps can otherwise disappear inside one poll cycle and
+//! make local/remote handoff feel flaky in the live launcher path.
+
+mod layout {
+ pub use lesavka_client::layout::*;
+}
+
+mod keyboard {
+ pub use lesavka_client::input::keyboard::*;
+}
+
+mod mouse {
+ pub use lesavka_client::input::mouse::*;
+}
+
+#[allow(warnings)]
+mod inputs_contract_extra {
+ include!(env!("LESAVKA_CLIENT_INPUTS_SRC"));
+
+ use evdev::AttributeSet;
+ use evdev::uinput::VirtualDevice;
+ use serial_test::serial;
+ use std::thread;
+
+ fn open_virtual_device(vdev: &mut VirtualDevice) -> Option {
+ for _ in 0..40 {
+ if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() {
+ if let Some(Ok(path)) = nodes.next() {
+ if let Ok(dev) = evdev::Device::open(path) {
+ let _ = dev.set_nonblocking(true);
+ return Some(dev);
+ }
+ }
+ }
+ thread::sleep(std::time::Duration::from_millis(10));
+ }
+ None
+ }
+
+ fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
+ let mut keys = AttributeSet::::new();
+ keys.insert(evdev::KeyCode::KEY_A);
+ keys.insert(evdev::KeyCode::KEY_ENTER);
+
+ let mut vdev = VirtualDevice::builder()
+ .ok()?
+ .name(name)
+ .with_keys(&keys)
+ .ok()?
+ .build()
+ .ok()?;
+
+ let dev = open_virtual_device(&mut vdev)?;
+ Some((vdev, dev))
+ }
+
+ #[test]
+ #[serial]
+ fn quick_toggle_detects_tap_when_press_and_release_land_in_same_poll_cycle() {
+ let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-input-quick-toggle-tap") else {
+ return;
+ };
+
+ let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
+ let (agg_kbd_tx, _) = tokio::sync::broadcast::channel(16);
+ let (agg_mou_tx, _) = tokio::sync::broadcast::channel(16);
+ let mut keyboard = KeyboardAggregator::new(dev, false, kbd_tx, None);
+
+ vdev.emit(&[
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1),
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0),
+ ])
+ .expect("emit quick-toggle tap");
+ thread::sleep(std::time::Duration::from_millis(20));
+ keyboard.process_events();
+
+ let mut agg = InputAggregator::new(false, agg_kbd_tx, agg_mou_tx, None);
+ agg.quick_toggle_key = Some(evdev::KeyCode::KEY_A);
+ agg.keyboards.push(keyboard);
+
+ assert!(
+ agg.quick_toggle_active(),
+ "quick-toggle should fire even when a tap starts and ends inside one poll batch"
+ );
+ assert!(
+ !agg.quick_toggle_active(),
+ "tap activation should be consumed after one observation"
+ );
+ }
+}
diff --git a/testing/tests/client_keyboard_activation_contract.rs b/testing/tests/client_keyboard_activation_contract.rs
new file mode 100644
index 0000000..97ebe39
--- /dev/null
+++ b/testing/tests/client_keyboard_activation_contract.rs
@@ -0,0 +1,112 @@
+//! Focused coverage for keyboard quick-toggle activation edges.
+//!
+//! Scope: exercise the keyboard aggregator's recent-press tracking directly.
+//! Targets: `client/src/input/keyboard.rs`.
+//! Why: the swap key needs to stay reliable even when a tap begins and ends
+//! before the next launcher/input poll cycle observes the key state.
+
+mod keymap {
+ pub use lesavka_client::input::keymap::*;
+}
+
+#[allow(warnings)]
+mod keyboard_activation_contract {
+ include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC"));
+
+ use evdev::AttributeSet;
+ use evdev::uinput::VirtualDevice;
+ use serial_test::serial;
+ use std::thread;
+
+ fn open_virtual_device(vdev: &mut VirtualDevice) -> Option {
+ for _ in 0..40 {
+ if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() {
+ if let Some(Ok(path)) = nodes.next() {
+ if let Ok(dev) = evdev::Device::open(path) {
+ let _ = dev.set_nonblocking(true);
+ return Some(dev);
+ }
+ }
+ }
+ thread::sleep(std::time::Duration::from_millis(10));
+ }
+ None
+ }
+
+ fn build_keyboard(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
+ let mut keys = AttributeSet::::new();
+ keys.insert(evdev::KeyCode::KEY_A);
+ keys.insert(evdev::KeyCode::KEY_ENTER);
+
+ let mut vdev = VirtualDevice::builder()
+ .ok()?
+ .name(name)
+ .with_keys(&keys)
+ .ok()?
+ .build()
+ .ok()?;
+
+ let dev = open_virtual_device(&mut vdev)?;
+ Some((vdev, dev))
+ }
+
+ fn new_aggregator(
+ dev: evdev::Device,
+ ) -> (
+ KeyboardAggregator,
+ tokio::sync::broadcast::Receiver,
+ ) {
+ let (tx, rx) = tokio::sync::broadcast::channel(16);
+ (KeyboardAggregator::new(dev, false, tx, None), rx)
+ }
+
+ #[test]
+ #[serial]
+ fn take_key_activation_consumes_a_fast_tap_once() {
+ let Some((mut vdev, dev)) = build_keyboard("lesavka-kbd-activation-tap") else {
+ return;
+ };
+ let (mut agg, _rx) = new_aggregator(dev);
+
+ vdev.emit(&[
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1),
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0),
+ ])
+ .expect("emit key tap");
+ thread::sleep(std::time::Duration::from_millis(20));
+ agg.process_events();
+
+ assert!(agg.take_key_activation(evdev::KeyCode::KEY_A));
+ assert!(!agg.take_key_activation(evdev::KeyCode::KEY_A));
+ }
+
+ #[test]
+ #[serial]
+ fn process_events_clears_stale_recent_key_presses_before_polling() {
+ let Some((_vdev, dev)) = build_keyboard("lesavka-kbd-activation-clear") else {
+ return;
+ };
+ let (mut agg, _rx) = new_aggregator(dev);
+ agg.recent_key_presses.insert(evdev::KeyCode::KEY_A);
+
+ agg.process_events();
+
+ assert!(!agg.take_key_activation(evdev::KeyCode::KEY_A));
+ }
+
+ #[test]
+ #[serial]
+ fn reset_state_clears_recent_key_presses_even_when_idle() {
+ let Some((_vdev, dev)) = build_keyboard("lesavka-kbd-activation-reset") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+ agg.recent_key_presses.insert(evdev::KeyCode::KEY_A);
+
+ agg.reset_state();
+
+ assert!(agg.recent_key_presses.is_empty());
+ let pkt = rx.try_recv().expect("empty report after idle reset");
+ assert_eq!(pkt.data, vec![0; 8]);
+ }
+}
diff --git a/testing/tests/client_keyboard_clipboard_contract.rs b/testing/tests/client_keyboard_clipboard_contract.rs
new file mode 100644
index 0000000..207e88e
--- /dev/null
+++ b/testing/tests/client_keyboard_clipboard_contract.rs
@@ -0,0 +1,106 @@
+//! Focused clipboard-read coverage for the client keyboard helper surface.
+//!
+//! Scope: isolate clipboard command and fallback reader behavior so the
+//! keyboard extra contract stays below the hygiene size cap.
+//! Targets: `client/src/input/keyboard.rs`.
+//! Why: clipboard sourcing must stay predictable across operator overrides,
+//! fallback tools, and coverage-mode shells.
+
+mod keymap {
+ pub use lesavka_client::input::keymap::*;
+}
+
+#[allow(warnings)]
+mod keyboard_clipboard_contract {
+ 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);
+ }
+
+ #[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 read_clipboard_text_returns_none_when_command_is_empty_and_fallback_fails() {
+ let empty_path = tempdir().expect("tempdir");
+ temp_env::with_vars(
+ [
+ ("LESAVKA_CLIPBOARD_CMD", Some("printf ''")),
+ ("PATH", empty_path.path().to_str()),
+ ],
+ || {
+ with_fake_path_command("wl-paste", "#!/usr/bin/env sh\nexit 1\n", || {
+ assert!(read_clipboard_text().is_none());
+ });
+ },
+ );
+ }
+
+ #[test]
+ #[cfg(coverage)]
+ #[serial]
+ fn read_clipboard_text_prefers_nonempty_command_output_in_coverage() {
+ with_var(
+ "LESAVKA_CLIPBOARD_CMD",
+ Some("printf 'coverage-clipboard'"),
+ || {
+ let text = read_clipboard_text().expect("coverage clipboard text");
+ assert_eq!(text, "coverage-clipboard");
+ },
+ );
+ }
+
+ #[test]
+ #[cfg(coverage)]
+ #[serial]
+ fn read_clipboard_text_tolerates_missing_shell_in_coverage() {
+ let dir = tempdir().expect("tempdir");
+ with_var(
+ "LESAVKA_CLIPBOARD_CMD",
+ Some("printf 'coverage-clipboard'"),
+ || {
+ with_var(
+ "PATH",
+ Some(dir.path().to_string_lossy().to_string()),
+ || {
+ assert!(read_clipboard_text().is_none());
+ },
+ );
+ },
+ );
+ }
+}
diff --git a/testing/tests/client_keyboard_include_extra_contract.rs b/testing/tests/client_keyboard_include_extra_contract.rs
index 87b0a31..df6e4d1 100644
--- a/testing/tests/client_keyboard_include_extra_contract.rs
+++ b/testing/tests/client_keyboard_include_extra_contract.rs
@@ -19,7 +19,7 @@ mod keyboard_contract_extra {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
- use temp_env::with_var;
+ use temp_env::{with_var, with_vars};
use tempfile::tempdir;
fn write_executable(dir: &Path, name: &str, body: &str) {
@@ -152,7 +152,7 @@ mod keyboard_contract_extra {
assert!(!pressed.contains(&evdev::KeyCode::KEY_A));
update_pressed_keys(&mut pressed, evdev::KeyCode::KEY_B, 2);
- assert!(!pressed.contains(&evdev::KeyCode::KEY_B));
+ assert!(pressed.contains(&evdev::KeyCode::KEY_B));
}
#[test]
@@ -172,23 +172,42 @@ mod keyboard_contract_extra {
#[test]
#[serial]
fn paste_rpc_enabled_from_env_honors_flag_and_key_variants() {
- with_var("LESAVKA_PASTE_RPC", Some("0"), || {
- with_var("LESAVKA_PASTE_KEY", Some("shared-key"), || {
+ let home = tempdir().expect("tempdir");
+ temp_env::with_vars(
+ [
+ ("HOME", home.path().to_str()),
+ ("LESAVKA_PASTE_KEY_FILE", None::<&str>),
+ ("LESAVKA_PASTE_RPC", Some("0")),
+ ("LESAVKA_PASTE_KEY", Some("shared-key")),
+ ],
+ || {
assert!(!paste_rpc_enabled_from_env());
- });
- });
+ },
+ );
- with_var("LESAVKA_PASTE_RPC", Some("1"), || {
- with_var("LESAVKA_PASTE_KEY", Some(" "), || {
+ temp_env::with_vars(
+ [
+ ("HOME", home.path().to_str()),
+ ("LESAVKA_PASTE_KEY_FILE", None::<&str>),
+ ("LESAVKA_PASTE_RPC", Some("1")),
+ ("LESAVKA_PASTE_KEY", Some(" ")),
+ ],
+ || {
assert!(!paste_rpc_enabled_from_env());
- });
- });
+ },
+ );
- with_var("LESAVKA_PASTE_RPC", Some("1"), || {
- with_var("LESAVKA_PASTE_KEY", Some("shared-key"), || {
+ temp_env::with_vars(
+ [
+ ("HOME", home.path().to_str()),
+ ("LESAVKA_PASTE_KEY_FILE", None::<&str>),
+ ("LESAVKA_PASTE_RPC", Some("1")),
+ ("LESAVKA_PASTE_KEY", Some("shared-key")),
+ ],
+ || {
assert!(paste_rpc_enabled_from_env());
- });
- });
+ },
+ );
}
#[test]
@@ -234,50 +253,6 @@ mod keyboard_contract_extra {
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 read_clipboard_text_returns_none_when_command_is_empty_and_fallback_fails() {
- with_var("LESAVKA_CLIPBOARD_CMD", Some("printf ''"), || {
- with_fake_path_command("wl-paste", "#!/usr/bin/env sh\nexit 1\n", || {
- assert!(read_clipboard_text().is_none());
- });
- });
- }
-
- #[test]
- #[cfg(coverage)]
- #[serial]
- fn read_clipboard_text_prefers_nonempty_command_output_in_coverage() {
- with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'coverage-clipboard'"), || {
- let text = read_clipboard_text().expect("coverage clipboard text");
- assert_eq!(text, "coverage-clipboard");
- });
- }
-
- #[test]
- #[cfg(coverage)]
- #[serial]
- fn read_clipboard_text_tolerates_missing_shell_in_coverage() {
- let dir = tempdir().expect("tempdir");
- with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'coverage-clipboard'"), || {
- with_var("PATH", Some(dir.path().to_string_lossy().to_string()), || {
- assert!(read_clipboard_text().is_none());
- });
- });
- }
-
#[test]
#[serial]
fn paste_via_rpc_returns_true_for_empty_clipboard_payload() {
@@ -392,8 +367,14 @@ mod keyboard_contract_extra {
saw_hid_payload = true;
}
}
- assert!(saw_hid_payload, "coverage paste path should emit HID reports");
- assert!(saw_empty, "coverage paste path should end with an empty report");
+ assert!(
+ saw_hid_payload,
+ "coverage paste path should emit HID reports"
+ );
+ assert!(
+ saw_empty,
+ "coverage paste path should end with an empty report"
+ );
}
#[test]
@@ -431,7 +412,10 @@ mod keyboard_contract_extra {
.try_recv()
.expect("debounced paste should still emit a swallowed empty report");
assert_eq!(pkt.data, vec![0; 8]);
- assert!(rx.try_recv().is_err(), "debounced paste should not emit HID reports");
+ assert!(
+ rx.try_recv().is_err(),
+ "debounced paste should not emit HID reports"
+ );
LAST_PASTE_MS.store(0, Ordering::Relaxed);
}
@@ -439,8 +423,8 @@ mod keyboard_contract_extra {
#[cfg(coverage)]
#[serial]
fn try_handle_paste_event_coverage_path_invokes_rpc_when_enabled() {
- let Some(dev) =
- open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-coverage-rpc"))
+ let Some(dev) = open_any_keyboard_device()
+ .or_else(|| build_keyboard("lesavka-include-kbd-coverage-rpc"))
else {
return;
};
@@ -453,13 +437,17 @@ mod keyboard_contract_extra {
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
- with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'rpc-coverage'"), || {
- with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
- with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || {
- assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
+ with_var(
+ "LESAVKA_CLIPBOARD_CMD",
+ Some("printf 'rpc-coverage'"),
+ || {
+ with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
+ with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || {
+ assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
+ });
});
- });
- });
+ },
+ );
let payload = paste_rx.try_recv().expect("rpc payload");
assert_eq!(payload, "rpc-coverage");
diff --git a/testing/tests/client_keyboard_shift_contract.rs b/testing/tests/client_keyboard_shift_contract.rs
new file mode 100644
index 0000000..bb58bab
--- /dev/null
+++ b/testing/tests/client_keyboard_shift_contract.rs
@@ -0,0 +1,268 @@
+//! Focused coverage for shifted live-key emission.
+//!
+//! Scope: verify the keyboard aggregator stages modifier state before shifted
+//! printable keys so firmware and bootloaders do not miss the modifier bit.
+//! Targets: `client/src/input/keyboard.rs`.
+//! Why: modifier chords and overlapping presses must remain trustworthy under
+//! real evdev timing so remote typing stays usable.
+
+mod keymap {
+ pub use lesavka_client::input::keymap::*;
+}
+
+#[allow(warnings)]
+mod keyboard_shift_contract {
+ include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC"));
+
+ use evdev::AttributeSet;
+ use evdev::uinput::VirtualDevice;
+ use serial_test::serial;
+ use std::thread;
+ use temp_env::with_var;
+
+ fn open_virtual_device(vdev: &mut VirtualDevice) -> Option {
+ for _ in 0..40 {
+ if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() {
+ if let Some(Ok(path)) = nodes.next() {
+ if let Ok(dev) = evdev::Device::open(path) {
+ let _ = dev.set_nonblocking(true);
+ return Some(dev);
+ }
+ }
+ }
+ thread::sleep(std::time::Duration::from_millis(10));
+ }
+ None
+ }
+
+ fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
+ let mut keys = AttributeSet::::new();
+ for key in [
+ evdev::KeyCode::KEY_LEFTSHIFT,
+ evdev::KeyCode::KEY_LEFTCTRL,
+ evdev::KeyCode::KEY_LEFTALT,
+ evdev::KeyCode::KEY_A,
+ evdev::KeyCode::KEY_S,
+ evdev::KeyCode::KEY_F,
+ evdev::KeyCode::KEY_9,
+ ] {
+ keys.insert(key);
+ }
+
+ let mut vdev = VirtualDevice::builder()
+ .ok()?
+ .name(name)
+ .with_keys(&keys)
+ .ok()?
+ .build()
+ .ok()?;
+
+ let dev = open_virtual_device(&mut vdev)?;
+ Some((vdev, dev))
+ }
+
+ fn build_keyboard(name: &str) -> Option {
+ build_keyboard_pair(name).map(|(_, dev)| dev)
+ }
+
+ fn new_aggregator(
+ dev: evdev::Device,
+ ) -> (
+ KeyboardAggregator,
+ tokio::sync::broadcast::Receiver,
+ ) {
+ let (tx, rx) = tokio::sync::broadcast::channel(16);
+ (KeyboardAggregator::new(dev, false, tx, None), rx)
+ }
+
+ #[test]
+ #[serial]
+ fn shifted_live_keypress_reasserts_modifier_before_key_usage() {
+ let Some(dev) = build_keyboard("lesavka-kbd-shift-stage") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+ agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTSHIFT);
+ agg.pressed_keys.insert(evdev::KeyCode::KEY_9);
+
+ with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
+ let report = agg.build_report();
+ agg.emit_live_report(evdev::KeyCode::KEY_9, 1, report);
+ });
+
+ let staged = rx.try_recv().expect("modifier stage report");
+ assert_eq!(staged.data, vec![0x02, 0, 0, 0, 0, 0, 0, 0]);
+
+ let combined = rx.try_recv().expect("combined shifted key report");
+ assert_eq!(combined.data, vec![0x02, 0, 0x26, 0, 0, 0, 0, 0]);
+ }
+
+ #[test]
+ #[serial]
+ fn unshifted_live_keypress_stays_single_report() {
+ let Some(dev) = build_keyboard("lesavka-kbd-unshifted-single") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+ agg.pressed_keys.insert(evdev::KeyCode::KEY_9);
+
+ with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
+ let report = agg.build_report();
+ agg.emit_live_report(evdev::KeyCode::KEY_9, 1, report);
+ });
+
+ let combined = rx.try_recv().expect("plain key report");
+ assert_eq!(combined.data, vec![0, 0, 0x26, 0, 0, 0, 0, 0]);
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ #[serial]
+ fn ctrl_chord_reasserts_modifier_before_key_usage() {
+ let Some(dev) = build_keyboard("lesavka-kbd-ctrl-stage") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+ agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
+ agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
+
+ with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
+ let report = agg.build_report();
+ agg.emit_live_report(evdev::KeyCode::KEY_A, 1, report);
+ });
+
+ let staged = rx.try_recv().expect("modifier stage report");
+ assert_eq!(staged.data, vec![0x01, 0, 0, 0, 0, 0, 0, 0]);
+
+ let combined = rx.try_recv().expect("combined ctrl chord report");
+ assert_eq!(combined.data, vec![0x01, 0, 0x04, 0, 0, 0, 0, 0]);
+ }
+
+ #[test]
+ #[serial]
+ fn alt_chord_reasserts_modifier_before_key_usage() {
+ let Some(dev) = build_keyboard("lesavka-kbd-alt-stage") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+ agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
+ agg.pressed_keys.insert(evdev::KeyCode::KEY_F);
+
+ with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
+ let report = agg.build_report();
+ agg.emit_live_report(evdev::KeyCode::KEY_F, 1, report);
+ });
+
+ let staged = rx.try_recv().expect("modifier stage report");
+ assert_eq!(staged.data, vec![0x04, 0, 0, 0, 0, 0, 0, 0]);
+
+ let combined = rx.try_recv().expect("combined alt chord report");
+ assert_eq!(combined.data, vec![0x04, 0, 0x09, 0, 0, 0, 0, 0]);
+ }
+
+ #[test]
+ #[serial]
+ fn process_events_emits_shifted_letter_with_modifier_bit() {
+ let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-kbd-shift-live") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+
+ vdev.emit(&[
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 1),
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1),
+ ])
+ .expect("emit shifted key");
+ thread::sleep(std::time::Duration::from_millis(25));
+
+ with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
+ agg.process_events();
+ });
+
+ let mut saw_shifted_a = false;
+ while let Ok(pkt) = rx.try_recv() {
+ if pkt.data == vec![0x02, 0, 0x04, 0, 0, 0, 0, 0] {
+ saw_shifted_a = true;
+ break;
+ }
+ }
+ assert!(
+ saw_shifted_a,
+ "expected shifted A report in live event stream"
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn process_events_emits_ctrl_chord_with_modifier_bit() {
+ let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-kbd-ctrl-live") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+
+ vdev.emit(&[
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTCTRL.0, 1),
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1),
+ ])
+ .expect("emit ctrl chord");
+ thread::sleep(std::time::Duration::from_millis(25));
+
+ with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
+ agg.process_events();
+ });
+
+ let mut saw_ctrl_a = false;
+ while let Ok(pkt) = rx.try_recv() {
+ if pkt.data == vec![0x01, 0, 0x04, 0, 0, 0, 0, 0] {
+ saw_ctrl_a = true;
+ break;
+ }
+ }
+ assert!(saw_ctrl_a, "expected ctrl+A report in live event stream");
+ }
+
+ #[test]
+ #[serial]
+ fn process_events_tracks_overlapping_plain_keys_without_sticking() {
+ let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-kbd-overlap-live") else {
+ return;
+ };
+ let (mut agg, mut rx) = new_aggregator(dev);
+
+ vdev.emit(&[
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1),
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_S.0, 1),
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0),
+ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_S.0, 0),
+ ])
+ .expect("emit overlapping plain keys");
+ thread::sleep(std::time::Duration::from_millis(25));
+
+ agg.process_events();
+
+ let mut reports = Vec::new();
+ while let Ok(pkt) = rx.try_recv() {
+ reports.push(pkt.data);
+ }
+
+ assert!(
+ reports.contains(&vec![0, 0, 0x04, 0, 0, 0, 0, 0]),
+ "expected A down report, got {reports:?}"
+ );
+ assert!(
+ reports.iter().any(|pkt| {
+ let keys = &pkt[2..8];
+ keys.contains(&0x04) && keys.contains(&0x16)
+ }),
+ "expected A+S overlap report, got {reports:?}"
+ );
+ assert!(
+ reports.contains(&vec![0, 0, 0x16, 0, 0, 0, 0, 0]),
+ "expected lone S report after A released, got {reports:?}"
+ );
+ assert!(
+ reports.contains(&vec![0; 8]),
+ "expected final empty report after both releases, got {reports:?}"
+ );
+ }
+}
diff --git a/testing/tests/client_paste_contract.rs b/testing/tests/client_paste_contract.rs
index 64d16cc..82c41db 100644
--- a/testing/tests/client_paste_contract.rs
+++ b/testing/tests/client_paste_contract.rs
@@ -11,15 +11,39 @@ use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce};
use lesavka_client::paste::build_paste_request;
use serial_test::serial;
use temp_env::with_var;
+use tempfile::tempdir;
const TEST_KEY_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
+const TEST_KEY_RAW: &str = "0123456789abcdef0123456789abcdef";
#[test]
#[serial]
fn build_paste_request_requires_a_shared_key() {
+ let dir = tempdir().expect("tempdir");
with_var("LESAVKA_PASTE_KEY", None::<&str>, || {
- let err = build_paste_request("hello").expect_err("missing key should fail");
- assert!(format!("{err:#}").contains("LESAVKA_PASTE_KEY"));
+ with_var("LESAVKA_PASTE_KEY_FILE", None::<&str>, || {
+ with_var("HOME", Some(dir.path().as_os_str()), || {
+ let err = build_paste_request("hello").expect_err("missing key should fail");
+ let rendered = format!("{err:#}");
+ assert!(
+ rendered.contains("paste key file") || rendered.contains("LESAVKA_PASTE_KEY")
+ );
+ });
+ });
+ });
+}
+
+#[test]
+#[serial]
+fn build_paste_request_requires_explicit_key_when_home_is_unset() {
+ with_var("LESAVKA_PASTE_KEY", None::<&str>, || {
+ with_var("LESAVKA_PASTE_KEY_FILE", None::<&str>, || {
+ with_var("HOME", None::<&str>, || {
+ let err = build_paste_request("hello").expect_err("missing key should fail");
+ let rendered = format!("{err:#}");
+ assert!(rendered.contains("LESAVKA_PASTE_KEY"));
+ });
+ });
});
}
@@ -34,6 +58,17 @@ fn build_paste_request_sets_encryption_fields() {
});
}
+#[test]
+#[serial]
+fn build_paste_request_accepts_raw_32_byte_shared_key() {
+ with_var("LESAVKA_PASTE_KEY", Some(TEST_KEY_RAW), || {
+ let req = build_paste_request("hello raw key").expect("build request");
+ assert!(req.encrypted);
+ assert_eq!(req.nonce.len(), 12);
+ assert!(!req.data.is_empty());
+ });
+}
+
#[test]
#[serial]
fn build_paste_request_truncates_plaintext_before_encryption() {
@@ -50,3 +85,57 @@ fn build_paste_request_truncates_plaintext_before_encryption() {
});
});
}
+
+#[test]
+#[serial]
+fn build_paste_request_loads_shared_key_from_file() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("paste-key");
+ std::fs::write(&path, TEST_KEY_HEX).expect("write key");
+ with_var("LESAVKA_PASTE_KEY", None::<&str>, || {
+ with_var("LESAVKA_PASTE_KEY_FILE", Some(path.as_os_str()), || {
+ let req = build_paste_request("hello world").expect("build request");
+ assert!(req.encrypted);
+ assert_eq!(req.nonce.len(), 12);
+ assert!(!req.data.is_empty());
+ });
+ });
+}
+
+#[test]
+#[serial]
+fn build_paste_request_uses_default_key_path_under_home() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join(".config/lesavka/paste-key");
+ std::fs::create_dir_all(path.parent().expect("paste key dir")).expect("create config dir");
+ std::fs::write(&path, TEST_KEY_HEX).expect("write key");
+
+ with_var("LESAVKA_PASTE_KEY", None::<&str>, || {
+ with_var("LESAVKA_PASTE_KEY_FILE", None::<&str>, || {
+ with_var("HOME", Some(dir.path().as_os_str()), || {
+ let req = build_paste_request("hello default path").expect("build request");
+ assert!(req.encrypted);
+ assert_eq!(req.nonce.len(), 12);
+ assert!(!req.data.is_empty());
+ });
+ });
+ });
+}
+
+#[test]
+#[serial]
+fn build_paste_request_rejects_empty_default_key_file() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join(".config/lesavka/paste-key");
+ std::fs::create_dir_all(path.parent().expect("paste key dir")).expect("create config dir");
+ std::fs::write(&path, "").expect("write empty key");
+
+ with_var("LESAVKA_PASTE_KEY", None::<&str>, || {
+ with_var("LESAVKA_PASTE_KEY_FILE", None::<&str>, || {
+ with_var("HOME", Some(dir.path().as_os_str()), || {
+ let err = build_paste_request("hello").expect_err("empty key file should fail");
+ assert!(format!("{err:#}").contains("is empty"));
+ });
+ });
+ });
+}
diff --git a/testing/tests/handshake_camera_contract.rs b/testing/tests/handshake_camera_contract.rs
index 3418c77..7eaaf3f 100644
--- a/testing/tests/handshake_camera_contract.rs
+++ b/testing/tests/handshake_camera_contract.rs
@@ -59,6 +59,9 @@ fn assert_default_caps(caps: &PeerCaps) {
assert_eq!(caps.camera_width, None);
assert_eq!(caps.camera_height, None);
assert_eq!(caps.camera_fps, None);
+ assert_eq!(caps.eye_width, None);
+ assert_eq!(caps.eye_height, None);
+ assert_eq!(caps.eye_fps, None);
}
struct UnimplementedHandshakeSvc;
@@ -101,6 +104,9 @@ impl Handshake for SparseHandshakeSvc {
camera_width: 0,
camera_height: 0,
camera_fps: 0,
+ eye_width: 0,
+ eye_height: 0,
+ eye_fps: 0,
}))
}
}
@@ -243,6 +249,9 @@ fn handshake_maps_empty_optional_fields_to_none() {
assert_eq!(caps.camera_width, None);
assert_eq!(caps.camera_height, None);
assert_eq!(caps.camera_fps, None);
+ assert_eq!(caps.eye_width, None);
+ assert_eq!(caps.eye_height, None);
+ assert_eq!(caps.eye_fps, None);
}
#[test]
@@ -272,6 +281,9 @@ fn handshake_service_direct_call_reports_capabilities() {
assert!(response.camera_width > 0);
assert!(response.camera_height > 0);
assert!(response.camera_fps > 0);
+ assert!(response.eye_width > 0);
+ assert!(response.eye_height > 0);
+ assert!(response.eye_fps > 0);
let _ = lesavka_server::handshake::HandshakeSvc::server();
});
diff --git a/testing/tests/server_main_binary_contract.rs b/testing/tests/server_main_binary_contract.rs
index 57b08bc..4c992e6 100644
--- a/testing/tests/server_main_binary_contract.rs
+++ b/testing/tests/server_main_binary_contract.rs
@@ -149,6 +149,9 @@ mod server_main_binary {
.capture_video(tonic::Request::new(MonitorRequest {
id: 9,
max_bitrate: 4_000,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
}))
.await
});
@@ -199,6 +202,9 @@ mod server_main_binary {
let req = MonitorRequest {
id: 0,
max_bitrate: 0,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
};
let rt = tokio::runtime::Runtime::new().expect("runtime");
diff --git a/testing/tests/server_main_binary_extra_contract.rs b/testing/tests/server_main_binary_extra_contract.rs
index 4f2c393..100ec8a 100644
--- a/testing/tests/server_main_binary_extra_contract.rs
+++ b/testing/tests/server_main_binary_extra_contract.rs
@@ -12,8 +12,8 @@ mod server_main_binary_extra {
use futures_util::stream;
use lesavka_common::lesavka::relay_client::RelayClient;
- use std::path::Path;
use serial_test::serial;
+ use std::path::Path;
use temp_env::with_var;
use tempfile::tempdir;
diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs
index a5fbac0..60f5826 100644
--- a/testing/tests/server_main_rpc_contract.rs
+++ b/testing/tests/server_main_rpc_contract.rs
@@ -78,6 +78,9 @@ mod server_main_rpc {
.capture_video(tonic::Request::new(MonitorRequest {
id: 0,
max_bitrate: 3_000,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
}))
.await
});
@@ -98,6 +101,9 @@ mod server_main_rpc {
.capture_video(tonic::Request::new(MonitorRequest {
id: 1,
max_bitrate: 3_000,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
}))
.await
});
@@ -124,6 +130,9 @@ mod server_main_rpc {
.capture_video(tonic::Request::new(MonitorRequest {
id: 0,
max_bitrate: 3_000,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
}))
.await
})
@@ -194,6 +203,9 @@ mod server_main_rpc {
let req = MonitorRequest {
id: 1,
max_bitrate: 0,
+ requested_width: 0,
+ requested_height: 0,
+ requested_fps: 0,
};
let rt = tokio::runtime::Runtime::new().expect("runtime");
diff --git a/testing/tests/server_video_include_contract.rs b/testing/tests/server_video_include_contract.rs
index 08204de..134b52d 100644
--- a/testing/tests/server_video_include_contract.rs
+++ b/testing/tests/server_video_include_contract.rs
@@ -238,12 +238,9 @@ mod video_include_contract {
with_var("LESAVKA_EYE_DEVICE_WAIT_MS", Some("50"), || {
with_var("LESAVKA_EYE_DEVICE_POLL_MS", Some("25"), || {
rt.block_on(async {
- let err = wait_for_eye_device(
- missing.to_str().expect("utf8 path"),
- "r",
- )
- .await
- .expect_err("missing eye device should time out");
+ let err = wait_for_eye_device(missing.to_str().expect("utf8 path"), "r")
+ .await
+ .expect_err("missing eye device should time out");
let rendered = format!("{err:#}");
assert!(rendered.contains("was not ready within 50 ms"));
});