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 { 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> { 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) } /// Read the local clipboard and drop trailing file-copy newlines so password /// pastes do not accidentally submit an extra Enter. #[cfg(not(coverage))] fn read_clipboard_text() -> Option { if let Ok(out) = Command::new("sh") .arg("-lc") .arg(std::env::var("LESAVKA_CLIPBOARD_CMD").unwrap_or_else( |_| "wl-paste --no-newline --type text/plain || xclip -selection clipboard -o || xsel -b -o".to_string(), )) .output() && out.status.success() { let text = trim_clipboard_text(String::from_utf8_lossy(&out.stdout).to_string()); if !text.is_empty() { return Some(text); } } None } /// Trim trailing clipboard newlines so pasted passwords do not gain a /// synthetic submit keystroke. fn trim_clipboard_text(text: String) -> String { text.trim_end_matches(['\r', '\n']).to_string() } #[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)); } }