diff --git a/breadclipd/src/content_kind.rs b/breadclipd/src/content_kind.rs index fd27a98..4e882e3 100644 --- a/breadclipd/src/content_kind.rs +++ b/breadclipd/src/content_kind.rs @@ -105,12 +105,29 @@ fn looks_like_code(text: &str) -> bool { }) .count(); - // Multi-line with a meaningful fraction of "code-shaped" lines, or an - // unambiguous single-line token (import/#include/fn signature), counts - // as code. A single short line of prose won't hit either bar. - token_hits >= 2 - || (lines.len() > 1 && brace_or_semicolon_lines * 2 >= lines.len()) - || (lines.len() == 1 && token_hits >= 1 && text.len() < 200) + // Multi-line with a meaningful fraction of "code-shaped" lines, or a + // couple of code tokens anywhere, counts as code. + if lines.len() > 1 { + return token_hits >= 2 || brace_or_semicolon_lines * 2 >= lines.len(); + } + + // A single line only counts as code if it actually *looks* like a code + // statement — a prose sentence that merely contains a keyword ("let me + // show you", "function of time", "class is a concept") must stay plain. + if lines.len() == 1 && token_hits >= 1 && text.len() < 200 { + let trimmed = text.trim_start(); + let import_like = trimmed.starts_with("import ") || trimmed.starts_with("#include"); + let code_shaped = trimmed.ends_with('{') + || trimmed.ends_with('}') + || trimmed.ends_with(';') + || trimmed.ends_with('(') + || trimmed.ends_with(')') + || trimmed.contains("=>") + || (trimmed.contains('(') && trimmed.contains(')')) + || trimmed.contains(" = "); + return import_like || code_shaped; + } + false } #[cfg(test)] @@ -170,6 +187,15 @@ mod tests { assert_eq!(detect("import numpy as np"), "code"); } + #[test] + fn single_line_prose_containing_a_keyword_is_plain() { + assert_eq!(detect("let me show you something"), "plain"); + assert_eq!(detect("function of time"), "plain"); + assert_eq!(detect("class is a concept"), "plain"); + assert_eq!(detect("const means constant"), "plain"); + assert_eq!(detect("def is short for define"), "plain"); + } + #[test] fn plain_prose_is_plain() { assert_eq!( diff --git a/breadclipd/src/ignore_rules.rs b/breadclipd/src/ignore_rules.rs new file mode 100644 index 0000000..cca4df7 --- /dev/null +++ b/breadclipd/src/ignore_rules.rs @@ -0,0 +1,182 @@ +//! Best-effort content heuristics for never persisting obvious secrets. +//! +//! The password-manager path (`CLIPBOARD_STATE=sensitive` / the +//! `x-kde-passwordManagerHint` MIME type) only catches copies an app +//! deliberately flagged. These rules catch copies that *look* like secrets +//! even when the app didn't flag them — one-time codes, credit card +//! numbers, private keys, credential lines. They are deliberately +//! conservative (a false "skip" is cheaper than a leaked password) and they +//! are a convenience, not a security boundary: the 0600 file permissions +//! and 0700 data dir are the real protection. + +/// Would persisting this text be a bad idea? Skipped copies never reach the +/// database (or the event bus), like the password-manager path. +pub fn is_sensitive(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return false; + } + looks_like_private_key(trimmed) + || looks_like_credential_line(trimmed) + || looks_like_otp(trimmed) + || contains_card_number(trimmed) + || contains_api_token(trimmed) +} + +/// PEM/OpenSSH private key blocks (and PGP blocks, which are just as +/// sensitive). +fn looks_like_private_key(text: &str) -> bool { + text.contains("-----BEGIN") || text.contains("PRIVATE KEY") +} + +/// A line that labels a credential directly: `password: hunter2`, +/// `passwd = hunter2`, etc. +fn looks_like_credential_line(text: &str) -> bool { + let first = text.lines().next().unwrap_or("").trim().to_ascii_lowercase(); + const PREFIXES: [&str; 8] = [ + "password:", + "password =", + "password=", + "passwd:", + "passwd =", + "passwd=", + "pw:", + "pass:", + ]; + PREFIXES.iter().any(|p| first.starts_with(p)) +} + +/// A one-time code: a short copy that mentions a code keyword and contains +/// a 6–8 digit run. A *bare* 6-digit number is deliberately not flagged — +/// too many legitimate numbers have that shape. +fn looks_like_otp(text: &str) -> bool { + if text.len() > 60 { + return false; + } + let lower = text.to_ascii_lowercase(); + const LABELS: [&str; 8] = [ + "code", "otp", "verification", "verif", "2fa", "passcode", "one-time", "one time", + ]; + if !LABELS.iter().any(|k| lower.contains(k)) { + return false; + } + has_digit_run(text, 6, 8) +} + +/// Credit card numbers: 13–19 digit runs (allowing spaces/dashes between +/// groups) that pass the Luhn check. +fn contains_card_number(text: &str) -> bool { + let mut run = String::new(); + for c in text.chars() { + if c.is_ascii_digit() { + run.push(c); + } else if (c == ' ' || c == '-') && !run.is_empty() { + run.push(' '); // keep one run across group separators + } else { + if card_like(&run) { + return true; + } + run.clear(); + } + } + card_like(&run) +} + +fn card_like(run: &str) -> bool { + let digits: String = run.chars().filter(|c| c.is_ascii_digit()).collect(); + (13..=19).contains(&digits.len()) && luhn_valid(&digits) +} + +fn luhn_valid(digits: &str) -> bool { + let mut sum: u32 = 0; + let mut double = false; + for b in digits.bytes().rev() { + let mut d = (b - b'0') as u32; + if double { + d *= 2; + if d > 9 { + d -= 9; + } + } + sum += d; + double = !double; + } + sum.is_multiple_of(10) +} + +/// Well-known API token prefixes (OpenAI, GitHub, Slack, AWS access keys). +fn contains_api_token(text: &str) -> bool { + const PREFIXES: [&str; 7] = ["sk-", "sk-proj-", "ghp_", "gho_", "xoxb-", "xoxp-", "AKIA"]; + PREFIXES.iter().any(|p| text.contains(p)) +} + +fn has_digit_run(text: &str, min: usize, max: usize) -> bool { + let mut run = 0usize; + for c in text.chars() { + if c.is_ascii_digit() { + run += 1; + } else { + if run >= min && run <= max { + return true; + } + run = 0; + } + } + run >= min && run <= max +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_key_blocks_are_sensitive() { + assert!(is_sensitive( + "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA..." + )); + assert!(is_sensitive("-----BEGIN PGP MESSAGE-----")); + } + + #[test] + fn credential_labeled_lines_are_sensitive() { + assert!(is_sensitive("password: hunter2")); + assert!(is_sensitive("Password = correct horse battery staple")); + assert!(is_sensitive("passwd=hunter2")); + } + + #[test] + fn labeled_one_time_codes_are_sensitive() { + assert!(is_sensitive("Your verification code is 483920")); + assert!(is_sensitive("483920 is your code")); + assert!(is_sensitive("OTP: 12345678")); + } + + #[test] + fn bare_numbers_are_not_sensitive() { + // A bare 6-digit number is too ambiguous to drop on purpose. + assert!(!is_sensitive("483920")); + assert!(!is_sensitive("The answer is 42")); + // A long random number that happens to be 16 digits but fails Luhn. + assert!(!is_sensitive("1234567890123456")); + } + + #[test] + fn luhn_valid_card_numbers_are_sensitive() { + assert!(is_sensitive("4111 1111 1111 1111")); + assert!(is_sensitive("card number: 4111-1111-1111-1111")); + } + + #[test] + fn api_token_prefixes_are_sensitive() { + assert!(is_sensitive("sk-proj-abc123def456")); + assert!(is_sensitive("ghp_1234567890abcdefghijklmnopqrstuvwxyz")); + assert!(is_sensitive("AKIAIOSFODNN7EXAMPLE")); + } + + #[test] + fn normal_prose_is_not_sensitive() { + assert!(!is_sensitive("just a normal sentence someone copied")); + assert!(!is_sensitive("here is the plan for next week")); + assert!(!is_sensitive("the password field in the form is empty")); + } +} diff --git a/breadclipd/src/main.rs b/breadclipd/src/main.rs index 012c6f1..981b0da 100644 --- a/breadclipd/src/main.rs +++ b/breadclipd/src/main.rs @@ -1,8 +1,11 @@ mod content_kind; +mod ignore_rules; use bread_utils::bread_client::BreadClient; -use breadclip_core::HistoryDb; -use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration}; +use bread_utils::singleton::{try_acquire, Acquire}; +use breadclip_core::{CaptureSource, HistoryDb}; +use serde_json::Value; +use std::{env, io::Read, process::Command, thread, time::Duration}; /// This app's id in bread's sibling-app namespace registry /// (`bread_shared::apps::KNOWN_APPS`) — events are published as @@ -14,15 +17,52 @@ const APP_ID: &str = "clip"; /// needed for a single internal flag). const CAPTURE_FLAG: &str = "--capture-once"; +/// Second positional arg distinguishing a capture spawned by the primary- +/// selection watcher from one spawned by the regular-clipboard watcher. +const PRIMARY_FLAG: &str = "--primary"; + /// MIME type convention (originating with KDE's Klipper) that password /// managers such as KeePassXC and Bitwarden set on clipboard content they -/// own, signaling "don't persist this". Any offer advertising it is skipped -/// entirely. +/// own, signaling "don't persist this". Only reachable on the fallback +/// capture path — when invoked through `wl-paste --watch`, `CLIPBOARD_STATE` +/// carries the same information from the same event (see `capture_once`). const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint"; -fn get_available_types() -> Vec { - Command::new("wl-paste") - .args(["--list-types"]) +/// Build a `wl-paste` command with a hard deadline. Without the timeout, a +/// stalled selection offer (source app died mid-transfer, compositor never +/// completes the handoff) leaves `wl-paste` blocked forever reading a pipe +/// that never closes — and a forever-blocked fallback capture would eat a +/// file descriptor per event over a long session. `timeout -k` guarantees a +/// SIGKILL if the initial SIGTERM doesn't land. Only used by the manual +/// fallback path (`capture_via_wl_paste`); the `--watch` path reads the +/// content from stdin and so has nothing that can hang. +fn wl_paste_cmd(primary: bool) -> Command { + let mut cmd = Command::new("timeout"); + cmd.args(["-k", "2", "5", "wl-paste"]); + if primary { + cmd.arg("--primary"); + } + cmd +} + +/// `wl-paste --watch` sets this in the spawned command's environment for +/// every clipboard event (see wl-paste(1)): `data` (read the content from +/// stdin), `nil` (empty clipboard), `clear` (explicitly cleared), or +/// `sensitive` (a password manager flagged the selection). Unset or +/// unrecognized means we were not invoked by `--watch` (e.g. a manual +/// `--capture-once` run) and we fall back to querying wl-paste directly. +const CLIPBOARD_STATE: &str = "CLIPBOARD_STATE"; + +/// Open the history DB with the retention caps from the user's config. The +/// `--capture-once` processes run per clipboard event, so they load the +/// config fresh rather than inheriting anything from the long-lived daemon. +fn open_db() -> Result { + HistoryDb::open_with(breadclip_core::config::load().retention) +} + +fn get_available_types(primary: bool) -> Vec { + wl_paste_cmd(primary) + .arg("--list-types") .output() .ok() .filter(|o| o.status.success()) @@ -31,8 +71,16 @@ fn get_available_types() -> Vec { .unwrap_or_default() } -fn get_clipboard_text() -> Option { - let output = Command::new("wl-paste") +fn get_clipboard_bytes(mime: &str, primary: bool) -> Option> { + let output = wl_paste_cmd(primary).args(["--type", mime]).output().ok()?; + if !output.status.success() || output.stdout.is_empty() { + return None; + } + Some(output.stdout) +} + +fn get_clipboard_text(primary: bool) -> Option { + let output = wl_paste_cmd(primary) .args(["--no-newline", "--type", "text/plain"]) .output() .ok()?; @@ -44,48 +92,80 @@ fn get_clipboard_text() -> Option { .filter(|s| !s.trim().is_empty()) } -fn get_clipboard_image() -> Option> { - let output = Command::new("wl-paste") - .args(["--type", "image/png"]) - .output() - .ok()?; - if !output.status.success() || output.stdout.is_empty() { - return None; +/// Identify an image payload by its magic bytes. `wl-paste --watch` hands us +/// the content on stdin without saying which offered type it picked, so the +/// bytes are the only reliable signal — and they're exact, because watch +/// mode never appends a trailing newline. +fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some("image/png"); } - Some(output.stdout) + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some("image/jpeg"); + } + None } -fn lock_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadclipd.lock") +fn source(primary: bool) -> CaptureSource { + if primary { + CaptureSource::Primary + } else { + CaptureSource::Clipboard + } } -// Returns false if another instance is already running. -fn acquire_lock() -> bool { - let path = lock_file(); - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(pid) = content.trim().parse::() { - let alive = fs::read_to_string(format!("/proc/{}/comm", pid)) - .map(|s| s.trim() == "breadclipd") - .unwrap_or(false); - if alive { - eprintln!("breadclipd: already running (pid {})", pid); - return false; - } +fn store_text(text: &str, primary: bool) { + let db = match open_db() { + Ok(db) => db, + Err(e) => { + eprintln!("breadclipd: failed to open database: {e}"); + return; + } + }; + match db.insert_text(text, source(primary)) { + Ok(()) => emit_copied(content_kind::detect(text), text.len()), + Err(e) => eprintln!("breadclipd: insert text: {e}"), + } +} + +fn store_image(bytes: Vec, mime: &str, primary: bool) { + let db = match open_db() { + Ok(db) => db, + Err(e) => { + eprintln!("breadclipd: failed to open database: {e}"); + return; + } + }; + match db.insert_image(&bytes, mime, source(primary)) { + Ok(()) => emit_copied("image", bytes.len()), + Err(e) => eprintln!("breadclipd: insert image: {e}"), + } +} + +/// Capture path for `wl-paste --watch` invocations: the clipboard content +/// arrives on our stdin together with its `CLIPBOARD_STATE` in one event, so +/// the sensitive check and the data read can't race each other (two separate +/// `wl-paste` calls could straddle a clipboard change mid-capture). +fn capture_from_stdin(primary: bool) { + let mut buf = Vec::new(); + if std::io::stdin().read_to_end(&mut buf).is_err() || buf.is_empty() { + return; + } + if let Some(mime) = sniff_image_mime(&buf) { + store_image(buf, mime, primary); + } else if let Ok(text) = String::from_utf8(buf) { + if !text.trim().is_empty() && !ignore_rules::is_sensitive(&text) { + store_text(&text, primary); } } - let _ = fs::write(&path, std::process::id().to_string()); - true } -/// Invoked once per clipboard-change event (as the handler command for -/// `wl-paste --watch`). Reads whatever is on the clipboard right now, skips -/// it entirely if a password manager flagged it as sensitive, and otherwise -/// persists a text or image entry to the history DB. -fn capture_once() { - let types = get_available_types(); +/// Fallback capture path for manual `--capture-once` invocations (no +/// `CLIPBOARD_STATE` was set by wl-paste): ask wl-paste directly. Same +/// behavior as the stdin path, including requesting whichever image type is +/// actually offered rather than assuming PNG. +fn capture_via_wl_paste(primary: bool) { + let types = get_available_types(primary); if types.iter().any(|t| t == PASSWORD_HINT_MIME) { // Password manager (KeePassXC, Bitwarden, etc.) marked this copy as @@ -97,31 +177,42 @@ fn capture_once() { let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg"); let has_text = types.iter().any(|t| t.starts_with("text/")); - let db = match HistoryDb::open() { - Ok(db) => db, - Err(e) => { - eprintln!("breadclipd: failed to open database: {e}"); - return; - } - }; - if has_image && !has_text { - if let Some(bytes) = get_clipboard_image() { - match db.insert_image(&bytes) { - Ok(()) => emit_copied("image", bytes.len()), - Err(e) => eprintln!("breadclipd: insert image: {e}"), - } + let mime = if types.iter().any(|t| t == "image/png") { + "image/png" + } else { + "image/jpeg" + }; + if let Some(bytes) = get_clipboard_bytes(mime, primary) { + store_image(bytes, mime, primary); } } else if has_text { - if let Some(text) = get_clipboard_text() { - match db.insert_text(&text) { - Ok(()) => emit_copied(content_kind::detect(&text), text.len()), - Err(e) => eprintln!("breadclipd: insert text: {e}"), + if let Some(text) = get_clipboard_text(primary) { + if !ignore_rules::is_sensitive(&text) { + store_text(&text, primary); } } } } +/// Invoked once per clipboard-change event (as the handler command for +/// `wl-paste --watch`). Stores a text or image entry to the history DB, +/// unless the selection was flagged as sensitive — in which case nothing is +/// ever persisted. +fn capture_once(primary: bool) { + match env::var(CLIPBOARD_STATE).as_deref() { + // Password manager (KeePassXC, Bitwarden, ...) flagged this selection + // as sensitive via `wl-copy --sensitive` — never persist it. + Ok("sensitive") => {} + // Empty clipboard (nil) or an explicit clear — nothing to store. + Ok("nil") | Ok("clear") => {} + Ok("data") => capture_from_stdin(primary), + // Unset or unrecognized: not invoked by `--watch`. Fall back to the + // direct wl-paste path rather than blocking on whatever stdin is. + _ => capture_via_wl_paste(primary), + } +} + /// Publishes `bread.clip.copied` into the bread event fabric. Fire-and-forget /// and non-fatal by design (`BreadClient::emit` never blocks or errors this /// caller) — breadd being absent or not installed must never affect @@ -134,19 +225,18 @@ fn emit_copied(kind: &str, len: usize) { ); } -/// Reacts to `bread.command.clip.*` verbs. Only `clear` maps to real, -/// existing breadclip functionality today — `pin`/`select` would need a new -/// "pinned" concept that doesn't exist anywhere in the history DB schema, -/// which is a real product decision for breadclip itself (does it want -/// pinning at all, and what would the GTK UI for it look like?), not -/// something to fabricate as a side effect of wiring up the event bus. See +/// Reacts to `bread.command.clip.*` verbs. Only `clear` and `pin` map to +/// real, existing breadclip functionality today — `select` would need a +/// "remote-activate a row" concept the popup doesn't expose over the bus, +/// which is a real product decision for breadclip itself, not something to +/// fabricate as a side effect of wiring up the event bus. See /// `breadclip/EVENTS.md` for the honest current status. /// -/// Emits `bread.clip..done`/`.failed` per the confirmation convention -/// in bread's Documentation.md — a module that started this command via -/// `bread.wait`/`bread.wait_any` can await the real outcome instead of -/// assuming success the moment it publishes the command. -fn handle_command(event_name: &str) { +/// Emits `bread.clip..done`/`bread.clip.pinned`/`.failed` per the +/// confirmation convention in bread's Documentation.md — a module that +/// started this command via `bread.wait`/`bread.wait_any` can await the real +/// outcome instead of assuming success the moment it publishes the command. +fn handle_command(event_name: &str, data: &Value) { let Some(verb) = event_name.strip_prefix("bread.command.clip.") else { return; }; @@ -164,22 +254,121 @@ fn handle_command(event_name: &str) { ); } }, + "pin" => match data.get("id").and_then(Value::as_i64) { + Some(id) => { + let pin = data.get("pin").and_then(Value::as_bool).unwrap_or(true); + match HistoryDb::open().and_then(|db| db.set_pinned(id, pin)) { + Ok(()) => { + eprintln!( + "breadclipd: {} entry {id} via bread.command.clip.pin", + if pin { "pinned" } else { "unpinned" } + ); + BreadClient::connect(APP_ID).emit( + "bread.clip.pinned", + serde_json::json!({ "id": id, "pinned": pin }), + ); + } + Err(e) => { + eprintln!("breadclipd: bread.command.clip.pin failed: {e}"); + BreadClient::connect(APP_ID).emit( + "bread.clip.pin.failed", + serde_json::json!({ "error": e.to_string() }), + ); + } + } + } + None => { + eprintln!("breadclipd: bread.command.clip.pin missing 'id'"); + BreadClient::connect(APP_ID).emit( + "bread.clip.pin.failed", + serde_json::json!({ "error": "missing 'id'" }), + ); + } + }, other => { eprintln!("breadclipd: ignoring unrecognized command verb '{other}'"); } } } +/// Backoff for the `wl-paste --watch` restart loops: 2s → 4s → 8s → 16s → +/// 32s, capped at 30s. A dead compositor keeps the daemon alive without +/// hammering either wl-paste or the journal. +fn restart_delay(consecutive_failures: u32) -> Duration { + Duration::from_secs((2u64 << consecutive_failures.saturating_sub(1).min(4)).min(30)) +} + +/// Log a watch-loop restart, but only on state changes — the first failure +/// announces it, then every 8th attempt. Otherwise a persistent failure +/// would spam the journal every two seconds forever. +fn log_restart(what: &str, detail: &str, attempt: u32) { + if attempt == 1 || attempt.is_multiple_of(8) { + eprintln!("breadclipd: {what} ({detail}), restarting (attempt {attempt})"); + } +} + +/// Runs one `wl-paste --watch` watcher for the daemon's whole lifetime, +/// restarting it with capped backoff whenever it exits (e.g. the compositor +/// connection dropped). `primary` selects the middle-click primary selection +/// watcher (`--watch --primary`) instead of the regular clipboard watcher. +fn watch_loop(exe: std::path::PathBuf, primary: bool) { + let label = if primary { + "wl-paste --watch (primary)" + } else { + "wl-paste --watch" + }; + let mut consecutive_failures: u32 = 0; + loop { + // `wl-paste --watch ` runs once per selection-change + // event instead of polling — zero idle cost between changes, and no + // forking wl-paste repeatedly. Each event re-invokes this same + // binary with --capture-once to do a single read-and-store pass. + let mut cmd = Command::new("wl-paste"); + cmd.arg("--watch"); + if primary { + cmd.arg("--primary"); + } + cmd.arg(&exe).arg(CAPTURE_FLAG); + if primary { + cmd.arg(PRIMARY_FLAG); + } + let status = cmd.status(); + + match status { + Ok(s) => { + consecutive_failures += 1; + log_restart(label, &format!("{s}"), consecutive_failures); + } + Err(e) => { + consecutive_failures += 1; + log_restart(label, &e.to_string(), consecutive_failures); + } + } + thread::sleep(restart_delay(consecutive_failures)); + } +} + fn main() { let args: Vec = env::args().collect(); if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) { - capture_once(); + capture_once(args.iter().any(|a| a == PRIMARY_FLAG)); return; } - if !acquire_lock() { - std::process::exit(1); - } + // flock(2)-based singleton (bread_utils::singleton): lock ownership is + // kernel-atomic and released automatically the instant this process + // dies, so there's no stale-pid-file case to reason about. + let _singleton = match try_acquire("breadclipd") { + Ok(Acquire::Acquired(guard)) => Some(guard), + Ok(Acquire::HeldByOther(pid)) => { + eprintln!("breadclipd: already running (pid {:?})", pid); + std::process::exit(1); + } + Err(e) => { + eprintln!("breadclipd: single-instance lock unavailable ({e}); continuing without it"); + None + } + }; // Wait briefly for WAYLAND_DISPLAY — common when started early in the session let mut retries = 0; @@ -189,14 +378,14 @@ fn main() { } if env::var("WAYLAND_DISPLAY").is_err() { eprintln!("breadclipd: WAYLAND_DISPLAY not set after waiting, exiting"); - let _ = fs::remove_file(lock_file()); std::process::exit(1); } + let cfg = breadclip_core::config::load(); + // Fail fast (before we start watching) if the DB can't be opened. - if let Err(e) = HistoryDb::open() { + if let Err(e) = HistoryDb::open_with(cfg.retention) { eprintln!("breadclipd: failed to open database: {e}"); - let _ = fs::remove_file(lock_file()); std::process::exit(1); } @@ -204,7 +393,6 @@ fn main() { Ok(p) => p, Err(e) => { eprintln!("breadclipd: could not resolve own executable path: {e}"); - let _ = fs::remove_file(lock_file()); std::process::exit(1); } }; @@ -219,29 +407,22 @@ fn main() { // delivering commands until it reconnects. let command_client = BreadClient::connect(APP_ID); let _commands = command_client.subscribe("bread.command.clip.**", |event| { - handle_command(&event.event); + handle_command(&event.event, &event.data); }); - // `wl-paste --watch ` runs once per clipboard-change event - // instead of polling — zero idle cost between changes, and no more - // forking wl-paste 4-6 times a second. Each event re-invokes this same - // binary with --capture-once to do a single read-and-store pass. - loop { - let status = Command::new("wl-paste") - .args(["--watch"]) - .arg(&exe) - .arg(CAPTURE_FLAG) - .status(); + // Regular-clipboard watcher, always on. + let clipboard_exe = exe.clone(); + thread::spawn(move || watch_loop(clipboard_exe, false)); + // Primary-selection watcher (middle-click), opt-in via config. + if cfg.capture_primary { + eprintln!("breadclipd: watching primary selection"); + thread::spawn(move || watch_loop(exe, true)); + } - match status { - Ok(s) => { - eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s"); - } - Err(e) => { - eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s"); - } - } - thread::sleep(Duration::from_secs(2)); + // The watcher threads own the daemon's lifetime; this thread just keeps + // the process (and the singleton guard above) alive. + loop { + thread::sleep(Duration::from_secs(3600)); } } @@ -267,6 +448,22 @@ mod tests { f(); } + #[test] + fn sniff_image_mime_detects_png_and_jpeg() { + assert_eq!(sniff_image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png")); + assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg")); + assert_eq!(sniff_image_mime(b"plain text"), None); + } + + #[test] + fn restart_delay_backs_off_and_caps() { + assert_eq!(restart_delay(1), Duration::from_secs(2)); + assert_eq!(restart_delay(2), Duration::from_secs(4)); + assert_eq!(restart_delay(3), Duration::from_secs(8)); + assert_eq!(restart_delay(4), Duration::from_secs(16)); + assert_eq!(restart_delay(100), Duration::from_secs(30)); + } + #[test] fn handle_command_clear_empties_history_even_with_no_daemon_reachable() { // The point of this test: handle_command's actual effect (clearing @@ -276,23 +473,57 @@ mod tests { // made contingent on the emit succeeding, this test would fail. with_isolated_history(|| { let db = HistoryDb::open().expect("open history db"); - db.insert_text("something to clear").unwrap(); + db.insert_text("something to clear", CaptureSource::Clipboard).unwrap(); assert_eq!(db.list_entries(10).unwrap().len(), 1); - handle_command("bread.command.clip.clear"); + handle_command("bread.command.clip.clear", &serde_json::json!({})); let db = HistoryDb::open().expect("reopen history db"); assert!(db.list_entries(10).unwrap().is_empty()); }); } + #[test] + fn handle_command_pin_toggles_pinned_state() { + with_isolated_history(|| { + let db = HistoryDb::open().expect("open history db"); + db.insert_text("to pin", CaptureSource::Clipboard).unwrap(); + let id = db.list_entries(10).unwrap()[0].id; + assert!(!db.list_entries(10).unwrap()[0].pinned); + + handle_command( + "bread.command.clip.pin", + &serde_json::json!({ "id": id, "pin": true }), + ); + assert!(db.list_entries(10).unwrap()[0].pinned); + + handle_command( + "bread.command.clip.pin", + &serde_json::json!({ "id": id, "pin": false }), + ); + assert!(!db.list_entries(10).unwrap()[0].pinned); + }); + } + + #[test] + fn handle_command_pin_without_id_is_a_no_op() { + with_isolated_history(|| { + let db = HistoryDb::open().expect("open history db"); + db.insert_text("untouched", CaptureSource::Clipboard).unwrap(); + + handle_command("bread.command.clip.pin", &serde_json::json!({})); + + assert!(!db.list_entries(10).unwrap()[0].pinned); + }); + } + #[test] fn handle_command_ignores_unrecognized_verb() { with_isolated_history(|| { let db = HistoryDb::open().expect("open history db"); - db.insert_text("should survive").unwrap(); + db.insert_text("should survive", CaptureSource::Clipboard).unwrap(); - handle_command("bread.command.clip.pin"); + handle_command("bread.command.clip.select", &serde_json::json!({})); let db = HistoryDb::open().expect("reopen history db"); assert_eq!( @@ -307,11 +538,11 @@ mod tests { fn handle_command_ignores_events_outside_its_own_command_namespace() { with_isolated_history(|| { let db = HistoryDb::open().expect("open history db"); - db.insert_text("should survive").unwrap(); + db.insert_text("should survive", CaptureSource::Clipboard).unwrap(); // Not a `bread.command.clip.*` event at all — must be a no-op. - handle_command("bread.command.pad.clear"); - handle_command("bread.clip.copied"); + handle_command("bread.command.pad.clear", &serde_json::json!({})); + handle_command("bread.clip.copied", &serde_json::json!({})); let db = HistoryDb::open().expect("reopen history db"); assert_eq!(db.list_entries(10).unwrap().len(), 1); diff --git a/contrib/breadclipd.service b/contrib/breadclipd.service index d206506..be5cb24 100644 --- a/contrib/breadclipd.service +++ b/contrib/breadclipd.service @@ -11,6 +11,11 @@ ExecStart=%h/.cargo/bin/breadclipd Restart=on-failure RestartSec=2 +# wl-paste/wl-copy live on the user's PATH (e.g. ~/.local/bin or +# ~/.cargo/bin via bakery/cargo install), which systemd user services don't +# inherit by default — without this, breadclipd starts but captures nothing. +Environment=PATH=%h/.local/bin:%h/.cargo/bin:/usr/local/bin:/usr/bin:/bin + # Forward stdout/stderr to the journal so `journalctl --user -u breadclipd` works StandardOutput=journal StandardError=journal