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, tokio::runtime::Builder as RuntimeBuilder, tonic::{Request, transport::Channel}, }; #[cfg(not(coverage))] /// Deliver already-captured clipboard text to the remote side, preferring the /// encrypted paste RPC and falling back to direct HID keyboard reports. pub fn send_clipboard_text_to_remote(server_addr: &str, text: &str) -> Result { match send_clipboard_via_rpc(server_addr, text) { Ok(()) => Ok("Clipboard delivered to remote".to_string()), Err(rpc_err) => match send_clipboard_via_hid(server_addr, text) { 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 timeout = clipboard_transport_timeout(); let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; rt.block_on(async { 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 = 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 { 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 timeout = clipboard_transport_timeout(); let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; rt.block_on(async { 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 { yield report; if !delay.is_zero() { tokio::time::sleep(delay).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; 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(()) }) } /// Convert clipboard text into press/release HID reports that match the /// server-side keyboard gadget mapping. fn build_hid_paste_reports(text: &str) -> Result> { 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::().ok()) .unwrap_or(18); Duration::from_millis(delay_ms) } fn clipboard_transport_timeout() -> Duration { let timeout_ms = std::env::var("LESAVKA_CLIPBOARD_TIMEOUT_MS") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(6_000); Duration::from_millis(timeout_ms) } #[cfg(test)] mod tests { use super::{build_hid_paste_reports, clipboard_hid_delay, clipboard_transport_timeout}; use std::time::Duration; #[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)); } #[test] fn clipboard_transport_timeout_has_stable_default() { assert_eq!(clipboard_transport_timeout(), Duration::from_millis(6_000)); } }