breadclipd: watch-driven capture, flock singleton, ignore rules, pin verb

- Capture now keys off wl-paste's `CLIPBOARD_STATE`: `sensitive`
  (password manager via `wl-copy --sensitive`, covers the old
  x-kde-passwordManagerHint case) and `nil`/`clear` persist nothing;
  `data` reads the content off stdin in the same event, so the
  sensitive check and the read can't straddle a clipboard change.
  A manual `--capture-once` with no CLIPBOARD_STATE falls back to
  querying wl-paste directly, now wrapped in `timeout -k 2 5` so a
  stalled offer can't block a capture process forever.
- Single-instance guard moves to `bread_utils::singleton` (flock) —
  kernel-atomic, auto-released on death, no stale pid file.
- `ignore_rules::is_sensitive`: conservative best-effort heuristics
  that skip copies which look like secrets even when unflagged —
  PEM/OpenSSH private key blocks, `password:`-style lines, labelled
  one-time codes, Luhn-valid card numbers, well-known token prefixes.
  A convenience, not a security boundary.
- `bread.command.clip.pin` verb (payload `{id, pin?}`, pin defaults
  true) → `set_pinned`, emits `bread.clip.pinned` / `.pin.failed`.
- Two independent `wl-paste --watch` loops (regular clipboard always
  on; primary selection when `capture.primary`), each restarting with
  2s→30s capped backoff and state-change-only journal logging.
- Image type is sniffed from magic bytes (PNG/JPEG) on the stdin path
  and requested by actual offered type on the fallback path — a JPEG
  is stored as `.jpg` / `image/jpeg`, not relabelled PNG.
- content_kind: a single line of prose that merely contains a keyword
  ("class is a concept") no longer classifies as code — it must look
  like a statement.
This commit is contained in:
Breadway 2026-08-31 15:07:22 +08:00
parent 967fd9f1b7
commit 83630033cd
4 changed files with 555 additions and 111 deletions

View file

