179 lines
6.7 KiB
Rust
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));
|
|
}
|
|
}
|