Config file, pinning, and history-DB hardening #1

Merged
Breadway merged 4 commits from feature/config-file into main 2026-08-31 18:14:16 +08:00
4 changed files with 555 additions and 111 deletions
Showing only changes of commit 83630033cd - Show all commits

View file

@ -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!(

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 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<String> {
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, 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()
.ok()
.filter(|o| o.status.success())
@ -31,8 +71,16 @@ fn get_available_types() -> Vec<String> {
.unwrap_or_default()
}
fn get_clipboard_text() -> Option<String> {
let output = Command::new("wl-paste")
fn get_clipboard_bytes(mime: &str, primary: bool) -> Option<Vec<u8>> {
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"])
.output()
.ok()?;
@ -44,48 +92,80 @@ fn get_clipboard_text() -> Option<String> {
.filter(|s| !s.trim().is_empty())
}
fn get_clipboard_image() -> Option<Vec<u8>> {
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::<u32>() {
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<u8>, 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.<verb>.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.<verb>.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 <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() {
let args: Vec<String> = 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 <cmd>` runs <cmd> 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);

View file

@ -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