lesavka/client/src/launcher/clipboard.rs

179 lines
6.7 KiB
Rust

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