launcher: default to GUI and hard-fail unsupported paste chars

This commit is contained in:
Brad Stein 2026-04-14 05:50:20 -03:00
parent 7225ed6a1a
commit fa47fa4e30
4 changed files with 106 additions and 9 deletions

View File

@ -12,7 +12,7 @@ pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality
pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode};
pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
if args.iter().any(|arg| arg == "--launcher") {
if should_run_launcher(args) {
let server_addr = resolve_server_addr(args);
ui::run_gui_launcher(server_addr)?;
return Ok(true);
@ -20,6 +20,14 @@ pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
Ok(false)
}
/// Decides when to present the GUI launcher instead of direct session startup.
fn should_run_launcher(args: &[String]) -> bool {
if args.iter().any(|arg| arg == "--no-launcher") {
return false;
}
args.iter().any(|arg| arg == "--launcher") || args.is_empty()
}
pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
let mut envs = BTreeMap::new();
envs.insert(
@ -107,4 +115,35 @@ mod tests {
let args = vec!["http://server:50051".to_string()];
assert!(!maybe_run_launcher(&args).expect("launcher check"));
}
#[test]
#[cfg(coverage)]
fn maybe_run_launcher_returns_true_with_launcher_flag() {
let args = vec!["--launcher".to_string()];
assert!(maybe_run_launcher(&args).expect("launcher should run"));
}
#[test]
#[cfg(coverage)]
fn maybe_run_launcher_defaults_to_launcher_for_empty_args() {
let args: Vec<String> = vec![];
assert!(maybe_run_launcher(&args).expect("launcher should run"));
}
#[test]
fn should_run_launcher_defaults_true_for_empty_args() {
assert!(should_run_launcher(&[]));
}
#[test]
fn should_run_launcher_honors_explicit_opt_out() {
let args = vec!["--no-launcher".to_string()];
assert!(!should_run_launcher(&args));
}
#[test]
fn should_run_launcher_respects_direct_server_args() {
let args = vec!["http://server:50051".to_string()];
assert!(!should_run_launcher(&args));
}
}

View File

@ -63,7 +63,7 @@
"client/src/launcher/mod.rs": {
"clippy_warnings": 4,
"doc_debt": 4,
"loc": 110
"loc": 149
},
"client/src/launcher/state.rs": {
"clippy_warnings": 8,
@ -193,7 +193,7 @@
"server/src/paste.rs": {
"clippy_warnings": 6,
"doc_debt": 3,
"loc": 146
"loc": 204
},
"server/src/runtime_support.rs": {
"clippy_warnings": 14,

View File

@ -46,7 +46,7 @@
},
"client/src/launcher/mod.rs": {
"line_percent": 96.15384615384616,
"loc": 110
"loc": 149
},
"client/src/launcher/state.rs": {
"line_percent": 99.32432432432432,
@ -134,7 +134,7 @@
},
"server/src/paste.rs": {
"line_percent": 96.73913043478261,
"loc": 146
"loc": 204
},
"server/src/runtime_support.rs": {
"line_percent": 96.42857142857143,

View File

@ -29,7 +29,7 @@ pub fn decrypt(req: &PasteRequest) -> Result<String> {
let plaintext = cipher
.decrypt(nonce, req.data.as_ref())
.map_err(|e| anyhow::anyhow!("paste decrypt failed: {e}"))?;
Ok(String::from_utf8(plaintext).context("paste plaintext not UTF-8")?)
String::from_utf8(plaintext).context("paste plaintext not UTF-8")
}
/// Type clipboard text into the HID keyboard gadget.
@ -51,6 +51,13 @@ pub async fn type_text(kb: &Mutex<File>, text: &str) -> Result<()> {
.unwrap_or(8);
let delay = std::time::Duration::from_millis(delay_ms);
let (unsupported_count, unsupported_preview) = unsupported_chars(text.chars().take(max));
if unsupported_count > 0 {
anyhow::bail!(
"paste contains {unsupported_count} unsupported character(s): {unsupported_preview}"
);
}
let mut kb = kb.lock().await;
for c in text.chars().take(max) {
if let Some((usage, mods)) = char_to_usage(c) {
@ -65,6 +72,26 @@ pub async fn type_text(kb: &Mutex<File>, text: &str) -> Result<()> {
Ok(())
}
/// Summarize unsupported clipboard characters for explicit operator feedback.
fn unsupported_chars(chars: impl Iterator<Item = char>) -> (usize, String) {
let mut count = 0usize;
let mut preview = Vec::new();
for ch in chars {
if char_to_usage(ch).is_some() {
continue;
}
count += 1;
if preview.len() < 6 {
preview.push(format!("{:?}", ch));
}
}
if count == 0 {
(0, String::new())
} else {
(count, preview.join(", "))
}
}
fn load_key() -> Result<[u8; 32]> {
let raw = std::env::var("LESAVKA_PASTE_KEY")
.context("LESAVKA_PASTE_KEY not set (required for PasteText RPC)")?;
@ -73,7 +100,7 @@ fn load_key() -> Result<[u8; 32]> {
#[cfg(test)]
mod tests {
use super::{decrypt, type_text};
use super::{decrypt, type_text, unsupported_chars};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use lesavka_common::lesavka::PasteRequest;
@ -134,12 +161,43 @@ mod tests {
.await
.expect("open temp file");
let kb = Mutex::new(file);
type_text(&kb, "A!🙂").await.expect("type text");
type_text(&kb, "A!?").await.expect("type text");
let mut bytes = Vec::new();
let mut file = File::open(&path).await.expect("reopen temp file");
file.read_to_end(&mut bytes).await.expect("read reports");
assert_eq!(bytes.len(), 32);
assert_eq!(bytes.len(), 48);
});
});
}
#[test]
fn unsupported_chars_reports_count_and_preview() {
let (count, preview) = unsupported_chars("ok🙂éx".chars());
assert_eq!(count, 2);
assert!(preview.contains("'🙂'"));
assert!(preview.contains("'é'"));
}
#[test]
#[serial]
/// Reject unsupported clipboard characters instead of silently dropping them.
fn type_text_rejects_unsupported_chars() {
let rt = Runtime::new().expect("runtime");
with_var("LESAVKA_PASTE_DELAY_MS", Some("0"), || {
rt.block_on(async {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("hidg0.bin");
let file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&path)
.await
.expect("open temp file");
let kb = Mutex::new(file);
let err = type_text(&kb, "pw🙂").await.expect_err("unsupported char should fail");
assert!(err.to_string().contains("unsupported character"));
});
});
}