launcher: stabilize relay controls and paste handling

This commit is contained in:
Brad Stein 2026-04-14 18:44:40 -03:00
parent 3897239ef4
commit 7d4754ba31
14 changed files with 751 additions and 197 deletions

View File

@ -542,6 +542,18 @@ fn focus_launcher_on_local_if_enabled() {
{ {
return; return;
} }
let focus_signal_path = std::env::var("LESAVKA_LAUNCHER_FOCUS_SIGNAL")
.unwrap_or_else(|_| "/tmp/lesavka-launcher-focus.signal".to_string());
let _ = std::fs::write(
focus_signal_path,
format!(
"{}\n",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
),
);
let title = std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE") let title = std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE")
.unwrap_or_else(|_| "Lesavka Launcher".to_string()); .unwrap_or_else(|_| "Lesavka Launcher".to_string());
let _ = std::process::Command::new("wmctrl") let _ = std::process::Command::new("wmctrl")

View File

@ -12,7 +12,8 @@ use tracing::{debug, error, trace};
use lesavka_common::lesavka::KeyboardReport; use lesavka_common::lesavka::KeyboardReport;
use super::keymap::{char_to_usage, is_modifier, keycode_to_usage}; use super::keymap::{is_modifier, keycode_to_usage};
use lesavka_common::hid::append_char_reports;
pub struct KeyboardAggregator { pub struct KeyboardAggregator {
dev: Device, dev: Device,
@ -371,9 +372,11 @@ impl KeyboardAggregator {
.unwrap_or(4096); .unwrap_or(4096);
for c in text.chars().take(max) { for c in text.chars().take(max) {
if let Some((usage, mods)) = char_to_usage(c) { let mut reports = Vec::with_capacity(4);
self.send_report([mods, 0, usage, 0, 0, 0, 0, 0]); if append_char_reports(&mut reports, c) {
self.send_report([0; 8]); for report in reports {
self.send_report(report);
}
} }
} }
} }
@ -404,11 +407,13 @@ impl KeyboardAggregator {
tracing::info!("📋 pasting {} chars", text.chars().count().min(max)); tracing::info!("📋 pasting {} chars", text.chars().count().min(max));
for c in text.chars().take(max) { for c in text.chars().take(max) {
if let Some((usage, mods)) = char_to_usage(c) { let mut reports = Vec::with_capacity(4);
self.send_report([mods, 0, usage, 0, 0, 0, 0, 0]); if append_char_reports(&mut reports, c) {
self.send_report([0; 8]); for report in reports {
if delay_ms > 0 { self.send_report(report);
std::thread::sleep(delay); if delay_ms > 0 {
std::thread::sleep(delay);
}
} }
} }
} }

View File

@ -0,0 +1,176 @@
use anyhow::{Result, anyhow};
use lesavka_common::{
hid::append_char_reports,
lesavka::KeyboardReport,
};
use std::time::Duration;
#[cfg(not(coverage))]
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"))?;
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) {
Ok(()) => Ok(format!("Clipboard delivered via HID fallback ({rpc_err})")),
Err(hid_err) => Err(anyhow!("rpc failed: {rpc_err}; hid fallback failed: {hid_err}")),
},
}
}
#[cfg(not(coverage))]
/// Use the shared-key paste RPC when both the launcher and relay host are
/// configured for encrypted clipboard delivery.
fn send_clipboard_via_rpc(server_addr: &str, text: &str) -> Result<()> {
let req = paste::build_paste_request(text)?;
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 mut cli = RelayClient::new(channel);
let reply = cli.paste_text(Request::new(req)).await?;
if reply.get_ref().ok {
Ok(())
} else {
Err(anyhow!("server rejected paste: {}", reply.get_ref().error))
}
})
}
#[cfg(not(coverage))]
/// Fall back to keyboard HID reports so launcher-driven paste still works
/// against relay hosts that have not been given `LESAVKA_PASTE_KEY`.
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 rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
rt.block_on(async {
let channel = Channel::from_shared(server_addr.to_string())?
.connect()
.await?;
let mut cli = RelayClient::new(channel);
let outbound = stream! {
for report in reports {
yield report;
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
};
let mut resp = cli.stream_keyboard(Request::new(outbound)).await?;
let mut echoed = 0usize;
while let Some(item) = resp.get_mut().message().await.transpose() {
item?;
echoed += 1;
if echoed >= report_count {
break;
}
}
Ok(())
})
}
/// Convert clipboard text into press/release HID reports that match the
/// server-side keyboard gadget mapping.
fn build_hid_paste_reports(text: &str) -> Result<Vec<KeyboardReport>> {
let mut raw_reports = Vec::with_capacity(text.len() * 4);
let mut unsupported = 0usize;
for ch in text.chars() {
if !append_char_reports(&mut raw_reports, ch) {
unsupported += 1;
}
}
if unsupported > 0 {
return Err(anyhow!(
"clipboard contains {unsupported} unsupported character(s) for HID fallback"
));
}
Ok(raw_reports
.into_iter()
.map(|data| KeyboardReport { data: data.to_vec() })
.collect())
}
/// Keep launcher-driven HID paste slightly slower than live typing so login
/// prompts have more time to digest each character.
fn clipboard_hid_delay() -> Duration {
let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(18);
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()
}
#[cfg(test)]
mod tests {
use super::{build_hid_paste_reports, clipboard_hid_delay, trim_clipboard_text};
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");
assert_eq!(reports.len(), 6);
assert_eq!(reports[0].data.len(), 8);
assert_eq!(reports[0].data, vec![0x02, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(reports[3].data, vec![0; 8]);
assert_eq!(reports[5].data, vec![0; 8]);
}
#[test]
fn build_hid_paste_reports_rejects_unsupported_chars() {
let err = build_hid_paste_reports("snowman \u{2603}").expect_err("unsupported");
assert!(format!("{err:#}").contains("unsupported character"));
}
#[test]
fn clipboard_hid_delay_has_stable_default() {
assert_eq!(clipboard_hid_delay(), Duration::from_millis(18));
}
}

View File

@ -147,7 +147,7 @@ mod tests {
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb")); assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
assert_eq!(report.recent_samples.len(), 1); assert_eq!(report.recent_samples.len(), 1);
assert_eq!(report.notes, vec!["first note".to_string()]); assert_eq!(report.notes, vec!["first note".to_string()]);
assert!(report.status.contains("mode=local")); assert!(report.status.contains("mode=remote"));
} }
#[test] #[test]

View File

@ -2,11 +2,12 @@ pub mod devices;
pub mod diagnostics; pub mod diagnostics;
pub mod state; pub mod state;
mod clipboard;
#[cfg(not(coverage))] #[cfg(not(coverage))]
mod preview; mod preview;
mod ui; mod ui;
use std::collections::BTreeMap; use std::{collections::BTreeMap, path::PathBuf};
use anyhow::Result; use anyhow::Result;
use crate::app_support::DEFAULT_SERVER_ADDR; use crate::app_support::DEFAULT_SERVER_ADDR;
@ -14,6 +15,9 @@ use crate::app_support::DEFAULT_SERVER_ADDR;
pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command}; pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command};
pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode}; pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode};
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 fn maybe_run_launcher(args: &[String]) -> Result<bool> { pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
if should_run_launcher(args) { if should_run_launcher(args) {
let server_addr = resolve_server_addr(args); let server_addr = resolve_server_addr(args);
@ -53,6 +57,12 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
envs envs
} }
pub fn launcher_focus_signal_path() -> PathBuf {
std::env::var(LAUNCHER_FOCUS_SIGNAL_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH))
}
fn resolve_server_addr(args: &[String]) -> String { fn resolve_server_addr(args: &[String]) -> String {
for window in args.windows(2) { for window in args.windows(2) {
if window[0] == "--server" { if window[0] == "--server" {

View File

@ -11,6 +11,8 @@ use gtk::{gdk, glib};
#[cfg(not(coverage))] #[cfg(not(coverage))]
use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}; use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient};
#[cfg(not(coverage))] #[cfg(not(coverage))]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(not(coverage))]
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[cfg(not(coverage))] #[cfg(not(coverage))]
use std::time::Duration; use std::time::Duration;
@ -51,31 +53,45 @@ impl LauncherPreview {
feed.install_on_picture(picture, status_label); feed.install_on_picture(picture, status_label);
} }
} }
pub fn set_enabled(&self, enabled: bool) {
for feed in &self.feeds {
feed.set_enabled(enabled);
}
}
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
struct PreviewFeed { struct PreviewFeed {
latest: Arc<Mutex<Option<PreviewFrame>>>, latest: Arc<Mutex<Option<PreviewFrame>>>,
enabled: Arc<AtomicBool>,
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
impl PreviewFeed { impl PreviewFeed {
fn spawn(server_addr: String, monitor_id: u32) -> Result<Self> { fn spawn(server_addr: String, monitor_id: u32) -> Result<Self> {
let latest = Arc::new(Mutex::new(None)); let latest = Arc::new(Mutex::new(None));
let enabled = Arc::new(AtomicBool::new(true));
let store = Arc::clone(&latest); let store = Arc::clone(&latest);
let enabled_flag = Arc::clone(&enabled);
std::thread::spawn(move || { std::thread::spawn(move || {
if let Err(err) = run_preview_feed(server_addr, monitor_id, store) { if let Err(err) = run_preview_feed(server_addr, monitor_id, store, enabled_flag) {
warn!(monitor_id, ?err, "launcher preview feed exited"); warn!(monitor_id, ?err, "launcher preview feed exited");
} }
}); });
Ok(Self { latest }) Ok(Self { latest, enabled })
} }
fn install_on_picture(&self, picture: &gtk::Picture, status_label: &gtk::Label) { fn install_on_picture(&self, picture: &gtk::Picture, status_label: &gtk::Label) {
let picture = picture.clone(); let picture = picture.clone();
let status_label = status_label.clone(); let status_label = status_label.clone();
let latest = Arc::clone(&self.latest); let latest = Arc::clone(&self.latest);
let enabled = Arc::clone(&self.enabled);
glib::timeout_add_local(Duration::from_millis(120), move || { glib::timeout_add_local(Duration::from_millis(120), move || {
if !enabled.load(Ordering::Relaxed) {
status_label.set_text("Paused for pop-out windows");
return glib::ControlFlow::Continue;
}
let next = latest.lock().ok().and_then(|mut slot| slot.take()); let next = latest.lock().ok().and_then(|mut slot| slot.take());
if let Some(frame) = next { if let Some(frame) = next {
let bytes = glib::Bytes::from_owned(frame.rgba); let bytes = glib::Bytes::from_owned(frame.rgba);
@ -92,6 +108,15 @@ impl PreviewFeed {
glib::ControlFlow::Continue glib::ControlFlow::Continue
}); });
} }
fn set_enabled(&self, enabled: bool) {
self.enabled.store(enabled, Ordering::Relaxed);
if !enabled {
if let Ok(mut slot) = self.latest.lock() {
*slot = None;
}
}
}
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
@ -107,6 +132,7 @@ fn run_preview_feed(
server_addr: String, server_addr: String,
monitor_id: u32, monitor_id: u32,
latest: Arc<Mutex<Option<PreviewFrame>>>, latest: Arc<Mutex<Option<PreviewFrame>>>,
enabled: Arc<AtomicBool>,
) -> Result<()> { ) -> Result<()> {
let (pipeline, appsrc, appsink) = build_preview_pipeline()?; let (pipeline, appsrc, appsink) = build_preview_pipeline()?;
pipeline pipeline
@ -136,6 +162,10 @@ fn run_preview_feed(
let _ = rt.block_on(async move { let _ = rt.block_on(async move {
loop { loop {
if !enabled.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(120)).await;
continue;
}
let channel = match Channel::from_shared(server_addr.clone()) { let channel = match Channel::from_shared(server_addr.clone()) {
Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await {
Ok(channel) => channel, Ok(channel) => channel,
@ -160,6 +190,9 @@ fn run_preview_feed(
Ok(mut stream) => { Ok(mut stream) => {
debug!(monitor_id, "launcher preview connected"); debug!(monitor_id, "launcher preview connected");
while let Some(item) = stream.get_mut().message().await.transpose() { while let Some(item) = stream.get_mut().message().await.transpose() {
if !enabled.load(Ordering::Relaxed) {
break;
}
match item { match item {
Ok(pkt) => push_preview_packet(&appsrc, pkt), Ok(pkt) => push_preview_packet(&appsrc, pkt),
Err(err) => { Err(err) => {

View File

@ -51,7 +51,7 @@ pub struct LauncherState {
impl Default for LauncherState { impl Default for LauncherState {
fn default() -> Self { fn default() -> Self {
Self { Self {
routing: InputRouting::Local, routing: InputRouting::Remote,
view_mode: ViewMode::Unified, view_mode: ViewMode::Unified,
devices: DeviceSelection::default(), devices: DeviceSelection::default(),
remote_active: false, remote_active: false,
@ -160,9 +160,9 @@ mod tests {
} }
#[test] #[test]
fn defaults_pick_local_unified_and_inactive_session() { fn defaults_pick_remote_unified_and_inactive_session() {
let state = LauncherState::new(); let state = LauncherState::new();
assert_eq!(state.routing, InputRouting::Local); assert_eq!(state.routing, InputRouting::Remote);
assert_eq!(state.view_mode, ViewMode::Unified); assert_eq!(state.view_mode, ViewMode::Unified);
assert!(!state.remote_active); assert!(!state.remote_active);
assert!(state.devices.camera.is_none()); assert!(state.devices.camera.is_none());

View File

@ -1,20 +1,22 @@
use anyhow::{Result, anyhow}; use anyhow::Result;
#[cfg(not(coverage))] #[cfg(not(coverage))]
use { use {
super::clipboard::send_clipboard_to_remote,
super::devices::DeviceCatalog, super::devices::DeviceCatalog,
super::diagnostics::quality_probe_command, super::diagnostics::quality_probe_command,
super::launcher_focus_signal_path,
super::preview::LauncherPreview, super::preview::LauncherPreview,
super::runtime_env_vars, super::runtime_env_vars,
super::state::{InputRouting, LauncherState, ViewMode}, super::state::{InputRouting, LauncherState, ViewMode},
crate::paste, super::LAUNCHER_FOCUS_SIGNAL_ENV,
gtk::prelude::*, gtk::prelude::*,
lesavka_common::lesavka::relay_client::RelayClient, gtk::glib,
std::cell::RefCell, std::cell::RefCell,
std::path::Path,
std::process::{Child, Command}, std::process::{Child, Command},
std::rc::Rc, std::rc::Rc,
tokio::runtime::Builder as RuntimeBuilder, std::time::Duration,
tonic::{Request, transport::Channel},
}; };
#[cfg(not(coverage))] #[cfg(not(coverage))]
@ -27,6 +29,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
state.borrow_mut().apply_catalog_defaults(&catalog); state.borrow_mut().apply_catalog_defaults(&catalog);
let child_proc = Rc::new(RefCell::new(None::<Child>)); let child_proc = Rc::new(RefCell::new(None::<Child>));
let server_addr = Rc::new(server_addr); let server_addr = Rc::new(server_addr);
let focus_signal_path = Rc::new(launcher_focus_signal_path());
let _ = std::fs::remove_file(focus_signal_path.as_path());
{ {
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
@ -43,6 +47,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let server_addr = Rc::clone(&server_addr); let server_addr = Rc::clone(&server_addr);
let focus_signal_path = Rc::clone(&focus_signal_path);
app.connect_activate(move |app| { app.connect_activate(move |app| {
let window = gtk::ApplicationWindow::builder() let window = gtk::ApplicationWindow::builder()
@ -191,27 +196,83 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
root.append(&probe_hint); root.append(&probe_hint);
let note = gtk::Label::new(Some( let note = gtk::Label::new(Some(
"The live preview stays in this launcher by default so you can watch both displays before handing control over. Start Relay keeps the preview here in unified mode, Pop Out Windows switches back to external video windows, and Use Remote Inputs hands keyboard and mouse to the remote side.", "The live preview stays in this launcher by default so you can watch both displays before handing control over. Start Relay now starts in remote control by default, Pop Out Windows pauses the preview and moves you back to the external video windows, and pressing the swap key returns local control and re-focuses this launcher.",
)); ));
note.set_wrap(true); note.set_wrap(true);
note.set_halign(gtk::Align::Start); note.set_halign(gtk::Align::Start);
root.append(&note); root.append(&note);
match LauncherPreview::new(server_addr.as_ref().to_string()) { let preview = match LauncherPreview::new(server_addr.as_ref().to_string()) {
Ok(preview) => { Ok(preview) => {
let preview = Rc::new(preview);
preview.install_on_picture(0, &left_picture, &left_status); preview.install_on_picture(0, &left_picture, &left_status);
preview.install_on_picture(1, &right_picture, &right_status); preview.install_on_picture(1, &right_picture, &right_status);
Some(preview)
} }
Err(err) => { Err(err) => {
let msg = format!("Preview unavailable: {err}"); let msg = format!("Preview unavailable: {err}");
left_status.set_text(&msg); left_status.set_text(&msg);
right_status.set_text(&msg); right_status.set_text(&msg);
None
} }
};
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
{
let window = window.clone();
let preview_frame = preview_frame.clone();
let child_proc = Rc::clone(&child_proc);
let focus_signal_path = Rc::clone(&focus_signal_path);
let routing_switch = routing_switch.clone();
let view_toggle_button = view_toggle_button.clone();
let input_toggle_button = input_toggle_button.clone();
let last_focus_marker =
Rc::new(RefCell::new(focus_signal_marker(focus_signal_path.as_path())));
let last_focus_marker_handle = Rc::clone(&last_focus_marker);
let status_label = status_label.clone();
let state = Rc::clone(&state);
let preview = preview.clone();
glib::timeout_add_local(Duration::from_millis(200), move || {
let next_marker = focus_signal_marker(focus_signal_path.as_path());
let mut last_marker = last_focus_marker_handle.borrow_mut();
if next_marker > *last_marker {
*last_marker = next_marker;
{
let mut state = state.borrow_mut();
state.set_routing(InputRouting::Local);
routing_switch.set_active(false);
sync_toggle_button_labels(
&state,
&view_toggle_button,
&input_toggle_button,
);
}
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
status_label.set_text("Local control restored - launcher focused");
window.present();
}
glib::ControlFlow::Continue
});
} }
{ {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let window = window.clone();
let preview_frame = preview_frame.clone();
let preview = preview.clone();
let status_label = status_label.clone(); let status_label = status_label.clone();
let routing_switch = routing_switch.clone(); let routing_switch = routing_switch.clone();
let view_combo = view_combo.clone(); let view_combo = view_combo.clone();
@ -259,12 +320,32 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
&server_entry, &server_entry,
server_addr.as_ref(), server_addr.as_ref(),
&toggle_key_combo, &toggle_key_combo,
preview.as_deref(),
); );
match spawn_result { match spawn_result {
Ok(()) => status_label.set_text(&format!("Started: {}", state.borrow().status_line())), Ok(()) => {
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
queue_breakout_window_surface(&window);
}
status_label.set_text(&format!("Started: {}", state.borrow().status_line()));
}
Err(err) => { Err(err) => {
let _ = state.borrow_mut().stop_remote(); let _ = state.borrow_mut().stop_remote();
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
status_label.set_text(&format!("Start failed: {err}")); status_label.set_text(&format!("Start failed: {err}"));
} }
} }
@ -274,10 +355,20 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{ {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let window = window.clone();
let preview_frame = preview_frame.clone();
let preview = preview.clone();
let status_label = status_label.clone(); let status_label = status_label.clone();
stop_button.connect_clicked(move |_| { stop_button.connect_clicked(move |_| {
stop_child_process(&child_proc); stop_child_process(&child_proc);
let _ = state.borrow_mut().stop_remote(); let _ = state.borrow_mut().stop_remote();
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
status_label.set_text("Relay ended"); status_label.set_text("Relay ended");
}); });
} }
@ -285,6 +376,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{ {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let window = window.clone();
let preview_frame = preview_frame.clone();
let preview = preview.clone();
let status_label = status_label.clone(); let status_label = status_label.clone();
let view_combo = view_combo.clone(); let view_combo = view_combo.clone();
let input_toggle_button = input_toggle_button.clone(); let input_toggle_button = input_toggle_button.clone();
@ -313,16 +407,43 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
&server_entry, &server_entry,
server_addr.as_ref(), server_addr.as_ref(),
&toggle_key_combo, &toggle_key_combo,
preview.as_deref(),
); );
match spawn_result { match spawn_result {
Ok(()) => status_label Ok(()) => {
.set_text(&format!("View switched live: {}", state.borrow().status_line())), sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
queue_breakout_window_surface(&window);
}
status_label
.set_text(&format!("View switched live: {}", state.borrow().status_line()));
}
Err(err) => { Err(err) => {
let _ = state.borrow_mut().stop_remote(); let _ = state.borrow_mut().stop_remote();
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
status_label.set_text(&format!("View switch failed: {err}")); status_label.set_text(&format!("View switch failed: {err}"));
} }
} }
} else { } else {
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
status_label.set_text(&format!("View ready: {}", state.borrow().status_line())); status_label.set_text(&format!("View ready: {}", state.borrow().status_line()));
} }
}); });
@ -331,6 +452,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
{ {
let state = Rc::clone(&state); let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);
let window = window.clone();
let preview_frame = preview_frame.clone();
let preview = preview.clone();
let status_label = status_label.clone(); let status_label = status_label.clone();
let routing_switch = routing_switch.clone(); let routing_switch = routing_switch.clone();
let input_toggle_button = input_toggle_button.clone(); let input_toggle_button = input_toggle_button.clone();
@ -359,18 +483,45 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
&server_entry, &server_entry,
server_addr.as_ref(), server_addr.as_ref(),
&toggle_key_combo, &toggle_key_combo,
preview.as_deref(),
); );
match spawn_result { match spawn_result {
Ok(()) => status_label.set_text(&format!( Ok(()) => {
"Input mode switched live: {}", sync_preview_runtime_state(
state.borrow().status_line() &window,
)), &preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
queue_breakout_window_surface(&window);
}
status_label.set_text(&format!(
"Input mode switched live: {}",
state.borrow().status_line()
));
}
Err(err) => { Err(err) => {
let _ = state.borrow_mut().stop_remote(); let _ = state.borrow_mut().stop_remote();
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
status_label.set_text(&format!("Input switch failed: {err}")); status_label.set_text(&format!("Input switch failed: {err}"));
} }
} }
} else { } else {
sync_preview_runtime_state(
&window,
&preview_frame,
preview.as_deref(),
&state.borrow(),
child_proc.borrow().is_some(),
);
status_label.set_text(&format!("Input ready: {}", state.borrow().status_line())); status_label.set_text(&format!("Input ready: {}", state.borrow().status_line()));
} }
}); });
@ -383,14 +534,36 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let server_addr = Rc::clone(&server_addr); let server_addr = Rc::clone(&server_addr);
clipboard_button.connect_clicked(move |_| { clipboard_button.connect_clicked(move |_| {
if child_proc.borrow().is_none() { if child_proc.borrow().is_none() {
status_label.set_text("Start Session before sending clipboard"); status_label.set_text("Start Relay before sending clipboard");
return; return;
} }
let server_addr = selected_server_addr(&server_entry, server_addr.as_ref()); let server_addr = selected_server_addr(&server_entry, server_addr.as_ref());
match send_clipboard_to_remote(&server_addr) { status_label.set_text("Sending clipboard to remote...");
Ok(()) => status_label.set_text("Clipboard delivered to remote"), let (result_tx, result_rx) = std::sync::mpsc::channel::<String>();
Err(err) => status_label.set_text(&format!("Clipboard send failed: {err}")), std::thread::spawn(move || {
} let message = match send_clipboard_to_remote(&server_addr) {
Ok(mode) => mode,
Err(err) => format!("Clipboard send failed: {err}"),
};
let _ = result_tx.send(message);
});
let status_label = status_label.clone();
glib::timeout_add_local(Duration::from_millis(100), move || {
match result_rx.try_recv() {
Ok(message) => {
status_label.set_text(&message);
glib::ControlFlow::Break
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
glib::ControlFlow::Continue
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
status_label.set_text("Clipboard send failed: launcher worker exited");
glib::ControlFlow::Break
}
}
});
}); });
} }
@ -449,49 +622,58 @@ fn relaunch_with_settings(
server_entry: &gtk::Entry, server_entry: &gtk::Entry,
server_fallback: &str, server_fallback: &str,
toggle_key_combo: &gtk::ComboBoxText, toggle_key_combo: &gtk::ComboBoxText,
preview: Option<&LauncherPreview>,
) -> Result<()> { ) -> Result<()> {
let server_addr = selected_server_addr(server_entry, server_fallback); let server_addr = selected_server_addr(server_entry, server_fallback);
let input_toggle_key = selected_toggle_key(toggle_key_combo); let input_toggle_key = selected_toggle_key(toggle_key_combo);
let mut state = state.borrow_mut(); let mut state = state.borrow_mut();
if matches!(state.view_mode, ViewMode::Breakout) {
if let Some(preview) = preview {
preview.set_enabled(false);
std::thread::sleep(Duration::from_millis(250));
}
} else if let Some(preview) = preview {
preview.set_enabled(true);
}
launch_or_restart_client(child_proc, &server_addr, &mut state, &input_toggle_key) launch_or_restart_client(child_proc, &server_addr, &mut state, &input_toggle_key)
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
/// Reads local clipboard text and sends it to the remote server's paste RPC. #[cfg(not(coverage))]
fn send_clipboard_to_remote(server_addr: &str) -> Result<()> { fn focus_signal_marker(path: &Path) -> u128 {
let text = read_clipboard_text().ok_or_else(|| anyhow!("clipboard is empty or unavailable"))?; std::fs::metadata(path)
let req = paste::build_paste_request(&text)?; .ok()
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; .and_then(|meta| meta.modified().ok())
rt.block_on(async { .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok())
let channel = Channel::from_shared(server_addr.to_string())? .map(|duration| duration.as_millis())
.connect() .unwrap_or_default()
.await?;
let mut cli = RelayClient::new(channel);
let reply = cli.paste_text(Request::new(req)).await?;
if reply.get_ref().ok {
Ok(())
} else {
Err(anyhow!("server rejected paste: {}", reply.get_ref().error))
}
})
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn read_clipboard_text() -> Option<String> { fn sync_preview_runtime_state(
if let Ok(out) = Command::new("sh") window: &gtk::ApplicationWindow,
.arg("-lc") preview_frame: &gtk::Frame,
.arg(std::env::var("LESAVKA_CLIPBOARD_CMD").unwrap_or_else( preview: Option<&LauncherPreview>,
|_| "wl-paste --no-newline --type text/plain || xclip -selection clipboard -o || xsel -b -o".to_string(), state: &LauncherState,
)) child_running: bool,
.output() ) {
&& out.status.success() let breakout_active = child_running && matches!(state.view_mode, ViewMode::Breakout);
{ preview_frame.set_visible(!breakout_active);
let text = String::from_utf8_lossy(&out.stdout).to_string(); if let Some(preview) = preview {
if !text.is_empty() { preview.set_enabled(!breakout_active);
return Some(text);
}
} }
None if !breakout_active {
window.present();
}
}
#[cfg(not(coverage))]
fn queue_breakout_window_surface(window: &gtk::ApplicationWindow) {
let window = window.clone();
glib::timeout_add_local(Duration::from_millis(350), move || {
window.minimize();
glib::ControlFlow::Break
});
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
@ -515,6 +697,7 @@ fn spawn_client_process(
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher"); command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher");
command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1");
command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path());
for (key, value) in runtime_env_vars(state) { for (key, value) in runtime_env_vars(state) {
command.env(key, value); command.env(key, value);
} }

View File

@ -18,6 +18,72 @@ pub struct UnifiedMonitorWindow {
left_src: gst_app::AppSrc, left_src: gst_app::AppSrc,
right_src: gst_app::AppSrc, right_src: gst_app::AppSrc,
} }
#[cfg(not(coverage))]
fn spawn_wmctrl_placement(id: u32, rect: layout::Rect) {
let x = rect.x;
let y = rect.y;
let w = rect.w;
let h = rect.h;
std::thread::spawn(move || {
for attempt in 1..=12 {
std::thread::sleep(std::time::Duration::from_millis(300));
let Some(window_id) = nth_lesavka_window_id(id as usize) else {
tracing::debug!("⌛ wmctrl: eye-{id} not mapped yet (attempt {attempt})");
continue;
};
let _ = Command::new("wmctrl")
.args([
"-i",
"-r",
&window_id,
"-b",
"remove,maximized_vert,maximized_horz",
])
.status();
let status = Command::new("wmctrl")
.args(["-i", "-r", &window_id, "-e", &format!("0,{x},{y},{w},{h}")])
.status();
match status {
Ok(st) if st.success() => {
tracing::info!("✅ wmctrl placed eye-{id} via {window_id} (attempt {attempt})");
break;
}
_ => tracing::debug!("⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})"),
}
}
});
}
#[cfg(not(coverage))]
fn nth_lesavka_window_id(index: usize) -> Option<String> {
let out = Command::new("wmctrl").args(["-lp"]).output().ok()?;
if !out.status.success() {
return None;
}
let mut windows = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let id = parts.next()?.to_string();
let _desktop = parts.next()?;
let _pid = parts.next()?;
let _host = parts.next()?;
let title = parts.collect::<Vec<_>>().join(" ");
if title == "lesavka-client" || title.starts_with("Lesavka-eye-") {
Some(id)
} else {
None
}
})
.collect::<Vec<_>>();
windows.sort();
windows.dedup();
if windows.len() < 2 {
return None;
}
windows.get(index).cloned()
}
#[allow(clippy::all)] #[allow(clippy::all)]
impl MonitorWindow { impl MonitorWindow {
#[cfg(coverage)] #[cfg(coverage)]
@ -101,123 +167,103 @@ impl MonitorWindow {
if sink_elem.find_property("window-title").is_some() { if sink_elem.find_property("window-title").is_some() {
let _ = sink_elem.set_property("window-title", &format!("Lesavka-eye-{id}")); let _ = sink_elem.set_property("window-title", &format!("Lesavka-eye-{id}"));
} }
if let Ok(overlay) = sink_elem.dynamic_cast::<VideoOverlay>() { if let Some(r) = rects.get(id as usize) {
if let Some(r) = rects.get(id as usize) { if let Ok(overlay) = sink_elem.dynamic_cast::<VideoOverlay>() {
// 1. Tell glimagesink how to crop the texture in its own window // 1. Tell glimagesink how to crop the texture in its own window
let _ = overlay.set_render_rectangle(0, 0, r.w, r.h); let _ = overlay.set_render_rectangle(0, 0, r.w, r.h);
debug!( debug!(
"🔲 eye-{id} → render_rectangle({}, {}, {}, {})", "🔲 eye-{id} → render_rectangle({}, {}, {}, {})",
0, 0, r.w, r.h 0, 0, r.w, r.h
); );
}
// 2. **Compositor-level** placement (Wayland only) // 2. **Compositor-level** placement (Wayland only)
if std::env::var_os("WAYLAND_DISPLAY").is_some() { if std::env::var_os("WAYLAND_DISPLAY").is_some() {
use std::process::{Command, ExitStatus}; use std::process::{Command, ExitStatus};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
// A small helper struct so the two branches return the same type // A small helper struct so the two branches return the same type
struct Placer { struct Placer {
name: &'static str, name: &'static str,
run: Arc<dyn Fn(&str) -> std::io::Result<ExitStatus> + Send + Sync>, run: Arc<dyn Fn(&str) -> std::io::Result<ExitStatus> + Send + Sync>,
} }
let placer = if Command::new("swaymsg") let placer = if Command::new("swaymsg")
.arg("-t") .arg("-t")
.arg("get_tree") .arg("get_tree")
.output() .output()
.is_ok() .map(|out| out.status.success())
{ .unwrap_or(false)
Placer { {
name: "swaymsg", Some(Placer {
run: Arc::new(|cmd| Command::new("swaymsg").arg(cmd).status()), name: "swaymsg",
} run: Arc::new(|cmd| Command::new("swaymsg").arg(cmd).status()),
} else if Command::new("hyprctl").arg("version").output().is_ok() { })
Placer { } else if Command::new("hyprctl")
name: "hyprctl", .arg("version")
run: Arc::new(|cmd| { .output()
Command::new("hyprctl") .map(|out| out.status.success())
.args(["dispatch", "exec", cmd]) .unwrap_or(false)
.status() {
}), Some(Placer {
} name: "hyprctl",
} else { run: Arc::new(|cmd| {
Placer { Command::new("hyprctl")
name: "noop", .args(["dispatch", "exec", cmd])
run: Arc::new(|_| { .status()
Err(std::io::Error::new( }),
std::io::ErrorKind::Other, })
"no swaymsg/hyprctl found", } else if std::env::var_os("DISPLAY").is_some()
)) && Command::new("wmctrl").arg("-m").output().is_ok()
}), {
} spawn_wmctrl_placement(id, *r);
None
} else {
None
};
if let Some(placer) = placer {
let cmd = match placer.name {
// Criteria string that works for i3-/sway-compatible IPC
"swaymsg" | "hyprctl" => format!(
r#"[title="^Lesavka-eye-{id}$"] \
resize set {w} {h}; \
move absolute position {x} {y}"#,
w = r.w,
h = r.h,
x = r.x,
y = r.y,
),
_ => String::new(),
}; };
if placer.name != "noop" { // Retry in a detached thread - avoids blocking GStreamer
let cmd = match placer.name { let placename = placer.name;
// Criteria string that works for i3-/sway-compatible IPC let runner = placer.run.clone();
"swaymsg" | "hyprctl" => format!( thread::spawn(move || {
r#"[title="^Lesavka-eye-{id}$"] \
resize set {w} {h}; \
move absolute position {x} {y}"#,
w = r.w,
h = r.h,
x = r.x,
y = r.y,
),
_ => String::new(),
};
// Retry in a detached thread - avoids blocking GStreamer
let placename = placer.name;
let runner = placer.run.clone();
thread::spawn(move || {
for attempt in 1..=10 {
thread::sleep(Duration::from_millis(300));
match runner(&cmd) {
Ok(st) if st.success() => {
tracing::info!(
"✅ {placename}: placed eye-{id} (attempt {attempt})"
);
break;
}
_ => tracing::debug!(
"⌛ {placename}: eye-{id} not mapped yet (attempt {attempt})"
),
}
}
});
}
}
// 3. X11 / Xwayland placement via wmctrl
else if std::env::var_os("DISPLAY").is_some() {
let title = format!("Lesavka-eye-{id}");
let w = r.w;
let h = r.h;
let x = r.x;
let y = r.y;
std::thread::spawn(move || {
for attempt in 1..=10 { for attempt in 1..=10 {
std::thread::sleep(std::time::Duration::from_millis(300)); thread::sleep(Duration::from_millis(300));
let status = Command::new("wmctrl") match runner(&cmd) {
.args(["-r", &title, "-e", &format!("0,{x},{y},{w},{h}")])
.status();
match status {
Ok(st) if st.success() => { Ok(st) if st.success() => {
tracing::info!( tracing::info!(
"wmctrl placed eye-{id} (attempt {attempt})" "✅ {placename}: placed eye-{id} (attempt {attempt})"
); );
break; break;
} }
_ => tracing::debug!( _ => tracing::debug!(
"wmctrl: eye-{id} not mapped yet (attempt {attempt})" "⌛ {placename}: eye-{id} not mapped yet (attempt {attempt})"
), ),
} }
} }
}); });
} }
} }
// 3. X11 / Xwayland placement via wmctrl
else if std::env::var_os("DISPLAY").is_some() {
spawn_wmctrl_placement(id, *r);
}
} }
} }

View File

@ -1,5 +1,7 @@
//! Shared HID mapping helpers used by both the client and server crates. //! Shared HID mapping helpers used by both the client and server crates.
pub type KeyboardHidReport = [u8; 8];
/// Map a printable character to a USB HID usage plus modifier byte. /// Map a printable character to a USB HID usage plus modifier byte.
/// ///
/// Inputs: a Unicode scalar value that should be typed through the HID gadget. /// Inputs: a Unicode scalar value that should be typed through the HID gadget.
@ -54,9 +56,34 @@ pub fn char_to_usage(c: char) -> Option<(u8, u8)> {
} }
} }
/// Append the HID report sequence needed to type a character.
///
/// Inputs: an output buffer plus the character that should be typed.
/// Outputs: `true` when the character is supported and reports were appended.
/// Why: some targets miss uppercase or shifted characters when they arrive as a
/// single modifier+usage pulse, so we emit a more human-like modifier press,
/// key press, key release, and modifier release sequence.
pub fn append_char_reports(out: &mut Vec<KeyboardHidReport>, c: char) -> bool {
let Some((usage, mods)) = char_to_usage(c) else {
return false;
};
if mods != 0 {
out.push([mods, 0, 0, 0, 0, 0, 0, 0]);
out.push([mods, 0, usage, 0, 0, 0, 0, 0]);
out.push([mods, 0, 0, 0, 0, 0, 0, 0]);
out.push([0; 8]);
} else {
out.push([0, 0, usage, 0, 0, 0, 0, 0]);
out.push([0; 8]);
}
true
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::char_to_usage; use super::{append_char_reports, char_to_usage};
#[test] #[test]
fn char_to_usage_maps_letters_numbers_and_shifted_symbols() { fn char_to_usage_maps_letters_numbers_and_shifted_symbols() {
@ -77,4 +104,31 @@ mod tests {
assert_eq!(char_to_usage('é'), None); assert_eq!(char_to_usage('é'), None);
assert_eq!(char_to_usage('\u{2603}'), None); assert_eq!(char_to_usage('\u{2603}'), None);
} }
#[test]
fn append_char_reports_expands_shifted_chars_into_four_steps() {
let mut reports = Vec::new();
assert!(append_char_reports(&mut reports, 'A'));
assert_eq!(reports.len(), 4);
assert_eq!(reports[0], [0x02, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(reports[1], [0x02, 0, 0x04, 0, 0, 0, 0, 0]);
assert_eq!(reports[2], [0x02, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(reports[3], [0; 8]);
}
#[test]
fn append_char_reports_keeps_unshifted_chars_as_press_and_release() {
let mut reports = Vec::new();
assert!(append_char_reports(&mut reports, 'a'));
assert_eq!(reports.len(), 2);
assert_eq!(reports[0], [0, 0, 0x04, 0, 0, 0, 0, 0]);
assert_eq!(reports[1], [0; 8]);
}
#[test]
fn append_char_reports_rejects_unsupported_chars() {
let mut reports = Vec::new();
assert!(!append_char_reports(&mut reports, '🙂'));
assert!(reports.is_empty());
}
} }

View File

@ -23,12 +23,12 @@
"client/src/input/inputs.rs": { "client/src/input/inputs.rs": {
"clippy_warnings": 42, "clippy_warnings": 42,
"doc_debt": 11, "doc_debt": 11,
"loc": 550 "loc": 562
}, },
"client/src/input/keyboard.rs": { "client/src/input/keyboard.rs": {
"clippy_warnings": 24, "clippy_warnings": 24,
"doc_debt": 17, "doc_debt": 17,
"loc": 565 "loc": 570
}, },
"client/src/input/keymap.rs": { "client/src/input/keymap.rs": {
"clippy_warnings": 8, "clippy_warnings": 8,
@ -50,6 +50,11 @@
"doc_debt": 8, "doc_debt": 8,
"loc": 317 "loc": 317
}, },
"client/src/launcher/clipboard.rs": {
"clippy_warnings": 2,
"doc_debt": 1,
"loc": 176
},
"client/src/launcher/devices.rs": { "client/src/launcher/devices.rs": {
"clippy_warnings": 6, "clippy_warnings": 6,
"doc_debt": 3, "doc_debt": 3,
@ -61,14 +66,14 @@
"loc": 172 "loc": 172
}, },
"client/src/launcher/mod.rs": { "client/src/launcher/mod.rs": {
"clippy_warnings": 4, "clippy_warnings": 6,
"doc_debt": 4, "doc_debt": 4,
"loc": 176 "loc": 182
}, },
"client/src/launcher/preview.rs": { "client/src/launcher/preview.rs": {
"clippy_warnings": 20, "clippy_warnings": 22,
"doc_debt": 7, "doc_debt": 9,
"loc": 258 "loc": 291
}, },
"client/src/launcher/state.rs": { "client/src/launcher/state.rs": {
"clippy_warnings": 8, "clippy_warnings": 8,
@ -77,8 +82,8 @@
}, },
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"clippy_warnings": 8, "clippy_warnings": 8,
"doc_debt": 7, "doc_debt": 8,
"loc": 615 "loc": 838
}, },
"client/src/layout.rs": { "client/src/layout.rs": {
"clippy_warnings": 6, "clippy_warnings": 6,
@ -117,8 +122,8 @@
}, },
"client/src/output/video.rs": { "client/src/output/video.rs": {
"clippy_warnings": 36, "clippy_warnings": 36,
"doc_debt": 2, "doc_debt": 4,
"loc": 499 "loc": 545
}, },
"client/src/paste.rs": { "client/src/paste.rs": {
"clippy_warnings": 2, "clippy_warnings": 2,
@ -138,7 +143,7 @@
"common/src/hid.rs": { "common/src/hid.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 2, "doc_debt": 2,
"loc": 80 "loc": 134
}, },
"common/src/lib.rs": { "common/src/lib.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -198,7 +203,7 @@
"server/src/paste.rs": { "server/src/paste.rs": {
"clippy_warnings": 6, "clippy_warnings": 6,
"doc_debt": 3, "doc_debt": 3,
"loc": 204 "loc": 205
}, },
"server/src/runtime_support.rs": { "server/src/runtime_support.rs": {
"clippy_warnings": 14, "clippy_warnings": 14,

View File

@ -17,12 +17,12 @@
"loc": 368 "loc": 368
}, },
"client/src/input/inputs.rs": { "client/src/input/inputs.rs": {
"line_percent": 97.0059880239521, "line_percent": 97.32,
"loc": 550 "loc": 562
}, },
"client/src/input/keyboard.rs": { "client/src/input/keyboard.rs": {
"line_percent": 95.27559055118111, "line_percent": 95.7,
"loc": 565 "loc": 570
}, },
"client/src/input/keymap.rs": { "client/src/input/keymap.rs": {
"line_percent": 100.0, "line_percent": 100.0,
@ -36,6 +36,10 @@
"line_percent": 97.32142857142857, "line_percent": 97.32142857142857,
"loc": 317 "loc": 317
}, },
"client/src/launcher/clipboard.rs": {
"line_percent": 97.96,
"loc": 176
},
"client/src/launcher/devices.rs": { "client/src/launcher/devices.rs": {
"line_percent": 98.09523809523807, "line_percent": 98.09523809523807,
"loc": 154 "loc": 154
@ -45,8 +49,8 @@
"loc": 172 "loc": 172
}, },
"client/src/launcher/mod.rs": { "client/src/launcher/mod.rs": {
"line_percent": 96.15384615384616, "line_percent": 95.08,
"loc": 176 "loc": 181
}, },
"client/src/launcher/state.rs": { "client/src/launcher/state.rs": {
"line_percent": 97.97297297297297, "line_percent": 97.97297297297297,
@ -54,7 +58,7 @@
}, },
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 615 "loc": 838
}, },
"client/src/layout.rs": { "client/src/layout.rs": {
"line_percent": 97.72727272727273, "line_percent": 97.72727272727273,
@ -77,8 +81,8 @@
"loc": 155 "loc": 155
}, },
"client/src/output/video.rs": { "client/src/output/video.rs": {
"line_percent": 96.11650485436894, "line_percent": 96.23,
"loc": 499 "loc": 545
}, },
"client/src/paste.rs": { "client/src/paste.rs": {
"line_percent": 96.29629629629629, "line_percent": 96.29629629629629,
@ -94,7 +98,7 @@
}, },
"common/src/hid.rs": { "common/src/hid.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 80 "loc": 134
}, },
"common/src/lib.rs": { "common/src/lib.rs": {
"line_percent": 100.0, "line_percent": 100.0,
@ -133,8 +137,8 @@
"loc": 508 "loc": 508
}, },
"server/src/paste.rs": { "server/src/paste.rs": {
"line_percent": 96.73913043478261, "line_percent": 97.08,
"loc": 204 "loc": 205
}, },
"server/src/runtime_support.rs": { "server/src/runtime_support.rs": {
"line_percent": 96.42857142857143, "line_percent": 96.42857142857143,

View File

@ -8,7 +8,7 @@ use tokio::fs::File;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use lesavka_common::hid::char_to_usage; use lesavka_common::hid::{append_char_reports, char_to_usage};
use lesavka_common::lesavka::PasteRequest; use lesavka_common::lesavka::PasteRequest;
use lesavka_common::paste::decode_shared_key; use lesavka_common::paste::decode_shared_key;
@ -60,12 +60,13 @@ pub async fn type_text(kb: &Mutex<File>, text: &str) -> Result<()> {
let mut kb = kb.lock().await; let mut kb = kb.lock().await;
for c in text.chars().take(max) { for c in text.chars().take(max) {
if let Some((usage, mods)) = char_to_usage(c) { let mut reports = Vec::with_capacity(4);
let report = [mods, 0, usage, 0, 0, 0, 0, 0]; if append_char_reports(&mut reports, c) {
kb.write_all(&report).await?; for report in reports {
kb.write_all(&[0u8; 8]).await?; kb.write_all(&report).await?;
if delay_ms > 0 { if delay_ms > 0 {
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
}
} }
} }
} }
@ -166,7 +167,7 @@ mod tests {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
let mut file = File::open(&path).await.expect("reopen temp file"); let mut file = File::open(&path).await.expect("reopen temp file");
file.read_to_end(&mut bytes).await.expect("read reports"); file.read_to_end(&mut bytes).await.expect("read reports");
assert_eq!(bytes.len(), 48); assert_eq!(bytes.len(), 96);
}); });
}); });
} }

View File

@ -286,14 +286,41 @@ mod keyboard_contract {
return; return;
}; };
let (mut agg, mut rx) = new_aggregator(dev); let (mut agg, mut rx) = new_aggregator(dev);
agg.pressed_keys.insert(evdev::KeyCode::KEY_A); agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
agg.reset_state(); agg.reset_state();
assert!(agg.pressed_keys.is_empty()); assert!(agg.pressed_keys.is_empty());
let pkt = rx.try_recv().expect("empty report after reset"); let pkt = rx.try_recv().expect("empty report after reset");
assert_eq!(pkt.data, vec![0; 8]); assert_eq!(pkt.data, vec![0; 8]);
} }
#[test]
#[serial]
fn reset_state_when_idle_still_emits_an_empty_report() {
let Some(dev) = open_any_keyboard_device()
.or_else(|| build_keyboard("lesavka-include-kbd-reset-idle").map(|(_, dev)| dev))
else {
return;
};
let (mut agg, mut rx) = new_aggregator(dev);
agg.reset_state();
let pkt = rx.try_recv().expect("idle reset should still publish empty report");
assert_eq!(pkt.data, vec![0; 8]);
}
#[test]
#[serial]
fn pressed_keys_snapshot_returns_the_current_keyset() {
let Some(dev) = open_any_keyboard_device()
.or_else(|| build_keyboard("lesavka-include-kbd-snapshot").map(|(_, dev)| dev))
else {
return;
};
let (mut agg, _) = new_aggregator(dev);
agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
let snapshot = agg.pressed_keys_snapshot();
assert!(snapshot.contains(&evdev::KeyCode::KEY_A));
assert!(snapshot.contains(&evdev::KeyCode::KEY_LEFTCTRL));
assert_eq!(snapshot.len(), 2);
}
#[test] #[test]
#[serial] #[serial]
fn set_send_false_blocks_manual_empty_report() { fn set_send_false_blocks_manual_empty_report() {
@ -307,7 +334,6 @@ mod keyboard_contract {
agg.send_empty_report(); agg.send_empty_report();
assert!(rx.try_recv().is_err()); assert!(rx.try_recv().is_err());
} }
#[test] #[test]
#[serial] #[serial]
fn process_events_respects_send_toggle() { fn process_events_respects_send_toggle() {
@ -326,7 +352,6 @@ mod keyboard_contract {
agg.process_events(); agg.process_events();
assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports"); assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports");
} }
#[test] #[test]
#[serial] #[serial]
fn paste_chord_active_supports_ctrl_v_variant() { fn paste_chord_active_supports_ctrl_v_variant() {