2026-04-14 18:44:40 -03:00
|
|
|
use anyhow::{Result, anyhow};
|
2026-04-14 23:03:18 -03:00
|
|
|
use lesavka_common::{hid::append_char_reports, lesavka::KeyboardReport};
|
2026-04-14 18:44:40 -03:00
|
|
|
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})")),
|
2026-04-14 23:03:18 -03:00
|
|
|
Err(hid_err) => Err(anyhow!(
|
|
|
|
|
"rpc failed: {rpc_err}; hid fallback failed: {hid_err}"
|
|
|
|
|
)),
|
2026-04-14 18:44:40 -03:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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()
|
2026-04-14 23:03:18 -03:00
|
|
|
.map(|data| KeyboardReport {
|
|
|
|
|
data: data.to_vec(),
|
|
|
|
|
})
|
2026-04-14 18:44:40 -03:00
|
|
|
.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));
|
|
|
|
|
}
|
|
|
|
|
}
|