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();
|
.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!(
|
||||||
|
|
|
||||||
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 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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue