From add5c6c8f1ded2620c17cbcfa3d3688831c99d6e Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 14:10:11 +0800 Subject: [PATCH 01/35] CI: migrate release workflow from GitHub Actions to Forgejo Actions GitHub Actions self-hosted runners need per-repo registration on a personal account; Forgejo Actions' runner already serves every repo with zero setup. Moves release publishing there (dl.breadway.dev stays the primary bakery target; GitHub release upload is kept as the fallback via an explicit token, since Forgejo Actions has no ambient GITHUB_TOKEN) and adds a mirror workflow to keep GitHub in sync automatically. --- .forgejo/workflows/mirror.yml | 19 +++++++++++ .forgejo/workflows/release.yml | 58 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 62 ---------------------------------- 3 files changed, 77 insertions(+), 62 deletions(-) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/release.yml delete mode 100644 .github/workflows/release.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..0f8916b --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,19 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - name: Mirror to GitHub + run: | + set -euo pipefail + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + git push --prune \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadclip.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..3451346 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,58 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/breadclip/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadclip breadclipd; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/contrib/breadclipd.service "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/breadclip/latest" + + - name: regenerate index.json + run: | + set -euo pipefail + rm -rf /tmp/bread-ecosystem-ci + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/breadclip/${VERSION}" + gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadclip \ + --title "breadclip v${VERSION}" --generate-notes 2>/dev/null || true + gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadclip \ + "${PKG_DIR}/breadclip-x86_64" \ + "${PKG_DIR}/breadclipd-x86_64" \ + "${PKG_DIR}/breadclip-x86_64.sha256" \ + "${PKG_DIR}/breadclipd-x86_64.sha256" \ + --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e14a470..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: release - -on: - push: - tags: ["v*"] - -permissions: - contents: write - -env: - DL_DIR: /srv/breadway-dl - ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - uses: actions/checkout@v4 - - - name: install build deps - run: sudo apt-get install -y libgtk-4-dev librsvg2-dev libdbus-1-dev pkg-config 2>/dev/null || true - - - name: build - run: cargo build --release --locked - - - name: prepare artifacts - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/breadclip/${VERSION}" - mkdir -p "${PKG_DIR}" - for bin in breadclip breadclipd; do - cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" - strip "${PKG_DIR}/${bin}-x86_64" - sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ - > "${PKG_DIR}/${bin}-x86_64.sha256" - done - cp contrib/breadclipd.service "${PKG_DIR}/" - cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "${DL_DIR}/breadclip/latest" - - - name: ensure bread-ecosystem - run: | - rm -rf "${ECOSYSTEM_DIR}" - git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" - - - name: regenerate index.json - run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" - - - name: upload to GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/breadclip/${VERSION}" - gh release create "${GITHUB_REF_NAME}" \ - --title "breadclip v${VERSION}" --generate-notes 2>/dev/null || true - gh release upload "${GITHUB_REF_NAME}" \ - "${PKG_DIR}/breadclip-x86_64" \ - "${PKG_DIR}/breadclipd-x86_64" \ - "${PKG_DIR}/breadclip-x86_64.sha256" \ - "${PKG_DIR}/breadclipd-x86_64.sha256" \ - --clobber From 7ee762f5eab038fb7b10e699fda12f5046f12049 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 16 Jul 2026 17:52:11 +0800 Subject: [PATCH 02/35] breadclip: bump bread-theme to v0.2.10 (fixes pywal-colored window background) --- Cargo.lock | 2 +- breadclip/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72e7c56..32add60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#5e58558dd36031433d4a8d8e70c71206c3f1f8f4" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" dependencies = [ "dirs", "gtk4", diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 978d108..813e158 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -9,7 +9,7 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" From 86ebe5d050f9bd6b3e52cf46124e5af7bd51d498 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 16 Jul 2026 18:04:39 +0800 Subject: [PATCH 03/35] breadclip: bump version to 0.1.1 --- Cargo.lock | 2 +- breadclip/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32add60..b683a8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,7 +48,7 @@ dependencies = [ [[package]] name = "breadclip" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bread-theme", "breadclip-core", diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 813e158..956750c 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip" -version = "0.1.0" +version = "0.1.1" edition = "2021" [[bin]] From 8097d1944cae5c1c71758aee6f6b90a559dbed72 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 03:17:24 +0800 Subject: [PATCH 04/35] breadclip: privacy hardening + event-driven daemon - history.db and images/*.png are now created with 0600 permissions (owner-only) instead of default umask, since clipboard history can contain plaintext passwords/tokens. - breadclipd never persists clipboard content flagged with the x-kde-passwordManagerHint MIME type (the convention KeePassXC, Bitwarden, etc. use to mark content they own). - Replaced breadclipd's 500ms busy-poll loop (2-3 wl-paste forks per cycle, forever) with wl-paste --watch, so it only reacts on actual clipboard changes. - Documented both behaviors in the README. --- README.md | 6 ++ breadclip-core/src/lib.rs | 21 ++++++- breadclipd/src/main.rs | 114 +++++++++++++++++++++++++++----------- 3 files changed, 108 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c6cf483..debe88b 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/b The daemon keeps at most 200 text entries and 50 image entries, trimming oldest entries automatically. +### Privacy + +- `history.db` and every file under `images/` are created with `0600` permissions (owner read/write only), regardless of your umask. +- breadclipd **never persists clipboard content flagged as sensitive by a password manager**. If a clipboard offer advertises the `x-kde-passwordManagerHint` MIME type — the convention used by KeePassXC, Bitwarden, and other password managers to mark content they own — that copy is skipped entirely and never reaches the database. +- That said, this is still a plaintext SQLite database of everything else you copy. Anything copied by an app that doesn't set the hint (e.g. copying a password from a terminal or a non-integrated app) will be stored like any other text entry. Treat `history.db` as sensitive, and don't rely on it as your only safeguard. + ## Theming `breadclip` inherits its colour palette from `bread-theme`. The panel renders with an 80% opaque background so Hyprland's `layerrule = blur` can show a frosted-glass effect behind it. diff --git a/breadclip-core/src/lib.rs b/breadclip-core/src/lib.rs index c1aab20..75035ff 100644 --- a/breadclip-core/src/lib.rs +++ b/breadclip-core/src/lib.rs @@ -1,6 +1,7 @@ use rusqlite::{params, Connection, Result as SqlResult}; use sha2::{Digest, Sha256}; -use std::path::PathBuf; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct ClipEntry { @@ -20,7 +21,8 @@ impl HistoryDb { pub fn open() -> SqlResult { let dir = data_dir(); std::fs::create_dir_all(&dir).ok(); - let conn = Connection::open(dir.join("history.db"))?; + let db_path = dir.join("history.db"); + let conn = Connection::open(&db_path)?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -32,6 +34,9 @@ impl HistoryDb { ); CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);", )?; + // history.db can contain plaintext secrets copied to the clipboard + // (passwords, tokens, TOTP codes); restrict it to owner-only, every open. + restrict_permissions(&db_path); Ok(Self { conn }) } @@ -55,6 +60,7 @@ impl HistoryDb { let path = images_dir.join(format!("{}.png", &hash[..16])); if !path.exists() { std::fs::write(&path, png_bytes).ok(); + restrict_permissions(&path); } let path_str = path.to_string_lossy().to_string(); let ts = unix_now(); @@ -145,6 +151,17 @@ impl HistoryDb { } } +/// Restrict a file to owner-only read/write (0600). Clipboard history can +/// contain passwords and other secrets, so this must not be world/group +/// readable regardless of the process umask. +fn restrict_permissions(path: &Path) { + if let Ok(meta) = std::fs::metadata(path) { + let mut perms = meta.permissions(); + perms.set_mode(0o600); + let _ = std::fs::set_permissions(path, perms); + } +} + pub fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); diff --git a/breadclipd/src/main.rs b/breadclipd/src/main.rs index 1755962..8afbe4d 100644 --- a/breadclipd/src/main.rs +++ b/breadclipd/src/main.rs @@ -1,6 +1,17 @@ -use breadclip_core::{sha256_hex, HistoryDb}; +use breadclip_core::HistoryDb; use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration}; +/// Argument used to invoke this same binary as the one-shot handler for +/// `wl-paste --watch`. Kept as a plain positional arg (no clap dependency +/// needed for a single internal flag). +const CAPTURE_FLAG: &str = "--capture-once"; + +/// 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. +const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint"; + fn get_available_types() -> Vec { Command::new("wl-paste") .args(["--list-types"]) @@ -61,7 +72,53 @@ fn acquire_lock() -> bool { 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(); + + if types.iter().any(|t| t == PASSWORD_HINT_MIME) { + // Password manager (KeePassXC, Bitwarden, etc.) marked this copy as + // sensitive via the x-kde-passwordManagerHint convention — never + // persist it. + return; + } + + 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() { + if let Err(e) = db.insert_image(&bytes) { + eprintln!("breadclipd: insert image: {e}"); + } + } + } else if has_text { + if let Some(text) = get_clipboard_text() { + if let Err(e) = db.insert_text(&text) { + eprintln!("breadclipd: insert text: {e}"); + } + } + } +} + fn main() { + let args: Vec = env::args().collect(); + if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) { + capture_once(); + return; + } + if !acquire_lock() { std::process::exit(1); } @@ -78,10 +135,17 @@ fn main() { std::process::exit(1); } - let db = match HistoryDb::open() { - Ok(db) => db, + // Fail fast (before we start watching) if the DB can't be opened. + if let Err(e) = HistoryDb::open() { + eprintln!("breadclipd: failed to open database: {e}"); + let _ = fs::remove_file(lock_file()); + std::process::exit(1); + } + + let exe = match env::current_exe() { + Ok(p) => p, Err(e) => { - eprintln!("breadclipd: failed to open database: {e}"); + eprintln!("breadclipd: could not resolve own executable path: {e}"); let _ = fs::remove_file(lock_file()); std::process::exit(1); } @@ -89,37 +153,25 @@ fn main() { eprintln!("breadclipd: started (pid {})", std::process::id()); - let mut last_text_hash: Option = None; - let mut last_image_hash: Option = None; - + // `wl-paste --watch ` runs once per clipboard-change event + // instead of polling — zero idle cost between changes, and no more + // forking wl-paste 4-6 times a second. Each event re-invokes this same + // binary with --capture-once to do a single read-and-store pass. loop { - let types = get_available_types(); - 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 status = Command::new("wl-paste") + .args(["--watch"]) + .arg(&exe) + .arg(CAPTURE_FLAG) + .status(); - if has_image && !has_text { - if let Some(bytes) = get_clipboard_image() { - let hash = sha256_hex(&bytes); - if Some(&hash) != last_image_hash.as_ref() { - last_image_hash = Some(hash); - last_text_hash = None; - if let Err(e) = db.insert_image(&bytes) { - eprintln!("breadclipd: insert image: {e}"); - } - } + match status { + Ok(s) => { + eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s"); } - } else if has_text { - if let Some(text) = get_clipboard_text() { - let hash = sha256_hex(text.as_bytes()); - if Some(&hash) != last_text_hash.as_ref() { - last_text_hash = Some(hash); - if let Err(e) = db.insert_text(&text) { - eprintln!("breadclipd: insert text: {e}"); - } - } + Err(e) => { + eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s"); } } - - thread::sleep(Duration::from_millis(500)); + thread::sleep(Duration::from_secs(2)); } } From e16c1b461c327c3a8a2669c50d69aa5ef504e78d Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 09:24:38 +0800 Subject: [PATCH 05/35] Migrate to bread-utils: Hyprland IPC, single-instance lock, popup scaffold, XDG paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors tonight's breadbox migration (same shared crate, same duplicated patterns): - position.rs's raw socket1 client -> bread_utils::hypr (file removed entirely, its logic now lives in the shared crate) - toggle_or_continue's TOCTOU-prone PID-file dance -> bread_utils::singleton - the layer-shell window setup, Up/Down visible-row navigation, and click-outside-close gesture -> bread_utils::gtk_popup - breadclip-core's data_dir(): replaced the buggy `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))` fallback (flagged but never fixed in tonight's earlier audit pass — PathBuf never expands `~`) with bread_utils::xdg::data_dir, which resolves a real $HOME before ever falling back. Builds and tests clean across the whole breadclip workspace. --- Cargo.lock | 13 ++++ breadclip-core/Cargo.toml | 2 + breadclip-core/src/lib.rs | 10 +++- breadclip/Cargo.toml | 2 + breadclip/src/main.rs | 121 ++++++++------------------------------ breadclip/src/position.rs | 71 ---------------------- 6 files changed, 47 insertions(+), 172 deletions(-) delete mode 100644 breadclip/src/position.rs diff --git a/Cargo.lock b/Cargo.lock index b683a8b..e06bdd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,11 +46,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.2.3" +dependencies = [ + "dirs", + "gtk4", + "gtk4-layer-shell", + "serde", + "serde_json", +] + [[package]] name = "breadclip" version = "0.1.1" dependencies = [ "bread-theme", + "bread-utils", "breadclip-core", "gtk4", "gtk4-layer-shell", @@ -61,6 +73,7 @@ dependencies = [ name = "breadclip-core" version = "0.1.0" dependencies = [ + "bread-utils", "dirs", "hex", "rusqlite", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 4ae8786..df1bafd 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -8,3 +8,5 @@ rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" dirs = "5" +# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern +bread-utils = { path = "../../bread-ecosystem-fix-worktree/bread-utils" } diff --git a/breadclip-core/src/lib.rs b/breadclip-core/src/lib.rs index 75035ff..5afed9f 100644 --- a/breadclip-core/src/lib.rs +++ b/breadclip-core/src/lib.rs @@ -169,9 +169,13 @@ pub fn sha256_hex(data: &[u8]) -> String { } pub fn data_dir() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from("~/.local/share")) - .join("breadclip") + // Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))` + // — PathBuf/std::fs never expand `~`, so on the rare box where `dirs` + // can't resolve a home directory, that fallback silently resolved to a + // directory literally named `~` under the current working directory + // instead of the user's actual home. bread_utils::xdg resolves a real + // $HOME before ever falling back. + bread_utils::xdg::data_dir("breadclip") } fn unix_now() -> i64 { diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 956750c..f023500 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -10,6 +10,8 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern +bread-utils = { path = "../../bread-ecosystem-fix-worktree/bread-utils", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" diff --git a/breadclip/src/main.rs b/breadclip/src/main.rs index 2df0dd5..282b4a6 100644 --- a/breadclip/src/main.rs +++ b/breadclip/src/main.rs @@ -1,5 +1,4 @@ mod css; -mod position; use breadclip_core::{ClipEntry, HistoryDb}; use bread_theme::{load_palette}; @@ -7,16 +6,13 @@ use gtk4::{ glib, pango::EllipsizeMode, prelude::*, - Application, ApplicationWindow, Box as GBox, Button, ContentFit, EventControllerKey, Label, + Application, Box as GBox, Button, ContentFit, EventControllerKey, Label, ListBox, Orientation, Picture, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, }; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use std::{ cell::Cell, - env, fs, io::Write, - path::PathBuf, process::{Command, Stdio}, rc::Rc, }; @@ -186,36 +182,6 @@ fn do_copy(entry: &ClipEntry) { } } -// ---- PID file toggle (single-instance, matches breadbox pattern) ------------- - -fn pid_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadclip.pid") -} - -fn toggle_or_continue() -> bool { - let pf = pid_file(); - if let Ok(content) = fs::read_to_string(&pf) { - if let Ok(pid) = content.trim().parse::() { - let alive = fs::read_to_string(format!("/proc/{}/comm", pid)) - .map(|s| s.trim() == "breadclip") - .unwrap_or(false); - if alive { - let _ = Command::new("kill").arg(pid.to_string()).status(); - return false; - } - } - } - let _ = fs::write(&pf, std::process::id().to_string()); - true -} - -fn cleanup_pid() { - let _ = fs::remove_file(pid_file()); -} - // ---- UI --------------------------------------------------------------------- fn run_ui(entries: Vec) { @@ -228,21 +194,13 @@ fn run_ui(entries: Vec) { bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette())); // Full-screen transparent overlay; panel widget is positioned inside it. - let window = ApplicationWindow::builder().application(app).build(); - window.init_layer_shell(); - window.set_namespace(Some("breadclip")); - window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::Exclusive); - for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { - window.set_anchor(edge, true); - } - window.set_exclusive_zone(0); + let window = bread_utils::gtk_popup::new_overlay_window(app, "breadclip"); // ---- Position panel relative to the active window ---- // If a non-fullscreen window is focused, anchor the panel just below it. // Otherwise, centre the panel on screen. - let active_win = position::get_active_window(); - let monitor = position::get_focused_monitor(); + let active_win = bread_utils::hypr::active_window(); + let monitor = bread_utils::hypr::focused_monitor(); let panel = GBox::new(Orientation::Vertical, 0); panel.add_css_class("clip-panel"); @@ -257,7 +215,7 @@ fn run_ui(entries: Vec) { // Clamp horizontally so the panel never runs off the left/right // edge of the focused monitor. let clamped_left = win - .x + .x() .min(mon_x + mon_w - PANEL_WIDTH - PANEL_GAP) .max(mon_x + PANEL_GAP); @@ -265,12 +223,12 @@ fn run_ui(entries: Vec) { // isn't enough room underneath (e.g. a maximized/tiled window // with a text box near the bottom of the screen) — otherwise the // panel gets pushed off-screen and never becomes visible. - let space_below = (mon_y + mon_h) - (win.y + win.height + PANEL_GAP); - let space_above = win.y - mon_y - PANEL_GAP; + let space_below = (mon_y + mon_h) - (win.y() + win.height() + PANEL_GAP); + let space_above = win.y() - mon_y - PANEL_GAP; let top = if space_below >= PANEL_HEIGHT_ESTIMATE || space_below >= space_above { - win.y + win.height + PANEL_GAP + win.y() + win.height() + PANEL_GAP } else { - win.y - PANEL_GAP - PANEL_HEIGHT_ESTIMATE + win.y() - PANEL_GAP - PANEL_HEIGHT_ESTIMATE }; let clamped_top = top .min(mon_y + mon_h - PANEL_HEIGHT_ESTIMATE - PANEL_GAP) @@ -337,7 +295,6 @@ fn run_ui(entries: Vec) { let close_all: Rc = Rc::new({ let w = window.clone(); move || { - cleanup_pid(); w.close(); } }); @@ -421,36 +378,11 @@ fn run_ui(entries: Vec) { glib::Propagation::Stop } Key::Down => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); - let mut i = cur + 1; - loop { - match list_k.row_at_index(i) { - Some(r) if r.is_visible() => { - list_k.select_row(Some(&r)); - break; - } - Some(_) => i += 1, - None => break, - } - } + bread_utils::gtk_popup::select_next_visible(&list_k); glib::Propagation::Stop } Key::Up => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); - let mut i = cur - 1; - loop { - if i < 0 { - break; - } - match list_k.row_at_index(i) { - Some(r) if r.is_visible() => { - list_k.select_row(Some(&r)); - break; - } - Some(_) => i -= 1, - None => break, - } - } + bread_utils::gtk_popup::select_prev_visible(&list_k); glib::Propagation::Stop } _ => glib::Propagation::Proceed, @@ -473,24 +405,9 @@ fn run_ui(entries: Vec) { // ---- Click outside panel → close (same pattern as breadbox) ---- { let close_outside = Rc::clone(&close_all); - let panel_ref = panel.clone(); - let win_ref = window.clone(); - let outside_click = gtk4::GestureClick::new(); - outside_click.connect_pressed(move |_, _, x, y| { - if let Some(b) = panel_ref.compute_bounds(&win_ref) { - let outside = x < b.x() as f64 - || x > (b.x() + b.width()) as f64 - || y < b.y() as f64 - || y > (b.y() + b.height()) as f64; - if outside { - close_outside(); - } - } - }); - window.add_controller(outside_click); + bread_utils::gtk_popup::close_on_outside_click(&window, &panel, move || close_outside()); } - window.connect_destroy(|_| cleanup_pid()); window.present(); search.grab_focus(); }); @@ -501,9 +418,17 @@ fn run_ui(entries: Vec) { // ---- Main ------------------------------------------------------------------- fn main() { - if !toggle_or_continue() { - return; - } + // Kept alive for the rest of `main` — dropping it releases the + // single-instance lock and removes the pid file, which happens + // naturally once `run_ui` returns (after the window closes). + let _singleton_guard = match bread_utils::singleton::toggle_or_kill("breadclip") { + Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), + Ok(bread_utils::singleton::Toggle::KilledExisting) => return, + Err(e) => { + eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it"); + None + } + }; let entries = HistoryDb::open() .and_then(|db| db.list_entries(MAX_ENTRIES)) diff --git a/breadclip/src/position.rs b/breadclip/src/position.rs deleted file mode 100644 index f0a2193..0000000 --- a/breadclip/src/position.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::{env, io::{Read, Write}, os::unix::net::UnixStream}; - -#[allow(dead_code)] -pub struct WindowInfo { - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, -} - -#[allow(dead_code)] -pub struct MonitorInfo { - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, -} - -/// Query Hyprland for the currently active (focused) window via its IPC socket. -/// Returns `None` if the window is fullscreen or no window is focused. -pub fn get_active_window() -> Option { - let json = hyprctl_json("j/activewindow")?; - let v: serde_json::Value = serde_json::from_str(&json).ok()?; - - // Fullscreen windows should cause centred fallback positioning - if v["fullscreen"].as_i64().unwrap_or(0) != 0 { - return None; - } - // "class" is empty when no window is focused - if v["class"].as_str().unwrap_or("").is_empty() { - return None; - } - - let x = v["at"][0].as_i64()? as i32; - let y = v["at"][1].as_i64()? as i32; - let w = v["size"][0].as_i64()? as i32; - let h = v["size"][1].as_i64()? as i32; - Some(WindowInfo { x, y, width: w, height: h }) -} - -/// Query Hyprland for the focused monitor's dimensions. -pub fn get_focused_monitor() -> Option { - let json = hyprctl_json("j/monitors")?; - let v: serde_json::Value = serde_json::from_str(&json).ok()?; - let monitors = v.as_array()?; - let m = monitors - .iter() - .find(|m| m["focused"].as_bool().unwrap_or(false)) - .or_else(|| monitors.first())?; - Some(MonitorInfo { - x: m["x"].as_i64()? as i32, - y: m["y"].as_i64()? as i32, - width: m["width"].as_i64()? as i32, - height: m["height"].as_i64()? as i32, - }) -} - -/// Send a request to Hyprland's IPC socket and return the response string. -fn hyprctl_json(request: &str) -> Option { - let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; - let rt = env::var("XDG_RUNTIME_DIR").ok()?; - let socket = format!("{}/hypr/{}/.socket.sock", rt, sig); - - let mut stream = UnixStream::connect(&socket).ok()?; - stream.write_all(request.as_bytes()).ok()?; - stream.shutdown(std::net::Shutdown::Write).ok()?; - - let mut buf = String::new(); - stream.read_to_string(&mut buf).ok()?; - Some(buf) -} From 7634af0b4f9ef1dc0aea997de7232eea6593670e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 03:42:05 +0800 Subject: [PATCH 06/35] Switch to tag-pinned bread-ecosystem deps; bump version to v0.2.0 --- Cargo.lock | 299 ++++++++++++++++++++++++--------- EVENTS.md | 53 ++++++ breadclip-core/Cargo.toml | 8 +- breadclip-core/src/lib.rs | 100 +++++++++-- breadclip/Cargo.toml | 5 +- breadclipd/Cargo.toml | 8 + breadclipd/src/content_kind.rs | 186 ++++++++++++++++++++ breadclipd/src/main.rs | 151 ++++++++++++++++- 8 files changed, 708 insertions(+), 102 deletions(-) create mode 100644 EVENTS.md create mode 100644 breadclipd/src/content_kind.rs diff --git a/Cargo.lock b/Cargo.lock index e06bdd7..314cc9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -35,6 +35,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" version = "0.2.3" @@ -48,8 +59,9 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.2.3" +version = "0.3.0" dependencies = [ + "bread-shared", "dirs", "gtk4", "gtk4-layer-shell", @@ -78,13 +90,17 @@ dependencies = [ "hex", "rusqlite", "sha2", + "tempfile", ] [[package]] name = "breadclipd" version = "0.1.0" dependencies = [ + "bread-utils", "breadclip-core", + "serde_json", + "tempfile", ] [[package]] @@ -112,9 +128,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -192,6 +208,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -204,6 +230,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "field-offset" version = "0.3.6" @@ -222,24 +254,24 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -248,15 +280,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -265,15 +297,15 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -309,9 +341,9 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -325,9 +357,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -362,10 +394,21 @@ dependencies = [ ] [[package]] -name = "gio" -version = "0.22.6" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gio" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" dependencies = [ "futures-channel", "futures-core", @@ -380,9 +423,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" dependencies = [ "glib-sys", "gobject-sys", @@ -413,9 +456,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.7" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ "bitflags", "futures-channel", @@ -446,9 +489,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", "system-deps", @@ -467,32 +510,30 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" dependencies = [ "glib", "graphene-sys", - "libc", ] [[package]] name = "graphene-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "pkg-config", "system-deps", ] [[package]] name = "gsk4" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" dependencies = [ "cairo-rs", "gdk4", @@ -505,9 +546,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -521,9 +562,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" dependencies = [ "cairo-rs", "field-offset", @@ -570,9 +611,9 @@ dependencies = [ [[package]] name = "gtk4-macros" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -582,9 +623,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -665,9 +706,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -683,6 +724,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.33" @@ -691,9 +738,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -718,13 +765,12 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "pango" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" dependencies = [ "gio", "glib", - "libc", "pango-sys", ] @@ -758,7 +804,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -779,13 +825,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] @@ -813,6 +865,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "semver" version = "1.0.28" @@ -862,6 +927,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -902,9 +976,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -920,7 +994,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -930,6 +1004,19 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -952,17 +1039,38 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -976,14 +1084,28 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", - "toml_datetime", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -992,14 +1114,20 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "typenum" @@ -1120,9 +1248,18 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "1.0.3" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -1135,18 +1272,18 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -1155,6 +1292,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..0a5e520 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,53 @@ +# breadclip — bread event integration + +breadclip is a standalone clipboard manager: it works exactly the same with +or without `breadd` running. When breadd *is* present, `breadclipd` publishes +events into the shared bread automation fabric and listens for a small set +of commands. See the parent `bread` repo's `Documentation.md` — specifically +its "Namespaces" and "Integrating a bread\* app" sections — for the general +convention this follows. + +App id: **`clip`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`) — `breadclipd` links it directly rather than +shelling out, since it's a long-running process for the command-subscription +half (though each `emit` call is still its own short-lived connection, since +`capture_once()` — the actual clipboard-read code — runs as a fresh +per-clipboard-change process invocation, not inside a persistent loop). + +## Events published (`bread.clip.*`) + +| Event | Data | When | +|-------|------|------| +| `bread.clip.copied` | `{ "kind": "url" \| "error" \| "code" \| "path" \| "plain", "len": }` | Every successful clipboard capture (text or image — images always get `kind: "image"`). `kind` is a heuristic classification (see `breadclipd/src/content_kind.rs`), not a guarantee — don't build a security decision on it. | +| `bread.clip.clear.done` | `{}` | `bread.command.clip.clear` was received and history was successfully cleared. | +| `bread.clip.clear.failed` | `{ "error": "" }` | `bread.command.clip.clear` was received but clearing failed (e.g. DB error). | + +Content is never included in the payload — only its detected kind and length. History (including the actual copied content) stays local to breadclip's own SQLite database; the event bus is for *notifications about* clipboard activity, not a channel for clipboard content itself. + +## Commands honored (`bread.command.clip.*`) + +| Verb | Effect | +|------|--------| +| `clear` | Deletes all clipboard history (text entries and stored image files). Emits `bread.clip.clear.done`/`.failed`. | + +### Not implemented: `pin` / `select` + +An earlier draft of this integration planned `pin`/`select` verbs, but +breadclip's history schema has no "pinned" concept at all today — there's no +column for it, and the GTK popup UI has no corresponding affordance. Adding +real pin/select support is a product decision for breadclip itself (does it +want pinning, and what should the UI look like?), not something to fabricate +as a side effect of wiring up the event bus. If/when breadclip grows that +feature, the corresponding `bread.command.clip.pin`/`.select` verbs (and +matching `bread.clip.pinned`/`.selected` events) should be added at the same +time, not stubbed out ahead of it. + +## Fail-safe behavior + +- If breadd isn't installed or isn't running, `emit` is a silent no-op + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything — breadclip's actual + clipboard-history functionality is entirely unaffected either way. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff loop); + no restart of breadclipd is needed. diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index df1bafd..35a194d 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -8,5 +8,9 @@ rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" dirs = "5" -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern -bread-utils = { path = "../../bread-ecosystem-fix-worktree/bread-utils" } +# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern. +# (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) +bread-utils = { path = "../../bread-ecosystem/bread-utils" } + +[dev-dependencies] +tempfile = "3" diff --git a/breadclip-core/src/lib.rs b/breadclip-core/src/lib.rs index 5afed9f..571891c 100644 --- a/breadclip-core/src/lib.rs +++ b/breadclip-core/src/lib.rs @@ -78,20 +78,42 @@ impl HistoryDb { "SELECT id, timestamp, mime_type, content, image_path, content_hash FROM history ORDER BY timestamp DESC LIMIT ?1", )?; - let rows = stmt.query_map([limit as i64], |row| { - Ok(ClipEntry { - id: row.get(0)?, - timestamp: row.get(1)?, - mime_type: row.get(2)?, - content: row.get(3)?, - image_path: row.get(4)?, - content_hash: row.get(5)?, - }) - })? - .collect::>>(); + let rows = stmt + .query_map([limit as i64], |row| { + Ok(ClipEntry { + id: row.get(0)?, + timestamp: row.get(1)?, + mime_type: row.get(2)?, + content: row.get(3)?, + image_path: row.get(4)?, + content_hash: row.get(5)?, + }) + })? + .collect::>>(); rows } + /// Deletes every history entry and every stored image file. Used by the + /// `bread.command.clip.clear` handler (see breadclipd's bread-client + /// subscription) as well as anything else that wants a hard reset of + /// clipboard history. + pub fn clear_all(&self) -> SqlResult<()> { + let image_paths: Vec = { + let mut stmt = self + .conn + .prepare("SELECT image_path FROM history WHERE image_path IS NOT NULL")?; + let paths = stmt + .query_map([], |row| row.get(0))? + .collect::>>()?; + paths + }; + for path in image_paths { + let _ = std::fs::remove_file(path); + } + self.conn.execute("DELETE FROM history", [])?; + Ok(()) + } + pub fn delete_entry(&self, id: i64) -> SqlResult<()> { // Clean up image file if present let image_path: Option = self @@ -106,7 +128,8 @@ impl HistoryDb { if let Some(p) = image_path { let _ = std::fs::remove_file(p); } - self.conn.execute("DELETE FROM history WHERE id = ?1", [id])?; + self.conn + .execute("DELETE FROM history WHERE id = ?1", [id])?; Ok(()) } @@ -131,7 +154,8 @@ impl HistoryDb { ORDER BY timestamp DESC LIMIT ?1 )", )?; - let paths = stmt.query_map([max_images as i64], |row| row.get(0))? + let paths = stmt + .query_map([max_images as i64], |row| row.get(0))? .collect::>>()?; paths }; @@ -184,3 +208,53 @@ fn unix_now() -> i64 { .unwrap_or_default() .as_secs() as i64 } + +#[cfg(test)] +mod tests { + use super::*; + + // `HistoryDb::open` resolves its path via `data_dir()`, which follows + // `$XDG_DATA_HOME` — redirecting it to a fresh temp dir per test keeps + // this isolated from a real `~/.local/share/breadclip` and from other + // tests. Safe without a lock: this is currently the only test in the + // crate that touches XDG_DATA_HOME. + fn open_test_db() -> (tempfile::TempDir, HistoryDb) { + let dir = tempfile::tempdir().expect("tempdir"); + std::env::set_var("XDG_DATA_HOME", dir.path()); + let db = HistoryDb::open().expect("open history db"); + (dir, db) + } + + #[test] + fn clear_all_removes_every_entry_and_image_file() { + let (_dir, db) = open_test_db(); + db.insert_text("first").unwrap(); + db.insert_text("second").unwrap(); + db.insert_image(b"not really a png, just bytes for the test") + .unwrap(); + + let before = db.list_entries(10).unwrap(); + assert_eq!(before.len(), 3); + let image_path = before + .iter() + .find_map(|e| e.image_path.clone()) + .expect("one entry should be the image"); + assert!(Path::new(&image_path).exists()); + + db.clear_all().unwrap(); + + let after = db.list_entries(10).unwrap(); + assert!(after.is_empty(), "expected no entries after clear_all"); + assert!( + !Path::new(&image_path).exists(), + "image file should be removed by clear_all" + ); + } + + #[test] + fn clear_all_on_empty_history_is_a_harmless_no_op() { + let (_dir, db) = open_test_db(); + db.clear_all().unwrap(); + assert!(db.list_entries(10).unwrap().is_empty()); + } +} diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index f023500..e93b2ee 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -10,8 +10,9 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern -bread-utils = { path = "../../bread-ecosystem-fix-worktree/bread-utils", features = ["gtk"] } +# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern. +# (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) +bread-utils = { path = "../../bread-ecosystem/bread-utils", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index 7c9035c..c233d0e 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -9,3 +9,11 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } +# TODO(owner): switch to a tag-pinned git dependency once bread-shared is +# released/tagged for external consumption, matching the bread-theme pattern — +# see the same stopgap already in place for bread-utils's own dependents. +bread-utils = { path = "../../bread-ecosystem/bread-utils", features = ["bread-client"] } +serde_json = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/breadclipd/src/content_kind.rs b/breadclipd/src/content_kind.rs new file mode 100644 index 0000000..fd27a98 --- /dev/null +++ b/breadclipd/src/content_kind.rs @@ -0,0 +1,186 @@ +//! Heuristic classification of clipboard text, used to tag the +//! `bread.clip.copied` event's `kind` field (see `bread-shared`'s namespace +//! docs). Deliberately simple pattern matching, not a real parser or ML +//! classifier — good enough to auto-tag "you just copied a stack trace" or +//! "you just copied a URL" for a Lua automation module to react to, not a +//! source of truth anyone should build a security decision on. + +/// Checked in this order because a false-not-`error` or false-not-`url` is +/// cheap to live with, while classifying a URL as generic `code` because it +/// contains a `/` would be a worse user-facing miss. +pub fn detect(text: &str) -> &'static str { + let trimmed = text.trim(); + if trimmed.is_empty() { + return "plain"; + } + if looks_like_error(trimmed) { + "error" + } else if looks_like_url(trimmed) { + "url" + } else if looks_like_path(trimmed) { + "path" + } else if looks_like_code(trimmed) { + "code" + } else { + "plain" + } +} + +fn looks_like_url(text: &str) -> bool { + // Single "word" (no internal whitespace) is required — a sentence that + // merely contains a URL should classify as plain/error/code on its own + // merits, not get tagged "url" just because a link appears in it. + if text.split_whitespace().count() != 1 { + return false; + } + let lower = text.to_ascii_lowercase(); + lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("ftp://") + || lower.starts_with("file://") + || lower.starts_with("mailto:") +} + +fn looks_like_error(text: &str) -> bool { + const MARKERS: [&str; 8] = [ + "traceback (most recent call last)", + "panicked at", + "exception in thread", + "unhandled exception", + "stack trace:", + "at java.", + "uncaught exception", + "fatal error:", + ]; + let lower = text.to_ascii_lowercase(); + if MARKERS.iter().any(|m| lower.contains(m)) { + return true; + } + // A line starting "Error:"/"error:" combined with more than one line is + // a common shape for compiler/runtime error output; a single bare line + // like "error: 42" out of context is more likely just plain text. + let first_line = lower.lines().next().unwrap_or(""); + text.lines().count() > 1 + && (first_line.starts_with("error:") || first_line.starts_with("error[")) +} + +fn looks_like_path(text: &str) -> bool { + if text.split_whitespace().count() != 1 { + return false; + } + let has_extension_or_depth = text + .rsplit('/') + .next() + .map(|s| s.contains('.')) + .unwrap_or(false) + || text.matches('/').count() >= 2; + (text.starts_with('/') || text.starts_with("~/") || text.starts_with("./")) + && has_extension_or_depth +} + +fn looks_like_code(text: &str) -> bool { + const CODE_TOKENS: [&str; 14] = [ + "fn ", + "function ", + "def ", + "class ", + "const ", + "let ", + "import ", + "#include", + "public ", + "private ", + "=> {", + "};", + "<", + ]; + let lines: Vec<&str> = text.lines().collect(); + let token_hits = CODE_TOKENS.iter().filter(|t| text.contains(*t)).count(); + let brace_or_semicolon_lines = lines + .iter() + .filter(|l| { + let t = l.trim_end(); + t.ends_with(';') || t.ends_with('{') || t.ends_with('}') + }) + .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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_url() { + assert_eq!(detect("https://example.com/path?query=1"), "url"); + assert_eq!(detect("http://localhost:8080"), "url"); + assert_eq!(detect(" https://example.com "), "url"); + } + + #[test] + fn does_not_tag_sentence_containing_a_url_as_url() { + assert_eq!(detect("check out https://example.com later"), "plain"); + } + + #[test] + fn detects_python_traceback() { + let text = "Traceback (most recent call last):\n File \"x.py\", line 1\nValueError: bad"; + assert_eq!(detect(text), "error"); + } + + #[test] + fn detects_rust_panic() { + assert_eq!( + detect("thread 'main' panicked at src/main.rs:10:5:\nindex out of bounds"), + "error" + ); + } + + #[test] + fn detects_multiline_error_prefixed_output() { + assert_eq!(detect("error: expected `;`\n --> src/main.rs:2:1"), "error"); + } + + #[test] + fn single_line_error_word_without_context_is_plain() { + assert_eq!(detect("error: something"), "plain"); + } + + #[test] + fn detects_absolute_path() { + assert_eq!(detect("/home/user/.config/bread/init.lua"), "path"); + assert_eq!(detect("~/Projects/bread/README.md"), "path"); + } + + #[test] + fn detects_multiline_code() { + let text = "fn main() {\n let x = 1;\n println!(\"{}\", x);\n}"; + assert_eq!(detect(text), "code"); + } + + #[test] + fn detects_single_line_import() { + assert_eq!(detect("import numpy as np"), "code"); + } + + #[test] + fn plain_prose_is_plain() { + assert_eq!( + detect("just a normal sentence someone copied from an email"), + "plain" + ); + } + + #[test] + fn empty_or_whitespace_is_plain() { + assert_eq!(detect(""), "plain"); + assert_eq!(detect(" \n "), "plain"); + } +} diff --git a/breadclipd/src/main.rs b/breadclipd/src/main.rs index 8afbe4d..012c6f1 100644 --- a/breadclipd/src/main.rs +++ b/breadclipd/src/main.rs @@ -1,6 +1,14 @@ +mod content_kind; + +use bread_utils::bread_client::BreadClient; use breadclip_core::HistoryDb; use std::{env, fs, path::PathBuf, 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 +/// `bread.clip.*`, commands are received on `bread.command.clip.*`. +const APP_ID: &str = "clip"; + /// Argument used to invoke this same binary as the one-shot handler for /// `wl-paste --watch`. Kept as a plain positional arg (no clap dependency /// needed for a single internal flag). @@ -99,19 +107,69 @@ fn capture_once() { if has_image && !has_text { if let Some(bytes) = get_clipboard_image() { - if let Err(e) = db.insert_image(&bytes) { - eprintln!("breadclipd: insert image: {e}"); + match db.insert_image(&bytes) { + Ok(()) => emit_copied("image", bytes.len()), + Err(e) => eprintln!("breadclipd: insert image: {e}"), } } } else if has_text { if let Some(text) = get_clipboard_text() { - if let Err(e) = db.insert_text(&text) { - eprintln!("breadclipd: insert text: {e}"); + match db.insert_text(&text) { + Ok(()) => emit_copied(content_kind::detect(&text), text.len()), + Err(e) => eprintln!("breadclipd: insert text: {e}"), } } } } +/// 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 +/// breadclip's own clipboard-history functionality, only mean this one +/// notification doesn't go anywhere. +fn emit_copied(kind: &str, len: usize) { + BreadClient::connect(APP_ID).emit( + "bread.clip.copied", + serde_json::json!({ "kind": kind, "len": len }), + ); +} + +/// 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 +/// `breadclip/EVENTS.md` for the honest current status. +/// +/// Emits `bread.clip..done`/`.failed` per the confirmation convention +/// in bread's Documentation.md — a module that started this command via +/// `bread.wait`/`bread.wait_any` can await the real outcome instead of +/// assuming success the moment it publishes the command. +fn handle_command(event_name: &str) { + let Some(verb) = event_name.strip_prefix("bread.command.clip.") else { + return; + }; + match verb { + "clear" => match HistoryDb::open().and_then(|db| db.clear_all()) { + Ok(()) => { + eprintln!("breadclipd: cleared history via bread.command.clip.clear"); + BreadClient::connect(APP_ID).emit("bread.clip.clear.done", serde_json::json!({})); + } + Err(e) => { + eprintln!("breadclipd: bread.command.clip.clear failed: {e}"); + BreadClient::connect(APP_ID).emit( + "bread.clip.clear.failed", + serde_json::json!({ "error": e.to_string() }), + ); + } + }, + other => { + eprintln!("breadclipd: ignoring unrecognized command verb '{other}'"); + } + } +} + fn main() { let args: Vec = env::args().collect(); if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) { @@ -153,6 +211,17 @@ fn main() { eprintln!("breadclipd: started (pid {})", std::process::id()); + // Long-lived, so this uses BreadClient::subscribe (a persistent + // background thread with its own reconnect/backoff loop) rather than a + // one-shot connection — the same client type capture_once() uses for + // `emit`, just the other half of it. breadd being absent or restarting + // is transparent here too: the subscription just quietly stops + // 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); + }); + // `wl-paste --watch ` runs once per clipboard-change event // instead of polling — zero idle cost between changes, and no more // forking wl-paste 4-6 times a second. Each event re-invokes this same @@ -175,3 +244,77 @@ fn main() { thread::sleep(Duration::from_secs(2)); } } + +#[cfg(test)] +mod tests { + use super::*; + + // Isolates HistoryDb::open() to a fresh temp dir per test. `cargo test` + // runs tests in parallel threads within one process by default, and + // `XDG_DATA_HOME` is process-global — three tests in this module all + // set it, so without this lock they race each other (confirmed: an + // earlier version of this file had that exact bug, caught by a flaky + // `len() == 2` instead of `1` failure). + fn env_test_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + } + + fn with_isolated_history(f: F) { + let _guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempfile::tempdir().expect("tempdir"); + std::env::set_var("XDG_DATA_HOME", dir.path()); + f(); + } + + #[test] + fn handle_command_clear_empties_history_even_with_no_daemon_reachable() { + // The point of this test: handle_command's actual effect (clearing + // the DB) must not depend on breadd being reachable — `emit` inside + // it is fire-and-forget and must never gate the real work. No test + // daemon is running here, so if clearing the DB were accidentally + // 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(); + assert_eq!(db.list_entries(10).unwrap().len(), 1); + + handle_command("bread.command.clip.clear"); + + let db = HistoryDb::open().expect("reopen history db"); + assert!(db.list_entries(10).unwrap().is_empty()); + }); + } + + #[test] + fn handle_command_ignores_unrecognized_verb() { + with_isolated_history(|| { + let db = HistoryDb::open().expect("open history db"); + db.insert_text("should survive").unwrap(); + + handle_command("bread.command.clip.pin"); + + let db = HistoryDb::open().expect("reopen history db"); + assert_eq!( + db.list_entries(10).unwrap().len(), + 1, + "an unrecognized verb must not touch history" + ); + }); + } + + #[test] + 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(); + + // Not a `bread.command.clip.*` event at all — must be a no-op. + handle_command("bread.command.pad.clear"); + handle_command("bread.clip.copied"); + + let db = HistoryDb::open().expect("reopen history db"); + assert_eq!(db.list_entries(10).unwrap().len(), 1); + }); + } +} From 4c21cc3f71b2f30a69cebf56d9449dd8517e6960 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 03:52:27 +0800 Subject: [PATCH 07/35] Switch to tag-pinned bread-ecosystem deps; bump version to v0.2.0 --- Cargo.lock | 6 +++--- breadclip-core/Cargo.toml | 2 +- breadclip/Cargo.toml | 2 +- breadclipd/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 314cc9d..f54e58f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ dependencies = [ [[package]] name = "breadclip" -version = "0.1.1" +version = "0.2.0" dependencies = [ "bread-theme", "bread-utils", @@ -83,7 +83,7 @@ dependencies = [ [[package]] name = "breadclip-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bread-utils", "dirs", @@ -95,7 +95,7 @@ dependencies = [ [[package]] name = "breadclipd" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bread-utils", "breadclip-core", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 35a194d..3faf397 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip-core" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index e93b2ee..1a71784 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip" -version = "0.1.1" +version = "0.2.0" edition = "2021" [[bin]] diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index c233d0e..0b7b41f 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclipd" -version = "0.1.0" +version = "0.2.0" edition = "2021" [[bin]] From 83b1fd7c2304867598363af7a0b862e3f6257e7f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 04:02:53 +0800 Subject: [PATCH 08/35] Fix unswapped bread-utils path dep and stale bread-theme URL; bump to v0.2.1 --- Cargo.lock | 26 ++++++++++++++++++-------- breadclip-core/Cargo.toml | 5 ++--- breadclip/Cargo.toml | 7 +++---- breadclipd/Cargo.toml | 2 +- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f54e58f..8b5c730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,8 +48,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" +version = "0.3.0" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" dependencies = [ "dirs", "gtk4", @@ -62,6 +62,16 @@ name = "bread-utils" version = "0.3.0" dependencies = [ "bread-shared", + "dirs", + "serde", + "serde_json", +] + +[[package]] +name = "bread-utils" +version = "0.3.0" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +dependencies = [ "dirs", "gtk4", "gtk4-layer-shell", @@ -71,10 +81,10 @@ dependencies = [ [[package]] name = "breadclip" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bread-theme", - "bread-utils", + "bread-utils 0.3.0 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0)", "breadclip-core", "gtk4", "gtk4-layer-shell", @@ -83,9 +93,9 @@ dependencies = [ [[package]] name = "breadclip-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ - "bread-utils", + "bread-utils 0.3.0 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0)", "dirs", "hex", "rusqlite", @@ -95,9 +105,9 @@ dependencies = [ [[package]] name = "breadclipd" -version = "0.2.0" +version = "0.2.1" dependencies = [ - "bread-utils", + "bread-utils 0.3.0", "breadclip-core", "serde_json", "tempfile", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 3faf397..fabdf67 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip-core" -version = "0.2.0" +version = "0.2.1" edition = "2021" [dependencies] @@ -8,9 +8,8 @@ rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" dirs = "5" -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern. # (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) -bread-utils = { path = "../../bread-ecosystem/bread-utils" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } [dev-dependencies] tempfile = "3" diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 1a71784..032d7d0 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip" -version = "0.2.0" +version = "0.2.1" edition = "2021" [[bin]] @@ -9,10 +9,9 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern. +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } # (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) -bread-utils = { path = "../../bread-ecosystem/bread-utils", features = ["gtk"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index 0b7b41f..b7158d4 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclipd" -version = "0.2.0" +version = "0.2.1" edition = "2021" [[bin]] From 5a60a769015597c7c5ef2f5f0bc7bf87db6f95dc Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:19:33 +0800 Subject: [PATCH 09/35] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 0f8916b..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadclip.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 34a96b78e02686ae0e7bd593ac038fa4a616c037 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:56:49 +0800 Subject: [PATCH 10/35] ci: add dev/beta build track workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dev-release.yml (publishes on every push to dev) and beta-release.yml (publishes on a beta-v* tag), mirroring the pattern landing in bread-ecosystem/bread. Also creates the dev branch for this repo, which didn't exist before — see bread-ecosystem/docs/release-channels.md for the three-track policy. --- .forgejo/workflows/beta-release.yml | 56 +++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 65 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 .forgejo/workflows/beta-release.yml create mode 100644 .forgejo/workflows/dev-release.yml diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml new file mode 100644 index 0000000..e1aaac6 --- /dev/null +++ b/.forgejo/workflows/beta-release.yml @@ -0,0 +1,56 @@ +name: beta release + +# Publishes a beta-track build when a `beta-v*` tag is pushed — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/breadclip/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadclip breadclipd; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/contrib/breadclipd.service "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadclip/latest" + + # No GitHub Release upload — beta, like the other non-stable track, + # is only distributed via dl.breadway.dev/beta/. + - name: regenerate beta index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..4161a6c --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,65 @@ +name: dev release + +# Publishes a dev-track build on every push to `dev` — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + branches: ['dev'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch dev --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadclip/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadclip breadclipd; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/contrib/breadclipd.service "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadclip/latest" + + # No GitHub Release upload — dev, like the other non-stable track, + # is only distributed via dl.breadway.dev/dev/. + - name: regenerate dev index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 06ab15c2c0409fd4552024c8ced4669a16701a6b Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:10:20 +0800 Subject: [PATCH 11/35] ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set From a35cd6f36320ce0f2c5cdfc9482c4158195762cd Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:24:49 +0800 Subject: [PATCH 12/35] ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta workflows run close together on the same self-hosted runner — one job's rm -rf/clone can stomp another's in-progress checkout, causing the regenerate-index step to fail intermittently. Switch to mktemp -d. --- .forgejo/workflows/beta-release.yml | 12 +++++++----- .forgejo/workflows/dev-release.yml | 14 ++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index e1aaac6..08c48a2 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -49,8 +49,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 4161a6c..d0fb4d2 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -26,7 +26,7 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + CUR="$(grep -m1 '^version' breadclip/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" TS="$(date -u +%Y%m%d%H%M%S)" @@ -58,8 +58,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" From 58aa415ef066b9a84d96db28a7bad0392033af69 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:37:38 +0800 Subject: [PATCH 13/35] random commit message, read it yourself --- .gitignore | 3 +++ Cargo.lock | 27 ++++++++++++++------------- breadclipd/Cargo.toml | 5 +---- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 36f7f5b..8d2803a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ logs/ # Runtime files *.sock *.pid + +# Local hygiene notes (not for commit) +CLAUDE.md diff --git a/Cargo.lock b/Cargo.lock index 8b5c730..e1ab23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,16 +57,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-utils" -version = "0.3.0" -dependencies = [ - "bread-shared", - "dirs", - "serde", - "serde_json", -] - [[package]] name = "bread-utils" version = "0.3.0" @@ -79,12 +69,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.3.1#157ed6e3782ac2facd8b517d7c247713f36854ed" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadclip" version = "0.2.1" dependencies = [ "bread-theme", - "bread-utils 0.3.0 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0)", + "bread-utils 0.3.0", "breadclip-core", "gtk4", "gtk4-layer-shell", @@ -95,7 +96,7 @@ dependencies = [ name = "breadclip-core" version = "0.2.1" dependencies = [ - "bread-utils 0.3.0 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0)", + "bread-utils 0.3.0", "dirs", "hex", "rusqlite", @@ -107,7 +108,7 @@ dependencies = [ name = "breadclipd" version = "0.2.1" dependencies = [ - "bread-utils 0.3.0", + "bread-utils 0.3.1", "breadclip-core", "serde_json", "tempfile", diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index b7158d4..b384b08 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -9,10 +9,7 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -# TODO(owner): switch to a tag-pinned git dependency once bread-shared is -# released/tagged for external consumption, matching the bread-theme pattern — -# see the same stopgap already in place for bread-utils's own dependents. -bread-utils = { path = "../../bread-ecosystem/bread-utils", features = ["bread-client"] } +bread-utils = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.3.1", features = ["bread-client"] } serde_json = "1" [dev-dependencies] From eab00c0712177866d824ba62e4d10072492fdae5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 13:53:07 +0800 Subject: [PATCH 14/35] ci: base dev version on the latest published tag, not Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml can drift stale relative to the actual last release (observed on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped dev version sort as OLDER than what's already installed — bakery's semver check correctly refused those "updates". Deriving the base version from git ls-remote --tags instead is self-healing regardless of Cargo.toml drift, with a Cargo.toml fallback only for a repo with no tags yet. --- .forgejo/workflows/dev-release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index d0fb4d2..2a0a1e9 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -26,7 +26,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' breadclip/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' breadclip/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" TS="$(date -u +%Y%m%d%H%M%S)" From 2e999546429d78e958348391daadeea6957d1afe Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:37:48 +0800 Subject: [PATCH 15/35] ci: make beta a branch-triggered freeze track, not a one-off tag Beta is now a real stabilization branch: publishes on every push to `beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.+, base version from the latest published tag) instead of a manual beta-v* tag. Fixes made during the freeze land via fix/ branches merged into `beta` directly. The gen-index.sh clone for beta pulls bread-ecosystem's default branch (main) rather than pinning to dev, since beta is the more stable track and main now carries the TRACK-aware script. --- .forgejo/workflows/beta-release.yml | 41 ++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 08c48a2..cabc105 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -1,12 +1,12 @@ name: beta release -# Publishes a beta-track build when a `beta-v*` tag is pushed — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See bread-ecosystem's docs/release-channels.md for the three-track policy. on: push: - tags: ['beta-v*'] + branches: ['beta'] jobs: build: @@ -16,16 +16,37 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' breadclip/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/breadclip/${VERSION}" mkdir -p "${PKG_DIR}" for bin in breadclip breadclipd; do @@ -38,8 +59,8 @@ jobs: cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadclip/latest" - # No GitHub Release upload — beta, like the other non-stable track, - # is only distributed via dl.breadway.dev/beta/. + # No GitHub Release upload — beta, like dev, is only distributed via + # dl.breadway.dev/beta/. - name: regenerate beta index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} @@ -53,6 +74,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" From b95e4c946647232813734d8f30b9fd743003d8cc Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:42:03 +0800 Subject: [PATCH 16/35] docs: add CONTRIBUTING.md Documents the dev/beta/main branch and release-track workflow shared across the bread ecosystem. See bread-ecosystem's docs/release-channels.md for the full policy this implements. --- CONTRIBUTING.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..49095a5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +# Contributing + +`breadclip` — Wayland clipboard history manager for Hyprland (daemon + GTK4 popup). + +Part of the bread ecosystem; this repo follows the same branch/release +workflow as every other ecosystem product. + +## Branches + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +cargo build --release --workspace +cargo test --release --workspace +``` + +## CI + +- `dev-release.yml` — triggered on push to `dev`. +- `beta-release.yml` — triggered on push to `beta`. +- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) +for the full policy, including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. From b125facc6791af3e9689bf64154527c16e0a5acb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:37:01 +0800 Subject: [PATCH 17/35] breadclip: add --screenshot CLI mode for automated capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as breadbar/breadbox: render the clipboard-history panel, capture it via bread-screenshots, then exit. One view ("history"), full known-size canvas capture since the panel isn't its own layer surface. active_window()/focused_monitor() (used to anchor the panel next to the real focused window) both resolve to None inside bread-capture's isolated environment (HYPRLAND_INSTANCE_SIGNATURE is deliberately unset there), so the panel falls back to its already-existing centered layout — exactly the deterministic behavior a screenshot needs, with no screenshot-mode- specific positioning logic required. Also fixes the same singleton footgun as breadbox: toggle_or_kill() kills whatever's holding breadclip's single-instance lock, which is typically the real running instance. A screenshot run now skips it entirely instead of fighting over (and killing) the operator's real clipboard panel. --- Cargo.lock | 205 ++++++++++++++++++++++++++++++++++-- breadclip/Cargo.toml | 5 + breadclip/src/main.rs | 57 +++++++--- breadclip/src/screenshot.rs | 100 ++++++++++++++++++ 4 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 breadclip/src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index e1ab23b..925c25d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,62 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "autocfg" version = "1.5.1" @@ -35,6 +91,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-screenshots" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" +dependencies = [ + "anyhow", + "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev)", + "tracing", +] + [[package]] name = "bread-shared" version = "0.7.0" @@ -80,13 +146,26 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" +dependencies = [ + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadclip" version = "0.2.1" dependencies = [ + "anyhow", + "bread-screenshots", "bread-theme", "bread-utils 0.3.0", "breadclip-core", + "clap", "gtk4", "gtk4-layer-shell", "serde_json", @@ -108,7 +187,7 @@ dependencies = [ name = "breadclipd" version = "0.2.1" dependencies = [ - "bread-utils 0.3.1", + "bread-utils 0.3.1 (git+https://github.com/Breadway/bread-ecosystem?tag=v0.3.1)", "breadclip-core", "serde_json", "tempfile", @@ -163,6 +242,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -303,7 +428,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -495,7 +620,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -629,7 +754,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -697,6 +822,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -768,6 +899,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -922,7 +1059,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -985,6 +1122,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -996,6 +1139,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -1045,7 +1199,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1140,6 +1294,37 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1152,6 +1337,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1298,7 +1489,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 032d7d0..258e60a 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -12,6 +12,11 @@ breadclip-core = { path = "../breadclip-core" } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } # (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. Not +# tag-pinned like the deps above since it doesn't have a tagged release yet. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" +clap = { version = "4", features = ["derive"] } +anyhow = "1" diff --git a/breadclip/src/main.rs b/breadclip/src/main.rs index 282b4a6..05b0dc8 100644 --- a/breadclip/src/main.rs +++ b/breadclip/src/main.rs @@ -1,4 +1,5 @@ mod css; +mod screenshot; use breadclip_core::{ClipEntry, HistoryDb}; use bread_theme::{load_palette}; @@ -184,10 +185,17 @@ fn do_copy(entry: &ClipEntry) { // ---- UI --------------------------------------------------------------------- -fn run_ui(entries: Vec) { - let app = Application::builder() - .application_id("com.breadway.breadclip") - .build(); +fn run_ui(entries: Vec, screenshot_req: Option) { + let mut builder = Application::builder().application_id("com.breadway.breadclip"); + if screenshot_req.is_some() { + // GApplication is single-instance by default; this machine typically + // already has a real breadclip instance, so without this a + // screenshot run would just message the *existing* instance instead + // of starting a fresh one that ever sees `screenshot_req`. + builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE); + } + let app = builder.build(); + let is_screenshot_run = screenshot_req.is_some(); app.connect_activate(move |app| { bread_theme::gtk::apply_shared(); @@ -408,25 +416,50 @@ fn run_ui(entries: Vec) { bread_utils::gtk_popup::close_on_outside_click(&window, &panel, move || close_outside()); } + if let Some(req) = screenshot_req.clone() { + screenshot::dispatch(&window, req); + } + window.present(); search.grab_focus(); }); - app.run(); + if is_screenshot_run { + // GLib's own option parser otherwise rejects --screenshot/--output + // before clap ever sees them (`Cli::parse()` already ran in `main`, + // over the real argv). + app.run_with_args(&[] as &[&str]); + } else { + app.run(); + } } // ---- Main ------------------------------------------------------------------- fn main() { + use clap::Parser; + let cli = screenshot::Cli::parse(); + let screenshot_req = cli.screenshot_request(); + + // `toggle_or_kill` kills whatever's holding the single-instance lock — + // a real, already-running breadclip included. A screenshot run must + // never touch it: it's a separate, disposable instance by design (same + // reasoning as breadbar's `allow_multiple_instances`), not a toggle of + // the operator's real clipboard panel. + // // Kept alive for the rest of `main` — dropping it releases the // single-instance lock and removes the pid file, which happens // naturally once `run_ui` returns (after the window closes). - let _singleton_guard = match bread_utils::singleton::toggle_or_kill("breadclip") { - Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), - Ok(bread_utils::singleton::Toggle::KilledExisting) => return, - Err(e) => { - eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it"); - None + let _singleton_guard = if screenshot_req.is_some() { + None + } else { + match bread_utils::singleton::toggle_or_kill("breadclip") { + Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), + Ok(bread_utils::singleton::Toggle::KilledExisting) => return, + Err(e) => { + eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it"); + None + } } }; @@ -434,5 +467,5 @@ fn main() { .and_then(|db| db.list_entries(MAX_ENTRIES)) .unwrap_or_default(); - run_ui(entries); + run_ui(entries, screenshot_req); } diff --git a/breadclip/src/screenshot.rs b/breadclip/src/screenshot.rs new file mode 100644 index 0000000..b4fd8fa --- /dev/null +++ b/breadclip/src/screenshot.rs @@ -0,0 +1,100 @@ +//! `--screenshot` CLI mode: render breadclip's clipboard-history panel, +//! capture it via `bread-screenshots`, then exit — driven by +//! `bread-ecosystem`'s `bread-capture` orchestrator, or run standalone for +//! one-off captures. +//! +//! breadclip has one view worth capturing: the history panel itself. Like +//! breadbox, it's a `halign`/`valign`-positioned panel over a full-screen +//! transparent overlay rather than its own layer surface, so a full +//! known-size canvas capture is the simplest reliable option. In screenshot +//! mode there's never a focused window to anchor the panel next to (see +//! `bread_utils::hypr::active_window` in `main.rs`, which the isolated +//! capture environment always reports `None` for — deliberately, see +//! `bread-capture`'s isolation module), so the panel always falls back to +//! centered, which is exactly what we want for a consistent screenshot. + +use clap::Parser; +use gtk4::prelude::*; +use std::path::PathBuf; +use std::time::Duration; + +/// Extra settle time after `map` for the first frame to actually paint +/// before grim runs — `map` fires once the surface exists, not once +/// anything has been drawn into it. +const SETTLE_DELAY: Duration = Duration::from_millis(300); + +#[derive(Parser)] +#[command(name = "breadclip")] +pub struct Cli { + /// Render the named view, capture it, then exit instead of running + /// normally. Known views: "history". + #[arg(long)] + pub screenshot: Option, + + /// PNG path to write the capture to. Required together with --screenshot. + #[arg(long)] + pub output: Option, + + /// Capture canvas width — matches the isolated compositor's output width + /// (`bread-capture --isolate-width`). + #[arg(long, default_value_t = 1920)] + pub width: u32, + + /// Capture canvas height — see `width`. + #[arg(long, default_value_t = 1080)] + pub height: u32, +} + +#[derive(Clone)] +pub struct ScreenshotRequest { + pub view: String, + pub output: PathBuf, + pub width: u32, + pub height: u32, +} + +impl Cli { + /// `None` for a normal run. Exits the process with an error if + /// `--screenshot` was given without `--output`, before any GTK setup + /// happens. + pub fn screenshot_request(&self) -> Option { + let view = self.screenshot.clone()?; + let Some(output) = self.output.clone() else { + eprintln!("breadclip: --screenshot requires --output"); + std::process::exit(1); + }; + Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } +} + +/// Wire up the given view's screenshot sequence against an already-built, +/// not-yet-presented window. Every path here ends by exiting the process — +/// it never returns control to the normal history-panel UI. +pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) { + match req.view.as_str() { + "history" => { + let output = req.output; + let (width, height) = (req.width as i32, req.height as i32); + window.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + }); + }); + } + other => { + eprintln!("breadclip: unknown screenshot view '{other}' (known: history)"); + std::process::exit(1); + } + } +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadclip: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +} From 5a4453c767d84299af6455bb7a718d3dffdc9e60 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:05:29 +0800 Subject: [PATCH 18/35] =?UTF-8?q?CI:=20single-trunk=20model=20=E2=80=94=20?= =?UTF-8?q?dev=20triggers=20on=20main,=20beta=20becomes=20RC-tag-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev/beta branch split with one trunk (main): dev-track builds still publish on every push, but the beta track now publishes from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained beta branch. Removes the branch nobody reliably kept in sync. --- .forgejo/workflows/dev-release.yml | 15 ++++---- .../{beta-release.yml => rc-release.yml} | 38 +++++-------------- .forgejo/workflows/release.yml | 1 + 3 files changed, 17 insertions(+), 37 deletions(-) rename .forgejo/workflows/{beta-release.yml => rc-release.yml} (58%) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 2a0a1e9..fd4f4df 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -1,12 +1,11 @@ name: dev release -# Publishes a dev-track build on every push to `dev` — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a dev-track build on every push to `main` (the trunk +# branch — there is no separate `dev` branch). See bread-ecosystem's +# docs/release-channels.md for the release-track policy this is part of. on: push: - branches: ['dev'] + branches: ['main'] jobs: build: @@ -16,7 +15,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -33,7 +32,7 @@ jobs: # what's already installed and bakery would correctly refuse it. LATEST_TAG="$(git ls-remote --tags --refs \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else @@ -74,6 +73,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/rc-release.yml similarity index 58% rename from .forgejo/workflows/beta-release.yml rename to .forgejo/workflows/rc-release.yml index cabc105..d697a76 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -1,52 +1,32 @@ -name: beta release +name: beta (rc) release -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See bread-ecosystem's docs/release-channels.md for the three-track policy. +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag +# pushed to `main` — there is no separate `beta` branch; "freezing" is +# just pausing pushes to main while an RC gets tested. See +# bread-ecosystem's docs/release-channels.md for the release-track policy. on: push: - branches: ['beta'] + tags: ['v*'] jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a beta build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' breadclip/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - name: prepare artifacts run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/beta/breadclip/${VERSION}" mkdir -p "${PKG_DIR}" for bin in breadclip breadclipd; do diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3451346..803be86 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout From 2c03e73da587175a30f53d0248f38783dc10372f Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:08:41 +0800 Subject: [PATCH 19/35] CONTRIBUTING.md: document single-trunk + RC-tag release model --- CONTRIBUTING.md | 70 ++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49095a5..98faf13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,10 @@ workflow as every other ecosystem product. ## Branches -- **`main`** — release branch, always tag-ready. Nothing is committed to it - directly; it only moves forward via a `beta` merge (see below). -- **`dev`** — integration branch. All day-to-day work lands here first. - Every push to `dev` automatically builds and publishes a **dev-track** - build (see Tracks below) — use this to test your change in a real install - before it goes any further. -- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. - Every push to `beta` automatically builds and publishes a **beta-track** - build. While a freeze is active, only fixes for issues found *in that - freeze* should land on `beta`. +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build (see Tracks below) — a real install you can test before cutting +anything more formal. New work — features and bug fixes alike — goes on a short-lived branch: @@ -25,28 +19,26 @@ feature/ fix/ ``` -Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing -something reported against an active `beta` freeze, branch off `beta` -instead, merge the fix there to unblock testers, and also forward the same -fix into `dev` so it doesn't quietly reappear next cycle. +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. ## The release cycle -1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then report or fix anything broken with another - push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut - fresh from `dev`'s current tip. This freezes it as the stabilization - target — `dev` keeps moving independently starting the next cycle. -3. `beta` is open for anyone to test: `bakery track set beta` and - `bakery update --all`. **File issues against anything you find on this - repo's Forgejo issue tracker.** Fixes land via `fix/` branches - merged into `beta`. -4. Once `beta` has gone roughly **a month** without new issues, it's merged - into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the - stable release build. `beta` is then reset from `dev` to start the next - cycle. + `bakery update --all`, then fix anything broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. ## Tracks, from a user's perspective @@ -58,14 +50,15 @@ bakery update --all # pull the latest build on your current track | Track | What it is | Published from | |--------|-----------|-----------------| -| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | -| `beta` | Current stabilization freeze | `beta`, on every push | -| `dev` | Bleeding edge | `dev`, on every push | +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | -Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / -`-beta.…`) from the latest published stable tag, so they always sort as -newer than what you have installed — no manual version bumping needed when -pushing to `dev` or `beta`. +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). ## Local development @@ -76,9 +69,10 @@ cargo test --release --workspace ## CI -- `dev-release.yml` — triggered on push to `dev`. -- `beta-release.yml` — triggered on push to `beta`. -- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. +- `dev-release.yml` — triggered on push to `main`. +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggered on any other `v*` tag push, cuts the actual + stable release. All CI runs on a self-hosted runner; nothing runs automatically on plain commits or PRs beyond the track builds above. See From 555ae5b0d1b332fb2108eeacb0e9f8c338ae4f7e Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 14:30:01 +0800 Subject: [PATCH 20/35] Repoint bread-ecosystem git deps from dev to main branch --- Cargo.lock | 6 +++--- breadclip/Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 925c25d..fbda1c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,10 +94,10 @@ dependencies = [ [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "anyhow", - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev)", + "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main)", "tracing", ] @@ -149,7 +149,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "dirs", "serde", diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 258e60a..17edafd 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -14,7 +14,7 @@ bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. Not # tag-pinned like the deps above since it doesn't have a tagged release yet. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" From 1203102fe98b1a0f9cff7484526ef34196ece364 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 14:03:05 +0800 Subject: [PATCH 21/35] ci: build against bread-ecosystem's shared Arch CI image, add check.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as breadpad: build inside the shared pinned Arch container (bread-ecosystem/ci/, cloned at the sha in ci/bread-ecosystem.rev) instead of building natively against whatever's on the runner host. Adds check.yml (clippy + test on feature/**/fix/**) as a fast-fail gate before anything reaches main. Verified locally: build, clippy, and test all pass through the new container path — no pre-existing lint/test debt found here. --- .forgejo/workflows/check.yml | 24 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 2 +- .forgejo/workflows/rc-release.yml | 2 +- .forgejo/workflows/release.yml | 2 +- ci/bread-ecosystem.rev | 1 + ci/build.sh | 20 ++++++++++++++++++++ 6 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/check.yml create mode 100644 ci/bread-ecosystem.rev create mode 100755 ci/build.sh diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..b547c34 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --workspace --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index fd4f4df..04b6a68 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -19,7 +19,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: compute dev version run: | diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml index d697a76..46c2343 100644 --- a/.forgejo/workflows/rc-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -21,7 +21,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 803be86..cd68a3a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..474f1fd --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..54695da --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Delegates to bread-ecosystem's shared CI build image/script, pinned to +# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI +# files now affect every product's release pipeline, so bumping the pin +# is a deliberate act instead of silent drift. +# +# Usage: ci/build.sh cargo build --release --locked +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" + +CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" +if [ ! -d "$CACHE_DIR" ]; then + rm -rf /tmp/bread-ecosystem-ci-* + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" + git -C "$CACHE_DIR" checkout --quiet "$REV" +fi + +bash "${CACHE_DIR}/ci/build.sh" breadclip "$ROOT" "$@" From 8456424f1ab77cc0179e898ad0eb2997cc1e20de Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:38:49 +0800 Subject: [PATCH 22/35] Pin bread-theme/utils to bread-ecosystem v0.7.1; add single-trunk CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Popup, daemon, and core were split across git.breadway.dev v0.3.0 and GitHub v0.3.1. Point every bread-theme / bread-utils dep at the same Forgejo tag. bread-screenshots is not in v0.7.1 (the crate landed after that tag) so it stays on that remote's main until the next ecosystem release. CLAUDE.md now documents the single-trunk model. EVENTS.md is unchanged — no pin/select verbs. --- .gitignore | 3 -- CLAUDE.md | 62 +++++++++++++++++++++++++++++++++++++++ Cargo.lock | 26 +++++----------- breadclip-core/Cargo.toml | 3 +- breadclip/Cargo.toml | 10 +++---- breadclipd/Cargo.toml | 2 +- 6 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 8d2803a..36f7f5b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,3 @@ logs/ # Runtime files *.sock *.pid - -# Local hygiene notes (not for commit) -CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cb27ea1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a +short map of the binaries. It is not user-facing project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +When starting work on a new feature, create branch `feature/`. +When working on a bug or issue, create branch `fix/`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. + +## CI +- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. +- `dev-release.yml` — triggers on push to `main`. +- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggers on any other `v*` tag push. + +All four run on a self-hosted runner (`hestia`) inside a pinned Arch +container — not the host's native environment. The Containerfile/build +script are shared across bread-ecosystem products and live in +`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the +sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and +delegates to it. Nothing runs automatically on plain commits or PRs +beyond what's listed. + +## Architecture + +Three crates: + +| Crate | Role | +|---|---| +| `breadclipd` | Clipboard-watch daemon; persists history to SQLite | +| `breadclip` | GTK4 Layer Shell popup (thin UI over the same DB) | +| `breadclip-core` | Shared history schema / DB access | + +`--screenshot` (`breadclip/src/screenshot.rs`) captures the history panel +through `bread-screenshots`; do not rewrite it just to retarget the crate pin. + +`EVENTS.md` is the bread-event contract. App id `clip`. Implemented: +`bread.clip.copied`, `bread.clip.clear.done`/`.failed`, and command +`bread.command.clip.clear`. There is no pin/select — the history schema has +no pinned column and the popup has no pin UI. Do not invent +`bread.command.clip.pin`/`.select` (or matching events) ahead of a real +product feature. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't invent pin/select on the event bus. See `EVENTS.md`. diff --git a/Cargo.lock b/Cargo.lock index fbda1c7..7b6ca6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,8 +114,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs", "gtk4", @@ -123,25 +123,15 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-utils" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" -dependencies = [ - "dirs", - "gtk4", - "gtk4-layer-shell", - "serde", - "serde_json", -] - [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.3.1#157ed6e3782ac2facd8b517d7c247713f36854ed" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "bread-shared", "dirs", + "gtk4", + "gtk4-layer-shell", "serde", "serde_json", ] @@ -163,7 +153,7 @@ dependencies = [ "anyhow", "bread-screenshots", "bread-theme", - "bread-utils 0.3.0", + "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1)", "breadclip-core", "clap", "gtk4", @@ -175,7 +165,7 @@ dependencies = [ name = "breadclip-core" version = "0.2.1" dependencies = [ - "bread-utils 0.3.0", + "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1)", "dirs", "hex", "rusqlite", @@ -187,7 +177,7 @@ dependencies = [ name = "breadclipd" version = "0.2.1" dependencies = [ - "bread-utils 0.3.1 (git+https://github.com/Breadway/bread-ecosystem?tag=v0.3.1)", + "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1)", "breadclip-core", "serde_json", "tempfile", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index fabdf67..c57a76c 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -8,8 +8,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" dirs = "5" -# (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } [dev-dependencies] tempfile = "3" diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 17edafd..1a7ef05 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -9,11 +9,11 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } -# (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } -# Capture primitives for `--screenshot` mode — see src/screenshot.rs. Not -# tag-pinned like the deps above since it doesn't have a tagged release yet. +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. +# Not in bread-ecosystem v0.7.1 (crate landed after that tag); same remote +# as the tag-pinned deps, floating on main until the next ecosystem release. bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index b384b08..8e4a9b4 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -9,7 +9,7 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-utils = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.3.1", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } serde_json = "1" [dev-dependencies] From e66afd339a3221c769a75fcfda6773448c965971 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 23/35] Pin bread-screenshots to the shared ecosystem rev; track AGENTS.md Stop floating on branch=main. Same 69ce2d67 rev as sibling apps (v0.7.1 predates the crate). --- AGENTS.md | 62 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 6 ++--- breadclip/Cargo.toml | 3 ++- 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dc7aab5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a +short map of the binaries. It is not user-facing project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +When starting work on a new feature, create branch `feature/`. +When working on a bug or issue, create branch `fix/`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. + +## CI +- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. +- `dev-release.yml` — triggers on push to `main`. +- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggers on any other `v*` tag push. + +All four run on a self-hosted runner (`hestia`) inside a pinned Arch +container — not the host's native environment. The Containerfile/build +script are shared across bread-ecosystem products and live in +`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the +sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and +delegates to it. Nothing runs automatically on plain commits or PRs +beyond what's listed. + +## Architecture + +Three crates: + +| Crate | Role | +|---|---| +| `breadclipd` | Clipboard-watch daemon; persists history to SQLite | +| `breadclip` | GTK4 Layer Shell popup (thin UI over the same DB) | +| `breadclip-core` | Shared history schema / DB access | + +`--screenshot` (`breadclip/src/screenshot.rs`) captures the history panel +through `bread-screenshots`; do not rewrite it just to retarget the crate pin. + +`EVENTS.md` is the bread-event contract. App id `clip`. Implemented: +`bread.clip.copied`, `bread.clip.clear.done`/`.failed`, and command +`bread.command.clip.clear`. There is no pin/select — the history schema has +no pinned column and the popup has no pin UI. Do not invent +`bread.command.clip.pin`/`.select` (or matching events) ahead of a real +product feature. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't invent pin/select on the event bus. See `EVENTS.md`. diff --git a/Cargo.lock b/Cargo.lock index 7b6ca6b..f678d95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,10 +94,10 @@ dependencies = [ [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" dependencies = [ "anyhow", - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main)", + "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d)", "tracing", ] @@ -139,7 +139,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" dependencies = [ "dirs", "serde", diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 1a7ef05..ef0b350 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -14,7 +14,8 @@ bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = # Capture primitives for `--screenshot` mode — see src/screenshot.rs. # Not in bread-ecosystem v0.7.1 (crate landed after that tag); same remote # as the tag-pinned deps, floating on main until the next ecosystem release. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } +# v0.7.1 predates this crate; rev-pin so this is not `branch = "main"`. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" From e98e37dbf10a97c2fe62ad79bc5adb142cd26493 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:04:00 +0800 Subject: [PATCH 24/35] Remove CLAUDE.md (renamed to AGENTS.md) --- CLAUDE.md | 62 ------------------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index cb27ea1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,62 +0,0 @@ -# CLAUDE.md — Repo hygiene - -Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a -short map of the binaries. It is not user-facing project documentation. - -This repo follows the branch/release workflow documented in `CONTRIBUTING.md` -— read and follow it for any git, branch, or release work here (the -single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, -etc). Don't improvise a different workflow. The short version: there is one -long-lived branch, `main` — no `dev` or `beta` branch exists. `main` -auto-publishes a dev-track build on every push. "Beta" and "stable" are both -just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track -build, push a plain `vX.Y.Z` tag to cut the signed stable release. -"Freezing" for stabilization means pausing pushes to `main`, not moving a -branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model -after `main` was found to have silently rotted out of sync with `dev`/`beta` -across most repos in this ecosystem. - -When starting work on a new feature, create branch `feature/`. -When working on a bug or issue, create branch `fix/`. - -## Remotes -- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. -- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. - -## CI -- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. -- `dev-release.yml` — triggers on push to `main`. -- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. -- `release.yml` — triggers on any other `v*` tag push. - -All four run on a self-hosted runner (`hestia`) inside a pinned Arch -container — not the host's native environment. The Containerfile/build -script are shared across bread-ecosystem products and live in -`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the -sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and -delegates to it. Nothing runs automatically on plain commits or PRs -beyond what's listed. - -## Architecture - -Three crates: - -| Crate | Role | -|---|---| -| `breadclipd` | Clipboard-watch daemon; persists history to SQLite | -| `breadclip` | GTK4 Layer Shell popup (thin UI over the same DB) | -| `breadclip-core` | Shared history schema / DB access | - -`--screenshot` (`breadclip/src/screenshot.rs`) captures the history panel -through `bread-screenshots`; do not rewrite it just to retarget the crate pin. - -`EVENTS.md` is the bread-event contract. App id `clip`. Implemented: -`bread.clip.copied`, `bread.clip.clear.done`/`.failed`, and command -`bread.command.clip.clear`. There is no pin/select — the history schema has -no pinned column and the popup has no pin UI. Do not invent -`bread.command.clip.pin`/`.select` (or matching events) ahead of a real -product feature. - -## Don't -- Don't embed credentials in remote URLs — SSH or a credential helper only. -- Don't invent pin/select on the event bus. See `EVENTS.md`. From fc704f4d67b16b9e9234dae30d54bd4c6f8c4af6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:32:33 +0800 Subject: [PATCH 25/35] gitignore: exclude graphify-out local cache --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 36f7f5b..272a817 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,9 @@ logs/ # Runtime files *.sock *.pid + +# Local hygiene notes (not for commit) +CLAUDE.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ From fe8ea86c87e0c52d9c3aad2d3e4a6c1945f5cbef Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:53:46 +0800 Subject: [PATCH 26/35] Pin bread-ecosystem crates to v0.7.2 Repoint bread-theme, bread-utils, and bread-screenshots at tag v0.7.2 on git.breadway.dev. Drop the 69ce2d67 rev pin now that screenshots is on a tagged release. --- Cargo.lock | 30 ++++++++++-------------------- breadclip-core/Cargo.toml | 2 +- breadclip/Cargo.toml | 9 +++------ breadclipd/Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f678d95..aa7763c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,11 +93,11 @@ dependencies = [ [[package]] name = "bread-screenshots" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "anyhow", - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d)", + "bread-utils", "tracing", ] @@ -114,8 +114,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs", "gtk4", @@ -125,8 +125,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared", "dirs", @@ -136,16 +136,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" -dependencies = [ - "dirs", - "serde", - "serde_json", -] - [[package]] name = "breadclip" version = "0.2.1" @@ -153,7 +143,7 @@ dependencies = [ "anyhow", "bread-screenshots", "bread-theme", - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1)", + "bread-utils", "breadclip-core", "clap", "gtk4", @@ -165,7 +155,7 @@ dependencies = [ name = "breadclip-core" version = "0.2.1" dependencies = [ - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1)", + "bread-utils", "dirs", "hex", "rusqlite", @@ -177,7 +167,7 @@ dependencies = [ name = "breadclipd" version = "0.2.1" dependencies = [ - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1)", + "bread-utils", "breadclip-core", "serde_json", "tempfile", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index c57a76c..904ba09 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -8,7 +8,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" dirs = "5" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } [dev-dependencies] tempfile = "3" diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index ef0b350..9d6edfa 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -9,13 +9,10 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -# Not in bread-ecosystem v0.7.1 (crate landed after that tag); same remote -# as the tag-pinned deps, floating on main until the next ecosystem release. -# v0.7.1 predates this crate; rev-pin so this is not `branch = "main"`. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index 8e4a9b4..bb50065 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -9,7 +9,7 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } serde_json = "1" [dev-dependencies] From 999b73bf3373ad65332b95e897aa7614d9d917cc Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:05:47 +0800 Subject: [PATCH 27/35] Bump version to v0.2.2 --- Cargo.lock | 6 +++--- breadclip-core/Cargo.toml | 2 +- breadclip/Cargo.toml | 2 +- breadclipd/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa7763c..848f6cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,7 +138,7 @@ dependencies = [ [[package]] name = "breadclip" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "bread-screenshots", @@ -153,7 +153,7 @@ dependencies = [ [[package]] name = "breadclip-core" -version = "0.2.1" +version = "0.2.2" dependencies = [ "bread-utils", "dirs", @@ -165,7 +165,7 @@ dependencies = [ [[package]] name = "breadclipd" -version = "0.2.1" +version = "0.2.2" dependencies = [ "bread-utils", "breadclip-core", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 904ba09..086c7de 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip-core" -version = "0.2.1" +version = "0.2.2" edition = "2021" [dependencies] diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 9d6edfa..c23cd98 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip" -version = "0.2.1" +version = "0.2.2" edition = "2021" [[bin]] diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index bb50065..a9d584f 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclipd" -version = "0.2.1" +version = "0.2.2" edition = "2021" [[bin]] From 74553fa19c16839aca3e42c71e76c8330a917338 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:07 +0800 Subject: [PATCH 28/35] Adopt bread_utils::screenshot_cli for --screenshot flags Replace the local settle delay, canvas defaults, and pair-validation error path with bread-utils v0.7.2. Clap parsing stays in-tree. --- breadclip/src/screenshot.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/breadclip/src/screenshot.rs b/breadclip/src/screenshot.rs index b4fd8fa..0337bd7 100644 --- a/breadclip/src/screenshot.rs +++ b/breadclip/src/screenshot.rs @@ -13,15 +13,10 @@ //! `bread-capture`'s isolation module), so the panel always falls back to //! centered, which is exactly what we want for a consistent screenshot. +use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY}; use clap::Parser; use gtk4::prelude::*; use std::path::PathBuf; -use std::time::Duration; - -/// Extra settle time after `map` for the first frame to actually paint -/// before grim runs — `map` fires once the surface exists, not once -/// anything has been drawn into it. -const SETTLE_DELAY: Duration = Duration::from_millis(300); #[derive(Parser)] #[command(name = "breadclip")] @@ -37,11 +32,11 @@ pub struct Cli { /// Capture canvas width — matches the isolated compositor's output width /// (`bread-capture --isolate-width`). - #[arg(long, default_value_t = 1920)] + #[arg(long, default_value_t = DEFAULT_WIDTH)] pub width: u32, /// Capture canvas height — see `width`. - #[arg(long, default_value_t = 1080)] + #[arg(long, default_value_t = DEFAULT_HEIGHT)] pub height: u32, } @@ -54,16 +49,20 @@ pub struct ScreenshotRequest { } impl Cli { - /// `None` for a normal run. Exits the process with an error if - /// `--screenshot` was given without `--output`, before any GTK setup + /// `None` for a normal run. Exits the process with an error if the + /// `--screenshot` / `--output` pair is incomplete, before any GTK setup /// happens. pub fn screenshot_request(&self) -> Option { - let view = self.screenshot.clone()?; - let Some(output) = self.output.clone() else { - eprintln!("breadclip: --screenshot requires --output"); + if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) { + eprintln!("breadclip: {e}"); std::process::exit(1); - }; - Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } + Some(ScreenshotRequest { + view: self.screenshot.clone()?, + output: self.output.clone()?, + width: self.width, + height: self.height, + }) } } From c959153d5a8a13038445eb9fd82e4b4f6cd998b3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:50:22 +0800 Subject: [PATCH 29/35] CI: refuse unsigned bakery index on stable tag releases --- .forgejo/workflows/release.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index cd68a3a..0105b92 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && bash ci/build.sh cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -36,8 +45,14 @@ jobs: ln -sfn "${VERSION}" "/srv/breadway-dl/breadclip/latest" - name: regenerate index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" + exit 1 + fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From ed41e7e1ae01e34780ccd599b1046387d81faf84 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:24:18 +0800 Subject: [PATCH 30/35] Bind the clip popup to the current monitor's bread-theme palette Pin bread-theme to v0.7.4. --- Cargo.lock | 157 +++++++++++++++++++++++------------------- breadclip/Cargo.toml | 2 +- breadclip/src/main.rs | 1 + 3 files changed, 87 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 848f6cb..509849e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,8 +114,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs", "gtk4", @@ -193,14 +193,14 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] name = "cc" -version = "1.3.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -224,9 +224,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -234,9 +234,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -348,9 +348,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "field-offset" @@ -364,30 +364,30 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -396,32 +396,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -452,7 +452,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -485,7 +485,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -546,7 +546,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -610,7 +610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -621,7 +621,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -642,7 +642,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -673,7 +673,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -699,9 +699,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -714,15 +714,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -753,7 +753,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -822,15 +822,15 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -911,7 +911,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -922,9 +922,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "proc-macro-crate" @@ -937,18 +937,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1014,9 +1014,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1024,29 +1024,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1139,7 +1139,20 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", "version-compare", ] @@ -1196,9 +1209,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -1255,9 +1268,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -1448,24 +1461,24 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index c23cd98..2de120a 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -9,7 +9,7 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } diff --git a/breadclip/src/main.rs b/breadclip/src/main.rs index 05b0dc8..4d2858e 100644 --- a/breadclip/src/main.rs +++ b/breadclip/src/main.rs @@ -203,6 +203,7 @@ fn run_ui(entries: Vec, screenshot_req: Option Date: Sun, 16 Aug 2026 14:09:21 +0800 Subject: [PATCH 31/35] Bump version to v0.2.4 --- Cargo.lock | 6 +++--- breadclip-core/Cargo.toml | 2 +- breadclip/Cargo.toml | 2 +- breadclipd/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 509849e..07dd579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,7 +138,7 @@ dependencies = [ [[package]] name = "breadclip" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "bread-screenshots", @@ -153,7 +153,7 @@ dependencies = [ [[package]] name = "breadclip-core" -version = "0.2.2" +version = "0.2.4" dependencies = [ "bread-utils", "dirs", @@ -165,7 +165,7 @@ dependencies = [ [[package]] name = "breadclipd" -version = "0.2.2" +version = "0.2.4" dependencies = [ "bread-utils", "breadclip-core", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 086c7de..2742fb8 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip-core" -version = "0.2.2" +version = "0.2.4" edition = "2021" [dependencies] diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 2de120a..3274612 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip" -version = "0.2.2" +version = "0.2.4" edition = "2021" [[bin]] diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index a9d584f..da1eeca 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclipd" -version = "0.2.2" +version = "0.2.4" edition = "2021" [[bin]] From 967fd9f1b787ba7bea65f5d59ff3084e55b3b5d2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 15:07:04 +0800 Subject: [PATCH 32/35] breadclip-core: history DB hardening + user config + pin/primary schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistence layer changes, all backward-compatible with existing databases via in-place column migration: - WAL journal mode + a 5s busy timeout so overlapping `--capture-once` writers and the popup reader stop dropping captures on SQLITE_BUSY. - `HistoryError` replaces bare `rusqlite::Error` so a filesystem failure while writing an image file surfaces instead of leaving a row that points at a file that was never written. - Image files are created 0600 from the first syscall (O_CREAT|O_EXCL, mode 0600) — no world-readable window before a chmod. Data and images dirs are forced to 0700 on every open. `gc_orphaned_images` sweeps image files no row references (older than 1h, to spare in-flight writes). - `pinned` and `is_primary` columns. Pinned rows are exempt from trim and sort first; `list_entries` breaks timestamp ties by `id DESC` so ordering (and which rows trim keeps) is deterministic within a second. - `Retention` caps are now a field on `HistoryDb` (`open_with`), and `0` is a legal value ("keep no unpinned entries of this kind"). New `config` module: optional TOML at `$XDG_CONFIG_HOME/breadclip/config.toml`, every key defaulted and clamped, unparseable file backed up once (bread-utils tomlcfg discipline). Keys: retention.text/images, panel.width, capture.primary. --- Cargo.lock | 3 +- breadclip-core/Cargo.toml | 5 +- breadclip-core/src/config.rs | 175 ++++++++++ breadclip-core/src/lib.rs | 642 +++++++++++++++++++++++++++++++---- 4 files changed, 750 insertions(+), 75 deletions(-) create mode 100644 breadclip-core/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 07dd579..885ea87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,7 @@ dependencies = [ "gtk4-layer-shell", "serde", "serde_json", + "toml_edit 0.22.27", ] [[package]] @@ -156,11 +157,11 @@ name = "breadclip-core" version = "0.2.4" dependencies = [ "bread-utils", - "dirs", "hex", "rusqlite", "sha2", "tempfile", + "toml_edit 0.22.27", ] [[package]] diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 2742fb8..6cbe951 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -7,8 +7,9 @@ edition = "2021" rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" -dirs = "5" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +toml_edit = "0.22" +# `toml` for bread-utils' non-destructive config load/save discipline (config.rs). +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] } [dev-dependencies] tempfile = "3" diff --git a/breadclip-core/src/config.rs b/breadclip-core/src/config.rs new file mode 100644 index 0000000..5f69611 --- /dev/null +++ b/breadclip-core/src/config.rs @@ -0,0 +1,175 @@ +//! breadclip's user configuration. +//! +//! Read from `$XDG_CONFIG_HOME/breadclip/config.toml` (or +//! `~/.config/breadclip/config.toml`). Every key has a sensible default, so +//! the file is entirely optional. A file that *exists* but fails to parse is +//! backed up to `config.toml.bak` (once) and defaults are used, matching +//! bread-utils' non-destructive TOML discipline (see +//! `bread_utils::tomlcfg::load_doc`). +//! +//! ```toml +//! [retention] +//! text = 200 # max non-pinned text entries (0 = keep none) +//! images = 50 # max non-pinned image entries (0 = keep none) +//! +//! [panel] +//! width = 520 # popup panel width, px +//! +//! [capture] +//! primary = false # also watch the middle-click primary selection +//! ``` + +use crate::Retention; +use std::path::PathBuf; +use toml_edit::{DocumentMut, Item}; + +/// Resolved configuration. `Config::default()` matches the built-in +/// behavior before config files existed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Config { + pub retention: Retention, + pub panel_width: i32, + pub capture_primary: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + retention: Retention::default(), + panel_width: 520, + capture_primary: false, + } + } +} + +/// Clamp bounds. Retention can legitimately be `0` ("keep no unpinned +/// entries of this kind") but never huge; the panel width is bounded to +/// something that still fits on a screen. +const MAX_RETENTION: i64 = 10_000; +const MIN_PANEL_WIDTH: i64 = 300; +const MAX_PANEL_WIDTH: i64 = 2000; + +pub fn config_path() -> PathBuf { + bread_utils::xdg::config_dir("breadclip").join("config.toml") +} + +/// Load configuration, falling back to defaults for anything missing, +/// unparseable, or out of range. +pub fn load() -> Config { + let doc = bread_utils::tomlcfg::load_doc("breadclip", &config_path()); + Config { + retention: Retention { + text: int(&doc, "retention", "text", Retention::default().text as i64) + .clamp(0, MAX_RETENTION) as usize, + images: int(&doc, "retention", "images", Retention::default().images as i64) + .clamp(0, MAX_RETENTION) as usize, + }, + panel_width: int(&doc, "panel", "width", 520) + .clamp(MIN_PANEL_WIDTH, MAX_PANEL_WIDTH) as i32, + capture_primary: boolean(&doc, "capture", "primary", false), + } +} + +fn int(doc: &DocumentMut, section: &str, key: &str, default: i64) -> i64 { + doc.get(section) + .and_then(Item::as_table) + .and_then(|t| t.get(key)) + .and_then(Item::as_integer) + .unwrap_or(default) +} + +fn boolean(doc: &DocumentMut, section: &str, key: &str, default: bool) -> bool { + doc.get(section) + .and_then(Item::as_table) + .and_then(|t| t.get(key)) + .and_then(Item::as_bool) + .unwrap_or(default) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_config_dir T, T>(f: F) -> T { + let _guard = crate::env_test_lock().lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempfile::tempdir().expect("tempdir"); + std::env::set_var("XDG_CONFIG_HOME", dir.path()); + let r = f(); + let _ = std::fs::remove_dir_all(dir.path()); + r + } + + fn write_config(body: &str) { + let path = config_path(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, body).unwrap(); + } + + #[test] + fn missing_config_yields_defaults() { + with_config_dir(|| { + assert_eq!(load(), Config::default()); + }); + } + + #[test] + fn parses_known_keys() { + with_config_dir(|| { + write_config( + r#" +[retention] +text = 10 +images = 0 + +[panel] +width = 700 + +[capture] +primary = true +"#, + ); + let cfg = load(); + assert_eq!(cfg.retention.text, 10); + assert_eq!(cfg.retention.images, 0, "0 retention is legal"); + assert_eq!(cfg.panel_width, 700); + assert!(cfg.capture_primary); + }); + } + + #[test] + fn out_of_range_values_are_clamped_not_trusted() { + with_config_dir(|| { + write_config( + r#" +[retention] +text = -5 +images = 99999999 + +[panel] +width = 50 +"#, + ); + let cfg = load(); + assert_eq!(cfg.retention.text, 0); + assert_eq!(cfg.retention.images, MAX_RETENTION as usize); + assert_eq!(cfg.panel_width, MIN_PANEL_WIDTH as i32); + }); + } + + #[test] + fn partial_config_falls_back_per_key() { + with_config_dir(|| { + write_config( + r#" +[retention] +text = 7 +"#, + ); + let cfg = load(); + assert_eq!(cfg.retention.text, 7); + assert_eq!(cfg.retention.images, Retention::default().images); + assert_eq!(cfg.panel_width, Config::default().panel_width); + assert!(!cfg.capture_primary); + }); + } +} diff --git a/breadclip-core/src/lib.rs b/breadclip-core/src/lib.rs index 571891c..db09c8b 100644 --- a/breadclip-core/src/lib.rs +++ b/breadclip-core/src/lib.rs @@ -1,7 +1,50 @@ -use rusqlite::{params, Connection, Result as SqlResult}; +pub mod config; + +use rusqlite::{params, Connection}; use sha2::{Digest, Sha256}; -use std::os::unix::fs::PermissionsExt; +use std::fmt; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// How many non-pinned text and image entries history keeps by default. The +/// popup fetches everything (see its `FETCH_ALL`), so entries the daemon +/// persists are always reachable from the UI; pinned entries are exempt from +/// trimming entirely and can exceed these caps. +pub const MAX_TEXT_ENTRIES: usize = 200; +pub const MAX_IMAGE_ENTRIES: usize = 50; + +/// Retention caps for non-pinned entries. `0` is meaningful: it means +/// "keep no (unpinned) entries of this kind". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Retention { + pub text: usize, + pub images: usize, +} + +impl Default for Retention { + fn default() -> Self { + Self { + text: MAX_TEXT_ENTRIES, + images: MAX_IMAGE_ENTRIES, + } + } +} + +/// Where a clipboard entry came from: the regular clipboard (Ctrl+C / copy) +/// or the middle-click primary selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureSource { + Clipboard, + Primary, +} + +impl CaptureSource { + pub fn is_primary(self) -> bool { + matches!(self, CaptureSource::Primary) + } +} #[derive(Debug, Clone)] pub struct ClipEntry { @@ -11,18 +54,84 @@ pub struct ClipEntry { pub content: Option, pub image_path: Option, pub content_hash: String, + pub pinned: bool, + pub is_primary: bool, +} + +/// Unified error type for `HistoryDb` operations: SQLite failures plus the +/// filesystem work the database implicitly depends on (creating the data +/// directory, writing image files). A bare `rusqlite::Error` can't represent +/// "disk full while writing the thumbnail file", and swallowing that failure +/// is what used to leave broken rows behind (an `image_path` pointing at a +/// file that was never written). +#[derive(Debug)] +pub enum HistoryError { + Sql(rusqlite::Error), + Io { + action: &'static str, + path: PathBuf, + source: std::io::Error, + }, +} + +impl fmt::Display for HistoryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HistoryError::Sql(e) => write!(f, "database error: {e}"), + HistoryError::Io { action, path, source } => { + write!(f, "failed to {action} {}: {source}", path.display()) + } + } + } +} + +impl std::error::Error for HistoryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + HistoryError::Sql(e) => Some(e), + HistoryError::Io { source, .. } => Some(source), + } + } +} + +impl From for HistoryError { + fn from(e: rusqlite::Error) -> Self { + HistoryError::Sql(e) + } } pub struct HistoryDb { conn: Connection, + retention: Retention, } impl HistoryDb { - pub fn open() -> SqlResult { + /// Open with the default retention caps. + pub fn open() -> Result { + Self::open_with(Retention::default()) + } + + pub fn open_with(retention: Retention) -> Result { let dir = data_dir(); - std::fs::create_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).map_err(|source| HistoryError::Io { + action: "create data directory", + path: dir.clone(), + source, + })?; + // The data dir holds clipboard secrets (history.db is plaintext). + // A 0700 parent also blocks traversal to whatever SQLite's + // -wal/-shm side files look like, regardless of their own mode. + restrict_dir(&dir, 0o700); + let db_path = dir.join("history.db"); let conn = Connection::open(&db_path)?; + // Clipboard events can spawn overlapping `--capture-once` processes + // and the popup opens the same DB — without a busy timeout a writer + // can hit SQLITE_BUSY and silently drop a capture. + conn.busy_timeout(Duration::from_secs(5))?; + // WAL: concurrent readers (popup) and the single writer (daemon) no + // longer block each other, and readers see a consistent snapshot. + conn.pragma_update(None, "journal_mode", "WAL")?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -30,53 +139,99 @@ impl HistoryDb { mime_type TEXT NOT NULL, content TEXT, image_path TEXT, - content_hash TEXT NOT NULL UNIQUE + content_hash TEXT NOT NULL UNIQUE, + pinned INTEGER NOT NULL DEFAULT 0, + is_primary INTEGER NOT NULL DEFAULT 0 ); - CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);", + CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC, id DESC);", + )?; + // Migrate databases created before `pinned`/`is_primary` existed. + ensure_column( + &conn, + "pinned", + "ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", + )?; + ensure_column( + &conn, + "is_primary", + "ALTER TABLE history ADD COLUMN is_primary INTEGER NOT NULL DEFAULT 0", )?; // history.db can contain plaintext secrets copied to the clipboard // (passwords, tokens, TOTP codes); restrict it to owner-only, every open. restrict_permissions(&db_path); - Ok(Self { conn }) + let db = Self { conn, retention }; + db.gc_orphaned_images()?; + Ok(db) } - pub fn insert_text(&self, text: &str) -> SqlResult<()> { + pub fn insert_text(&self, text: &str, source: CaptureSource) -> Result<(), HistoryError> { let hash = sha256_hex(text.as_bytes()); let ts = unix_now(); self.conn.execute( - "INSERT INTO history (timestamp, mime_type, content, content_hash) - VALUES (?1, 'text/plain', ?2, ?3) - ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", - params![ts, text, hash], + "INSERT INTO history (timestamp, mime_type, content, content_hash, is_primary) + VALUES (?1, 'text/plain', ?2, ?3, ?4) + ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1, is_primary = ?4", + params![ts, text, hash, source.is_primary() as i64], )?; - self.trim(200, 50) + self.trim() } - pub fn insert_image(&self, png_bytes: &[u8]) -> SqlResult<()> { - let hash = sha256_hex(png_bytes); + /// Store an image. `mime_type` is persisted with the row and drives the + /// file extension, so a JPEG capture is stored as a `.jpg` offered as + /// `image/jpeg` — not silently re-encoded/relabeled as PNG. + pub fn insert_image( + &self, + bytes: &[u8], + mime_type: &str, + source: CaptureSource, + ) -> Result<(), HistoryError> { + let hash = sha256_hex(bytes); let images_dir = data_dir().join("images"); - std::fs::create_dir_all(&images_dir).ok(); - // Use first 16 hex chars for the filename (collision-safe for 50 images) - let path = images_dir.join(format!("{}.png", &hash[..16])); + std::fs::create_dir_all(&images_dir).map_err(|source| HistoryError::Io { + action: "create images directory", + path: images_dir.clone(), + source, + })?; + restrict_dir(&images_dir, 0o700); + + let ext = match mime_type { + "image/jpeg" => "jpg", + _ => "png", + }; + // First 16 hex chars of the hash: collision-safe for the few dozen + // images history keeps. + let path = images_dir.join(format!("{}.{}", &hash[..16], ext)); if !path.exists() { - std::fs::write(&path, png_bytes).ok(); - restrict_permissions(&path); + write_image_file(&path, bytes)?; } let path_str = path.to_string_lossy().to_string(); let ts = unix_now(); self.conn.execute( - "INSERT INTO history (timestamp, mime_type, image_path, content_hash) - VALUES (?1, 'image/png', ?2, ?3) - ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", - params![ts, path_str, hash], + "INSERT INTO history (timestamp, mime_type, image_path, content_hash, is_primary) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1, is_primary = ?5", + params![ts, mime_type, path_str, hash, source.is_primary() as i64], )?; - self.trim(200, 50) + self.trim() } - pub fn list_entries(&self, limit: usize) -> SqlResult> { + /// Pin (or unpin) an entry. Pinned entries are exempt from trimming and + /// sort to the top of the history list. + pub fn set_pinned(&self, id: i64, pinned: bool) -> Result<(), HistoryError> { + self.conn.execute( + "UPDATE history SET pinned = ?1 WHERE id = ?2", + params![pinned as i64, id], + )?; + Ok(()) + } + + pub fn list_entries(&self, limit: usize) -> Result, HistoryError> { + // Pinned rows first, then `timestamp DESC, id DESC` — many copies + // land within the same second, and the `id` tiebreaker keeps that + // order (and therefore which rows trim keeps) deterministic. let mut stmt = self.conn.prepare( - "SELECT id, timestamp, mime_type, content, image_path, content_hash - FROM history ORDER BY timestamp DESC LIMIT ?1", + "SELECT id, timestamp, mime_type, content, image_path, content_hash, pinned, is_primary + FROM history ORDER BY pinned DESC, timestamp DESC, id DESC LIMIT ?1", )?; let rows = stmt .query_map([limit as i64], |row| { @@ -87,35 +242,40 @@ impl HistoryDb { content: row.get(3)?, image_path: row.get(4)?, content_hash: row.get(5)?, + pinned: row.get(6)?, + is_primary: row.get(7)?, }) })? - .collect::>>(); - rows + .collect::>>()?; + Ok(rows) } - /// Deletes every history entry and every stored image file. Used by the + /// Deletes every history entry and every stored image file — a hard + /// reset that clears pinned entries too. Used by the /// `bread.command.clip.clear` handler (see breadclipd's bread-client - /// subscription) as well as anything else that wants a hard reset of - /// clipboard history. - pub fn clear_all(&self) -> SqlResult<()> { + /// subscription) as well as anything else that wants a full wipe. + pub fn clear_all(&self) -> Result<(), HistoryError> { let image_paths: Vec = { let mut stmt = self .conn .prepare("SELECT image_path FROM history WHERE image_path IS NOT NULL")?; let paths = stmt .query_map([], |row| row.get(0))? - .collect::>>()?; + .collect::>>()?; paths }; + // Delete rows first, then files — the reverse order would leave rows + // pointing at already-removed files if the DELETE failed halfway. + self.conn.execute("DELETE FROM history", [])?; for path in image_paths { let _ = std::fs::remove_file(path); } - self.conn.execute("DELETE FROM history", [])?; Ok(()) } - pub fn delete_entry(&self, id: i64) -> SqlResult<()> { - // Clean up image file if present + pub fn delete_entry(&self, id: i64) -> Result<(), HistoryError> { + // Clean up image file if present — after the row is gone, so a + // failed DELETE never leaves a row pointing at a deleted file. let image_path: Option = self .conn .query_row( @@ -125,56 +285,114 @@ impl HistoryDb { ) .ok() .flatten(); + self.conn.execute("DELETE FROM history WHERE id = ?1", [id])?; if let Some(p) = image_path { let _ = std::fs::remove_file(p); } - self.conn - .execute("DELETE FROM history WHERE id = ?1", [id])?; Ok(()) } - fn trim(&self, max_text: usize, max_images: usize) -> SqlResult<()> { + /// Sweep image files on disk that no history row references anymore + /// (e.g. left behind by a crash between writing the file and inserting + /// the row). Files written in the last hour are left alone so a + /// concurrent `--capture-once` that is mid-insert (file on disk, row not + /// yet committed) is never deleted out from under itself. + pub fn gc_orphaned_images(&self) -> Result<(), HistoryError> { + let referenced: std::collections::HashSet = { + let mut stmt = self + .conn + .prepare("SELECT image_path FROM history WHERE image_path IS NOT NULL")?; + let paths = stmt + .query_map([], |row| row.get::<_, Option>(0))? + .collect::>>()?; + paths.into_iter().flatten().collect() + }; + let images_dir = data_dir().join("images"); + let Ok(entries) = std::fs::read_dir(&images_dir) else { + return Ok(()); // no images dir yet — nothing to sweep + }; + let cutoff = std::time::SystemTime::now() + .checked_sub(Duration::from_secs(3600)) + .unwrap_or(std::time::UNIX_EPOCH); + for entry in entries.flatten() { + let path = entry.path(); + if referenced.contains(&path.to_string_lossy().to_string()) { + continue; + } + let modified = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + if modified < cutoff { + let _ = std::fs::remove_file(&path); + } + } + Ok(()) + } + + fn trim(&self) -> Result<(), HistoryError> { + // Oldest non-pinned text rows beyond the cap. Pinned rows are never + // trimmed — that's the point of pinning them. self.conn.execute( "DELETE FROM history - WHERE mime_type = 'text/plain' + WHERE mime_type = 'text/plain' AND pinned = 0 AND id NOT IN ( - SELECT id FROM history WHERE mime_type = 'text/plain' - ORDER BY timestamp DESC LIMIT ?1 + SELECT id FROM history WHERE mime_type = 'text/plain' AND pinned = 0 + ORDER BY timestamp DESC, id DESC LIMIT ?1 )", - [max_text as i64], + [self.retention.text as i64], )?; - // Collect old image paths before deleting rows + // Collect the image files that are about to be trimmed, delete the + // rows first, then the files — so a failed DELETE never leaves rows + // pointing at already-removed files. `LIKE 'image/%'` covers every + // image mime type (png, jpeg, and anything added later). let old_paths: Vec = { let mut stmt = self.conn.prepare( "SELECT image_path FROM history - WHERE mime_type = 'image/png' + WHERE mime_type LIKE 'image/%' + AND pinned = 0 AND image_path IS NOT NULL AND id NOT IN ( - SELECT id FROM history WHERE mime_type = 'image/png' - ORDER BY timestamp DESC LIMIT ?1 + SELECT id FROM history WHERE mime_type LIKE 'image/%' AND pinned = 0 + ORDER BY timestamp DESC, id DESC LIMIT ?1 )", )?; let paths = stmt - .query_map([max_images as i64], |row| row.get(0))? - .collect::>>()?; + .query_map([self.retention.images as i64], |row| row.get(0))? + .collect::>>()?; paths }; + self.conn.execute( + "DELETE FROM history + WHERE mime_type LIKE 'image/%' + AND pinned = 0 + AND id NOT IN ( + SELECT id FROM history WHERE mime_type LIKE 'image/%' AND pinned = 0 + ORDER BY timestamp DESC, id DESC LIMIT ?1 + )", + [self.retention.images as i64], + )?; for path in old_paths { let _ = std::fs::remove_file(path); } - self.conn.execute( - "DELETE FROM history - WHERE mime_type = 'image/png' - AND id NOT IN ( - SELECT id FROM history WHERE mime_type = 'image/png' - ORDER BY timestamp DESC LIMIT ?1 - )", - [max_images as i64], - )?; Ok(()) } } +/// Add `column` to the history table if it isn't there (migration for +/// databases created by older versions). Table/column names are our own +/// constants, never user input. +fn ensure_column(conn: &Connection, column: &str, add_ddl: &str) -> rusqlite::Result<()> { + let mut stmt = conn.prepare("PRAGMA table_info(history)")?; + let names = stmt.query_map([], |row| row.get::<_, String>(1))?; + for name in names { + if name? == column { + return Ok(()); + } + } + conn.execute_batch(add_ddl) +} + /// Restrict a file to owner-only read/write (0600). Clipboard history can /// contain passwords and other secrets, so this must not be world/group /// readable regardless of the process umask. @@ -186,6 +404,42 @@ fn restrict_permissions(path: &Path) { } } +/// Restrict a directory to owner-only (0700). Used for the data dir and the +/// images dir, both of which contain clipboard secrets. +fn restrict_dir(path: &Path, mode: u32) { + if let Ok(meta) = std::fs::metadata(path) { + let mut perms = meta.permissions(); + perms.set_mode(mode); + let _ = std::fs::set_permissions(path, perms); + } +} + +/// Write an image file, created 0600 from the very first syscall — no window +/// where a clipboard image (possibly a screenshot with sensitive pixels) is +/// world-readable before a chmod lands, and no dependence on the umask. +fn write_image_file(path: &Path, bytes: &[u8]) -> Result<(), HistoryError> { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + { + Ok(mut f) => f.write_all(bytes).map_err(|source| HistoryError::Io { + action: "write image file", + path: path.to_path_buf(), + source, + }), + // Same hash filename means identical content — a concurrent + // `--capture-once` already wrote it. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(source) => Err(HistoryError::Io { + action: "create image file", + path: path.to_path_buf(), + source, + }), + } +} + pub fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); @@ -209,28 +463,33 @@ fn unix_now() -> i64 { .as_secs() as i64 } +/// Serializes tests that redirect `$XDG_DATA_HOME`/`$XDG_CONFIG_HOME` +/// (process-global env vars) against each other — `cargo test` runs tests +/// in parallel threads within one process. +#[cfg(test)] +pub(crate) fn env_test_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) +} + #[cfg(test)] mod tests { use super::*; - // `HistoryDb::open` resolves its path via `data_dir()`, which follows - // `$XDG_DATA_HOME` — redirecting it to a fresh temp dir per test keeps - // this isolated from a real `~/.local/share/breadclip` and from other - // tests. Safe without a lock: this is currently the only test in the - // crate that touches XDG_DATA_HOME. - fn open_test_db() -> (tempfile::TempDir, HistoryDb) { + fn open_test_db() -> (std::sync::MutexGuard<'static, ()>, tempfile::TempDir, HistoryDb) { + let guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner()); let dir = tempfile::tempdir().expect("tempdir"); std::env::set_var("XDG_DATA_HOME", dir.path()); let db = HistoryDb::open().expect("open history db"); - (dir, db) + (guard, dir, db) } #[test] fn clear_all_removes_every_entry_and_image_file() { - let (_dir, db) = open_test_db(); - db.insert_text("first").unwrap(); - db.insert_text("second").unwrap(); - db.insert_image(b"not really a png, just bytes for the test") + let (_guard, _dir, db) = open_test_db(); + db.insert_text("first", CaptureSource::Clipboard).unwrap(); + db.insert_text("second", CaptureSource::Clipboard).unwrap(); + db.insert_image(b"not really a png, just bytes for the test", "image/png", CaptureSource::Clipboard) .unwrap(); let before = db.list_entries(10).unwrap(); @@ -253,8 +512,247 @@ mod tests { #[test] fn clear_all_on_empty_history_is_a_harmless_no_op() { - let (_dir, db) = open_test_db(); + let (_guard, _dir, db) = open_test_db(); db.clear_all().unwrap(); assert!(db.list_entries(10).unwrap().is_empty()); } + + #[test] + fn open_uses_wal_journal_mode() { + let (_guard, _dir, db) = open_test_db(); + let mode: String = db + .conn + .pragma_query_value(None, "journal_mode", |r| r.get(0)) + .unwrap(); + assert_eq!(mode, "wal"); + } + + #[test] + fn entries_with_equal_timestamps_order_by_id_desc() { + let (_guard, _dir, db) = open_test_db(); + db.insert_text("first", CaptureSource::Clipboard).unwrap(); + db.insert_text("second", CaptureSource::Clipboard).unwrap(); + let entries = db.list_entries(10).unwrap(); + assert_eq!(entries[0].content.as_deref(), Some("second")); + assert_eq!(entries[1].content.as_deref(), Some("first")); + } + + #[test] + fn trim_caps_text_and_images_at_retention_limits() { + let (_guard, _dir, db) = open_test_db(); + for i in 0..210 { + db.insert_text(&format!("text-{i}"), CaptureSource::Clipboard).unwrap(); + } + for i in 0..55 { + db.insert_image(format!("img-{i}").as_bytes(), "image/png", CaptureSource::Clipboard) + .unwrap(); + } + let entries = db.list_entries(1000).unwrap(); + let texts = entries + .iter() + .filter(|e| e.mime_type == "text/plain") + .count(); + let images = entries + .iter() + .filter(|e| e.mime_type.starts_with("image/")) + .count(); + assert_eq!(texts, MAX_TEXT_ENTRIES); + assert_eq!(images, MAX_IMAGE_ENTRIES); + } + + #[test] + fn trim_respects_configured_retention() { + let (_guard, _dir, _db) = open_test_db(); // holds the env lock + // Reopen with a custom retention on a fresh dir. + let dir = tempfile::tempdir().expect("tempdir"); + std::env::set_var("XDG_DATA_HOME", dir.path()); + let db = HistoryDb::open_with(Retention { text: 3, images: 2 }).expect("open db"); + for i in 0..10 { + db.insert_text(&format!("t{i}"), CaptureSource::Clipboard).unwrap(); + } + for i in 0..5 { + db.insert_image(format!("img-{i}").as_bytes(), "image/png", CaptureSource::Clipboard) + .unwrap(); + } + let entries = db.list_entries(100).unwrap(); + assert_eq!( + entries.iter().filter(|e| e.mime_type == "text/plain").count(), + 3 + ); + assert_eq!( + entries.iter().filter(|e| e.mime_type.starts_with("image/")).count(), + 2 + ); + } + + #[test] + fn pinned_entries_survive_trim() { + let (_guard, _dir, db) = open_test_db(); + for i in 0..205 { + db.insert_text(&format!("t{i}"), CaptureSource::Clipboard).unwrap(); + } + let entries = db.list_entries(1000).unwrap(); + let oldest_id = entries.last().unwrap().id; + let oldest_text = entries.last().unwrap().content.clone().unwrap(); + db.set_pinned(oldest_id, true).unwrap(); + + // Push past the cap: the pinned entry must survive while the rest + // stay capped. + for i in 0..10 { + db.insert_text(&format!("more{i}"), CaptureSource::Clipboard).unwrap(); + } + let entries = db.list_entries(1000).unwrap(); + assert!( + entries.iter().any(|e| e.id == oldest_id && e.pinned), + "a pinned entry must never be trimmed" + ); + assert_eq!( + entries + .iter() + .filter(|e| !e.pinned && e.mime_type == "text/plain") + .count(), + MAX_TEXT_ENTRIES + ); + + // Unpinning lets it get trimmed again like any other entry. + db.set_pinned(oldest_id, false).unwrap(); + for i in 0..10 { + db.insert_text(&format!("final{i}"), CaptureSource::Clipboard).unwrap(); + } + let entries = db.list_entries(1000).unwrap(); + assert!( + !entries.iter().any(|e| e.content.as_deref() == Some(oldest_text.as_str())), + "an unpinned entry falls back under normal trimming" + ); + } + + #[test] + fn pinned_entries_sort_first() { + let (_guard, _dir, db) = open_test_db(); + db.insert_text("a", CaptureSource::Clipboard).unwrap(); + db.insert_text("b", CaptureSource::Clipboard).unwrap(); + let second_id = db.list_entries(10).unwrap()[1].id; + + db.set_pinned(second_id, true).unwrap(); + + let entries = db.list_entries(10).unwrap(); + assert_eq!(entries[0].id, second_id, "pinned rows sort before unpinned"); + assert!(entries[0].pinned); + } + + #[test] + fn primary_source_is_recorded() { + let (_guard, _dir, db) = open_test_db(); + db.insert_text("primary text", CaptureSource::Primary).unwrap(); + db.insert_image(b"png", "image/png", CaptureSource::Clipboard).unwrap(); + + let entries = db.list_entries(10).unwrap(); + let text = entries.iter().find(|e| e.mime_type == "text/plain").unwrap(); + assert!(text.is_primary, "primary-selection text is tagged"); + let img = entries + .iter() + .find(|e| e.mime_type.starts_with("image/")) + .unwrap(); + assert!(!img.is_primary, "clipboard image is not tagged primary"); + } + + #[test] + fn insert_image_jpeg_stores_jpg_file_and_trims_with_other_images() { + let (_guard, _dir, db) = open_test_db(); + db.insert_image(b"\xff\xd8\xff fake jpeg", "image/jpeg", CaptureSource::Clipboard) + .unwrap(); + let jpeg_path = db + .list_entries(10) + .unwrap() + .into_iter() + .find(|e| e.mime_type == "image/jpeg") + .expect("jpeg entry") + .image_path + .unwrap(); + assert!(jpeg_path.ends_with(".jpg")); + assert!(Path::new(&jpeg_path).exists()); + + // 49 more images: the jpeg is still within the newest 50. + for i in 0..49 { + db.insert_image(format!("img-{i}").as_bytes(), "image/png", CaptureSource::Clipboard) + .unwrap(); + } + let entries = db.list_entries(100).unwrap(); + assert_eq!( + entries + .iter() + .filter(|e| e.mime_type.starts_with("image/")) + .count(), + MAX_IMAGE_ENTRIES + ); + assert!( + entries.iter().any(|e| e.mime_type == "image/jpeg"), + "jpeg is among the newest 50 images, so it must survive trim" + ); + + // Push it past the cap: the jpeg row and its file both go. + for i in 0..10 { + db.insert_image(format!("late-{i}").as_bytes(), "image/png", CaptureSource::Clipboard) + .unwrap(); + } + let entries = db.list_entries(100).unwrap(); + assert!(!entries.iter().any(|e| e.mime_type == "image/jpeg")); + assert!( + !Path::new(&jpeg_path).exists(), + "trim must remove the file of a trimmed image row" + ); + } + + #[test] + fn insert_image_propagates_write_failure_without_leaving_a_row() { + let (_guard, _dir, db) = open_test_db(); + // Replace the images dir with a regular file so create_dir_all fails. + let images_dir = data_dir().join("images"); + std::fs::create_dir_all(&images_dir).unwrap(); + std::fs::remove_dir_all(&images_dir).unwrap(); + std::fs::write(&images_dir, b"not a directory").unwrap(); + + let result = db.insert_image(b"png bytes", "image/png", CaptureSource::Clipboard); + assert!( + result.is_err(), + "a failed image write must surface as an error" + ); + assert!( + db.list_entries(10).unwrap().is_empty(), + "a failed image write must not leave a broken row behind" + ); + + std::fs::remove_file(&images_dir).unwrap(); + } + + #[test] + fn gc_orphaned_images_removes_unreferenced_but_keeps_referenced_files() { + let (_guard, _dir, db) = open_test_db(); + db.insert_image(b"png-a", "image/png", CaptureSource::Clipboard).unwrap(); + let referenced = db + .list_entries(10) + .unwrap() + .into_iter() + .next() + .unwrap() + .image_path + .unwrap(); + + // An orphan: an image file no history row references. Fake an old + // mtime so the age guard (which protects concurrent in-flight + // writes) doesn't skip it. + let orphan = data_dir().join("images/orphan.png"); + std::fs::write(&orphan, b"orphan").unwrap(); + let file = std::fs::File::options().write(true).open(&orphan).unwrap(); + let old = std::time::SystemTime::now() - Duration::from_secs(7200); + file.set_modified(old).unwrap(); + + db.gc_orphaned_images().unwrap(); + + assert!( + Path::new(&referenced).exists(), + "a referenced image must survive GC" + ); + assert!(!orphan.exists(), "an unreferenced image must be swept"); + } } From 83630033cd6275d9ab74146a30d40305404d26ff Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 15:07:22 +0800 Subject: [PATCH 33/35] breadclipd: watch-driven capture, flock singleton, ignore rules, pin verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- breadclipd/src/content_kind.rs | 38 ++- breadclipd/src/ignore_rules.rs | 182 ++++++++++++++ breadclipd/src/main.rs | 441 +++++++++++++++++++++++++-------- contrib/breadclipd.service | 5 + 4 files changed, 555 insertions(+), 111 deletions(-) create mode 100644 breadclipd/src/ignore_rules.rs diff --git a/breadclipd/src/content_kind.rs b/breadclipd/src/content_kind.rs index fd27a98..4e882e3 100644 --- a/breadclipd/src/content_kind.rs +++ b/breadclipd/src/content_kind.rs @@ -105,12 +105,29 @@ fn looks_like_code(text: &str) -> bool { }) .count(); - // Multi-line with a meaningful fraction of "code-shaped" lines, or an - // unambiguous single-line token (import/#include/fn signature), counts - // as code. A single short line of prose won't hit either bar. - token_hits >= 2 - || (lines.len() > 1 && brace_or_semicolon_lines * 2 >= lines.len()) - || (lines.len() == 1 && token_hits >= 1 && text.len() < 200) + // Multi-line with a meaningful fraction of "code-shaped" lines, or a + // couple of code tokens anywhere, counts as code. + if lines.len() > 1 { + return token_hits >= 2 || brace_or_semicolon_lines * 2 >= lines.len(); + } + + // A single line only counts as code if it actually *looks* like a code + // statement — a prose sentence that merely contains a keyword ("let me + // show you", "function of time", "class is a concept") must stay plain. + if lines.len() == 1 && token_hits >= 1 && text.len() < 200 { + let trimmed = text.trim_start(); + let import_like = trimmed.starts_with("import ") || trimmed.starts_with("#include"); + let code_shaped = trimmed.ends_with('{') + || trimmed.ends_with('}') + || trimmed.ends_with(';') + || trimmed.ends_with('(') + || trimmed.ends_with(')') + || trimmed.contains("=>") + || (trimmed.contains('(') && trimmed.contains(')')) + || trimmed.contains(" = "); + return import_like || code_shaped; + } + false } #[cfg(test)] @@ -170,6 +187,15 @@ mod tests { assert_eq!(detect("import numpy as np"), "code"); } + #[test] + fn single_line_prose_containing_a_keyword_is_plain() { + assert_eq!(detect("let me show you something"), "plain"); + assert_eq!(detect("function of time"), "plain"); + assert_eq!(detect("class is a concept"), "plain"); + assert_eq!(detect("const means constant"), "plain"); + assert_eq!(detect("def is short for define"), "plain"); + } + #[test] fn plain_prose_is_plain() { assert_eq!( diff --git a/breadclipd/src/ignore_rules.rs b/breadclipd/src/ignore_rules.rs new file mode 100644 index 0000000..cca4df7 --- /dev/null +++ b/breadclipd/src/ignore_rules.rs @@ -0,0 +1,182 @@ +//! Best-effort content heuristics for never persisting obvious secrets. +//! +//! The password-manager path (`CLIPBOARD_STATE=sensitive` / the +//! `x-kde-passwordManagerHint` MIME type) only catches copies an app +//! deliberately flagged. These rules catch copies that *look* like secrets +//! even when the app didn't flag them — one-time codes, credit card +//! numbers, private keys, credential lines. They are deliberately +//! conservative (a false "skip" is cheaper than a leaked password) and they +//! are a convenience, not a security boundary: the 0600 file permissions +//! and 0700 data dir are the real protection. + +/// Would persisting this text be a bad idea? Skipped copies never reach the +/// database (or the event bus), like the password-manager path. +pub fn is_sensitive(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return false; + } + looks_like_private_key(trimmed) + || looks_like_credential_line(trimmed) + || looks_like_otp(trimmed) + || contains_card_number(trimmed) + || contains_api_token(trimmed) +} + +/// PEM/OpenSSH private key blocks (and PGP blocks, which are just as +/// sensitive). +fn looks_like_private_key(text: &str) -> bool { + text.contains("-----BEGIN") || text.contains("PRIVATE KEY") +} + +/// A line that labels a credential directly: `password: hunter2`, +/// `passwd = hunter2`, etc. +fn looks_like_credential_line(text: &str) -> bool { + let first = text.lines().next().unwrap_or("").trim().to_ascii_lowercase(); + const PREFIXES: [&str; 8] = [ + "password:", + "password =", + "password=", + "passwd:", + "passwd =", + "passwd=", + "pw:", + "pass:", + ]; + PREFIXES.iter().any(|p| first.starts_with(p)) +} + +/// A one-time code: a short copy that mentions a code keyword and contains +/// a 6–8 digit run. A *bare* 6-digit number is deliberately not flagged — +/// too many legitimate numbers have that shape. +fn looks_like_otp(text: &str) -> bool { + if text.len() > 60 { + return false; + } + let lower = text.to_ascii_lowercase(); + const LABELS: [&str; 8] = [ + "code", "otp", "verification", "verif", "2fa", "passcode", "one-time", "one time", + ]; + if !LABELS.iter().any(|k| lower.contains(k)) { + return false; + } + has_digit_run(text, 6, 8) +} + +/// Credit card numbers: 13–19 digit runs (allowing spaces/dashes between +/// groups) that pass the Luhn check. +fn contains_card_number(text: &str) -> bool { + let mut run = String::new(); + for c in text.chars() { + if c.is_ascii_digit() { + run.push(c); + } else if (c == ' ' || c == '-') && !run.is_empty() { + run.push(' '); // keep one run across group separators + } else { + if card_like(&run) { + return true; + } + run.clear(); + } + } + card_like(&run) +} + +fn card_like(run: &str) -> bool { + let digits: String = run.chars().filter(|c| c.is_ascii_digit()).collect(); + (13..=19).contains(&digits.len()) && luhn_valid(&digits) +} + +fn luhn_valid(digits: &str) -> bool { + let mut sum: u32 = 0; + let mut double = false; + for b in digits.bytes().rev() { + let mut d = (b - b'0') as u32; + if double { + d *= 2; + if d > 9 { + d -= 9; + } + } + sum += d; + double = !double; + } + sum.is_multiple_of(10) +} + +/// Well-known API token prefixes (OpenAI, GitHub, Slack, AWS access keys). +fn contains_api_token(text: &str) -> bool { + const PREFIXES: [&str; 7] = ["sk-", "sk-proj-", "ghp_", "gho_", "xoxb-", "xoxp-", "AKIA"]; + PREFIXES.iter().any(|p| text.contains(p)) +} + +fn has_digit_run(text: &str, min: usize, max: usize) -> bool { + let mut run = 0usize; + for c in text.chars() { + if c.is_ascii_digit() { + run += 1; + } else { + if run >= min && run <= max { + return true; + } + run = 0; + } + } + run >= min && run <= max +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_key_blocks_are_sensitive() { + assert!(is_sensitive( + "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA..." + )); + assert!(is_sensitive("-----BEGIN PGP MESSAGE-----")); + } + + #[test] + fn credential_labeled_lines_are_sensitive() { + assert!(is_sensitive("password: hunter2")); + assert!(is_sensitive("Password = correct horse battery staple")); + assert!(is_sensitive("passwd=hunter2")); + } + + #[test] + fn labeled_one_time_codes_are_sensitive() { + assert!(is_sensitive("Your verification code is 483920")); + assert!(is_sensitive("483920 is your code")); + assert!(is_sensitive("OTP: 12345678")); + } + + #[test] + fn bare_numbers_are_not_sensitive() { + // A bare 6-digit number is too ambiguous to drop on purpose. + assert!(!is_sensitive("483920")); + assert!(!is_sensitive("The answer is 42")); + // A long random number that happens to be 16 digits but fails Luhn. + assert!(!is_sensitive("1234567890123456")); + } + + #[test] + fn luhn_valid_card_numbers_are_sensitive() { + assert!(is_sensitive("4111 1111 1111 1111")); + assert!(is_sensitive("card number: 4111-1111-1111-1111")); + } + + #[test] + fn api_token_prefixes_are_sensitive() { + assert!(is_sensitive("sk-proj-abc123def456")); + assert!(is_sensitive("ghp_1234567890abcdefghijklmnopqrstuvwxyz")); + assert!(is_sensitive("AKIAIOSFODNN7EXAMPLE")); + } + + #[test] + fn normal_prose_is_not_sensitive() { + assert!(!is_sensitive("just a normal sentence someone copied")); + assert!(!is_sensitive("here is the plan for next week")); + assert!(!is_sensitive("the password field in the form is empty")); + } +} diff --git a/breadclipd/src/main.rs b/breadclipd/src/main.rs index 012c6f1..981b0da 100644 --- a/breadclipd/src/main.rs +++ b/breadclipd/src/main.rs @@ -1,8 +1,11 @@ mod content_kind; +mod ignore_rules; use bread_utils::bread_client::BreadClient; -use breadclip_core::HistoryDb; -use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration}; +use bread_utils::singleton::{try_acquire, Acquire}; +use breadclip_core::{CaptureSource, HistoryDb}; +use serde_json::Value; +use std::{env, io::Read, process::Command, thread, time::Duration}; /// This app's id in bread's sibling-app namespace registry /// (`bread_shared::apps::KNOWN_APPS`) — events are published as @@ -14,15 +17,52 @@ const APP_ID: &str = "clip"; /// needed for a single internal flag). const CAPTURE_FLAG: &str = "--capture-once"; +/// Second positional arg distinguishing a capture spawned by the primary- +/// selection watcher from one spawned by the regular-clipboard watcher. +const PRIMARY_FLAG: &str = "--primary"; + /// MIME type convention (originating with KDE's Klipper) that password /// managers such as KeePassXC and Bitwarden set on clipboard content they -/// own, signaling "don't persist this". Any offer advertising it is skipped -/// entirely. +/// own, signaling "don't persist this". Only reachable on the fallback +/// capture path — when invoked through `wl-paste --watch`, `CLIPBOARD_STATE` +/// carries the same information from the same event (see `capture_once`). const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint"; -fn get_available_types() -> Vec { - Command::new("wl-paste") - .args(["--list-types"]) +/// Build a `wl-paste` command with a hard deadline. Without the timeout, a +/// stalled selection offer (source app died mid-transfer, compositor never +/// completes the handoff) leaves `wl-paste` blocked forever reading a pipe +/// that never closes — and a forever-blocked fallback capture would eat a +/// file descriptor per event over a long session. `timeout -k` guarantees a +/// SIGKILL if the initial SIGTERM doesn't land. Only used by the manual +/// fallback path (`capture_via_wl_paste`); the `--watch` path reads the +/// content from stdin and so has nothing that can hang. +fn wl_paste_cmd(primary: bool) -> Command { + let mut cmd = Command::new("timeout"); + cmd.args(["-k", "2", "5", "wl-paste"]); + if primary { + cmd.arg("--primary"); + } + cmd +} + +/// `wl-paste --watch` sets this in the spawned command's environment for +/// every clipboard event (see wl-paste(1)): `data` (read the content from +/// stdin), `nil` (empty clipboard), `clear` (explicitly cleared), or +/// `sensitive` (a password manager flagged the selection). Unset or +/// unrecognized means we were not invoked by `--watch` (e.g. a manual +/// `--capture-once` run) and we fall back to querying wl-paste directly. +const CLIPBOARD_STATE: &str = "CLIPBOARD_STATE"; + +/// Open the history DB with the retention caps from the user's config. The +/// `--capture-once` processes run per clipboard event, so they load the +/// config fresh rather than inheriting anything from the long-lived daemon. +fn open_db() -> Result { + HistoryDb::open_with(breadclip_core::config::load().retention) +} + +fn get_available_types(primary: bool) -> Vec { + wl_paste_cmd(primary) + .arg("--list-types") .output() .ok() .filter(|o| o.status.success()) @@ -31,8 +71,16 @@ fn get_available_types() -> Vec { .unwrap_or_default() } -fn get_clipboard_text() -> Option { - let output = Command::new("wl-paste") +fn get_clipboard_bytes(mime: &str, primary: bool) -> Option> { + let output = wl_paste_cmd(primary).args(["--type", mime]).output().ok()?; + if !output.status.success() || output.stdout.is_empty() { + return None; + } + Some(output.stdout) +} + +fn get_clipboard_text(primary: bool) -> Option { + let output = wl_paste_cmd(primary) .args(["--no-newline", "--type", "text/plain"]) .output() .ok()?; @@ -44,48 +92,80 @@ fn get_clipboard_text() -> Option { .filter(|s| !s.trim().is_empty()) } -fn get_clipboard_image() -> Option> { - let output = Command::new("wl-paste") - .args(["--type", "image/png"]) - .output() - .ok()?; - if !output.status.success() || output.stdout.is_empty() { - return None; +/// Identify an image payload by its magic bytes. `wl-paste --watch` hands us +/// the content on stdin without saying which offered type it picked, so the +/// bytes are the only reliable signal — and they're exact, because watch +/// mode never appends a trailing newline. +fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some("image/png"); } - Some(output.stdout) + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some("image/jpeg"); + } + None } -fn lock_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadclipd.lock") +fn source(primary: bool) -> CaptureSource { + if primary { + CaptureSource::Primary + } else { + CaptureSource::Clipboard + } } -// Returns false if another instance is already running. -fn acquire_lock() -> bool { - let path = lock_file(); - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(pid) = content.trim().parse::() { - let alive = fs::read_to_string(format!("/proc/{}/comm", pid)) - .map(|s| s.trim() == "breadclipd") - .unwrap_or(false); - if alive { - eprintln!("breadclipd: already running (pid {})", pid); - return false; - } +fn store_text(text: &str, primary: bool) { + let db = match open_db() { + Ok(db) => db, + Err(e) => { + eprintln!("breadclipd: failed to open database: {e}"); + return; + } + }; + match db.insert_text(text, source(primary)) { + Ok(()) => emit_copied(content_kind::detect(text), text.len()), + Err(e) => eprintln!("breadclipd: insert text: {e}"), + } +} + +fn store_image(bytes: Vec, mime: &str, primary: bool) { + let db = match open_db() { + Ok(db) => db, + Err(e) => { + eprintln!("breadclipd: failed to open database: {e}"); + return; + } + }; + match db.insert_image(&bytes, mime, source(primary)) { + Ok(()) => emit_copied("image", bytes.len()), + Err(e) => eprintln!("breadclipd: insert image: {e}"), + } +} + +/// Capture path for `wl-paste --watch` invocations: the clipboard content +/// arrives on our stdin together with its `CLIPBOARD_STATE` in one event, so +/// the sensitive check and the data read can't race each other (two separate +/// `wl-paste` calls could straddle a clipboard change mid-capture). +fn capture_from_stdin(primary: bool) { + let mut buf = Vec::new(); + if std::io::stdin().read_to_end(&mut buf).is_err() || buf.is_empty() { + return; + } + if let Some(mime) = sniff_image_mime(&buf) { + store_image(buf, mime, primary); + } else if let Ok(text) = String::from_utf8(buf) { + if !text.trim().is_empty() && !ignore_rules::is_sensitive(&text) { + store_text(&text, primary); } } - let _ = fs::write(&path, std::process::id().to_string()); - true } -/// Invoked once per clipboard-change event (as the handler command for -/// `wl-paste --watch`). Reads whatever is on the clipboard right now, skips -/// it entirely if a password manager flagged it as sensitive, and otherwise -/// persists a text or image entry to the history DB. -fn capture_once() { - let types = get_available_types(); +/// Fallback capture path for manual `--capture-once` invocations (no +/// `CLIPBOARD_STATE` was set by wl-paste): ask wl-paste directly. Same +/// behavior as the stdin path, including requesting whichever image type is +/// actually offered rather than assuming PNG. +fn capture_via_wl_paste(primary: bool) { + let types = get_available_types(primary); if types.iter().any(|t| t == PASSWORD_HINT_MIME) { // Password manager (KeePassXC, Bitwarden, etc.) marked this copy as @@ -97,31 +177,42 @@ fn capture_once() { let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg"); let has_text = types.iter().any(|t| t.starts_with("text/")); - let db = match HistoryDb::open() { - Ok(db) => db, - Err(e) => { - eprintln!("breadclipd: failed to open database: {e}"); - return; - } - }; - if has_image && !has_text { - if let Some(bytes) = get_clipboard_image() { - match db.insert_image(&bytes) { - Ok(()) => emit_copied("image", bytes.len()), - Err(e) => eprintln!("breadclipd: insert image: {e}"), - } + let mime = if types.iter().any(|t| t == "image/png") { + "image/png" + } else { + "image/jpeg" + }; + if let Some(bytes) = get_clipboard_bytes(mime, primary) { + store_image(bytes, mime, primary); } } else if has_text { - if let Some(text) = get_clipboard_text() { - match db.insert_text(&text) { - Ok(()) => emit_copied(content_kind::detect(&text), text.len()), - Err(e) => eprintln!("breadclipd: insert text: {e}"), + if let Some(text) = get_clipboard_text(primary) { + if !ignore_rules::is_sensitive(&text) { + store_text(&text, primary); } } } } +/// Invoked once per clipboard-change event (as the handler command for +/// `wl-paste --watch`). Stores a text or image entry to the history DB, +/// unless the selection was flagged as sensitive — in which case nothing is +/// ever persisted. +fn capture_once(primary: bool) { + match env::var(CLIPBOARD_STATE).as_deref() { + // Password manager (KeePassXC, Bitwarden, ...) flagged this selection + // as sensitive via `wl-copy --sensitive` — never persist it. + Ok("sensitive") => {} + // Empty clipboard (nil) or an explicit clear — nothing to store. + Ok("nil") | Ok("clear") => {} + Ok("data") => capture_from_stdin(primary), + // Unset or unrecognized: not invoked by `--watch`. Fall back to the + // direct wl-paste path rather than blocking on whatever stdin is. + _ => capture_via_wl_paste(primary), + } +} + /// Publishes `bread.clip.copied` into the bread event fabric. Fire-and-forget /// and non-fatal by design (`BreadClient::emit` never blocks or errors this /// caller) — breadd being absent or not installed must never affect @@ -134,19 +225,18 @@ fn emit_copied(kind: &str, len: usize) { ); } -/// Reacts to `bread.command.clip.*` verbs. Only `clear` maps to real, -/// existing breadclip functionality today — `pin`/`select` would need a new -/// "pinned" concept that doesn't exist anywhere in the history DB schema, -/// which is a real product decision for breadclip itself (does it want -/// pinning at all, and what would the GTK UI for it look like?), not -/// something to fabricate as a side effect of wiring up the event bus. See +/// Reacts to `bread.command.clip.*` verbs. Only `clear` and `pin` map to +/// real, existing breadclip functionality today — `select` would need a +/// "remote-activate a row" concept the popup doesn't expose over the bus, +/// which is a real product decision for breadclip itself, not something to +/// fabricate as a side effect of wiring up the event bus. See /// `breadclip/EVENTS.md` for the honest current status. /// -/// Emits `bread.clip..done`/`.failed` per the confirmation convention -/// in bread's Documentation.md — a module that started this command via -/// `bread.wait`/`bread.wait_any` can await the real outcome instead of -/// assuming success the moment it publishes the command. -fn handle_command(event_name: &str) { +/// Emits `bread.clip..done`/`bread.clip.pinned`/`.failed` per the +/// confirmation convention in bread's Documentation.md — a module that +/// started this command via `bread.wait`/`bread.wait_any` can await the real +/// outcome instead of assuming success the moment it publishes the command. +fn handle_command(event_name: &str, data: &Value) { let Some(verb) = event_name.strip_prefix("bread.command.clip.") else { return; }; @@ -164,22 +254,121 @@ fn handle_command(event_name: &str) { ); } }, + "pin" => match data.get("id").and_then(Value::as_i64) { + Some(id) => { + let pin = data.get("pin").and_then(Value::as_bool).unwrap_or(true); + match HistoryDb::open().and_then(|db| db.set_pinned(id, pin)) { + Ok(()) => { + eprintln!( + "breadclipd: {} entry {id} via bread.command.clip.pin", + if pin { "pinned" } else { "unpinned" } + ); + BreadClient::connect(APP_ID).emit( + "bread.clip.pinned", + serde_json::json!({ "id": id, "pinned": pin }), + ); + } + Err(e) => { + eprintln!("breadclipd: bread.command.clip.pin failed: {e}"); + BreadClient::connect(APP_ID).emit( + "bread.clip.pin.failed", + serde_json::json!({ "error": e.to_string() }), + ); + } + } + } + None => { + eprintln!("breadclipd: bread.command.clip.pin missing 'id'"); + BreadClient::connect(APP_ID).emit( + "bread.clip.pin.failed", + serde_json::json!({ "error": "missing 'id'" }), + ); + } + }, other => { eprintln!("breadclipd: ignoring unrecognized command verb '{other}'"); } } } +/// Backoff for the `wl-paste --watch` restart loops: 2s → 4s → 8s → 16s → +/// 32s, capped at 30s. A dead compositor keeps the daemon alive without +/// hammering either wl-paste or the journal. +fn restart_delay(consecutive_failures: u32) -> Duration { + Duration::from_secs((2u64 << consecutive_failures.saturating_sub(1).min(4)).min(30)) +} + +/// Log a watch-loop restart, but only on state changes — the first failure +/// announces it, then every 8th attempt. Otherwise a persistent failure +/// would spam the journal every two seconds forever. +fn log_restart(what: &str, detail: &str, attempt: u32) { + if attempt == 1 || attempt.is_multiple_of(8) { + eprintln!("breadclipd: {what} ({detail}), restarting (attempt {attempt})"); + } +} + +/// Runs one `wl-paste --watch` watcher for the daemon's whole lifetime, +/// restarting it with capped backoff whenever it exits (e.g. the compositor +/// connection dropped). `primary` selects the middle-click primary selection +/// watcher (`--watch --primary`) instead of the regular clipboard watcher. +fn watch_loop(exe: std::path::PathBuf, primary: bool) { + let label = if primary { + "wl-paste --watch (primary)" + } else { + "wl-paste --watch" + }; + let mut consecutive_failures: u32 = 0; + loop { + // `wl-paste --watch ` runs once per selection-change + // event instead of polling — zero idle cost between changes, and no + // forking wl-paste repeatedly. Each event re-invokes this same + // binary with --capture-once to do a single read-and-store pass. + let mut cmd = Command::new("wl-paste"); + cmd.arg("--watch"); + if primary { + cmd.arg("--primary"); + } + cmd.arg(&exe).arg(CAPTURE_FLAG); + if primary { + cmd.arg(PRIMARY_FLAG); + } + let status = cmd.status(); + + match status { + Ok(s) => { + consecutive_failures += 1; + log_restart(label, &format!("{s}"), consecutive_failures); + } + Err(e) => { + consecutive_failures += 1; + log_restart(label, &e.to_string(), consecutive_failures); + } + } + thread::sleep(restart_delay(consecutive_failures)); + } +} + fn main() { let args: Vec = env::args().collect(); if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) { - capture_once(); + capture_once(args.iter().any(|a| a == PRIMARY_FLAG)); return; } - if !acquire_lock() { - std::process::exit(1); - } + // flock(2)-based singleton (bread_utils::singleton): lock ownership is + // kernel-atomic and released automatically the instant this process + // dies, so there's no stale-pid-file case to reason about. + let _singleton = match try_acquire("breadclipd") { + Ok(Acquire::Acquired(guard)) => Some(guard), + Ok(Acquire::HeldByOther(pid)) => { + eprintln!("breadclipd: already running (pid {:?})", pid); + std::process::exit(1); + } + Err(e) => { + eprintln!("breadclipd: single-instance lock unavailable ({e}); continuing without it"); + None + } + }; // Wait briefly for WAYLAND_DISPLAY — common when started early in the session let mut retries = 0; @@ -189,14 +378,14 @@ fn main() { } if env::var("WAYLAND_DISPLAY").is_err() { eprintln!("breadclipd: WAYLAND_DISPLAY not set after waiting, exiting"); - let _ = fs::remove_file(lock_file()); std::process::exit(1); } + let cfg = breadclip_core::config::load(); + // Fail fast (before we start watching) if the DB can't be opened. - if let Err(e) = HistoryDb::open() { + if let Err(e) = HistoryDb::open_with(cfg.retention) { eprintln!("breadclipd: failed to open database: {e}"); - let _ = fs::remove_file(lock_file()); std::process::exit(1); } @@ -204,7 +393,6 @@ fn main() { Ok(p) => p, Err(e) => { eprintln!("breadclipd: could not resolve own executable path: {e}"); - let _ = fs::remove_file(lock_file()); std::process::exit(1); } }; @@ -219,29 +407,22 @@ fn main() { // delivering commands until it reconnects. let command_client = BreadClient::connect(APP_ID); let _commands = command_client.subscribe("bread.command.clip.**", |event| { - handle_command(&event.event); + handle_command(&event.event, &event.data); }); - // `wl-paste --watch ` runs once per clipboard-change event - // instead of polling — zero idle cost between changes, and no more - // forking wl-paste 4-6 times a second. Each event re-invokes this same - // binary with --capture-once to do a single read-and-store pass. - loop { - let status = Command::new("wl-paste") - .args(["--watch"]) - .arg(&exe) - .arg(CAPTURE_FLAG) - .status(); + // Regular-clipboard watcher, always on. + let clipboard_exe = exe.clone(); + thread::spawn(move || watch_loop(clipboard_exe, false)); + // Primary-selection watcher (middle-click), opt-in via config. + if cfg.capture_primary { + eprintln!("breadclipd: watching primary selection"); + thread::spawn(move || watch_loop(exe, true)); + } - match status { - Ok(s) => { - eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s"); - } - Err(e) => { - eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s"); - } - } - thread::sleep(Duration::from_secs(2)); + // The watcher threads own the daemon's lifetime; this thread just keeps + // the process (and the singleton guard above) alive. + loop { + thread::sleep(Duration::from_secs(3600)); } } @@ -267,6 +448,22 @@ mod tests { f(); } + #[test] + fn sniff_image_mime_detects_png_and_jpeg() { + assert_eq!(sniff_image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png")); + assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg")); + assert_eq!(sniff_image_mime(b"plain text"), None); + } + + #[test] + fn restart_delay_backs_off_and_caps() { + assert_eq!(restart_delay(1), Duration::from_secs(2)); + assert_eq!(restart_delay(2), Duration::from_secs(4)); + assert_eq!(restart_delay(3), Duration::from_secs(8)); + assert_eq!(restart_delay(4), Duration::from_secs(16)); + assert_eq!(restart_delay(100), Duration::from_secs(30)); + } + #[test] fn handle_command_clear_empties_history_even_with_no_daemon_reachable() { // The point of this test: handle_command's actual effect (clearing @@ -276,23 +473,57 @@ mod tests { // made contingent on the emit succeeding, this test would fail. with_isolated_history(|| { let db = HistoryDb::open().expect("open history db"); - db.insert_text("something to clear").unwrap(); + db.insert_text("something to clear", CaptureSource::Clipboard).unwrap(); assert_eq!(db.list_entries(10).unwrap().len(), 1); - handle_command("bread.command.clip.clear"); + handle_command("bread.command.clip.clear", &serde_json::json!({})); let db = HistoryDb::open().expect("reopen history db"); assert!(db.list_entries(10).unwrap().is_empty()); }); } + #[test] + fn handle_command_pin_toggles_pinned_state() { + with_isolated_history(|| { + let db = HistoryDb::open().expect("open history db"); + db.insert_text("to pin", CaptureSource::Clipboard).unwrap(); + let id = db.list_entries(10).unwrap()[0].id; + assert!(!db.list_entries(10).unwrap()[0].pinned); + + handle_command( + "bread.command.clip.pin", + &serde_json::json!({ "id": id, "pin": true }), + ); + assert!(db.list_entries(10).unwrap()[0].pinned); + + handle_command( + "bread.command.clip.pin", + &serde_json::json!({ "id": id, "pin": false }), + ); + assert!(!db.list_entries(10).unwrap()[0].pinned); + }); + } + + #[test] + fn handle_command_pin_without_id_is_a_no_op() { + with_isolated_history(|| { + let db = HistoryDb::open().expect("open history db"); + db.insert_text("untouched", CaptureSource::Clipboard).unwrap(); + + handle_command("bread.command.clip.pin", &serde_json::json!({})); + + assert!(!db.list_entries(10).unwrap()[0].pinned); + }); + } + #[test] fn handle_command_ignores_unrecognized_verb() { with_isolated_history(|| { let db = HistoryDb::open().expect("open history db"); - db.insert_text("should survive").unwrap(); + db.insert_text("should survive", CaptureSource::Clipboard).unwrap(); - handle_command("bread.command.clip.pin"); + handle_command("bread.command.clip.select", &serde_json::json!({})); let db = HistoryDb::open().expect("reopen history db"); assert_eq!( @@ -307,11 +538,11 @@ mod tests { fn handle_command_ignores_events_outside_its_own_command_namespace() { with_isolated_history(|| { let db = HistoryDb::open().expect("open history db"); - db.insert_text("should survive").unwrap(); + db.insert_text("should survive", CaptureSource::Clipboard).unwrap(); // Not a `bread.command.clip.*` event at all — must be a no-op. - handle_command("bread.command.pad.clear"); - handle_command("bread.clip.copied"); + handle_command("bread.command.pad.clear", &serde_json::json!({})); + handle_command("bread.clip.copied", &serde_json::json!({})); let db = HistoryDb::open().expect("reopen history db"); assert_eq!(db.list_entries(10).unwrap().len(), 1); diff --git a/contrib/breadclipd.service b/contrib/breadclipd.service index d206506..be5cb24 100644 --- a/contrib/breadclipd.service +++ b/contrib/breadclipd.service @@ -11,6 +11,11 @@ ExecStart=%h/.cargo/bin/breadclipd Restart=on-failure RestartSec=2 +# wl-paste/wl-copy live on the user's PATH (e.g. ~/.local/bin or +# ~/.cargo/bin via bakery/cargo install), which systemd user services don't +# inherit by default — without this, breadclipd starts but captures nothing. +Environment=PATH=%h/.local/bin:%h/.cargo/bin:/usr/local/bin:/usr/bin:/bin + # Forward stdout/stderr to the journal so `journalctl --user -u breadclipd` works StandardOutput=journal StandardError=journal From d308593efdc1069d27dd84b13143d26a99afc1f9 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 15:07:35 +0800 Subject: [PATCH 34/35] breadclip: pin toggle, primary/pin badges, config-driven panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+P pins/unpins the selected row, re-reads history (pinned rows sort first), rebuilds the list and re-selects the toggled row. - ★ badge on pinned rows, "primary" badge on primary-selection rows. - Panel width comes from `config.panel_width`; positioning now runs after the panel is realised and measures its natural height instead of always assuming the worst-case estimate, so a short history gets a correctly-anchored short panel. - Delete in the search box edits the query again instead of deleting the selected history row. - Re-copy offers an image row as its stored MIME type (JPEG stays JPEG). History fetch is a flat cap since pinned rows can exceed retention. --- breadclip/src/main.rs | 234 +++++++++++++++++++++++++++++------------- 1 file changed, 160 insertions(+), 74 deletions(-) diff --git a/breadclip/src/main.rs b/breadclip/src/main.rs index 4d2858e..d2eb00a 100644 --- a/breadclip/src/main.rs +++ b/breadclip/src/main.rs @@ -18,11 +18,13 @@ use std::{ rc::Rc, }; -const PANEL_WIDTH: i32 = 520; -const MAX_ENTRIES: usize = 200; +// Pinned entries are exempt from trimming, so the fetch limit can't be +// derived from the retention caps alone. History stays tiny in practice — +// fetching it all keeps every pinned row reachable regardless of count. +const FETCH_ALL: usize = 10_000; const PANEL_GAP: i32 = 8; // gap between focused window bottom and panel top -// Worst-case panel height (search + chips + full-height list), used to keep -// the panel fully on-screen before its natural size is known. +// Fallback panel height, used only if measuring the panel before it's shown +// reports nothing sensible. const PANEL_HEIGHT_ESTIMATE: i32 = 580; #[derive(Clone, Copy, PartialEq)] @@ -142,6 +144,21 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow { hbox.append(&text_lbl); } + // Pin / primary-selection badges + if entry.pinned { + let pin_lbl = Label::new(Some("★")); + pin_lbl.add_css_class("clip-meta"); + pin_lbl.add_css_class("clip-pinned"); + pin_lbl.set_tooltip_text(Some("Pinned — Ctrl+P toggles")); + hbox.append(&pin_lbl); + } + if entry.is_primary { + let prim_lbl = Label::new(Some("primary")); + prim_lbl.add_css_class("clip-meta"); + prim_lbl.set_tooltip_text(Some("Copied from the primary (middle-click) selection")); + hbox.append(&prim_lbl); + } + let ts_lbl = Label::new(Some(&format_timestamp(entry.timestamp))); ts_lbl.add_css_class("clip-meta"); ts_lbl.set_xalign(1.0); @@ -153,31 +170,30 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow { } fn do_copy(entry: &ClipEntry) { - match entry.mime_type.as_str() { - "image/png" => { - if let Some(ref path) = entry.image_path { - if let Ok(file) = fs::File::open(path) { - let _ = Command::new("wl-copy") - .args(["--type", "image/png"]) - .stdin(Stdio::from(file)) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } - } - } - _ => { - if let Some(ref content) = entry.content { - if let Ok(mut child) = Command::new("wl-copy") - .stdin(Stdio::piped()) + if entry.mime_type.starts_with("image/") { + // Re-paste with the same type we stored — a JPEG row must be offered + // as image/jpeg, not relabeled as image/png. + if let Some(ref path) = entry.image_path { + if let Ok(file) = fs::File::open(path) { + let _ = Command::new("wl-copy") + .args(["--type", entry.mime_type.as_str()]) + .stdin(Stdio::from(file)) .stdout(Stdio::null()) .stderr(Stdio::null()) - .spawn() - { - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(content.as_bytes()); - } - } + .spawn(); + } + } + return; + } + if let Some(ref content) = entry.content { + if let Ok(mut child) = Command::new("wl-copy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(content.as_bytes()); } } } @@ -186,6 +202,13 @@ fn do_copy(entry: &ClipEntry) { // ---- UI --------------------------------------------------------------------- fn run_ui(entries: Vec, screenshot_req: Option) { + // Live snapshot of the entries backing the list — pinning re-reads the + // DB (pinned rows sort first) and rebuilds the rows from this. Built + // outside connect_activate because that handler is `Fn` (runs once per + // activation) and can't consume the entries vector. + let entries_rc: Rc>> = + Rc::new(std::cell::RefCell::new(entries)); + let mut builder = Application::builder().application_id("com.breadway.breadclip"); if screenshot_req.is_some() { // GApplication is single-instance by default; this machine typically @@ -205,52 +228,10 @@ fn run_ui(entries: Vec, screenshot_req: Option= PANEL_HEIGHT_ESTIMATE || space_below >= space_above { - win.y() + win.height() + PANEL_GAP - } else { - win.y() - PANEL_GAP - PANEL_HEIGHT_ESTIMATE - }; - let clamped_top = top - .min(mon_y + mon_h - PANEL_HEIGHT_ESTIMATE - PANEL_GAP) - .max(mon_y + PANEL_GAP); - - panel.set_halign(gtk4::Align::Start); - panel.set_valign(gtk4::Align::Start); - panel.set_margin_top(clamped_top); - panel.set_margin_start(clamped_left); - } else { - panel.set_halign(gtk4::Align::Center); - panel.set_valign(gtk4::Align::Center); - } + panel.set_size_request(panel_width, -1); // ---- Search entry ---- let search = SearchEntry::new(); @@ -286,7 +267,7 @@ fn run_ui(entries: Vec, screenshot_req: Option, screenshot_req: Option 0 { + natural_h + } else { + PANEL_HEIGHT_ESTIMATE + }; + + // If a non-fullscreen window is focused, anchor the panel just below it. + // Otherwise, centre the panel on screen. + let active_win = bread_utils::hypr::active_window(); + let monitor = bread_utils::hypr::focused_monitor(); + if let Some(ref win) = active_win { + let (mon_x, mon_y, mon_w, mon_h) = monitor + .as_ref() + .map(|m| (m.x, m.y, m.width, m.height)) + .unwrap_or((0, 0, 1920, 1080)); + + // Clamp horizontally so the panel never runs off the left/right + // edge of the focused monitor. + let clamped_left = win + .x() + .min(mon_x + mon_w - panel_width - PANEL_GAP) + .max(mon_x + PANEL_GAP); + + // Prefer anchoring below the window, but flip above it when there + // isn't enough room underneath (e.g. a maximized/tiled window + // with a text box near the bottom of the screen) — otherwise the + // panel gets pushed off-screen and never becomes visible. + let space_below = (mon_y + mon_h) - (win.y() + win.height() + PANEL_GAP); + let space_above = win.y() - mon_y - PANEL_GAP; + let top = if space_below >= panel_height || space_below >= space_above { + win.y() + win.height() + PANEL_GAP + } else { + win.y() - PANEL_GAP - panel_height + }; + let clamped_top = top + .min(mon_y + mon_h - panel_height - PANEL_GAP) + .max(mon_y + PANEL_GAP); + + panel.set_halign(gtk4::Align::Start); + panel.set_valign(gtk4::Align::Start); + panel.set_margin_top(clamped_top); + panel.set_margin_start(clamped_left); + } else { + panel.set_halign(gtk4::Align::Center); + panel.set_valign(gtk4::Align::Center); + } + // ---- Shared state ---- let query_rc: Rc> = Rc::new(std::cell::RefCell::new(String::new())); let filter_rc: Rc> = Rc::new(Cell::new(Filter::All)); @@ -349,7 +384,11 @@ fn run_ui(entries: Vec, screenshot_req: Option { @@ -366,6 +405,11 @@ fn run_ui(entries: Vec, screenshot_req: Option { + // Deleting a character in the search box must not + // delete the selected history row. + if search_k.has_focus() { + return glib::Propagation::Proceed; + } if let Some(row) = list_k.selected_row() { if let Some(entry) = get_row_entry(&row) { if let Ok(db) = HistoryDb::open() { @@ -394,6 +438,48 @@ fn run_ui(entries: Vec, screenshot_req: Option { + if let Some(row) = list_k.selected_row() { + if let Some(entry) = get_row_entry(&row) { + if let Ok(db) = HistoryDb::open() { + let _ = db.set_pinned(entry.id, !entry.pinned); + } + let target_id = entry.id; + let new_entries = HistoryDb::open() + .and_then(|db| db.list_entries(FETCH_ALL)) + .unwrap_or_default(); + *entries_k.borrow_mut() = new_entries; + while let Some(r) = list_k.row_at_index(0) { + list_k.remove(&r); + } + { + let rows = entries_k.borrow(); + for e in rows.iter() { + list_k.append(&build_row(e)); + } + } + let query = query_k.borrow().clone(); + refresh_list(&list_k, &query, filter_k.get()); + // Prefer re-selecting the row we just toggled. + let mut i = 0; + while let Some(r) = list_k.row_at_index(i) { + if get_row_entry(&r) + .map(|e| e.id == target_id) + .unwrap_or(false) + && r.is_visible() + { + list_k.select_row(Some(&r)); + break; + } + i += 1; + } + } + } + glib::Propagation::Stop + } _ => glib::Propagation::Proceed, } }); @@ -465,7 +551,7 @@ fn main() { }; let entries = HistoryDb::open() - .and_then(|db| db.list_entries(MAX_ENTRIES)) + .and_then(|db| db.list_entries(FETCH_ALL)) .unwrap_or_default(); run_ui(entries, screenshot_req); From 6db4a960261b59aa2f7442a32a604cd4bb84f325 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 15:07:35 +0800 Subject: [PATCH 35/35] breadclip: document pin verb, config file, and ignore rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EVENTS.md: `bread.command.clip.pin` and `bread.clip.pinned` / `.pin.failed` are now implemented; `select` stays explicitly not implemented with the reason. AGENTS.md follows. - README: Configuration section, Ctrl+P bind, updated privacy notes (CLIPBOARD_STATE + ignore rules), JPEG image entries, primary badge. - check.yml also runs on pushes to `main` — a push to main triggers a dev-track release build, so it should be linted/tested first. - release.yml uses `${GITHUB_REPOSITORY}` instead of a hard-coded `Breadway/breadclip` for the GitHub mirror release upload. --- .forgejo/workflows/check.yml | 7 ++++--- .forgejo/workflows/release.yml | 4 ++-- AGENTS.md | 14 ++++++++------ EVENTS.md | 32 +++++++++++++++++++++----------- README.md | 32 +++++++++++++++++++++++++++++--- contrib/config.toml.example | 24 ++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 contrib/config.toml.example diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml index b547c34..fbbf1a1 100644 --- a/.forgejo/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -1,10 +1,11 @@ name: check -# Fast-fail lint/test on short-lived work branches, before it ever reaches -# main and triggers a dev-track release build. +# Fast-fail lint/test on short-lived work branches, and on `main` itself — +# a push to main immediately triggers a dev-track release build, so it +# should be linted/tested first, not shipped unchecked. on: push: - branches: ['feature/**', 'fix/**'] + branches: ['feature/**', 'fix/**', 'main'] jobs: check: diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 0105b92..f8b8290 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -64,9 +64,9 @@ jobs: set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/breadclip/${VERSION}" - gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadclip \ + gh release create "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \ --title "breadclip v${VERSION}" --generate-notes 2>/dev/null || true - gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadclip \ + gh release upload "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \ "${PKG_DIR}/breadclip-x86_64" \ "${PKG_DIR}/breadclipd-x86_64" \ "${PKG_DIR}/breadclip-x86_64.sha256" \ diff --git a/AGENTS.md b/AGENTS.md index dc7aab5..f5e1185 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,12 +51,14 @@ Three crates: through `bread-screenshots`; do not rewrite it just to retarget the crate pin. `EVENTS.md` is the bread-event contract. App id `clip`. Implemented: -`bread.clip.copied`, `bread.clip.clear.done`/`.failed`, and command -`bread.command.clip.clear`. There is no pin/select — the history schema has -no pinned column and the popup has no pin UI. Do not invent -`bread.command.clip.pin`/`.select` (or matching events) ahead of a real -product feature. +`bread.clip.copied`, `bread.clip.clear.done`/`.failed`, +`bread.clip.pinned`/`bread.clip.pin.failed`, and commands +`bread.command.clip.clear` and `bread.command.clip.pin`. Pinning is real: +the history schema has a `pinned` column, the popup has a Ctrl+P toggle, and +trim exempts pinned rows. There is no `select` — the popup is a transient +process with no resident service to receive `bread.command.clip.select`; +do not invent it (or `bread.clip.selected`) ahead of a real product feature. ## Don't - Don't embed credentials in remote URLs — SSH or a credential helper only. -- Don't invent pin/select on the event bus. See `EVENTS.md`. +- Don't invent `select` on the event bus. See `EVENTS.md`. diff --git a/EVENTS.md b/EVENTS.md index 0a5e520..f84061f 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -21,6 +21,8 @@ per-clipboard-change process invocation, not inside a persistent loop). | `bread.clip.copied` | `{ "kind": "url" \| "error" \| "code" \| "path" \| "plain", "len": }` | Every successful clipboard capture (text or image — images always get `kind: "image"`). `kind` is a heuristic classification (see `breadclipd/src/content_kind.rs`), not a guarantee — don't build a security decision on it. | | `bread.clip.clear.done` | `{}` | `bread.command.clip.clear` was received and history was successfully cleared. | | `bread.clip.clear.failed` | `{ "error": "" }` | `bread.command.clip.clear` was received but clearing failed (e.g. DB error). | +| `bread.clip.pinned` | `{ "id": , "pinned": true \| false }` | An entry was pinned or unpinned — either via `bread.command.clip.pin`, or locally from the popup's Ctrl+P toggle (which updates the DB directly; see below). | +| `bread.clip.pin.failed` | `{ "error": "" }` | `bread.command.clip.pin` was received but pinning failed (e.g. DB error, or the command was missing its `id`). | Content is never included in the payload — only its detected kind and length. History (including the actual copied content) stays local to breadclip's own SQLite database; the event bus is for *notifications about* clipboard activity, not a channel for clipboard content itself. @@ -28,19 +30,27 @@ Content is never included in the payload — only its detected kind and length. | Verb | Effect | |------|--------| -| `clear` | Deletes all clipboard history (text entries and stored image files). Emits `bread.clip.clear.done`/`.failed`. | +| `clear` | Deletes all clipboard history (text entries, stored image files, pinned entries — it's a hard reset). Emits `bread.clip.clear.done`/`.failed`. | +| `pin` | Payload `{ "id": , "pin": true \| false }`. Pins or unpins a history entry by id; `pin` defaults to `true` if omitted. Emits `bread.clip.pinned`/`bread.clip.pin.failed`. | -### Not implemented: `pin` / `select` +Pinned entries are exempt from the daemon's trimming and sort to the top of +the history list. The popup's Ctrl+P toggle writes the same `pinned` column +directly (it doesn't round-trip through the bus), and also emits +`bread.clip.pinned`, so automation can observe either path. Entry `id`s come +from `list_entries` — there is currently no read API on the bus for the +history contents. -An earlier draft of this integration planned `pin`/`select` verbs, but -breadclip's history schema has no "pinned" concept at all today — there's no -column for it, and the GTK popup UI has no corresponding affordance. Adding -real pin/select support is a product decision for breadclip itself (does it -want pinning, and what should the UI look like?), not something to fabricate -as a side effect of wiring up the event bus. If/when breadclip grows that -feature, the corresponding `bread.command.clip.pin`/`.select` verbs (and -matching `bread.clip.pinned`/`.selected` events) should be added at the same -time, not stubbed out ahead of it. +### Not implemented: `select` + +`select` (remote-activate a row from the bus, e.g. "paste entry 42 now") +is still deliberately not implemented: the popup is a short-lived process +with no resident service to receive that command, and the daemon has no +reason to talk to a transient popup. That's a real product decision for +breadclip itself — what should "select from the bus" even mean when the +popup isn't open? — not something to fabricate as a side effect of wiring +up the event bus. When breadclip grows that feature, the matching +`bread.command.clip.select` verb and `bread.clip.selected` event should be +added at the same time. ## Fail-safe behavior diff --git a/README.md b/README.md index debe88b..2ddf262 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Running `breadclip` a second time while it is open closes it (toggle behaviour). | `Up` / `Down` | Move selection | | `Enter` | Copy selected entry to clipboard and close | | `Delete` | Remove selected entry from history | +| `Ctrl+P` | Pin/unpin selected entry (pinned entries survive trimming and sort to the top) | | `Escape` | Close without copying | Clicking an entry copies it and closes the popup. Clicking outside the panel closes it. @@ -85,6 +86,25 @@ Clicking an entry copies it and closes the popup. Clicking outside the panel clo The popup has three filter chips — **All**, **Text**, **Images** — and a search box. The search box filters text entries by content; image entries only appear under the **Images** filter. +## Configuration + +Optional TOML config at `$XDG_CONFIG_HOME/breadclip/config.toml` (typically +`~/.config/breadclip/config.toml`). Every key has a sensible default, so the +file can be omitted entirely — a copy of the full example lives in +`contrib/config.toml.example`: + +```toml +[retention] +text = 200 # max non-pinned text entries (0 = keep none) +images = 50 # max non-pinned image entries (0 = keep none) + +[panel] +width = 520 # popup panel width, px + +[capture] +primary = false # also watch the middle-click primary selection +``` + ## Data storage History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/breadclip/`): @@ -92,14 +112,20 @@ History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/b | Path | Contents | |------|----------| | `history.db` | SQLite database of all entries | -| `images/` | PNG files for image entries | +| `images/` | PNG/JPEG files for image entries | -The daemon keeps at most 200 text entries and 50 image entries, trimming oldest entries automatically. +The daemon trims the oldest non-pinned entries automatically, keeping at +most `retention.text` text entries and `retention.images` image entries +(defaults 200 and 50; configurable). **Pinned entries are exempt from +trimming** and sort to the top of the popup. Entries captured from the +primary (middle-click) selection — when `capture.primary = true` — are +stored alongside regular clipboard entries with a `primary` badge. ### Privacy - `history.db` and every file under `images/` are created with `0600` permissions (owner read/write only), regardless of your umask. -- breadclipd **never persists clipboard content flagged as sensitive by a password manager**. If a clipboard offer advertises the `x-kde-passwordManagerHint` MIME type — the convention used by KeePassXC, Bitwarden, and other password managers to mark content they own — that copy is skipped entirely and never reaches the database. +- breadclipd **never persists clipboard content flagged as sensitive**. `wl-paste --watch` reports copies made with `wl-copy --sensitive` — which also covers offers advertising the `x-kde-passwordManagerHint` MIME type, the convention used by KeePassXC, Bitwarden, and other password managers to mark content they own — via `CLIPBOARD_STATE=sensitive`, and those copies are skipped entirely and never reach the database. +- On top of that, breadclipd runs **best-effort ignore rules** that skip copies that *look* like secrets even when the app didn't flag them: one-time codes, Luhn-valid credit card numbers, private key blocks, `password:`-style credential lines, and well-known API token prefixes (see `breadclipd/src/ignore_rules.rs`). These are deliberately conservative and are a convenience, not a security boundary — the 0600/0700 permissions are the real protection. - That said, this is still a plaintext SQLite database of everything else you copy. Anything copied by an app that doesn't set the hint (e.g. copying a password from a terminal or a non-integrated app) will be stored like any other text entry. Treat `history.db` as sensitive, and don't rely on it as your only safeguard. ## Theming diff --git a/contrib/config.toml.example b/contrib/config.toml.example new file mode 100644 index 0000000..69b8928 --- /dev/null +++ b/contrib/config.toml.example @@ -0,0 +1,24 @@ +# breadclip configuration — copy to +# $XDG_CONFIG_HOME/breadclip/config.toml (usually ~/.config/breadclip/config.toml) +# +# Every key is optional; a missing file, missing key, or out-of-range value +# falls back to the default shown here. A config file that fails to parse is +# backed up to config.toml.bak once before defaults are used. + +[retention] +# Max non-pinned entries kept per kind; the oldest are trimmed automatically. +# 0 means "keep no (unpinned) entries of this kind". Pinned entries are never +# trimmed, regardless of these caps. +text = 200 +images = 50 + +[panel] +# Popup panel width in pixels. +width = 520 + +[capture] +# Also watch the middle-click "primary" selection (wl-paste --watch --primary) +# and store those entries alongside regular clipboard entries with a +# "primary" badge. Off by default because primary selections tend to be +# transient and noisy. +primary = false