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:
parent
967fd9f1b7
commit
83630033cd
4 changed files with 555 additions and 111 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
182
breadclipd/src/ignore_rules.rs
Normal file
182
breadclipd/src/ignore_rules.rs
Normal 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 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue