lesavka: harden relay input and install flow
This commit is contained in:
parent
e1092afee2
commit
59ed4e5724
@ -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 |
BIN
client/assets/icons/hicolor/1024x1024/apps/lesavka.png
Normal file
BIN
client/assets/icons/hicolor/1024x1024/apps/lesavka.png
Normal file
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 |
3
client/assets/icons/hicolor/scalable/apps/lesavka.svg
Normal file
3
client/assets/icons/hicolor/scalable/apps/lesavka.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.9 MiB |
11
client/assets/linux/lesavka.desktop
Normal file
11
client/assets/linux/lesavka.desktop
Normal 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
|
||||
@ -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] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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()?;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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: >k::Picture,
|
||||
status_label: >k::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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
ORIG_USER=${SUDO_USER:-$(id -un)}
|
||||
SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel 2>/dev/null || true)
|
||||
REF=${LESAVKA_REF:-master}
|
||||
REPO_URL=${LESAVKA_REPO_URL:-ssh://git@scm.bstein.dev:2242/bstein/lesavka.git}
|
||||
SRC=/var/src/lesavka
|
||||
|
||||
log() {
|
||||
printf '==> %s\n' "$*"
|
||||
@ -15,7 +16,7 @@ sudo pacman -Syq --needed --noconfirm \
|
||||
git rustup protobuf gcc clang evtest base-devel \
|
||||
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
|
||||
pipewire pipewire-pulse \
|
||||
wmctrl qt6-tools wl-clipboard xclip xsel
|
||||
wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils
|
||||
|
||||
ensure_yay() {
|
||||
if command -v yay >/dev/null 2>&1; then
|
||||
@ -49,19 +50,21 @@ log "2. Ensuring Rust toolchain"
|
||||
sudo rustup default stable
|
||||
sudo -u "$ORIG_USER" rustup default stable
|
||||
|
||||
# 3. clone / update into a user-writable dir (or use local repo if present)
|
||||
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
|
||||
if [[ -n ${REPO_ROOT:-} && -d $REPO_ROOT/.git ]]; then
|
||||
SRC="$REPO_ROOT"
|
||||
echo "==> 3. Using local repo at $SRC"
|
||||
# 3. clone / update into a canonical workspace checkout
|
||||
log "3. Syncing source checkout for ref ${REF}"
|
||||
if [[ ! -d /var/src ]]; then
|
||||
sudo mkdir -p /var/src
|
||||
fi
|
||||
sudo chown "$ORIG_USER":"$ORIG_USER" /var/src
|
||||
if [[ -d $SRC/.git ]]; then
|
||||
sudo -u "$ORIG_USER" git -C "$SRC" fetch --all --tags --prune
|
||||
else
|
||||
SRC="$USER_HOME/.local/src/lesavka"
|
||||
sudo -u "$ORIG_USER" mkdir -p "$(dirname "$SRC")"
|
||||
if [[ -d $SRC/.git ]]; then
|
||||
sudo -u "$ORIG_USER" git -C "$SRC" pull --ff-only
|
||||
else
|
||||
sudo -u "$ORIG_USER" git clone "$PWD" "$SRC"
|
||||
fi
|
||||
sudo -u "$ORIG_USER" git clone "$REPO_URL" "$SRC"
|
||||
fi
|
||||
if sudo -u "$ORIG_USER" git -C "$SRC" rev-parse --verify --quiet "origin/$REF" >/dev/null; then
|
||||
sudo -u "$ORIG_USER" git -C "$SRC" checkout -B "$REF" "origin/$REF"
|
||||
else
|
||||
sudo -u "$ORIG_USER" git -C "$SRC" checkout --force "$REF"
|
||||
fi
|
||||
|
||||
# 4. build
|
||||
@ -69,45 +72,36 @@ log "4. Building client release binary"
|
||||
sudo -u "$ORIG_USER" bash -c "cd '$SRC/client' && cargo clean && cargo build --release"
|
||||
|
||||
# 5. install binary
|
||||
log "5. Installing /usr/local/bin/lesavka-client"
|
||||
sudo install -Dm755 "$SRC/client/target/release/lesavka-client" /usr/local/bin/lesavka-client
|
||||
log "5. Installing launchable client binaries"
|
||||
sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client
|
||||
sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka
|
||||
|
||||
# 6. systemd service for system scope: /etc/systemd/system/lesavka-client.service
|
||||
sudo tee /etc/systemd/system/lesavka-client.service >/dev/null <<'EOF'
|
||||
[Unit]
|
||||
Description=Lesavka Client
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
log "6. Registering desktop application"
|
||||
sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \
|
||||
/usr/share/icons/hicolor/1024x1024/apps/lesavka.png
|
||||
sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \
|
||||
/usr/share/pixmaps/lesavka.png
|
||||
sudo install -Dm644 "$SRC/client/assets/linux/lesavka.desktop" \
|
||||
/usr/share/applications/lesavka.desktop
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
sudo update-desktop-database /usr/share/applications
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
sudo gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
Environment=RUST_LOG=debug
|
||||
Environment=LESAVKA_DEV_MODE=1
|
||||
Environment=LESAVKA_SERVER_ADDR=http://38.28.125.112:50051
|
||||
|
||||
ExecStart=/usr/local/bin/lesavka-client
|
||||
Restart=no
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
# 7. Call the *user* instance inside the caller’s session
|
||||
log "7. Reloading/starting service"
|
||||
log "7. Removing legacy auto-start service"
|
||||
sudo systemctl disable --now lesavka-client.service >/dev/null 2>&1 || true
|
||||
sudo rm -f /etc/systemd/system/lesavka-client.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lesavka-client.service
|
||||
sudo systemctl restart lesavka-client || true
|
||||
|
||||
echo
|
||||
echo "✅ lesavka-client install complete"
|
||||
echo " Binary: /usr/local/bin/lesavka-client"
|
||||
echo " Build source: $SRC/client/target/release/lesavka-client"
|
||||
echo " Service: systemctl status lesavka-client --no-pager"
|
||||
echo " Launch alias: /usr/local/bin/lesavka"
|
||||
echo " Desktop entry: /usr/share/applications/lesavka.desktop"
|
||||
echo " Build source: $SRC/target/release/lesavka-client"
|
||||
echo
|
||||
echo "Fish quick start:"
|
||||
echo " set -gx LESAVKA_SERVER_ADDR http://<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"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
95
testing/tests/client_inputs_extra_contract.rs
Normal file
95
testing/tests/client_inputs_extra_contract.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
112
testing/tests/client_keyboard_activation_contract.rs
Normal file
112
testing/tests/client_keyboard_activation_contract.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
106
testing/tests/client_keyboard_clipboard_contract.rs
Normal file
106
testing/tests/client_keyboard_clipboard_contract.rs
Normal 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());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
268
testing/tests/client_keyboard_shift_contract.rs
Normal file
268
testing/tests/client_keyboard_shift_contract.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user