@ -105,12 +105,29 @@ fn looks_like_code(text: &str) -> bool {
}) })
.count(); .count();
// Multi-line with a meaningful fraction of "code-shaped" lines, or an // Multi-line with a meaningful fraction of "code-shaped" lines, or a
// unambiguous single-line token (import/#include/fn signature), counts // couple of code tokens anywhere, counts as code.
// as code. A single short line of prose won't hit either bar. if lines.len() > 1 {
token_hits >= 2 return token_hits >= 2 || brace_or_semicolon_lines * 2 >= lines.len();
|| (lines.len() > 1 && brace_or_semicolon_lines * 2 >= lines.len()) }
|| (lines.len() == 1 && token_hits >= 1 && text.len() < 200)
// 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)] #[cfg(test)]
@ -170,6 +187,15 @@ mod tests {
assert_eq!(detect("import numpy as np"), "code"); 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] #[test]
fn plain_prose_is_plain() { fn plain_prose_is_plain() {
assert_eq!( assert_eq!(

View file

@ -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 68 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: 1319 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"));
}
}

View file

@ -1,8 +1,11 @@
mod content_kind; mod content_kind;
mod ignore_rules;
use bread_utils::bread_client::BreadClient; use bread_utils::bread_client::BreadClient;
use breadclip_core::HistoryDb; use bread_utils::singleton::{try_acquire, Acquire};
use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration}; 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 /// This app's id in bread's sibling-app namespace registry
/// (`bread_shared::apps::KNOWN_APPS`) — events are published as /// (`bread_shared::apps::KNOWN_APPS`) — events are published as
@ -14,15 +17,52 @@ const APP_ID: &str = "clip";
/// needed for a single internal flag). /// needed for a single internal flag).
const CAPTURE_FLAG: &str = "--capture-once"; 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 /// MIME type convention (originating with KDE's Klipper) that password
/// managers such as KeePassXC and Bitwarden set on clipboard content they /// managers such as KeePassXC and Bitwarden set on clipboard content they
/// own, signaling "don't persist this". Any offer advertising it is skipped /// own, signaling "don't persist this". Only reachable on the fallback
/// entirely. /// 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"; const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint";
fn get_available_types() -> Vec<String> { /// Build a `wl-paste` command with a hard deadline. Without the timeout, a
Command::new("wl-paste") /// stalled selection offer (source app died mid-transfer, compositor never
.args(["--list-types"]) /// 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, breadclip_core::HistoryError> {
HistoryDb::open_with(breadclip_core::config::load().retention)
}
fn get_available_types(primary: bool) -> Vec<String> {
wl_paste_cmd(primary)
.arg("--list-types")
.output() .output()
.ok() .ok()
.filter(|o| o.status.success()) .filter(|o| o.status.success())
@ -31,8 +71,16 @@ fn get_available_types() -> Vec<String> {
.unwrap_or_default() .unwrap_or_default()
} }
fn get_clipboard_text() -> Option<String> { fn get_clipboard_bytes(mime: &str, primary: bool) -> Option<Vec<u8>> {
let output = Command::new("wl-paste") 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<String> {
let output = wl_paste_cmd(primary)
.args(["--no-newline", "--type", "text/plain"]) .args(["--no-newline", "--type", "text/plain"])
.output() .output()
.ok()?; .ok()?;
@ -44,48 +92,80 @@ fn get_clipboard_text() -> Option<String> {
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
} }
fn get_clipboard_image() -> Option<Vec<u8>> { /// Identify an image payload by its magic bytes. `wl-paste --watch` hands us
let output = Command::new("wl-paste") /// the content on stdin without saying which offered type it picked, so the
.args(["--type", "image/png"]) /// bytes are the only reliable signal — and they're exact, because watch
.output() /// mode never appends a trailing newline.
.ok()?; fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> {
if !output.status.success() || output.stdout.is_empty() { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return None; return Some("image/png");
} }
Some(output.stdout) if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
None
} }
fn lock_file() -> PathBuf { fn source(primary: bool) -> CaptureSource {
env::var("XDG_RUNTIME_DIR") if primary {
.map(PathBuf::from) CaptureSource::Primary
.unwrap_or_else(|_| PathBuf::from("/tmp")) } else {
.join("breadclipd.lock") CaptureSource::Clipboard
}
} }
// Returns false if another instance is already running. fn store_text(text: &str, primary: bool) {
fn acquire_lock() -> bool { let db = match open_db() {
let path = lock_file(); Ok(db) => db,
if let Ok(content) = fs::read_to_string(&path) { Err(e) => {
if let Ok(pid) = content.trim().parse::<u32>() { eprintln!("breadclipd: failed to open database: {e}");
let alive = fs::read_to_string(format!("/proc/{}/comm", pid)) return;
.map(|s| s.trim() == "breadclipd")
.unwrap_or(false);
if alive {
eprintln!("breadclipd: already running (pid {})", pid);
return false;
} }
};
match db.insert_text(text, source(primary)) {
Ok(()) => emit_copied(content_kind::detect(text), text.len()),
Err(e) => eprintln!("breadclipd: insert text: {e}"),
} }
}
let _ = fs::write(&path, std::process::id().to_string());
true
} }
/// Invoked once per clipboard-change event (as the handler command for fn store_image(bytes: Vec<u8>, mime: &str, primary: bool) {
/// `wl-paste --watch`). Reads whatever is on the clipboard right now, skips let db = match open_db() {
/// it entirely if a password manager flagged it as sensitive, and otherwise Ok(db) => db,
/// persists a text or image entry to the history DB. Err(e) => {
fn capture_once() { eprintln!("breadclipd: failed to open database: {e}");
let types = get_available_types(); 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);
}
}
}
/// 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) { if types.iter().any(|t| t == PASSWORD_HINT_MIME) {
// Password manager (KeePassXC, Bitwarden, etc.) marked this copy as // 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_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg");
let has_text = types.iter().any(|t| t.starts_with("text/")); 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 has_image && !has_text {
if let Some(bytes) = get_clipboard_image() { let mime = if types.iter().any(|t| t == "image/png") {
match db.insert_image(&bytes) { "image/png"
Ok(()) => emit_copied("image", bytes.len()), } else {
Err(e) => eprintln!("breadclipd: insert image: {e}"), "image/jpeg"
} };
if let Some(bytes) = get_clipboard_bytes(mime, primary) {
store_image(bytes, mime, primary);
} }
} else if has_text { } else if has_text {
if let Some(text) = get_clipboard_text() { if let Some(text) = get_clipboard_text(primary) {
match db.insert_text(&text) { if !ignore_rules::is_sensitive(&text) {
Ok(()) => emit_copied(content_kind::detect(&text), text.len()), store_text(&text, primary);
Err(e) => eprintln!("breadclipd: insert text: {e}"),
} }
} }
} }
} }
/// 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 /// Publishes `bread.clip.copied` into the bread event fabric. Fire-and-forget
/// and non-fatal by design (`BreadClient::emit` never blocks or errors this /// and non-fatal by design (`BreadClient::emit` never blocks or errors this
/// caller) — breadd being absent or not installed must never affect /// 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, /// Reacts to `bread.command.clip.*` verbs. Only `clear` and `pin` map to
/// existing breadclip functionality today — `pin`/`select` would need a new /// real, existing breadclip functionality today — `select` would need a
/// "pinned" concept that doesn't exist anywhere in the history DB schema, /// "remote-activate a row" concept the popup doesn't expose over the bus,
/// which is a real product decision for breadclip itself (does it want /// which is a real product decision for breadclip itself, not something to
/// pinning at all, and what would the GTK UI for it look like?), not /// fabricate as a side effect of wiring up the event bus. See
/// something to fabricate as a side effect of wiring up the event bus. See
/// `breadclip/EVENTS.md` for the honest current status. /// `breadclip/EVENTS.md` for the honest current status.
/// ///
/// Emits `bread.clip.<verb>.done`/`.failed` per the confirmation convention /// Emits `bread.clip.<verb>.done`/`bread.clip.pinned`/`.failed` per the
/// in bread's Documentation.md — a module that started this command via /// confirmation convention in bread's Documentation.md — a module that
/// `bread.wait`/`bread.wait_any` can await the real outcome instead of /// started this command via `bread.wait`/`bread.wait_any` can await the real
/// assuming success the moment it publishes the command. /// outcome instead of assuming success the moment it publishes the command.
fn handle_command(event_name: &str) { fn handle_command(event_name: &str, data: &Value) {
let Some(verb) = event_name.strip_prefix("bread.command.clip.") else { let Some(verb) = event_name.strip_prefix("bread.command.clip.") else {
return; 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 => { other => {
eprintln!("breadclipd: ignoring unrecognized command verb '{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 <cmd>` runs <cmd> 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() { fn main() {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) { if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) {
capture_once(); capture_once(args.iter().any(|a| a == PRIMARY_FLAG));
return; return;
} }
if !acquire_lock() { // 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); 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 // Wait briefly for WAYLAND_DISPLAY — common when started early in the session
let mut retries = 0; let mut retries = 0;
@ -189,14 +378,14 @@ fn main() {
} }
if env::var("WAYLAND_DISPLAY").is_err() { if env::var("WAYLAND_DISPLAY").is_err() {
eprintln!("breadclipd: WAYLAND_DISPLAY not set after waiting, exiting"); eprintln!("breadclipd: WAYLAND_DISPLAY not set after waiting, exiting");
let _ = fs::remove_file(lock_file());
std::process::exit(1); std::process::exit(1);
} }
let cfg = breadclip_core::config::load();
// Fail fast (before we start watching) if the DB can't be opened. // 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}"); eprintln!("breadclipd: failed to open database: {e}");
let _ = fs::remove_file(lock_file());
std::process::exit(1); std::process::exit(1);
} }
@ -204,7 +393,6 @@ fn main() {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
eprintln!("breadclipd: could not resolve own executable path: {e}"); eprintln!("breadclipd: could not resolve own executable path: {e}");
let _ = fs::remove_file(lock_file());
std::process::exit(1); std::process::exit(1);
} }
}; };
@ -219,29 +407,22 @@ fn main() {
// delivering commands until it reconnects. // delivering commands until it reconnects.
let command_client = BreadClient::connect(APP_ID); let command_client = BreadClient::connect(APP_ID);
let _commands = command_client.subscribe("bread.command.clip.**", |event| { let _commands = command_client.subscribe("bread.command.clip.**", |event| {
handle_command(&event.event); handle_command(&event.event, &event.data);
}); });
// `wl-paste --watch <cmd>` runs <cmd> once per clipboard-change event // Regular-clipboard watcher, always on.
// instead of polling — zero idle cost between changes, and no more let clipboard_exe = exe.clone();
// forking wl-paste 4-6 times a second. Each event re-invokes this same thread::spawn(move || watch_loop(clipboard_exe, false));
// binary with --capture-once to do a single read-and-store pass. // Primary-selection watcher (middle-click), opt-in via config.
loop { if cfg.capture_primary {
let status = Command::new("wl-paste") eprintln!("breadclipd: watching primary selection");
.args(["--watch"]) thread::spawn(move || watch_loop(exe, true));
.arg(&exe) }
.arg(CAPTURE_FLAG)
.status();
match status { // The watcher threads own the daemon's lifetime; this thread just keeps
Ok(s) => { // the process (and the singleton guard above) alive.
eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s"); loop {
} thread::sleep(Duration::from_secs(3600));
Err(e) => {
eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s");
}
}
thread::sleep(Duration::from_secs(2));
} }
} }
@ -267,6 +448,22 @@ mod tests {
f(); 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] #[test]
fn handle_command_clear_empties_history_even_with_no_daemon_reachable() { fn handle_command_clear_empties_history_even_with_no_daemon_reachable() {
// The point of this test: handle_command's actual effect (clearing // 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. // made contingent on the emit succeeding, this test would fail.
with_isolated_history(|| { with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db"); 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); 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"); let db = HistoryDb::open().expect("reopen history db");
assert!(db.list_entries(10).unwrap().is_empty()); 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] #[test]
fn handle_command_ignores_unrecognized_verb() { fn handle_command_ignores_unrecognized_verb() {
with_isolated_history(|| { with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db"); 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"); let db = HistoryDb::open().expect("reopen history db");
assert_eq!( assert_eq!(
@ -307,11 +538,11 @@ mod tests {
fn handle_command_ignores_events_outside_its_own_command_namespace() { fn handle_command_ignores_events_outside_its_own_command_namespace() {
with_isolated_history(|| { with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db"); 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. // Not a `bread.command.clip.*` event at all — must be a no-op.
handle_command("bread.command.pad.clear"); handle_command("bread.command.pad.clear", &serde_json::json!({}));
handle_command("bread.clip.copied"); handle_command("bread.clip.copied", &serde_json::json!({}));
let db = HistoryDb::open().expect("reopen history db"); let db = HistoryDb::open().expect("reopen history db");
assert_eq!(db.list_entries(10).unwrap().len(), 1); assert_eq!(db.list_entries(10).unwrap().len(), 1);

View file

@ -11,6 +11,11 @@ ExecStart=%h/.cargo/bin/breadclipd
Restart=on-failure Restart=on-failure
RestartSec=2 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 # Forward stdout/stderr to the journal so `journalctl --user -u breadclipd` works
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal