diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index b2768b8..c155d36 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -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 { - 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 { 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 { 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 = 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)); + } } diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 0dab04d..028c386 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -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, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 2e79647..61b8842 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -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, diff --git a/server/src/paste.rs b/server/src/paste.rs index 2aee7c8..5011c32 100644 --- a/server/src/paste.rs +++ b/server/src/paste.rs @@ -29,7 +29,7 @@ pub fn decrypt(req: &PasteRequest) -> Result { 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, 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, text: &str) -> Result<()> { Ok(()) } +/// Summarize unsupported clipboard characters for explicit operator feedback. +fn unsupported_chars(chars: impl Iterator) -> (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")); }); }); }