diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml deleted file mode 100644 index fbbf1a1..0000000 --- a/.forgejo/workflows/check.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: check - -# 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/**', 'main'] - -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 deleted file mode 100644 index 04b6a68..0000000 --- a/.forgejo/workflows/dev-release.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: dev release - -# 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: ['main'] - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch main --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: compute dev version - run: | - set -euo pipefail - cd src - # 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//' | (grep -v -- '-' || true) | 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))-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-* 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 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/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/rc-release.yml b/.forgejo/workflows/rc-release.yml deleted file mode 100644 index 46c2343..0000000 --- a/.forgejo/workflows/rc-release.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: beta (rc) release - -# 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: - 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 "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - 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 - 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 dev, 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-* 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 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/release.yml b/.forgejo/workflows/release.yml index f8b8290..3451346 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,7 +6,6 @@ on: jobs: build: - if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -17,16 +16,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - 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 - } + run: cd src && cargo build --release --locked - name: prepare artifacts run: | @@ -45,14 +35,8 @@ 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 @@ -64,9 +48,9 @@ jobs: set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/breadclip/${VERSION}" - gh release create "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \ + 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 "${GITHUB_REPOSITORY}" \ + 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" \ diff --git a/.gitignore b/.gitignore index 272a817..36f7f5b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,9 +29,3 @@ 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/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index f5e1185..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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`, -`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 `select` on the event bus. See `EVENTS.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 98faf13..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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 - -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: - -``` -feature/ -fix/ -``` - -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 - -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 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 - -``` -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 | a `vX.Y.Z` tag | -| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | -| `dev` | Bleeding edge | `main`, on every push | - -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 - -```sh -cargo build --release --workspace -cargo test --release --workspace -``` - -## CI - -- `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 -[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. diff --git a/Cargo.lock b/Cargo.lock index 885ea87..8b5c730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,62 +14,6 @@ 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" @@ -91,16 +35,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bread-screenshots" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" -dependencies = [ - "anyhow", - "bread-utils", - "tracing", -] - [[package]] name = "bread-shared" version = "0.7.0" @@ -114,8 +48,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" +version = "0.3.0" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" dependencies = [ "dirs", "gtk4", @@ -125,28 +59,33 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +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", "serde", "serde_json", - "toml_edit 0.22.27", ] [[package]] name = "breadclip" -version = "0.2.4" +version = "0.2.1" dependencies = [ - "anyhow", - "bread-screenshots", "bread-theme", - "bread-utils", + "bread-utils 0.3.0 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0)", "breadclip-core", - "clap", "gtk4", "gtk4-layer-shell", "serde_json", @@ -154,21 +93,21 @@ dependencies = [ [[package]] name = "breadclip-core" -version = "0.2.4" +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", "sha2", "tempfile", - "toml_edit 0.22.27", ] [[package]] name = "breadclipd" -version = "0.2.4" +version = "0.2.1" dependencies = [ - "bread-utils", + "bread-utils 0.3.0", "breadclip-core", "serde_json", "tempfile", @@ -194,14 +133,14 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] name = "cc" -version = "1.4.3" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -223,52 +162,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "clap" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -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" @@ -349,9 +242,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "field-offset" @@ -365,30 +258,30 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -397,32 +290,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -453,7 +346,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -486,7 +379,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -547,7 +440,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", "windows-sys 0.61.2", ] @@ -601,7 +494,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -611,7 +504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -622,7 +515,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -643,7 +536,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -674,7 +567,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -700,9 +593,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ "bitflags", "gdk4", @@ -715,15 +608,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps 8.0.0", + "system-deps", ] [[package]] @@ -735,7 +628,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -754,7 +647,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -803,12 +696,6 @@ 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" @@ -823,15 +710,15 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -880,12 +767,6 @@ 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" @@ -912,7 +793,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -923,9 +804,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "proc-macro-crate" @@ -938,18 +819,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1015,9 +896,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -1025,29 +906,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1103,12 +984,6 @@ 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" @@ -1120,17 +995,6 @@ 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" @@ -1140,20 +1004,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "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", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -1193,7 +1044,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1210,9 +1061,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -1269,9 +1120,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.4", ] @@ -1288,37 +1139,6 @@ 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" @@ -1331,12 +1151,6 @@ 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" @@ -1462,28 +1276,28 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.29" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] diff --git a/EVENTS.md b/EVENTS.md index f84061f..0a5e520 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -21,8 +21,6 @@ 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. @@ -30,27 +28,19 @@ Content is never included in the payload — only its detected kind and length. | Verb | Effect | |------|--------| -| `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`. | +| `clear` | Deletes all clipboard history (text entries and stored image files). Emits `bread.clip.clear.done`/`.failed`. | -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. +### Not implemented: `pin` / `select` -### 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. +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 diff --git a/README.md b/README.md index 2ddf262..debe88b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,6 @@ 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. @@ -86,25 +85,6 @@ 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/`): @@ -112,20 +92,14 @@ History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/b | Path | Contents | |------|----------| | `history.db` | SQLite database of all entries | -| `images/` | PNG/JPEG files for image entries | +| `images/` | PNG files for image entries | -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. +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**. `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. +- 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 diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 6cbe951..fabdf67 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -1,15 +1,15 @@ [package] name = "breadclip-core" -version = "0.2.4" +version = "0.2.1" edition = "2021" [dependencies] rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" -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"] } +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" } [dev-dependencies] tempfile = "3" diff --git a/breadclip-core/src/config.rs b/breadclip-core/src/config.rs deleted file mode 100644 index 5f69611..0000000 --- a/breadclip-core/src/config.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! 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 db09c8b..571891c 100644 --- a/breadclip-core/src/lib.rs +++ b/breadclip-core/src/lib.rs @@ -1,50 +1,7 @@ -pub mod config; - -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, Result as SqlResult}; use sha2::{Digest, Sha256}; -use std::fmt; -use std::io::Write; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::fs::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 { @@ -54,84 +11,18 @@ 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 { - /// Open with the default retention caps. - pub fn open() -> Result { - Self::open_with(Retention::default()) - } - - pub fn open_with(retention: Retention) -> Result { + pub fn open() -> SqlResult { let dir = data_dir(); - 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); - + std::fs::create_dir_all(&dir).ok(); 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, @@ -139,99 +30,53 @@ impl HistoryDb { mime_type TEXT NOT NULL, content TEXT, image_path TEXT, - content_hash TEXT NOT NULL UNIQUE, - pinned INTEGER NOT NULL DEFAULT 0, - is_primary INTEGER NOT NULL DEFAULT 0 + content_hash TEXT NOT NULL UNIQUE ); - 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", + 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); - let db = Self { conn, retention }; - db.gc_orphaned_images()?; - Ok(db) + Ok(Self { conn }) } - pub fn insert_text(&self, text: &str, source: CaptureSource) -> Result<(), HistoryError> { + pub fn insert_text(&self, text: &str) -> SqlResult<()> { let hash = sha256_hex(text.as_bytes()); let ts = unix_now(); self.conn.execute( - "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], + "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], )?; - self.trim() + self.trim(200, 50) } - /// 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); + pub fn insert_image(&self, png_bytes: &[u8]) -> SqlResult<()> { + let hash = sha256_hex(png_bytes); let images_dir = data_dir().join("images"); - 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)); + 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])); if !path.exists() { - write_image_file(&path, bytes)?; + std::fs::write(&path, png_bytes).ok(); + restrict_permissions(&path); } 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, 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], + "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], )?; - self.trim() + self.trim(200, 50) } - /// 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. + pub fn list_entries(&self, limit: usize) -> SqlResult> { let mut stmt = self.conn.prepare( - "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", + "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| { @@ -242,40 +87,35 @@ 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::>>()?; - Ok(rows) + .collect::>>(); + rows } - /// Deletes every history entry and every stored image file — a hard - /// reset that clears pinned entries too. Used by the + /// 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 full wipe. - pub fn clear_all(&self) -> Result<(), HistoryError> { + /// 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::>>()?; + .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) -> 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. + pub fn delete_entry(&self, id: i64) -> SqlResult<()> { + // Clean up image file if present let image_path: Option = self .conn .query_row( @@ -285,114 +125,56 @@ 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(()) } - /// 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. + fn trim(&self, max_text: usize, max_images: usize) -> SqlResult<()> { self.conn.execute( "DELETE FROM history - WHERE mime_type = 'text/plain' AND pinned = 0 + WHERE mime_type = 'text/plain' AND id NOT IN ( - SELECT id FROM history WHERE mime_type = 'text/plain' AND pinned = 0 - ORDER BY timestamp DESC, id DESC LIMIT ?1 + SELECT id FROM history WHERE mime_type = 'text/plain' + ORDER BY timestamp DESC LIMIT ?1 )", - [self.retention.text as i64], + [max_text as i64], )?; - // 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). + // Collect old image paths before deleting rows let old_paths: Vec = { let mut stmt = self.conn.prepare( "SELECT image_path FROM history - WHERE mime_type LIKE 'image/%' - AND pinned = 0 + WHERE mime_type = 'image/png' AND image_path IS NOT NULL AND id NOT IN ( - SELECT id FROM history WHERE mime_type LIKE 'image/%' AND pinned = 0 - ORDER BY timestamp DESC, id DESC LIMIT ?1 + SELECT id FROM history WHERE mime_type = 'image/png' + ORDER BY timestamp DESC LIMIT ?1 )", )?; let paths = stmt - .query_map([self.retention.images as i64], |row| row.get(0))? - .collect::>>()?; + .query_map([max_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. @@ -404,42 +186,6 @@ 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); @@ -463,33 +209,28 @@ 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::*; - fn open_test_db() -> (std::sync::MutexGuard<'static, ()>, tempfile::TempDir, HistoryDb) { - let guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner()); + // `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"); - (guard, dir, db) + (dir, db) } #[test] fn clear_all_removes_every_entry_and_image_file() { - 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) + 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(); @@ -512,247 +253,8 @@ mod tests { #[test] fn clear_all_on_empty_history_is_a_harmless_no_op() { - let (_guard, _dir, db) = open_test_db(); + let (_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"); - } } diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 3274612..032d7d0 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclip" -version = "0.2.4" +version = "0.2.1" edition = "2021" [[bin]] @@ -9,12 +9,9 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -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" } +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"] } 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 d2eb00a..282b4a6 100644 --- a/breadclip/src/main.rs +++ b/breadclip/src/main.rs @@ -1,5 +1,4 @@ mod css; -mod screenshot; use breadclip_core::{ClipEntry, HistoryDb}; use bread_theme::{load_palette}; @@ -18,13 +17,11 @@ use std::{ rc::Rc, }; -// 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_WIDTH: i32 = 520; +const MAX_ENTRIES: usize = 200; const PANEL_GAP: i32 = 8; // gap between focused window bottom and panel top -// Fallback panel height, used only if measuring the panel before it's shown -// reports nothing sensible. +// Worst-case panel height (search + chips + full-height list), used to keep +// the panel fully on-screen before its natural size is known. const PANEL_HEIGHT_ESTIMATE: i32 = 580; #[derive(Clone, Copy, PartialEq)] @@ -144,21 +141,6 @@ 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); @@ -170,30 +152,31 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow { } fn do_copy(entry: &ClipEntry) { - 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(); + 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(); + } } } - 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()); + _ => { + 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()); + } + } } } } @@ -201,24 +184,10 @@ 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 - // 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(); +fn run_ui(entries: Vec) { + let app = Application::builder() + .application_id("com.breadway.breadclip") + .build(); app.connect_activate(move |app| { bread_theme::gtk::apply_shared(); @@ -226,12 +195,53 @@ 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); + } // ---- Search entry ---- let search = SearchEntry::new(); @@ -267,7 +277,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)); @@ -384,11 +340,7 @@ fn run_ui(entries: Vec, screenshot_req: Option { @@ -405,11 +357,6 @@ 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() { @@ -438,48 +385,6 @@ 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, } }); @@ -503,56 +408,31 @@ fn run_ui(entries: Vec, screenshot_req: Option 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 = 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(FETCH_ALL)) + .and_then(|db| db.list_entries(MAX_ENTRIES)) .unwrap_or_default(); - run_ui(entries, screenshot_req); + run_ui(entries); } diff --git a/breadclip/src/screenshot.rs b/breadclip/src/screenshot.rs deleted file mode 100644 index 0337bd7..0000000 --- a/breadclip/src/screenshot.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! `--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 bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY}; -use clap::Parser; -use gtk4::prelude::*; -use std::path::PathBuf; - -#[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 = DEFAULT_WIDTH)] - pub width: u32, - - /// Capture canvas height — see `width`. - #[arg(long, default_value_t = DEFAULT_HEIGHT)] - 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 the - /// `--screenshot` / `--output` pair is incomplete, before any GTK setup - /// happens. - pub fn screenshot_request(&self) -> Option { - if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) { - eprintln!("breadclip: {e}"); - std::process::exit(1); - } - Some(ScreenshotRequest { - view: self.screenshot.clone()?, - output: self.output.clone()?, - 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); - } - } -} diff --git a/breadclipd/Cargo.toml b/breadclipd/Cargo.toml index da1eeca..b7158d4 100644 --- a/breadclipd/Cargo.toml +++ b/breadclipd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadclipd" -version = "0.2.4" +version = "0.2.1" edition = "2021" [[bin]] @@ -9,7 +9,10 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +# 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] diff --git a/breadclipd/src/content_kind.rs b/breadclipd/src/content_kind.rs index 4e882e3..fd27a98 100644 --- a/breadclipd/src/content_kind.rs +++ b/breadclipd/src/content_kind.rs @@ -105,29 +105,12 @@ fn looks_like_code(text: &str) -> bool { }) .count(); - // 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 + // 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)] @@ -187,15 +170,6 @@ 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 deleted file mode 100644 index cca4df7..0000000 --- a/breadclipd/src/ignore_rules.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! 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 981b0da..012c6f1 100644 --- a/breadclipd/src/main.rs +++ b/breadclipd/src/main.rs @@ -1,11 +1,8 @@ mod content_kind; -mod ignore_rules; use bread_utils::bread_client::BreadClient; -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}; +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 @@ -17,52 +14,15 @@ 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". 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`). +/// own, signaling "don't persist this". Any offer advertising it is skipped +/// entirely. const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint"; -/// 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") +fn get_available_types() -> Vec { + Command::new("wl-paste") + .args(["--list-types"]) .output() .ok() .filter(|o| o.status.success()) @@ -71,16 +31,8 @@ fn get_available_types(primary: bool) -> Vec { .unwrap_or_default() } -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) +fn get_clipboard_text() -> Option { + let output = Command::new("wl-paste") .args(["--no-newline", "--type", "text/plain"]) .output() .ok()?; @@ -92,80 +44,48 @@ fn get_clipboard_text(primary: bool) -> Option { .filter(|s| !s.trim().is_empty()) } -/// 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"); +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; } - if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { - return Some("image/jpeg"); - } - None + Some(output.stdout) } -fn source(primary: bool) -> CaptureSource { - if primary { - CaptureSource::Primary - } else { - CaptureSource::Clipboard - } +fn lock_file() -> PathBuf { + env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("breadclipd.lock") } -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); +// 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; + } } } + let _ = fs::write(&path, std::process::id().to_string()); + true } -/// 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); +/// 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 @@ -177,39 +97,28 @@ fn capture_via_wl_paste(primary: bool) { let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg"); let has_text = types.iter().any(|t| t.starts_with("text/")); - if has_image && !has_text { - 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); + let db = match HistoryDb::open() { + Ok(db) => db, + Err(e) => { + eprintln!("breadclipd: failed to open database: {e}"); + return; } - } else if has_text { - if let Some(text) = get_clipboard_text(primary) { - if !ignore_rules::is_sensitive(&text) { - store_text(&text, primary); + }; + + 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}"), + } + } + } 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}"), } } - } -} - -/// 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), } } @@ -225,18 +134,19 @@ fn emit_copied(kind: &str, len: usize) { ); } -/// 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 +/// 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`/`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) { +/// 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; }; @@ -254,121 +164,22 @@ fn handle_command(event_name: &str, data: &Value) { ); } }, - "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(args.iter().any(|a| a == PRIMARY_FLAG)); + capture_once(); return; } - // 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 - } - }; + if !acquire_lock() { + std::process::exit(1); + } // Wait briefly for WAYLAND_DISPLAY — common when started early in the session let mut retries = 0; @@ -378,14 +189,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_with(cfg.retention) { + if let Err(e) = HistoryDb::open() { eprintln!("breadclipd: failed to open database: {e}"); + let _ = fs::remove_file(lock_file()); std::process::exit(1); } @@ -393,6 +204,7 @@ 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); } }; @@ -407,22 +219,29 @@ 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, &event.data); + handle_command(&event.event); }); - // 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)); - } - - // The watcher threads own the daemon's lifetime; this thread just keeps - // the process (and the singleton guard above) alive. + // `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 { - thread::sleep(Duration::from_secs(3600)); + let status = Command::new("wl-paste") + .args(["--watch"]) + .arg(&exe) + .arg(CAPTURE_FLAG) + .status(); + + 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)); } } @@ -448,22 +267,6 @@ 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 @@ -473,57 +276,23 @@ 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", CaptureSource::Clipboard).unwrap(); + db.insert_text("something to clear").unwrap(); assert_eq!(db.list_entries(10).unwrap().len(), 1); - handle_command("bread.command.clip.clear", &serde_json::json!({})); + 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_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", CaptureSource::Clipboard).unwrap(); + db.insert_text("should survive").unwrap(); - handle_command("bread.command.clip.select", &serde_json::json!({})); + handle_command("bread.command.clip.pin"); let db = HistoryDb::open().expect("reopen history db"); assert_eq!( @@ -538,11 +307,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", CaptureSource::Clipboard).unwrap(); + 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", &serde_json::json!({})); - handle_command("bread.clip.copied", &serde_json::json!({})); + 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); diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev deleted file mode 100644 index 474f1fd..0000000 --- a/ci/bread-ecosystem.rev +++ /dev/null @@ -1 +0,0 @@ -620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh deleted file mode 100755 index 54695da..0000000 --- a/ci/build.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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" "$@" diff --git a/contrib/breadclipd.service b/contrib/breadclipd.service index be5cb24..d206506 100644 --- a/contrib/breadclipd.service +++ b/contrib/breadclipd.service @@ -11,11 +11,6 @@ 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 diff --git a/contrib/config.toml.example b/contrib/config.toml.example deleted file mode 100644 index 69b8928..0000000 --- a/contrib/config.toml.example +++ /dev/null @@ -1,24 +0,0 @@ -# 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