lesavka/client/src/launcher/clipboard.rs

178 lines
6.2 KiB
Rust
Raw Normal View History

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