lesavka: harden relay input and install flow

This commit is contained in:
Brad Stein 2026-04-16 12:58:05 -03:00
parent e1092afee2
commit 59ed4e5724
48 changed files with 5057 additions and 1037 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

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

View File

@ -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<KeyboardReport, BroadcastStreamRecvError>,
) -> Option<KeyboardReport> {
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<MouseReport, BroadcastStreamRecvError>,
) -> Option<MouseReport> {
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] })
}
}
}

View File

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

View File

@ -17,6 +17,9 @@ pub struct PeerCaps {
pub camera_width: Option<u32>,
pub camera_height: Option<u32>,
pub camera_fps: Option<u32>,
pub eye_width: Option<u32>,
pub eye_height: Option<u32>,
pub eye_fps: Option<u32>,
}
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

View File

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

View File

@ -32,6 +32,8 @@ pub struct InputAggregator {
paste_tx: Option<UnboundedSender<String>>,
keyboards: Vec<KeyboardAggregator>,
mice: Vec<MouseAggregator>,
selected_keyboard_path: Option<String>,
selected_mouse_path: Option<String>,
capture_remote_boot: bool,
quick_toggle_key: Option<KeyCode>,
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<PathBuf>,
#[cfg(not(coverage))]
clipboard_control_marker: u128,
#[cfg(not(coverage))]
routing_state_path: Option<PathBuf>,
#[cfg(not(coverage))]
published_remote_capture: Option<bool>,
@ -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<PathBuf> {
.filter(|path| !path.as_os_str().is_empty())
}
fn input_device_override_from_env(key: &str) -> Option<String> {
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<bool> {
let raw = std::fs::read_to_string(path).ok()?;

View File

@ -26,6 +26,7 @@ pub struct KeyboardAggregator {
paste_chord_armed: bool,
paste_chord_consumed: bool,
pressed_keys: HashSet<KeyCode>,
recent_key_presses: HashSet<KeyCode>,
}
/*───────── 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<KeyCode>, 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<InputEvent> = 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<KeyCode> {
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::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or_else(|| Duration::from_millis(24))
}
#[cfg(coverage)]
fn read_clipboard_text() -> Option<String> {
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));

View File

@ -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<String> {
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<String> {
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<String> {
/// 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<String> {
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::<u64>().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));
}
}

View File

@ -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<LocalCameraPreview>,
selected_camera: Option<String>,
microphone: Option<Child>,
microphone: Option<LocalMicrophoneMonitor>,
speaker: Option<Child>,
microphone_replay: Option<Child>,
microphone_buffer: Arc<Mutex<Vec<u8>>>,
microphone_level: Arc<Mutex<f64>>,
}
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<bool> {
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<bool> {
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<bool> {
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<Command>) -> Result<bool> {
fn toggle_child(&mut self, kind: DeviceTestKind, command: Result<Command>) -> Result<bool> {
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<Child> {
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<Child> {
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<Vec<u8>> {
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<String>,
}
struct LocalMicrophoneMonitor {
running: Arc<AtomicBool>,
generation: Arc<AtomicU64>,
}
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<Mutex<Vec<u8>>>,
level: Arc<Mutex<f64>>,
) -> Result<Self> {
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<String> {
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<Mutex<Vec<u8>>>,
level: Arc<Mutex<f64>>,
generation: Arc<AtomicU64>,
running: Arc<AtomicBool>,
) -> 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::<gst::Pipeline>()
.expect("microphone monitor pipeline");
let appsink = pipeline
.by_name("mic_preview_sink")
.context("missing microphone preview appsink")?
.downcast::<gst_app::AppSink>()
.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<PreviewFrame> {
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<Command> {
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<Command> {
let sink_prop = sink
.filter(|value| !value.trim().is_empty())
@ -413,6 +668,18 @@ fn build_speaker_test(sink: Option<&str>) -> Result<Command> {
)))
}
fn build_microphone_replay_test(path: &str, sink: Option<&str>) -> Result<Command> {
let sink_prop = sink
.filter(|value| !value.trim().is_empty())
.map(|value| format!("device={}", quote(value)))
.unwrap_or_default();
Ok(shell_command(format!(
"gst-launch-1.0 -q 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>) -> String {
escape(Cow::Owned(value.into())).into_owned()
}
fn push_recent_audio(buffer: &Arc<Mutex<Vec<u8>>>, 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<Mutex<f64>>, 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<u8> {
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());
}
}

View File

@ -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<String>,
pub microphones: Vec<String>,
pub speakers: Vec<String>,
pub keyboards: Vec<String>,
pub mice: Vec<String>,
}
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<String>) -> 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<String> {
set.into_iter().collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputDeviceKind {
Keyboard,
Mouse,
}
fn discover_input_devices(kind: InputDeviceKind) -> Vec<String> {
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<InputDeviceKind> {
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::*;

View File

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

View File

@ -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<bool> {
if should_run_launcher(args) {
@ -50,6 +52,9 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
"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<String, String> {
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]

View File

@ -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<Mutex<String>>,
inline_feeds: [PreviewFeed; 2],
window_feeds: [PreviewFeed; 2],
log_sink: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
inline_feeds: Arc<Mutex<[PreviewFeed; 2]>>,
window_feeds: Arc<Mutex<[PreviewFeed; 2]>>,
}
#[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<Self> {
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<String>) {
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: &gtk::Picture,
status_label: &gtk::Label,
) -> Option<PreviewBinding> {
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<Mutex<[PreviewFeed; 2]>>,
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<Mutex<SharedPreviewState>>,
session_active: Arc<AtomicBool>,
active_bindings: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
profile: PreviewProfile,
}
#[cfg(not(coverage))]
@ -168,6 +327,8 @@ struct SharedPreviewState {
status: String,
generation: u64,
clear_picture: bool,
last_logged_error: Option<String>,
last_logged_status: Option<String>,
}
#[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<String>, 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<Mutex<String>>,
monitor_id: u32,
profile: PreviewProfile,
log_sink: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
) -> Result<Self> {
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<String>, 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<AtomicBool>,
active_bindings: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
shared: Arc<Mutex<SharedPreviewState>>,
log_sink: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
) -> 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<Mutex<SharedPreviewState>>,
log_sink: &Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
monitor_id: u32,
status: impl Into<String>,
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<Mutex<SharedPreviewState>>,
log_sink: &Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
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<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
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::<gst::Pipeline>()
@ -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);
}
}

View File

@ -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<Self> {
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<Self> {
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<String>,
pub microphone: Option<String>,
pub speaker: Option<String>,
pub keyboard: Option<String>,
pub mouse: Option<String>,
}
#[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<String>,
@ -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<CaptureSizeChoice> {
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<BreakoutSizeChoice> {
breakout_size_options(
self.breakout_limit,
self.breakout_display,
self.preview_source,
)
}
pub fn select_camera(&mut self, camera: Option<String>) {
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<String>) {
self.devices.keyboard = normalize_selection(keyboard);
}
pub fn select_mouse(&mut self, mouse: Option<String>) {
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<String>) {
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<BreakoutSizeChoice> {
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<CaptureSizeChoice> {
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<String>) -> Option<String> {
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);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<PasteRequest> {
}
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<String> {
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<PathBuf> {
std::env::var_os("HOME").map(|home| {
let mut path = PathBuf::from(home);
path.push(".config/lesavka/paste-key");
path
})
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 callers 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://<server-ip>: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"

View File

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

View File

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

View File

@ -16,6 +16,7 @@ impl Handshake for HandshakeSvc {
_req: Request<Empty>,
) -> Result<Response<HandshakeSet>, 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,
}))
}
}

View File

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

View File

@ -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<Item = char>) -> (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<String> {
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");
});
});
}
}

View File

@ -288,8 +288,18 @@ pub async fn write_hid_report(
dev: &Arc<Mutex<tokio::fs::File>>,
data: &[u8],
) -> std::io::Result<()> {
let attempts = std::env::var("LESAVKA_HID_WRITE_RETRIES")
.ok()
.and_then(|value| value.parse::<u32>().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::<u64>().ok())
.unwrap_or(2)
.max(1);
let mut last_error: Option<std::io::Error> = 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)))

View File

@ -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<VideoStream> {
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<VideoStream> {
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<VideoStream> {
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<VideoStream> {
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!(

View File

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

View File

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

View File

@ -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<evdev::Device> {
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::<evdev::KeyCode>::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"
);
}
}

View File

@ -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<evdev::Device> {
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::<evdev::KeyCode>::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<KeyboardReport>,
) {
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]);
}
}

View File

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

View File

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

View File

@ -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<evdev::Device> {
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::<evdev::KeyCode>::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<evdev::Device> {
build_keyboard_pair(name).map(|(_, dev)| dev)
}
fn new_aggregator(
dev: evdev::Device,
) -> (
KeyboardAggregator,
tokio::sync::broadcast::Receiver<KeyboardReport>,
) {
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:?}"
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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