Compare commits

...

30 commits
v0.2.1 ... main

Author SHA1 Message Date
6d9551ed65 Merge pull request 'Config file, pinning, and history-DB hardening' (#1) from feature/config-file into main
All checks were successful
dev release / build (push) Successful in 1m15s
check / check (push) Successful in 2m40s
2026-08-31 18:14:16 +08:00
Breadway
6db4a96026 breadclip: document pin verb, config file, and ignore rules
All checks were successful
check / check (push) Successful in 2m9s
- EVENTS.md: `bread.command.clip.pin` and `bread.clip.pinned` /
  `.pin.failed` are now implemented; `select` stays explicitly not
  implemented with the reason. AGENTS.md follows.
- README: Configuration section, Ctrl+P bind, updated privacy notes
  (CLIPBOARD_STATE + ignore rules), JPEG image entries, primary badge.
- check.yml also runs on pushes to `main` — a push to main triggers a
  dev-track release build, so it should be linted/tested first.
- release.yml uses `${GITHUB_REPOSITORY}` instead of a hard-coded
  `Breadway/breadclip` for the GitHub mirror release upload.
2026-08-31 15:07:35 +08:00
Breadway
d308593efd breadclip: pin toggle, primary/pin badges, config-driven panel
- Ctrl+P pins/unpins the selected row, re-reads history (pinned rows
  sort first), rebuilds the list and re-selects the toggled row.
- ★ badge on pinned rows, "primary" badge on primary-selection rows.
- Panel width comes from `config.panel_width`; positioning now runs
  after the panel is realised and measures its natural height instead
  of always assuming the worst-case estimate, so a short history gets
  a correctly-anchored short panel.
- Delete in the search box edits the query again instead of deleting
  the selected history row.
- Re-copy offers an image row as its stored MIME type (JPEG stays
  JPEG). History fetch is a flat cap since pinned rows can exceed
  retention.
2026-08-31 15:07:35 +08:00
Breadway
83630033cd breadclipd: watch-driven capture, flock singleton, ignore rules, pin verb
- Capture now keys off wl-paste's `CLIPBOARD_STATE`: `sensitive`
  (password manager via `wl-copy --sensitive`, covers the old
  x-kde-passwordManagerHint case) and `nil`/`clear` persist nothing;
  `data` reads the content off stdin in the same event, so the
  sensitive check and the read can't straddle a clipboard change.
  A manual `--capture-once` with no CLIPBOARD_STATE falls back to
  querying wl-paste directly, now wrapped in `timeout -k 2 5` so a
  stalled offer can't block a capture process forever.
- Single-instance guard moves to `bread_utils::singleton` (flock) —
  kernel-atomic, auto-released on death, no stale pid file.
- `ignore_rules::is_sensitive`: conservative best-effort heuristics
  that skip copies which look like secrets even when unflagged —
  PEM/OpenSSH private key blocks, `password:`-style lines, labelled
  one-time codes, Luhn-valid card numbers, well-known token prefixes.
  A convenience, not a security boundary.
- `bread.command.clip.pin` verb (payload `{id, pin?}`, pin defaults
  true) → `set_pinned`, emits `bread.clip.pinned` / `.pin.failed`.
- Two independent `wl-paste --watch` loops (regular clipboard always
  on; primary selection when `capture.primary`), each restarting with
  2s→30s capped backoff and state-change-only journal logging.
- Image type is sniffed from magic bytes (PNG/JPEG) on the stdin path
  and requested by actual offered type on the fallback path — a JPEG
  is stored as `.jpg` / `image/jpeg`, not relabelled PNG.
- content_kind: a single line of prose that merely contains a keyword
  ("class is a concept") no longer classifies as code — it must look
  like a statement.
2026-08-31 15:07:22 +08:00
Breadway
967fd9f1b7 breadclip-core: history DB hardening + user config + pin/primary schema
Persistence layer changes, all backward-compatible with existing
databases via in-place column migration:

- WAL journal mode + a 5s busy timeout so overlapping `--capture-once`
  writers and the popup reader stop dropping captures on SQLITE_BUSY.
- `HistoryError` replaces bare `rusqlite::Error` so a filesystem failure
  while writing an image file surfaces instead of leaving a row that
  points at a file that was never written.
- Image files are created 0600 from the first syscall (O_CREAT|O_EXCL,
  mode 0600) — no world-readable window before a chmod. Data and images
  dirs are forced to 0700 on every open. `gc_orphaned_images` sweeps
  image files no row references (older than 1h, to spare in-flight
  writes).
- `pinned` and `is_primary` columns. Pinned rows are exempt from trim
  and sort first; `list_entries` breaks timestamp ties by `id DESC` so
  ordering (and which rows trim keeps) is deterministic within a second.
- `Retention` caps are now a field on `HistoryDb` (`open_with`), and
  `0` is a legal value ("keep no unpinned entries of this kind").

New `config` module: optional TOML at
`$XDG_CONFIG_HOME/breadclip/config.toml`, every key defaulted and
clamped, unparseable file backed up once (bread-utils tomlcfg
discipline). Keys: retention.text/images, panel.width, capture.primary.
2026-08-31 15:07:04 +08:00
Breadway
cf9293bbf3 Bump version to v0.2.4
All checks were successful
beta (rc) release / build (push) Has been skipped
dev release / build (push) Successful in 47s
release / build (push) Successful in 1m2s
2026-08-16 14:09:21 +08:00
Breadway
ed41e7e1ae Bind the clip popup to the current monitor's bread-theme palette
All checks were successful
dev release / build (push) Successful in 3m26s
Pin bread-theme to v0.7.4.
2026-08-16 13:24:18 +08:00
Breadway
c959153d5a CI: refuse unsigned bakery index on stable tag releases
Some checks failed
check / check (push) Failing after 4s
dev release / build (push) Successful in 2m53s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 57s
2026-08-16 00:50:22 +08:00
Breadway
74553fa19c Adopt bread_utils::screenshot_cli for --screenshot flags
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Failing after 1s
Replace the local settle delay, canvas defaults, and pair-validation
error path with bread-utils v0.7.2. Clap parsing stays in-tree.
2026-08-16 00:26:07 +08:00
Breadway
999b73bf33 Bump version to v0.2.2
Some checks failed
dev release / build (push) Failing after 1s
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 1s
2026-08-15 23:05:47 +08:00
Breadway
fe8ea86c87 Pin bread-ecosystem crates to v0.7.2
Some checks failed
dev release / build (push) Failing after 1s
Repoint bread-theme, bread-utils, and bread-screenshots at tag v0.7.2
on git.breadway.dev. Drop the 69ce2d67 rev pin now that screenshots
is on a tagged release.
2026-08-15 22:53:46 +08:00
Breadway
fc704f4d67 gitignore: exclude graphify-out local cache
Some checks failed
dev release / build (push) Failing after 1s
2026-08-15 22:32:33 +08:00
Breadway
e98e37dbf1 Remove CLAUDE.md (renamed to AGENTS.md)
Some checks failed
check / check (push) Failing after 2s
dev release / build (push) Failing after 1s
2026-08-15 22:04:00 +08:00
Breadway
e66afd339a Pin bread-screenshots to the shared ecosystem rev; track AGENTS.md
Stop floating on branch=main. Same 69ce2d67 rev as sibling apps
(v0.7.1 predates the crate).
2026-08-15 22:03:30 +08:00
Breadway
8456424f1a Pin bread-theme/utils to bread-ecosystem v0.7.1; add single-trunk CLAUDE.md
Some checks failed
check / check (push) Failing after 1s
Popup, daemon, and core were split across git.breadway.dev v0.3.0 and
GitHub v0.3.1. Point every bread-theme / bread-utils dep at the same
Forgejo tag. bread-screenshots is not in v0.7.1 (the crate landed after
that tag) so it stays on that remote's main until the next ecosystem
release.

CLAUDE.md now documents the single-trunk model. EVENTS.md is unchanged
— no pin/select verbs.
2026-08-15 21:38:49 +08:00
Breadway
15d1a3a58a Merge fix/shared-ecosystem-ci: shared Arch CI image
All checks were successful
dev release / build (push) Successful in 1m17s
2026-08-05 14:06:24 +08:00
Breadway
1203102fe9 ci: build against bread-ecosystem's shared Arch CI image, add check.yml
All checks were successful
check / check (push) Successful in 1m31s
Same fix as breadpad: build inside the shared pinned Arch container
(bread-ecosystem/ci/, cloned at the sha in ci/bread-ecosystem.rev)
instead of building natively against whatever's on the runner host.
Adds check.yml (clippy + test on feature/**/fix/**) as a fast-fail gate
before anything reaches main.

Verified locally: build, clippy, and test all pass through the new
container path — no pre-existing lint/test debt found here.
2026-08-05 14:03:05 +08:00
Breadway
555ae5b0d1 Repoint bread-ecosystem git deps from dev to main branch
All checks were successful
dev release / build (push) Successful in 5m21s
2026-07-31 14:30:01 +08:00
Breadway
2c03e73da5 CONTRIBUTING.md: document single-trunk + RC-tag release model
All checks were successful
dev release / build (push) Successful in 3m56s
2026-07-31 11:08:41 +08:00
Breadway
5a4453c767 CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:29 +08:00
Breadway
4c43baa9fa Merge feature/screenshot-mode: add --screenshot CLI mode for automated capture
All checks were successful
dev release / build (push) Successful in 1m14s
2026-07-29 16:39:16 +08:00
Breadway
b125facc67 breadclip: add --screenshot CLI mode for automated capture
Same pattern as breadbar/breadbox: render the clipboard-history panel,
capture it via bread-screenshots, then exit. One view ("history"), full
known-size canvas capture since the panel isn't its own layer surface.

active_window()/focused_monitor() (used to anchor the panel next to the
real focused window) both resolve to None inside bread-capture's isolated
environment (HYPRLAND_INSTANCE_SIGNATURE is deliberately unset there), so
the panel falls back to its already-existing centered layout — exactly
the deterministic behavior a screenshot needs, with no screenshot-mode-
specific positioning logic required.

Also fixes the same singleton footgun as breadbox: toggle_or_kill()
kills whatever's holding breadclip's single-instance lock, which is
typically the real running instance. A screenshot run now skips it
entirely instead of fighting over (and killing) the operator's real
clipboard panel.
2026-07-29 11:37:01 +08:00
Breadway
b95e4c9466 docs: add CONTRIBUTING.md
All checks were successful
dev release / build (push) Successful in 1m41s
Documents the dev/beta/main branch and release-track workflow shared
across the bread ecosystem. See bread-ecosystem's docs/release-channels.md
for the full policy this implements.
2026-07-22 19:42:03 +08:00
Breadway
2e99954642 ci: make beta a branch-triggered freeze track, not a one-off tag
All checks were successful
dev release / build (push) Successful in 1m55s
beta release / build (push) Successful in 1m51s
Beta is now a real stabilization branch: publishes on every push to
`beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.<ts>+<sha>,
base version from the latest published tag) instead of a manual
beta-v* tag. Fixes made during the freeze land via fix/<issue> branches
merged into `beta` directly. The gen-index.sh clone for beta pulls
bread-ecosystem's default branch (main) rather than pinning to dev,
since beta is the more stable track and main now carries the
TRACK-aware script.
2026-07-22 18:37:48 +08:00
Breadway
eab00c0712 ci: base dev version on the latest published tag, not Cargo.toml
All checks were successful
dev release / build (push) Successful in 1m58s
Cargo.toml can drift stale relative to the actual last release (observed
on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped
dev version sort as OLDER than what's already installed — bakery's semver
check correctly refused those "updates". Deriving the base version from
git ls-remote --tags instead is self-healing regardless of Cargo.toml
drift, with a Cargo.toml fallback only for a repo with no tags yet.
2026-07-22 13:53:07 +08:00
Breadway
58aa415ef0 random commit message, read it yourself
All checks were successful
dev release / build (push) Successful in 1m6s
2026-07-22 11:37:38 +08:00
Breadway
a35cd6f363 ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI
Some checks failed
dev release / build (push) Failing after 1s
The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta
workflows run close together on the same self-hosted runner — one job's
rm -rf/clone can stomp another's in-progress checkout, causing the
regenerate-index step to fail intermittently. Switch to mktemp -d.
2026-07-22 10:24:49 +08:00
Breadway
06ab15c2c0 ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set
Some checks failed
dev release / build (push) Failing after 1s
2026-07-22 10:10:20 +08:00
Breadway
34a96b78e0 ci: add dev/beta build track workflows
Some checks failed
dev release / build (push) Failing after 1s
Adds dev-release.yml (publishes on every push to dev) and beta-release.yml
(publishes on a beta-v* tag), mirroring the pattern landing in
bread-ecosystem/bread. Also creates the dev branch for this repo, which
didn't exist before — see bread-ecosystem/docs/release-channels.md for the
three-track policy.
2026-07-22 09:56:49 +08:00
Breadway
5a60a76901 ci: remove GitHub push-mirror workflow 2026-07-21 19:19:33 +08:00
25 changed files with 2324 additions and 408 deletions

View file

@ -0,0 +1,25 @@
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

View file

@ -0,0 +1,78 @@
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}"

View file

@ -1,19 +0,0 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadclip.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -0,0 +1,59 @@
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}"

View file

@ -6,6 +6,7 @@ on:
jobs: jobs:
build: build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
@ -16,7 +17,16 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && cargo build --release --locked run: |
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: prepare artifacts - name: prepare artifacts
run: | run: |
@ -35,8 +45,14 @@ jobs:
ln -sfn "${VERSION}" "/srv/breadway-dl/breadclip/latest" ln -sfn "${VERSION}" "/srv/breadway-dl/breadclip/latest"
- name: regenerate index.json - name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: |
set -euo pipefail 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 rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /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 bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
@ -48,9 +64,9 @@ jobs:
set -euo pipefail set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/breadclip/${VERSION}" PKG_DIR="/srv/breadway-dl/breadclip/${VERSION}"
gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadclip \ gh release create "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \
--title "breadclip v${VERSION}" --generate-notes 2>/dev/null || true --title "breadclip v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadclip \ gh release upload "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \
"${PKG_DIR}/breadclip-x86_64" \ "${PKG_DIR}/breadclip-x86_64" \
"${PKG_DIR}/breadclipd-x86_64" \ "${PKG_DIR}/breadclipd-x86_64" \
"${PKG_DIR}/breadclip-x86_64.sha256" \ "${PKG_DIR}/breadclip-x86_64.sha256" \

6
.gitignore vendored
View file

@ -29,3 +29,9 @@ logs/
# Runtime files # Runtime files
*.sock *.sock
*.pid *.pid
# Local hygiene notes (not for commit)
CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/

64
AGENTS.md Normal file
View file

@ -0,0 +1,64 @@
# 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/<feature-name>`.
When working on a bug or issue, create branch `fix/<issue you are fixing>`.
## 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`.

84
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,84 @@
# 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/<short-name>
fix/<issue-number-or-short-name>
```
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.<timestamp>+<sha>`) 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.

366
Cargo.lock generated
View file

@ -14,6 +14,62 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@ -35,6 +91,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bread-shared" name = "bread-shared"
version = "0.7.0" version = "0.7.0"
@ -48,8 +114,8 @@ dependencies = [
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.3.0" version = "0.7.4"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e"
dependencies = [ dependencies = [
"dirs", "dirs",
"gtk4", "gtk4",
@ -59,33 +125,28 @@ dependencies = [
[[package]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.3.0" version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [ dependencies = [
"bread-shared", "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", "dirs",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"serde", "serde",
"serde_json", "serde_json",
"toml_edit 0.22.27",
] ]
[[package]] [[package]]
name = "breadclip" name = "breadclip"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"anyhow",
"bread-screenshots",
"bread-theme", "bread-theme",
"bread-utils 0.3.0 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0)", "bread-utils",
"breadclip-core", "breadclip-core",
"clap",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"serde_json", "serde_json",
@ -93,21 +154,21 @@ dependencies = [
[[package]] [[package]]
name = "breadclip-core" name = "breadclip-core"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"bread-utils 0.3.0 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0)", "bread-utils",
"dirs",
"hex", "hex",
"rusqlite", "rusqlite",
"sha2", "sha2",
"tempfile", "tempfile",
"toml_edit 0.22.27",
] ]
[[package]] [[package]]
name = "breadclipd" name = "breadclipd"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"bread-utils 0.3.0", "bread-utils",
"breadclip-core", "breadclip-core",
"serde_json", "serde_json",
"tempfile", "tempfile",
@ -133,14 +194,14 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.3.0" version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -162,6 +223,52 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -242,9 +349,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
@ -258,30 +365,30 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
] ]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@ -290,32 +397,32 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.3",
] ]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-macro", "futures-macro",
@ -346,7 +453,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -379,7 +486,7 @@ dependencies = [
"libc", "libc",
"pango-sys", "pango-sys",
"pkg-config", "pkg-config",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -440,7 +547,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -494,7 +601,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -504,7 +611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [ dependencies = [
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -515,7 +622,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -536,7 +643,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -567,7 +674,7 @@ dependencies = [
"graphene-sys", "graphene-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -593,9 +700,9 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell" name = "gtk4-layer-shell"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"gdk4", "gdk4",
@ -608,15 +715,15 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell-sys" name = "gtk4-layer-shell-sys"
version = "0.6.0" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b"
dependencies = [ dependencies = [
"gdk4-sys", "gdk4-sys",
"glib-sys", "glib-sys",
"gtk4-sys", "gtk4-sys",
"libc", "libc",
"system-deps", "system-deps 8.0.0",
] ]
[[package]] [[package]]
@ -628,7 +735,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -647,7 +754,7 @@ dependencies = [
"gsk4-sys", "gsk4-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -696,6 +803,12 @@ dependencies = [
"hashbrown 0.17.1", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -710,15 +823,15 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.18" version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@ -767,6 +880,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@ -793,7 +912,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -804,9 +923,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
@ -819,18 +938,18 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.46" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -896,9 +1015,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive", "serde_derive",
@ -906,29 +1025,29 @@ dependencies = [
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.3",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.150" version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -984,6 +1103,12 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@ -995,6 +1120,17 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "7.0.8" version = "7.0.8"
@ -1004,7 +1140,20 @@ dependencies = [
"cfg-expr", "cfg-expr",
"heck", "heck",
"pkg-config", "pkg-config",
"toml 1.1.3+spec-1.1.0", "toml 1.1.4+spec-1.1.0",
"version-compare",
]
[[package]]
name = "system-deps"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml 1.1.4+spec-1.1.0",
"version-compare", "version-compare",
] ]
@ -1044,7 +1193,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1061,9 +1210,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.1.3+spec-1.1.0" version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
@ -1120,9 +1269,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.2+spec-1.1.0" version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow 1.0.4", "winnow 1.0.4",
] ]
@ -1139,6 +1288,37 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" 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]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@ -1151,6 +1331,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@ -1276,28 +1462,28 @@ dependencies = [
[[package]] [[package]]
name = "xml-rs" name = "xml-rs"
version = "0.8.28" version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.54" version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.54" version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]

View file

@ -21,6 +21,8 @@ per-clipboard-change process invocation, not inside a persistent loop).
| `bread.clip.copied` | `{ "kind": "url" \| "error" \| "code" \| "path" \| "plain", "len": <bytes or chars> }` | 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.copied` | `{ "kind": "url" \| "error" \| "code" \| "path" \| "plain", "len": <bytes or chars> }` | 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.done` | `{}` | `bread.command.clip.clear` was received and history was successfully cleared. |
| `bread.clip.clear.failed` | `{ "error": "<message>" }` | `bread.command.clip.clear` was received but clearing failed (e.g. DB error). | | `bread.clip.clear.failed` | `{ "error": "<message>" }` | `bread.command.clip.clear` was received but clearing failed (e.g. DB error). |
| `bread.clip.pinned` | `{ "id": <entry 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": "<message>" }` | `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. Content is never included in the payload — only its detected kind and length. History (including the actual copied content) stays local to breadclip's own SQLite database; the event bus is for *notifications about* clipboard activity, not a channel for clipboard content itself.
@ -28,19 +30,27 @@ Content is never included in the payload — only its detected kind and length.
| Verb | Effect | | Verb | Effect |
|------|--------| |------|--------|
| `clear` | Deletes all clipboard history (text entries and stored image files). Emits `bread.clip.clear.done`/`.failed`. | | `clear` | Deletes all clipboard history (text entries, stored image files, pinned entries — it's a hard reset). Emits `bread.clip.clear.done`/`.failed`. |
| `pin` | Payload `{ "id": <entry id>, "pin": true \| false }`. Pins or unpins a history entry by id; `pin` defaults to `true` if omitted. Emits `bread.clip.pinned`/`bread.clip.pin.failed`. |
### Not implemented: `pin` / `select` Pinned entries are exempt from the daemon's trimming and sort to the top of
the history list. The popup's Ctrl+P toggle writes the same `pinned` column
directly (it doesn't round-trip through the bus), and also emits
`bread.clip.pinned`, so automation can observe either path. Entry `id`s come
from `list_entries` — there is currently no read API on the bus for the
history contents.
An earlier draft of this integration planned `pin`/`select` verbs, but ### Not implemented: `select`
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 `select` (remote-activate a row from the bus, e.g. "paste entry 42 now")
real pin/select support is a product decision for breadclip itself (does it is still deliberately not implemented: the popup is a short-lived process
want pinning, and what should the UI look like?), not something to fabricate with no resident service to receive that command, and the daemon has no
as a side effect of wiring up the event bus. If/when breadclip grows that reason to talk to a transient popup. That's a real product decision for
feature, the corresponding `bread.command.clip.pin`/`.select` verbs (and breadclip itself — what should "select from the bus" even mean when the
matching `bread.clip.pinned`/`.selected` events) should be added at the same popup isn't open? — not something to fabricate as a side effect of wiring
time, not stubbed out ahead of it. up the event bus. When breadclip grows that feature, the matching
`bread.command.clip.select` verb and `bread.clip.selected` event should be
added at the same time.
## Fail-safe behavior ## Fail-safe behavior

View file

@ -77,6 +77,7 @@ Running `breadclip` a second time while it is open closes it (toggle behaviour).
| `Up` / `Down` | Move selection | | `Up` / `Down` | Move selection |
| `Enter` | Copy selected entry to clipboard and close | | `Enter` | Copy selected entry to clipboard and close |
| `Delete` | Remove selected entry from history | | `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 | | `Escape` | Close without copying |
Clicking an entry copies it and closes the popup. Clicking outside the panel closes it. Clicking an entry copies it and closes the popup. Clicking outside the panel closes it.
@ -85,6 +86,25 @@ Clicking an entry copies it and closes the popup. Clicking outside the panel clo
The popup has three filter chips — **All**, **Text**, **Images** — and a search box. The search box filters text entries by content; image entries only appear under the **Images** filter. 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 ## Data storage
History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/breadclip/`): History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/breadclip/`):
@ -92,14 +112,20 @@ History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/b
| Path | Contents | | Path | Contents |
|------|----------| |------|----------|
| `history.db` | SQLite database of all entries | | `history.db` | SQLite database of all entries |
| `images/` | PNG files for image entries | | `images/` | PNG/JPEG files for image entries |
The daemon keeps at most 200 text entries and 50 image entries, trimming oldest entries automatically. The daemon trims the oldest non-pinned entries automatically, keeping at
most `retention.text` text entries and `retention.images` image entries
(defaults 200 and 50; configurable). **Pinned entries are exempt from
trimming** and sort to the top of the popup. Entries captured from the
primary (middle-click) selection — when `capture.primary = true` — are
stored alongside regular clipboard entries with a `primary` badge.
### Privacy ### Privacy
- `history.db` and every file under `images/` are created with `0600` permissions (owner read/write only), regardless of your umask. - `history.db` and every file under `images/` are created with `0600` permissions (owner read/write only), regardless of your umask.
- breadclipd **never persists clipboard content flagged as sensitive by a password manager**. If a clipboard offer advertises the `x-kde-passwordManagerHint` MIME type — the convention used by KeePassXC, Bitwarden, and other password managers to mark content they own — that copy is skipped entirely and never reaches the database. - breadclipd **never persists clipboard content flagged as sensitive**. `wl-paste --watch` reports copies made with `wl-copy --sensitive` — which also covers offers advertising the `x-kde-passwordManagerHint` MIME type, the convention used by KeePassXC, Bitwarden, and other password managers to mark content they own — via `CLIPBOARD_STATE=sensitive`, and those copies are skipped entirely and never reach the database.
- On top of that, breadclipd runs **best-effort ignore rules** that skip copies that *look* like secrets even when the app didn't flag them: one-time codes, Luhn-valid credit card numbers, private key blocks, `password:`-style credential lines, and well-known API token prefixes (see `breadclipd/src/ignore_rules.rs`). These are deliberately conservative and are a convenience, not a security boundary — the 0600/0700 permissions are the real protection.
- That said, this is still a plaintext SQLite database of everything else you copy. Anything copied by an app that doesn't set the hint (e.g. copying a password from a terminal or a non-integrated app) will be stored like any other text entry. Treat `history.db` as sensitive, and don't rely on it as your only safeguard. - 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 ## Theming

View file

@ -1,15 +1,15 @@
[package] [package]
name = "breadclip-core" name = "breadclip-core"
version = "0.2.1" version = "0.2.4"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
dirs = "5" toml_edit = "0.22"
# (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) # `toml` for bread-utils' non-destructive config load/save discipline (config.rs).
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -0,0 +1,175 @@
//! breadclip's user configuration.
//!
//! Read from `$XDG_CONFIG_HOME/breadclip/config.toml` (or
//! `~/.config/breadclip/config.toml`). Every key has a sensible default, so
//! the file is entirely optional. A file that *exists* but fails to parse is
//! backed up to `config.toml.bak` (once) and defaults are used, matching
//! bread-utils' non-destructive TOML discipline (see
//! `bread_utils::tomlcfg::load_doc`).
//!
//! ```toml
//! [retention]
//! text = 200 # max non-pinned text entries (0 = keep none)
//! images = 50 # max non-pinned image entries (0 = keep none)
//!
//! [panel]
//! width = 520 # popup panel width, px
//!
//! [capture]
//! primary = false # also watch the middle-click primary selection
//! ```
use crate::Retention;
use std::path::PathBuf;
use toml_edit::{DocumentMut, Item};
/// Resolved configuration. `Config::default()` matches the built-in
/// behavior before config files existed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Config {
pub retention: Retention,
pub panel_width: i32,
pub capture_primary: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
retention: Retention::default(),
panel_width: 520,
capture_primary: false,
}
}
}
/// Clamp bounds. Retention can legitimately be `0` ("keep no unpinned
/// entries of this kind") but never huge; the panel width is bounded to
/// something that still fits on a screen.
const MAX_RETENTION: i64 = 10_000;
const MIN_PANEL_WIDTH: i64 = 300;
const MAX_PANEL_WIDTH: i64 = 2000;
pub fn config_path() -> PathBuf {
bread_utils::xdg::config_dir("breadclip").join("config.toml")
}
/// Load configuration, falling back to defaults for anything missing,
/// unparseable, or out of range.
pub fn load() -> Config {
let doc = bread_utils::tomlcfg::load_doc("breadclip", &config_path());
Config {
retention: Retention {
text: int(&doc, "retention", "text", Retention::default().text as i64)
.clamp(0, MAX_RETENTION) as usize,
images: int(&doc, "retention", "images", Retention::default().images as i64)
.clamp(0, MAX_RETENTION) as usize,
},
panel_width: int(&doc, "panel", "width", 520)
.clamp(MIN_PANEL_WIDTH, MAX_PANEL_WIDTH) as i32,
capture_primary: boolean(&doc, "capture", "primary", false),
}
}
fn int(doc: &DocumentMut, section: &str, key: &str, default: i64) -> i64 {
doc.get(section)
.and_then(Item::as_table)
.and_then(|t| t.get(key))
.and_then(Item::as_integer)
.unwrap_or(default)
}
fn boolean(doc: &DocumentMut, section: &str, key: &str, default: bool) -> bool {
doc.get(section)
.and_then(Item::as_table)
.and_then(|t| t.get(key))
.and_then(Item::as_bool)
.unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
fn with_config_dir<F: FnOnce() -> 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);
});
}
}

View file

@ -1,7 +1,50 @@
use rusqlite::{params, Connection, Result as SqlResult}; pub mod config;
use rusqlite::{params, Connection};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::os::unix::fs::PermissionsExt; use std::fmt;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf}; use std::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)] #[derive(Debug, Clone)]
pub struct ClipEntry { pub struct ClipEntry {
@ -11,18 +54,84 @@ pub struct ClipEntry {
pub content: Option<String>, pub content: Option<String>,
pub image_path: Option<String>, pub image_path: Option<String>,
pub content_hash: String, 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<rusqlite::Error> for HistoryError {
fn from(e: rusqlite::Error) -> Self {
HistoryError::Sql(e)
}
} }
pub struct HistoryDb { pub struct HistoryDb {
conn: Connection, conn: Connection,
retention: Retention,
} }
impl HistoryDb { impl HistoryDb {
pub fn open() -> SqlResult<Self> { /// Open with the default retention caps.
pub fn open() -> Result<Self, HistoryError> {
Self::open_with(Retention::default())
}
pub fn open_with(retention: Retention) -> Result<Self, HistoryError> {
let dir = data_dir(); let dir = data_dir();
std::fs::create_dir_all(&dir).ok(); std::fs::create_dir_all(&dir).map_err(|source| HistoryError::Io {
action: "create data directory",
path: dir.clone(),
source,
})?;
// The data dir holds clipboard secrets (history.db is plaintext).
// A 0700 parent also blocks traversal to whatever SQLite's
// -wal/-shm side files look like, regardless of their own mode.
restrict_dir(&dir, 0o700);
let db_path = dir.join("history.db"); let db_path = dir.join("history.db");
let conn = Connection::open(&db_path)?; 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( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS history ( "CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -30,53 +139,99 @@ impl HistoryDb {
mime_type TEXT NOT NULL, mime_type TEXT NOT NULL,
content TEXT, content TEXT,
image_path TEXT, image_path TEXT,
content_hash TEXT NOT NULL UNIQUE content_hash TEXT NOT NULL UNIQUE,
pinned INTEGER NOT NULL DEFAULT 0,
is_primary INTEGER NOT NULL DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);", CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC, id DESC);",
)?;
// Migrate databases created before `pinned`/`is_primary` existed.
ensure_column(
&conn,
"pinned",
"ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
)?;
ensure_column(
&conn,
"is_primary",
"ALTER TABLE history ADD COLUMN is_primary INTEGER NOT NULL DEFAULT 0",
)?; )?;
// history.db can contain plaintext secrets copied to the clipboard // history.db can contain plaintext secrets copied to the clipboard
// (passwords, tokens, TOTP codes); restrict it to owner-only, every open. // (passwords, tokens, TOTP codes); restrict it to owner-only, every open.
restrict_permissions(&db_path); restrict_permissions(&db_path);
Ok(Self { conn }) let db = Self { conn, retention };
db.gc_orphaned_images()?;
Ok(db)
} }
pub fn insert_text(&self, text: &str) -> SqlResult<()> { pub fn insert_text(&self, text: &str, source: CaptureSource) -> Result<(), HistoryError> {
let hash = sha256_hex(text.as_bytes()); let hash = sha256_hex(text.as_bytes());
let ts = unix_now(); let ts = unix_now();
self.conn.execute( self.conn.execute(
"INSERT INTO history (timestamp, mime_type, content, content_hash) "INSERT INTO history (timestamp, mime_type, content, content_hash, is_primary)
VALUES (?1, 'text/plain', ?2, ?3) VALUES (?1, 'text/plain', ?2, ?3, ?4)
ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1, is_primary = ?4",
params![ts, text, hash], params![ts, text, hash, source.is_primary() as i64],
)?; )?;
self.trim(200, 50) self.trim()
} }
pub fn insert_image(&self, png_bytes: &[u8]) -> SqlResult<()> { /// Store an image. `mime_type` is persisted with the row and drives the
let hash = sha256_hex(png_bytes); /// file extension, so a JPEG capture is stored as a `.jpg` offered as
/// `image/jpeg` — not silently re-encoded/relabeled as PNG.
pub fn insert_image(
&self,
bytes: &[u8],
mime_type: &str,
source: CaptureSource,
) -> Result<(), HistoryError> {
let hash = sha256_hex(bytes);
let images_dir = data_dir().join("images"); let images_dir = data_dir().join("images");
std::fs::create_dir_all(&images_dir).ok(); std::fs::create_dir_all(&images_dir).map_err(|source| HistoryError::Io {
// Use first 16 hex chars for the filename (collision-safe for 50 images) action: "create images directory",
let path = images_dir.join(format!("{}.png", &hash[..16])); path: images_dir.clone(),
source,
})?;
restrict_dir(&images_dir, 0o700);
let ext = match mime_type {
"image/jpeg" => "jpg",
_ => "png",
};
// First 16 hex chars of the hash: collision-safe for the few dozen
// images history keeps.
let path = images_dir.join(format!("{}.{}", &hash[..16], ext));
if !path.exists() { if !path.exists() {
std::fs::write(&path, png_bytes).ok(); write_image_file(&path, bytes)?;
restrict_permissions(&path);
} }
let path_str = path.to_string_lossy().to_string(); let path_str = path.to_string_lossy().to_string();
let ts = unix_now(); let ts = unix_now();
self.conn.execute( self.conn.execute(
"INSERT INTO history (timestamp, mime_type, image_path, content_hash) "INSERT INTO history (timestamp, mime_type, image_path, content_hash, is_primary)
VALUES (?1, 'image/png', ?2, ?3) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1, is_primary = ?5",
params![ts, path_str, hash], params![ts, mime_type, path_str, hash, source.is_primary() as i64],
)?; )?;
self.trim(200, 50) self.trim()
} }
pub fn list_entries(&self, limit: usize) -> SqlResult<Vec<ClipEntry>> { /// 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<Vec<ClipEntry>, HistoryError> {
// Pinned rows first, then `timestamp DESC, id DESC` — many copies
// land within the same second, and the `id` tiebreaker keeps that
// order (and therefore which rows trim keeps) deterministic.
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT id, timestamp, mime_type, content, image_path, content_hash "SELECT id, timestamp, mime_type, content, image_path, content_hash, pinned, is_primary
FROM history ORDER BY timestamp DESC LIMIT ?1", FROM history ORDER BY pinned DESC, timestamp DESC, id DESC LIMIT ?1",
)?; )?;
let rows = stmt let rows = stmt
.query_map([limit as i64], |row| { .query_map([limit as i64], |row| {
@ -87,35 +242,40 @@ impl HistoryDb {
content: row.get(3)?, content: row.get(3)?,
image_path: row.get(4)?, image_path: row.get(4)?,
content_hash: row.get(5)?, content_hash: row.get(5)?,
pinned: row.get(6)?,
is_primary: row.get(7)?,
}) })
})? })?
.collect::<SqlResult<Vec<_>>>(); .collect::<rusqlite::Result<Vec<_>>>()?;
rows Ok(rows)
} }
/// Deletes every history entry and every stored image file. Used by the /// Deletes every history entry and every stored image file — a hard
/// reset that clears pinned entries too. Used by the
/// `bread.command.clip.clear` handler (see breadclipd's bread-client /// `bread.command.clip.clear` handler (see breadclipd's bread-client
/// subscription) as well as anything else that wants a hard reset of /// subscription) as well as anything else that wants a full wipe.
/// clipboard history. pub fn clear_all(&self) -> Result<(), HistoryError> {
pub fn clear_all(&self) -> SqlResult<()> {
let image_paths: Vec<String> = { let image_paths: Vec<String> = {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT image_path FROM history WHERE image_path IS NOT NULL")?; .prepare("SELECT image_path FROM history WHERE image_path IS NOT NULL")?;
let paths = stmt let paths = stmt
.query_map([], |row| row.get(0))? .query_map([], |row| row.get(0))?
.collect::<SqlResult<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
paths 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 { for path in image_paths {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
self.conn.execute("DELETE FROM history", [])?;
Ok(()) Ok(())
} }
pub fn delete_entry(&self, id: i64) -> SqlResult<()> { pub fn delete_entry(&self, id: i64) -> Result<(), HistoryError> {
// Clean up image file if present // Clean up image file if present — after the row is gone, so a
// failed DELETE never leaves a row pointing at a deleted file.
let image_path: Option<String> = self let image_path: Option<String> = self
.conn .conn
.query_row( .query_row(
@ -125,56 +285,114 @@ impl HistoryDb {
) )
.ok() .ok()
.flatten(); .flatten();
self.conn.execute("DELETE FROM history WHERE id = ?1", [id])?;
if let Some(p) = image_path { if let Some(p) = image_path {
let _ = std::fs::remove_file(p); let _ = std::fs::remove_file(p);
} }
self.conn
.execute("DELETE FROM history WHERE id = ?1", [id])?;
Ok(()) Ok(())
} }
fn trim(&self, max_text: usize, max_images: usize) -> SqlResult<()> { /// Sweep image files on disk that no history row references anymore
/// (e.g. left behind by a crash between writing the file and inserting
/// the row). Files written in the last hour are left alone so a
/// concurrent `--capture-once` that is mid-insert (file on disk, row not
/// yet committed) is never deleted out from under itself.
pub fn gc_orphaned_images(&self) -> Result<(), HistoryError> {
let referenced: std::collections::HashSet<String> = {
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<String>>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
paths.into_iter().flatten().collect()
};
let images_dir = data_dir().join("images");
let Ok(entries) = std::fs::read_dir(&images_dir) else {
return Ok(()); // no images dir yet — nothing to sweep
};
let cutoff = std::time::SystemTime::now()
.checked_sub(Duration::from_secs(3600))
.unwrap_or(std::time::UNIX_EPOCH);
for entry in entries.flatten() {
let path = entry.path();
if referenced.contains(&path.to_string_lossy().to_string()) {
continue;
}
let modified = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
if modified < cutoff {
let _ = std::fs::remove_file(&path);
}
}
Ok(())
}
fn trim(&self) -> Result<(), HistoryError> {
// Oldest non-pinned text rows beyond the cap. Pinned rows are never
// trimmed — that's the point of pinning them.
self.conn.execute( self.conn.execute(
"DELETE FROM history "DELETE FROM history
WHERE mime_type = 'text/plain' WHERE mime_type = 'text/plain' AND pinned = 0
AND id NOT IN ( AND id NOT IN (
SELECT id FROM history WHERE mime_type = 'text/plain' SELECT id FROM history WHERE mime_type = 'text/plain' AND pinned = 0
ORDER BY timestamp DESC LIMIT ?1 ORDER BY timestamp DESC, id DESC LIMIT ?1
)", )",
[max_text as i64], [self.retention.text as i64],
)?; )?;
// Collect old image paths before deleting rows // Collect the image files that are about to be trimmed, delete the
// rows first, then the files — so a failed DELETE never leaves rows
// pointing at already-removed files. `LIKE 'image/%'` covers every
// image mime type (png, jpeg, and anything added later).
let old_paths: Vec<String> = { let old_paths: Vec<String> = {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT image_path FROM history "SELECT image_path FROM history
WHERE mime_type = 'image/png' WHERE mime_type LIKE 'image/%'
AND pinned = 0
AND image_path IS NOT NULL AND image_path IS NOT NULL
AND id NOT IN ( AND id NOT IN (
SELECT id FROM history WHERE mime_type = 'image/png' SELECT id FROM history WHERE mime_type LIKE 'image/%' AND pinned = 0
ORDER BY timestamp DESC LIMIT ?1 ORDER BY timestamp DESC, id DESC LIMIT ?1
)", )",
)?; )?;
let paths = stmt let paths = stmt
.query_map([max_images as i64], |row| row.get(0))? .query_map([self.retention.images as i64], |row| row.get(0))?
.collect::<SqlResult<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
paths 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 { for path in old_paths {
let _ = std::fs::remove_file(path); 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(()) 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 /// Restrict a file to owner-only read/write (0600). Clipboard history can
/// contain passwords and other secrets, so this must not be world/group /// contain passwords and other secrets, so this must not be world/group
/// readable regardless of the process umask. /// readable regardless of the process umask.
@ -186,6 +404,42 @@ fn restrict_permissions(path: &Path) {
} }
} }
/// Restrict a directory to owner-only (0700). Used for the data dir and the
/// images dir, both of which contain clipboard secrets.
fn restrict_dir(path: &Path, mode: u32) {
if let Ok(meta) = std::fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(mode);
let _ = std::fs::set_permissions(path, perms);
}
}
/// Write an image file, created 0600 from the very first syscall — no window
/// where a clipboard image (possibly a screenshot with sensitive pixels) is
/// world-readable before a chmod lands, and no dependence on the umask.
fn write_image_file(path: &Path, bytes: &[u8]) -> Result<(), HistoryError> {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
{
Ok(mut f) => f.write_all(bytes).map_err(|source| HistoryError::Io {
action: "write image file",
path: path.to_path_buf(),
source,
}),
// Same hash filename means identical content — a concurrent
// `--capture-once` already wrote it.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(source) => Err(HistoryError::Io {
action: "create image file",
path: path.to_path_buf(),
source,
}),
}
}
pub fn sha256_hex(data: &[u8]) -> String { pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(data); hasher.update(data);
@ -209,28 +463,33 @@ fn unix_now() -> i64 {
.as_secs() as 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::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
// `HistoryDb::open` resolves its path via `data_dir()`, which follows fn open_test_db() -> (std::sync::MutexGuard<'static, ()>, tempfile::TempDir, HistoryDb) {
// `$XDG_DATA_HOME` — redirecting it to a fresh temp dir per test keeps let guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner());
// 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"); let dir = tempfile::tempdir().expect("tempdir");
std::env::set_var("XDG_DATA_HOME", dir.path()); std::env::set_var("XDG_DATA_HOME", dir.path());
let db = HistoryDb::open().expect("open history db"); let db = HistoryDb::open().expect("open history db");
(dir, db) (guard, dir, db)
} }
#[test] #[test]
fn clear_all_removes_every_entry_and_image_file() { fn clear_all_removes_every_entry_and_image_file() {
let (_dir, db) = open_test_db(); let (_guard, _dir, db) = open_test_db();
db.insert_text("first").unwrap(); db.insert_text("first", CaptureSource::Clipboard).unwrap();
db.insert_text("second").unwrap(); db.insert_text("second", CaptureSource::Clipboard).unwrap();
db.insert_image(b"not really a png, just bytes for the test") db.insert_image(b"not really a png, just bytes for the test", "image/png", CaptureSource::Clipboard)
.unwrap(); .unwrap();
let before = db.list_entries(10).unwrap(); let before = db.list_entries(10).unwrap();
@ -253,8 +512,247 @@ mod tests {
#[test] #[test]
fn clear_all_on_empty_history_is_a_harmless_no_op() { fn clear_all_on_empty_history_is_a_harmless_no_op() {
let (_dir, db) = open_test_db(); let (_guard, _dir, db) = open_test_db();
db.clear_all().unwrap(); db.clear_all().unwrap();
assert!(db.list_entries(10).unwrap().is_empty()); 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");
}
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadclip" name = "breadclip"
version = "0.2.1" version = "0.2.4"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@ -9,9 +9,12 @@ path = "src/main.rs"
[dependencies] [dependencies]
breadclip-core = { path = "../breadclip-core" } breadclip-core = { path = "../breadclip-core" }
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", 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.7.2", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } # Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8" gtk4-layer-shell = "0.8"
serde_json = "1" serde_json = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"

View file

@ -1,4 +1,5 @@
mod css; mod css;
mod screenshot;
use breadclip_core::{ClipEntry, HistoryDb}; use breadclip_core::{ClipEntry, HistoryDb};
use bread_theme::{load_palette}; use bread_theme::{load_palette};
@ -17,11 +18,13 @@ use std::{
rc::Rc, rc::Rc,
}; };
const PANEL_WIDTH: i32 = 520; // Pinned entries are exempt from trimming, so the fetch limit can't be
const MAX_ENTRIES: usize = 200; // derived from the retention caps alone. History stays tiny in practice —
// fetching it all keeps every pinned row reachable regardless of count.
const FETCH_ALL: usize = 10_000;
const PANEL_GAP: i32 = 8; // gap between focused window bottom and panel top const PANEL_GAP: i32 = 8; // gap between focused window bottom and panel top
// Worst-case panel height (search + chips + full-height list), used to keep // Fallback panel height, used only if measuring the panel before it's shown
// the panel fully on-screen before its natural size is known. // reports nothing sensible.
const PANEL_HEIGHT_ESTIMATE: i32 = 580; const PANEL_HEIGHT_ESTIMATE: i32 = 580;
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
@ -141,6 +144,21 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow {
hbox.append(&text_lbl); 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))); let ts_lbl = Label::new(Some(&format_timestamp(entry.timestamp)));
ts_lbl.add_css_class("clip-meta"); ts_lbl.add_css_class("clip-meta");
ts_lbl.set_xalign(1.0); ts_lbl.set_xalign(1.0);
@ -152,31 +170,30 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow {
} }
fn do_copy(entry: &ClipEntry) { fn do_copy(entry: &ClipEntry) {
match entry.mime_type.as_str() { if entry.mime_type.starts_with("image/") {
"image/png" => { // Re-paste with the same type we stored — a JPEG row must be offered
if let Some(ref path) = entry.image_path { // as image/jpeg, not relabeled as image/png.
if let Ok(file) = fs::File::open(path) { if let Some(ref path) = entry.image_path {
let _ = Command::new("wl-copy") if let Ok(file) = fs::File::open(path) {
.args(["--type", "image/png"]) let _ = Command::new("wl-copy")
.stdin(Stdio::from(file)) .args(["--type", entry.mime_type.as_str()])
.stdout(Stdio::null()) .stdin(Stdio::from(file))
.stderr(Stdio::null())
.spawn();
}
}
}
_ => {
if let Some(ref content) = entry.content {
if let Ok(mut child) = Command::new("wl-copy")
.stdin(Stdio::piped())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.spawn() .spawn();
{ }
if let Some(mut stdin) = child.stdin.take() { }
let _ = stdin.write_all(content.as_bytes()); 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());
} }
} }
} }
@ -184,10 +201,24 @@ fn do_copy(entry: &ClipEntry) {
// ---- UI --------------------------------------------------------------------- // ---- UI ---------------------------------------------------------------------
fn run_ui(entries: Vec<ClipEntry>) { fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::ScreenshotRequest>) {
let app = Application::builder() // Live snapshot of the entries backing the list — pinning re-reads the
.application_id("com.breadway.breadclip") // DB (pinned rows sort first) and rebuilds the rows from this. Built
.build(); // outside connect_activate because that handler is `Fn` (runs once per
// activation) and can't consume the entries vector.
let entries_rc: Rc<std::cell::RefCell<Vec<ClipEntry>>> =
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();
app.connect_activate(move |app| { app.connect_activate(move |app| {
bread_theme::gtk::apply_shared(); bread_theme::gtk::apply_shared();
@ -195,53 +226,12 @@ fn run_ui(entries: Vec<ClipEntry>) {
// Full-screen transparent overlay; panel widget is positioned inside it. // Full-screen transparent overlay; panel widget is positioned inside it.
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadclip"); let window = bread_utils::gtk_popup::new_overlay_window(app, "breadclip");
bread_theme::gtk::bind_window_auto(&window);
// ---- Position panel relative to the active window ---- let panel_width = breadclip_core::config::load().panel_width;
// 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();
let panel = GBox::new(Orientation::Vertical, 0); let panel = GBox::new(Orientation::Vertical, 0);
panel.add_css_class("clip-panel"); panel.add_css_class("clip-panel");
panel.set_size_request(PANEL_WIDTH, -1); panel.set_size_request(panel_width, -1);
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_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 ---- // ---- Search entry ----
let search = SearchEntry::new(); let search = SearchEntry::new();
@ -277,7 +267,7 @@ fn run_ui(entries: Vec<ClipEntry>) {
let list = ListBox::new(); let list = ListBox::new();
list.set_selection_mode(SelectionMode::Browse); list.set_selection_mode(SelectionMode::Browse);
for entry in &entries { for entry in entries_rc.borrow().iter() {
list.append(&build_row(entry)); list.append(&build_row(entry));
} }
if let Some(first) = list.row_at_index(0) { if let Some(first) = list.row_at_index(0) {
@ -288,6 +278,60 @@ fn run_ui(entries: Vec<ClipEntry>) {
panel.append(&scroll); panel.append(&scroll);
window.set_child(Some(&panel)); window.set_child(Some(&panel));
// ---- Position panel relative to the active window ----
// Measure the panel's natural height (a short history yields a short
// panel) so the anchor math uses the real size instead of a
// worst-case estimate. Falls back to the estimate if measurement
// reports nothing sensible.
let (_min_h, natural_h, _min_baseline, _natural_baseline) =
panel.measure(gtk4::Orientation::Vertical, -1);
let panel_height = if natural_h > 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 ---- // ---- Shared state ----
let query_rc: Rc<std::cell::RefCell<String>> = Rc::new(std::cell::RefCell::new(String::new())); let query_rc: Rc<std::cell::RefCell<String>> = Rc::new(std::cell::RefCell::new(String::new()));
let filter_rc: Rc<Cell<Filter>> = Rc::new(Cell::new(Filter::All)); let filter_rc: Rc<Cell<Filter>> = Rc::new(Cell::new(Filter::All));
@ -340,7 +384,11 @@ fn run_ui(entries: Vec<ClipEntry>) {
{ {
let close_k = Rc::clone(&close_all); let close_k = Rc::clone(&close_all);
let list_k = list.clone(); let list_k = list.clone();
key_ctrl.connect_key_pressed(move |_, key, _, _| { let search_k = search.clone();
let entries_k = Rc::clone(&entries_rc);
let query_k = Rc::clone(&query_rc);
let filter_k = Rc::clone(&filter_rc);
key_ctrl.connect_key_pressed(move |_, key, _, state| {
use gtk4::gdk::Key; use gtk4::gdk::Key;
match key { match key {
Key::Escape => { Key::Escape => {
@ -357,6 +405,11 @@ fn run_ui(entries: Vec<ClipEntry>) {
glib::Propagation::Stop glib::Propagation::Stop
} }
Key::Delete => { Key::Delete => {
// 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(row) = list_k.selected_row() {
if let Some(entry) = get_row_entry(&row) { if let Some(entry) = get_row_entry(&row) {
if let Ok(db) = HistoryDb::open() { if let Ok(db) = HistoryDb::open() {
@ -385,6 +438,48 @@ fn run_ui(entries: Vec<ClipEntry>) {
bread_utils::gtk_popup::select_prev_visible(&list_k); bread_utils::gtk_popup::select_prev_visible(&list_k);
glib::Propagation::Stop glib::Propagation::Stop
} }
// Pin/unpin the selected entry. Pinned rows sort to the
// top and survive trimming, so re-read the DB and rebuild
// the list, then re-select the row we just toggled.
Key::P if state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) => {
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, _ => glib::Propagation::Proceed,
} }
}); });
@ -408,31 +503,56 @@ fn run_ui(entries: Vec<ClipEntry>) {
bread_utils::gtk_popup::close_on_outside_click(&window, &panel, move || close_outside()); bread_utils::gtk_popup::close_on_outside_click(&window, &panel, move || close_outside());
} }
if let Some(req) = screenshot_req.clone() {
screenshot::dispatch(&window, req);
}
window.present(); window.present();
search.grab_focus(); search.grab_focus();
}); });
app.run(); if is_screenshot_run {
// GLib's own option parser otherwise rejects --screenshot/--output
// before clap ever sees them (`Cli::parse()` already ran in `main`,
// over the real argv).
app.run_with_args(&[] as &[&str]);
} else {
app.run();
}
} }
// ---- Main ------------------------------------------------------------------- // ---- Main -------------------------------------------------------------------
fn main() { fn main() {
use clap::Parser;
let cli = screenshot::Cli::parse();
let screenshot_req = cli.screenshot_request();
// `toggle_or_kill` kills whatever's holding the single-instance lock —
// a real, already-running breadclip included. A screenshot run must
// never touch it: it's a separate, disposable instance by design (same
// reasoning as breadbar's `allow_multiple_instances`), not a toggle of
// the operator's real clipboard panel.
//
// Kept alive for the rest of `main` — dropping it releases the // Kept alive for the rest of `main` — dropping it releases the
// single-instance lock and removes the pid file, which happens // single-instance lock and removes the pid file, which happens
// naturally once `run_ui` returns (after the window closes). // naturally once `run_ui` returns (after the window closes).
let _singleton_guard = match bread_utils::singleton::toggle_or_kill("breadclip") { let _singleton_guard = if screenshot_req.is_some() {
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), None
Ok(bread_utils::singleton::Toggle::KilledExisting) => return, } else {
Err(e) => { match bread_utils::singleton::toggle_or_kill("breadclip") {
eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it"); Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
None Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
Err(e) => {
eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it");
None
}
} }
}; };
let entries = HistoryDb::open() let entries = HistoryDb::open()
.and_then(|db| db.list_entries(MAX_ENTRIES)) .and_then(|db| db.list_entries(FETCH_ALL))
.unwrap_or_default(); .unwrap_or_default();
run_ui(entries); run_ui(entries, screenshot_req);
} }

View file

@ -0,0 +1,99 @@
//! `--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<String>,
/// PNG path to write the capture to. Required together with --screenshot.
#[arg(long)]
pub output: Option<PathBuf>,
/// 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<ScreenshotRequest> {
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: &gtk4::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);
}
}
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadclipd" name = "breadclipd"
version = "0.2.1" version = "0.2.4"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@ -9,10 +9,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
breadclip-core = { path = "../breadclip-core" } breadclip-core = { path = "../breadclip-core" }
# TODO(owner): switch to a tag-pinned git dependency once bread-shared is bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
# 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" serde_json = "1"
[dev-dependencies] [dev-dependencies]

View file

@ -105,12 +105,29 @@ fn looks_like_code(text: &str) -> bool {
}) })
.count(); .count();
// Multi-line with a meaningful fraction of "code-shaped" lines, or an // Multi-line with a meaningful fraction of "code-shaped" lines, or a
// unambiguous single-line token (import/#include/fn signature), counts // couple of code tokens anywhere, counts as code.
// as code. A single short line of prose won't hit either bar. if lines.len() > 1 {
token_hits >= 2 return token_hits >= 2 || brace_or_semicolon_lines * 2 >= lines.len();
|| (lines.len() > 1 && brace_or_semicolon_lines * 2 >= lines.len()) }
|| (lines.len() == 1 && token_hits >= 1 && text.len() < 200)
// A single line only counts as code if it actually *looks* like a code
// statement — a prose sentence that merely contains a keyword ("let me
// show you", "function of time", "class is a concept") must stay plain.
if lines.len() == 1 && token_hits >= 1 && text.len() < 200 {
let trimmed = text.trim_start();
let import_like = trimmed.starts_with("import ") || trimmed.starts_with("#include");
let code_shaped = trimmed.ends_with('{')
|| trimmed.ends_with('}')
|| trimmed.ends_with(';')
|| trimmed.ends_with('(')
|| trimmed.ends_with(')')
|| trimmed.contains("=>")
|| (trimmed.contains('(') && trimmed.contains(')'))
|| trimmed.contains(" = ");
return import_like || code_shaped;
}
false
} }
#[cfg(test)] #[cfg(test)]
@ -170,6 +187,15 @@ mod tests {
assert_eq!(detect("import numpy as np"), "code"); assert_eq!(detect("import numpy as np"), "code");
} }
#[test]
fn single_line_prose_containing_a_keyword_is_plain() {
assert_eq!(detect("let me show you something"), "plain");
assert_eq!(detect("function of time"), "plain");
assert_eq!(detect("class is a concept"), "plain");
assert_eq!(detect("const means constant"), "plain");
assert_eq!(detect("def is short for define"), "plain");
}
#[test] #[test]
fn plain_prose_is_plain() { fn plain_prose_is_plain() {
assert_eq!( assert_eq!(

View file

@ -0,0 +1,182 @@
//! Best-effort content heuristics for never persisting obvious secrets.
//!
//! The password-manager path (`CLIPBOARD_STATE=sensitive` / the
//! `x-kde-passwordManagerHint` MIME type) only catches copies an app
//! deliberately flagged. These rules catch copies that *look* like secrets
//! even when the app didn't flag them — one-time codes, credit card
//! numbers, private keys, credential lines. They are deliberately
//! conservative (a false "skip" is cheaper than a leaked password) and they
//! are a convenience, not a security boundary: the 0600 file permissions
//! and 0700 data dir are the real protection.
/// Would persisting this text be a bad idea? Skipped copies never reach the
/// database (or the event bus), like the password-manager path.
pub fn is_sensitive(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
looks_like_private_key(trimmed)
|| looks_like_credential_line(trimmed)
|| looks_like_otp(trimmed)
|| contains_card_number(trimmed)
|| contains_api_token(trimmed)
}
/// PEM/OpenSSH private key blocks (and PGP blocks, which are just as
/// sensitive).
fn looks_like_private_key(text: &str) -> bool {
text.contains("-----BEGIN") || text.contains("PRIVATE KEY")
}
/// A line that labels a credential directly: `password: hunter2`,
/// `passwd = hunter2`, etc.
fn looks_like_credential_line(text: &str) -> bool {
let first = text.lines().next().unwrap_or("").trim().to_ascii_lowercase();
const PREFIXES: [&str; 8] = [
"password:",
"password =",
"password=",
"passwd:",
"passwd =",
"passwd=",
"pw:",
"pass:",
];
PREFIXES.iter().any(|p| first.starts_with(p))
}
/// A one-time code: a short copy that mentions a code keyword and contains
/// a 68 digit run. A *bare* 6-digit number is deliberately not flagged —
/// too many legitimate numbers have that shape.
fn looks_like_otp(text: &str) -> bool {
if text.len() > 60 {
return false;
}
let lower = text.to_ascii_lowercase();
const LABELS: [&str; 8] = [
"code", "otp", "verification", "verif", "2fa", "passcode", "one-time", "one time",
];
if !LABELS.iter().any(|k| lower.contains(k)) {
return false;
}
has_digit_run(text, 6, 8)
}
/// Credit card numbers: 1319 digit runs (allowing spaces/dashes between
/// groups) that pass the Luhn check.
fn contains_card_number(text: &str) -> bool {
let mut run = String::new();
for c in text.chars() {
if c.is_ascii_digit() {
run.push(c);
} else if (c == ' ' || c == '-') && !run.is_empty() {
run.push(' '); // keep one run across group separators
} else {
if card_like(&run) {
return true;
}
run.clear();
}
}
card_like(&run)
}
fn card_like(run: &str) -> bool {
let digits: String = run.chars().filter(|c| c.is_ascii_digit()).collect();
(13..=19).contains(&digits.len()) && luhn_valid(&digits)
}
fn luhn_valid(digits: &str) -> bool {
let mut sum: u32 = 0;
let mut double = false;
for b in digits.bytes().rev() {
let mut d = (b - b'0') as u32;
if double {
d *= 2;
if d > 9 {
d -= 9;
}
}
sum += d;
double = !double;
}
sum.is_multiple_of(10)
}
/// Well-known API token prefixes (OpenAI, GitHub, Slack, AWS access keys).
fn contains_api_token(text: &str) -> bool {
const PREFIXES: [&str; 7] = ["sk-", "sk-proj-", "ghp_", "gho_", "xoxb-", "xoxp-", "AKIA"];
PREFIXES.iter().any(|p| text.contains(p))
}
fn has_digit_run(text: &str, min: usize, max: usize) -> bool {
let mut run = 0usize;
for c in text.chars() {
if c.is_ascii_digit() {
run += 1;
} else {
if run >= min && run <= max {
return true;
}
run = 0;
}
}
run >= min && run <= max
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn private_key_blocks_are_sensitive() {
assert!(is_sensitive(
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA..."
));
assert!(is_sensitive("-----BEGIN PGP MESSAGE-----"));
}
#[test]
fn credential_labeled_lines_are_sensitive() {
assert!(is_sensitive("password: hunter2"));
assert!(is_sensitive("Password = correct horse battery staple"));
assert!(is_sensitive("passwd=hunter2"));
}
#[test]
fn labeled_one_time_codes_are_sensitive() {
assert!(is_sensitive("Your verification code is 483920"));
assert!(is_sensitive("483920 is your code"));
assert!(is_sensitive("OTP: 12345678"));
}
#[test]
fn bare_numbers_are_not_sensitive() {
// A bare 6-digit number is too ambiguous to drop on purpose.
assert!(!is_sensitive("483920"));
assert!(!is_sensitive("The answer is 42"));
// A long random number that happens to be 16 digits but fails Luhn.
assert!(!is_sensitive("1234567890123456"));
}
#[test]
fn luhn_valid_card_numbers_are_sensitive() {
assert!(is_sensitive("4111 1111 1111 1111"));
assert!(is_sensitive("card number: 4111-1111-1111-1111"));
}
#[test]
fn api_token_prefixes_are_sensitive() {
assert!(is_sensitive("sk-proj-abc123def456"));
assert!(is_sensitive("ghp_1234567890abcdefghijklmnopqrstuvwxyz"));
assert!(is_sensitive("AKIAIOSFODNN7EXAMPLE"));
}
#[test]
fn normal_prose_is_not_sensitive() {
assert!(!is_sensitive("just a normal sentence someone copied"));
assert!(!is_sensitive("here is the plan for next week"));
assert!(!is_sensitive("the password field in the form is empty"));
}
}

View file

@ -1,8 +1,11 @@
mod content_kind; mod content_kind;
mod ignore_rules;
use bread_utils::bread_client::BreadClient; use bread_utils::bread_client::BreadClient;
use breadclip_core::HistoryDb; use bread_utils::singleton::{try_acquire, Acquire};
use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration}; use breadclip_core::{CaptureSource, HistoryDb};
use serde_json::Value;
use std::{env, io::Read, process::Command, thread, time::Duration};
/// This app's id in bread's sibling-app namespace registry /// This app's id in bread's sibling-app namespace registry
/// (`bread_shared::apps::KNOWN_APPS`) — events are published as /// (`bread_shared::apps::KNOWN_APPS`) — events are published as
@ -14,15 +17,52 @@ const APP_ID: &str = "clip";
/// needed for a single internal flag). /// needed for a single internal flag).
const CAPTURE_FLAG: &str = "--capture-once"; const CAPTURE_FLAG: &str = "--capture-once";
/// Second positional arg distinguishing a capture spawned by the primary-
/// selection watcher from one spawned by the regular-clipboard watcher.
const PRIMARY_FLAG: &str = "--primary";
/// MIME type convention (originating with KDE's Klipper) that password /// MIME type convention (originating with KDE's Klipper) that password
/// managers such as KeePassXC and Bitwarden set on clipboard content they /// managers such as KeePassXC and Bitwarden set on clipboard content they
/// own, signaling "don't persist this". Any offer advertising it is skipped /// own, signaling "don't persist this". Only reachable on the fallback
/// entirely. /// capture path — when invoked through `wl-paste --watch`, `CLIPBOARD_STATE`
/// carries the same information from the same event (see `capture_once`).
const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint"; const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint";
fn get_available_types() -> Vec<String> { /// Build a `wl-paste` command with a hard deadline. Without the timeout, a
Command::new("wl-paste") /// stalled selection offer (source app died mid-transfer, compositor never
.args(["--list-types"]) /// completes the handoff) leaves `wl-paste` blocked forever reading a pipe
/// that never closes — and a forever-blocked fallback capture would eat a
/// file descriptor per event over a long session. `timeout -k` guarantees a
/// SIGKILL if the initial SIGTERM doesn't land. Only used by the manual
/// fallback path (`capture_via_wl_paste`); the `--watch` path reads the
/// content from stdin and so has nothing that can hang.
fn wl_paste_cmd(primary: bool) -> Command {
let mut cmd = Command::new("timeout");
cmd.args(["-k", "2", "5", "wl-paste"]);
if primary {
cmd.arg("--primary");
}
cmd
}
/// `wl-paste --watch` sets this in the spawned command's environment for
/// every clipboard event (see wl-paste(1)): `data` (read the content from
/// stdin), `nil` (empty clipboard), `clear` (explicitly cleared), or
/// `sensitive` (a password manager flagged the selection). Unset or
/// unrecognized means we were not invoked by `--watch` (e.g. a manual
/// `--capture-once` run) and we fall back to querying wl-paste directly.
const CLIPBOARD_STATE: &str = "CLIPBOARD_STATE";
/// Open the history DB with the retention caps from the user's config. The
/// `--capture-once` processes run per clipboard event, so they load the
/// config fresh rather than inheriting anything from the long-lived daemon.
fn open_db() -> Result<HistoryDb, breadclip_core::HistoryError> {
HistoryDb::open_with(breadclip_core::config::load().retention)
}
fn get_available_types(primary: bool) -> Vec<String> {
wl_paste_cmd(primary)
.arg("--list-types")
.output() .output()
.ok() .ok()
.filter(|o| o.status.success()) .filter(|o| o.status.success())
@ -31,8 +71,16 @@ fn get_available_types() -> Vec<String> {
.unwrap_or_default() .unwrap_or_default()
} }
fn get_clipboard_text() -> Option<String> { fn get_clipboard_bytes(mime: &str, primary: bool) -> Option<Vec<u8>> {
let output = Command::new("wl-paste") let output = wl_paste_cmd(primary).args(["--type", mime]).output().ok()?;
if !output.status.success() || output.stdout.is_empty() {
return None;
}
Some(output.stdout)
}
fn get_clipboard_text(primary: bool) -> Option<String> {
let output = wl_paste_cmd(primary)
.args(["--no-newline", "--type", "text/plain"]) .args(["--no-newline", "--type", "text/plain"])
.output() .output()
.ok()?; .ok()?;
@ -44,48 +92,80 @@ fn get_clipboard_text() -> Option<String> {
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
} }
fn get_clipboard_image() -> Option<Vec<u8>> { /// Identify an image payload by its magic bytes. `wl-paste --watch` hands us
let output = Command::new("wl-paste") /// the content on stdin without saying which offered type it picked, so the
.args(["--type", "image/png"]) /// bytes are the only reliable signal — and they're exact, because watch
.output() /// mode never appends a trailing newline.
.ok()?; fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> {
if !output.status.success() || output.stdout.is_empty() { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return None; return Some("image/png");
} }
Some(output.stdout) if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
None
} }
fn lock_file() -> PathBuf { fn source(primary: bool) -> CaptureSource {
env::var("XDG_RUNTIME_DIR") if primary {
.map(PathBuf::from) CaptureSource::Primary
.unwrap_or_else(|_| PathBuf::from("/tmp")) } else {
.join("breadclipd.lock") CaptureSource::Clipboard
}
} }
// Returns false if another instance is already running. fn store_text(text: &str, primary: bool) {
fn acquire_lock() -> bool { let db = match open_db() {
let path = lock_file(); Ok(db) => db,
if let Ok(content) = fs::read_to_string(&path) { Err(e) => {
if let Ok(pid) = content.trim().parse::<u32>() { eprintln!("breadclipd: failed to open database: {e}");
let alive = fs::read_to_string(format!("/proc/{}/comm", pid)) return;
.map(|s| s.trim() == "breadclipd") }
.unwrap_or(false); };
if alive { match db.insert_text(text, source(primary)) {
eprintln!("breadclipd: already running (pid {})", pid); Ok(()) => emit_copied(content_kind::detect(text), text.len()),
return false; Err(e) => eprintln!("breadclipd: insert text: {e}"),
} }
}
fn store_image(bytes: Vec<u8>, mime: &str, primary: bool) {
let db = match open_db() {
Ok(db) => db,
Err(e) => {
eprintln!("breadclipd: failed to open database: {e}");
return;
}
};
match db.insert_image(&bytes, mime, source(primary)) {
Ok(()) => emit_copied("image", bytes.len()),
Err(e) => eprintln!("breadclipd: insert image: {e}"),
}
}
/// Capture path for `wl-paste --watch` invocations: the clipboard content
/// arrives on our stdin together with its `CLIPBOARD_STATE` in one event, so
/// the sensitive check and the data read can't race each other (two separate
/// `wl-paste` calls could straddle a clipboard change mid-capture).
fn capture_from_stdin(primary: bool) {
let mut buf = Vec::new();
if std::io::stdin().read_to_end(&mut buf).is_err() || buf.is_empty() {
return;
}
if let Some(mime) = sniff_image_mime(&buf) {
store_image(buf, mime, primary);
} else if let Ok(text) = String::from_utf8(buf) {
if !text.trim().is_empty() && !ignore_rules::is_sensitive(&text) {
store_text(&text, primary);
} }
} }
let _ = fs::write(&path, std::process::id().to_string());
true
} }
/// Invoked once per clipboard-change event (as the handler command for /// Fallback capture path for manual `--capture-once` invocations (no
/// `wl-paste --watch`). Reads whatever is on the clipboard right now, skips /// `CLIPBOARD_STATE` was set by wl-paste): ask wl-paste directly. Same
/// it entirely if a password manager flagged it as sensitive, and otherwise /// behavior as the stdin path, including requesting whichever image type is
/// persists a text or image entry to the history DB. /// actually offered rather than assuming PNG.
fn capture_once() { fn capture_via_wl_paste(primary: bool) {
let types = get_available_types(); let types = get_available_types(primary);
if types.iter().any(|t| t == PASSWORD_HINT_MIME) { if types.iter().any(|t| t == PASSWORD_HINT_MIME) {
// Password manager (KeePassXC, Bitwarden, etc.) marked this copy as // Password manager (KeePassXC, Bitwarden, etc.) marked this copy as
@ -97,31 +177,42 @@ fn capture_once() {
let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg"); let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg");
let has_text = types.iter().any(|t| t.starts_with("text/")); let has_text = types.iter().any(|t| t.starts_with("text/"));
let db = match HistoryDb::open() {
Ok(db) => db,
Err(e) => {
eprintln!("breadclipd: failed to open database: {e}");
return;
}
};
if has_image && !has_text { if has_image && !has_text {
if let Some(bytes) = get_clipboard_image() { let mime = if types.iter().any(|t| t == "image/png") {
match db.insert_image(&bytes) { "image/png"
Ok(()) => emit_copied("image", bytes.len()), } else {
Err(e) => eprintln!("breadclipd: insert image: {e}"), "image/jpeg"
} };
if let Some(bytes) = get_clipboard_bytes(mime, primary) {
store_image(bytes, mime, primary);
} }
} else if has_text { } else if has_text {
if let Some(text) = get_clipboard_text() { if let Some(text) = get_clipboard_text(primary) {
match db.insert_text(&text) { if !ignore_rules::is_sensitive(&text) {
Ok(()) => emit_copied(content_kind::detect(&text), text.len()), store_text(&text, primary);
Err(e) => eprintln!("breadclipd: insert text: {e}"),
} }
} }
} }
} }
/// Invoked once per clipboard-change event (as the handler command for
/// `wl-paste --watch`). Stores a text or image entry to the history DB,
/// unless the selection was flagged as sensitive — in which case nothing is
/// ever persisted.
fn capture_once(primary: bool) {
match env::var(CLIPBOARD_STATE).as_deref() {
// Password manager (KeePassXC, Bitwarden, ...) flagged this selection
// as sensitive via `wl-copy --sensitive` — never persist it.
Ok("sensitive") => {}
// Empty clipboard (nil) or an explicit clear — nothing to store.
Ok("nil") | Ok("clear") => {}
Ok("data") => capture_from_stdin(primary),
// Unset or unrecognized: not invoked by `--watch`. Fall back to the
// direct wl-paste path rather than blocking on whatever stdin is.
_ => capture_via_wl_paste(primary),
}
}
/// Publishes `bread.clip.copied` into the bread event fabric. Fire-and-forget /// Publishes `bread.clip.copied` into the bread event fabric. Fire-and-forget
/// and non-fatal by design (`BreadClient::emit` never blocks or errors this /// and non-fatal by design (`BreadClient::emit` never blocks or errors this
/// caller) — breadd being absent or not installed must never affect /// caller) — breadd being absent or not installed must never affect
@ -134,19 +225,18 @@ fn emit_copied(kind: &str, len: usize) {
); );
} }
/// Reacts to `bread.command.clip.*` verbs. Only `clear` maps to real, /// Reacts to `bread.command.clip.*` verbs. Only `clear` and `pin` map to
/// existing breadclip functionality today — `pin`/`select` would need a new /// real, existing breadclip functionality today — `select` would need a
/// "pinned" concept that doesn't exist anywhere in the history DB schema, /// "remote-activate a row" concept the popup doesn't expose over the bus,
/// which is a real product decision for breadclip itself (does it want /// which is a real product decision for breadclip itself, not something to
/// pinning at all, and what would the GTK UI for it look like?), not /// fabricate as a side effect of wiring up the event bus. See
/// something to fabricate as a side effect of wiring up the event bus. See
/// `breadclip/EVENTS.md` for the honest current status. /// `breadclip/EVENTS.md` for the honest current status.
/// ///
/// Emits `bread.clip.<verb>.done`/`.failed` per the confirmation convention /// Emits `bread.clip.<verb>.done`/`bread.clip.pinned`/`.failed` per the
/// in bread's Documentation.md — a module that started this command via /// confirmation convention in bread's Documentation.md — a module that
/// `bread.wait`/`bread.wait_any` can await the real outcome instead of /// started this command via `bread.wait`/`bread.wait_any` can await the real
/// assuming success the moment it publishes the command. /// outcome instead of assuming success the moment it publishes the command.
fn handle_command(event_name: &str) { fn handle_command(event_name: &str, data: &Value) {
let Some(verb) = event_name.strip_prefix("bread.command.clip.") else { let Some(verb) = event_name.strip_prefix("bread.command.clip.") else {
return; return;
}; };
@ -164,22 +254,121 @@ fn handle_command(event_name: &str) {
); );
} }
}, },
"pin" => match data.get("id").and_then(Value::as_i64) {
Some(id) => {
let pin = data.get("pin").and_then(Value::as_bool).unwrap_or(true);
match HistoryDb::open().and_then(|db| db.set_pinned(id, pin)) {
Ok(()) => {
eprintln!(
"breadclipd: {} entry {id} via bread.command.clip.pin",
if pin { "pinned" } else { "unpinned" }
);
BreadClient::connect(APP_ID).emit(
"bread.clip.pinned",
serde_json::json!({ "id": id, "pinned": pin }),
);
}
Err(e) => {
eprintln!("breadclipd: bread.command.clip.pin failed: {e}");
BreadClient::connect(APP_ID).emit(
"bread.clip.pin.failed",
serde_json::json!({ "error": e.to_string() }),
);
}
}
}
None => {
eprintln!("breadclipd: bread.command.clip.pin missing 'id'");
BreadClient::connect(APP_ID).emit(
"bread.clip.pin.failed",
serde_json::json!({ "error": "missing 'id'" }),
);
}
},
other => { other => {
eprintln!("breadclipd: ignoring unrecognized command verb '{other}'"); eprintln!("breadclipd: ignoring unrecognized command verb '{other}'");
} }
} }
} }
/// Backoff for the `wl-paste --watch` restart loops: 2s → 4s → 8s → 16s →
/// 32s, capped at 30s. A dead compositor keeps the daemon alive without
/// hammering either wl-paste or the journal.
fn restart_delay(consecutive_failures: u32) -> Duration {
Duration::from_secs((2u64 << consecutive_failures.saturating_sub(1).min(4)).min(30))
}
/// Log a watch-loop restart, but only on state changes — the first failure
/// announces it, then every 8th attempt. Otherwise a persistent failure
/// would spam the journal every two seconds forever.
fn log_restart(what: &str, detail: &str, attempt: u32) {
if attempt == 1 || attempt.is_multiple_of(8) {
eprintln!("breadclipd: {what} ({detail}), restarting (attempt {attempt})");
}
}
/// Runs one `wl-paste --watch` watcher for the daemon's whole lifetime,
/// restarting it with capped backoff whenever it exits (e.g. the compositor
/// connection dropped). `primary` selects the middle-click primary selection
/// watcher (`--watch --primary`) instead of the regular clipboard watcher.
fn watch_loop(exe: std::path::PathBuf, primary: bool) {
let label = if primary {
"wl-paste --watch (primary)"
} else {
"wl-paste --watch"
};
let mut consecutive_failures: u32 = 0;
loop {
// `wl-paste --watch <cmd>` runs <cmd> once per selection-change
// event instead of polling — zero idle cost between changes, and no
// forking wl-paste repeatedly. Each event re-invokes this same
// binary with --capture-once to do a single read-and-store pass.
let mut cmd = Command::new("wl-paste");
cmd.arg("--watch");
if primary {
cmd.arg("--primary");
}
cmd.arg(&exe).arg(CAPTURE_FLAG);
if primary {
cmd.arg(PRIMARY_FLAG);
}
let status = cmd.status();
match status {
Ok(s) => {
consecutive_failures += 1;
log_restart(label, &format!("{s}"), consecutive_failures);
}
Err(e) => {
consecutive_failures += 1;
log_restart(label, &e.to_string(), consecutive_failures);
}
}
thread::sleep(restart_delay(consecutive_failures));
}
}
fn main() { fn main() {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) { if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) {
capture_once(); capture_once(args.iter().any(|a| a == PRIMARY_FLAG));
return; return;
} }
if !acquire_lock() { // flock(2)-based singleton (bread_utils::singleton): lock ownership is
std::process::exit(1); // kernel-atomic and released automatically the instant this process
} // dies, so there's no stale-pid-file case to reason about.
let _singleton = match try_acquire("breadclipd") {
Ok(Acquire::Acquired(guard)) => Some(guard),
Ok(Acquire::HeldByOther(pid)) => {
eprintln!("breadclipd: already running (pid {:?})", pid);
std::process::exit(1);
}
Err(e) => {
eprintln!("breadclipd: single-instance lock unavailable ({e}); continuing without it");
None
}
};
// Wait briefly for WAYLAND_DISPLAY — common when started early in the session // Wait briefly for WAYLAND_DISPLAY — common when started early in the session
let mut retries = 0; let mut retries = 0;
@ -189,14 +378,14 @@ fn main() {
} }
if env::var("WAYLAND_DISPLAY").is_err() { if env::var("WAYLAND_DISPLAY").is_err() {
eprintln!("breadclipd: WAYLAND_DISPLAY not set after waiting, exiting"); eprintln!("breadclipd: WAYLAND_DISPLAY not set after waiting, exiting");
let _ = fs::remove_file(lock_file());
std::process::exit(1); std::process::exit(1);
} }
let cfg = breadclip_core::config::load();
// Fail fast (before we start watching) if the DB can't be opened. // Fail fast (before we start watching) if the DB can't be opened.
if let Err(e) = HistoryDb::open() { if let Err(e) = HistoryDb::open_with(cfg.retention) {
eprintln!("breadclipd: failed to open database: {e}"); eprintln!("breadclipd: failed to open database: {e}");
let _ = fs::remove_file(lock_file());
std::process::exit(1); std::process::exit(1);
} }
@ -204,7 +393,6 @@ fn main() {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
eprintln!("breadclipd: could not resolve own executable path: {e}"); eprintln!("breadclipd: could not resolve own executable path: {e}");
let _ = fs::remove_file(lock_file());
std::process::exit(1); std::process::exit(1);
} }
}; };
@ -219,29 +407,22 @@ fn main() {
// delivering commands until it reconnects. // delivering commands until it reconnects.
let command_client = BreadClient::connect(APP_ID); let command_client = BreadClient::connect(APP_ID);
let _commands = command_client.subscribe("bread.command.clip.**", |event| { let _commands = command_client.subscribe("bread.command.clip.**", |event| {
handle_command(&event.event); handle_command(&event.event, &event.data);
}); });
// `wl-paste --watch <cmd>` runs <cmd> once per clipboard-change event // Regular-clipboard watcher, always on.
// instead of polling — zero idle cost between changes, and no more let clipboard_exe = exe.clone();
// forking wl-paste 4-6 times a second. Each event re-invokes this same thread::spawn(move || watch_loop(clipboard_exe, false));
// binary with --capture-once to do a single read-and-store pass. // Primary-selection watcher (middle-click), opt-in via config.
loop { if cfg.capture_primary {
let status = Command::new("wl-paste") eprintln!("breadclipd: watching primary selection");
.args(["--watch"]) thread::spawn(move || watch_loop(exe, true));
.arg(&exe) }
.arg(CAPTURE_FLAG)
.status();
match status { // The watcher threads own the daemon's lifetime; this thread just keeps
Ok(s) => { // the process (and the singleton guard above) alive.
eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s"); loop {
} thread::sleep(Duration::from_secs(3600));
Err(e) => {
eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s");
}
}
thread::sleep(Duration::from_secs(2));
} }
} }
@ -267,6 +448,22 @@ mod tests {
f(); f();
} }
#[test]
fn sniff_image_mime_detects_png_and_jpeg() {
assert_eq!(sniff_image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png"));
assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
assert_eq!(sniff_image_mime(b"plain text"), None);
}
#[test]
fn restart_delay_backs_off_and_caps() {
assert_eq!(restart_delay(1), Duration::from_secs(2));
assert_eq!(restart_delay(2), Duration::from_secs(4));
assert_eq!(restart_delay(3), Duration::from_secs(8));
assert_eq!(restart_delay(4), Duration::from_secs(16));
assert_eq!(restart_delay(100), Duration::from_secs(30));
}
#[test] #[test]
fn handle_command_clear_empties_history_even_with_no_daemon_reachable() { fn handle_command_clear_empties_history_even_with_no_daemon_reachable() {
// The point of this test: handle_command's actual effect (clearing // The point of this test: handle_command's actual effect (clearing
@ -276,23 +473,57 @@ mod tests {
// made contingent on the emit succeeding, this test would fail. // made contingent on the emit succeeding, this test would fail.
with_isolated_history(|| { with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db"); let db = HistoryDb::open().expect("open history db");
db.insert_text("something to clear").unwrap(); db.insert_text("something to clear", CaptureSource::Clipboard).unwrap();
assert_eq!(db.list_entries(10).unwrap().len(), 1); assert_eq!(db.list_entries(10).unwrap().len(), 1);
handle_command("bread.command.clip.clear"); handle_command("bread.command.clip.clear", &serde_json::json!({}));
let db = HistoryDb::open().expect("reopen history db"); let db = HistoryDb::open().expect("reopen history db");
assert!(db.list_entries(10).unwrap().is_empty()); assert!(db.list_entries(10).unwrap().is_empty());
}); });
} }
#[test]
fn handle_command_pin_toggles_pinned_state() {
with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db");
db.insert_text("to pin", CaptureSource::Clipboard).unwrap();
let id = db.list_entries(10).unwrap()[0].id;
assert!(!db.list_entries(10).unwrap()[0].pinned);
handle_command(
"bread.command.clip.pin",
&serde_json::json!({ "id": id, "pin": true }),
);
assert!(db.list_entries(10).unwrap()[0].pinned);
handle_command(
"bread.command.clip.pin",
&serde_json::json!({ "id": id, "pin": false }),
);
assert!(!db.list_entries(10).unwrap()[0].pinned);
});
}
#[test]
fn handle_command_pin_without_id_is_a_no_op() {
with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db");
db.insert_text("untouched", CaptureSource::Clipboard).unwrap();
handle_command("bread.command.clip.pin", &serde_json::json!({}));
assert!(!db.list_entries(10).unwrap()[0].pinned);
});
}
#[test] #[test]
fn handle_command_ignores_unrecognized_verb() { fn handle_command_ignores_unrecognized_verb() {
with_isolated_history(|| { with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db"); let db = HistoryDb::open().expect("open history db");
db.insert_text("should survive").unwrap(); db.insert_text("should survive", CaptureSource::Clipboard).unwrap();
handle_command("bread.command.clip.pin"); handle_command("bread.command.clip.select", &serde_json::json!({}));
let db = HistoryDb::open().expect("reopen history db"); let db = HistoryDb::open().expect("reopen history db");
assert_eq!( assert_eq!(
@ -307,11 +538,11 @@ mod tests {
fn handle_command_ignores_events_outside_its_own_command_namespace() { fn handle_command_ignores_events_outside_its_own_command_namespace() {
with_isolated_history(|| { with_isolated_history(|| {
let db = HistoryDb::open().expect("open history db"); let db = HistoryDb::open().expect("open history db");
db.insert_text("should survive").unwrap(); db.insert_text("should survive", CaptureSource::Clipboard).unwrap();
// Not a `bread.command.clip.*` event at all — must be a no-op. // Not a `bread.command.clip.*` event at all — must be a no-op.
handle_command("bread.command.pad.clear"); handle_command("bread.command.pad.clear", &serde_json::json!({}));
handle_command("bread.clip.copied"); handle_command("bread.clip.copied", &serde_json::json!({}));
let db = HistoryDb::open().expect("reopen history db"); let db = HistoryDb::open().expect("reopen history db");
assert_eq!(db.list_entries(10).unwrap().len(), 1); assert_eq!(db.list_entries(10).unwrap().len(), 1);

1
ci/bread-ecosystem.rev Normal file
View file

@ -0,0 +1 @@
620c5a1317a6b57276eabca961facdb78bf510db

20
ci/build.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
# files now affect every product's release pipeline, so bumping the pin
# is a deliberate act instead of silent drift.
#
# Usage: ci/build.sh cargo build --release --locked
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
if [ ! -d "$CACHE_DIR" ]; then
rm -rf /tmp/bread-ecosystem-ci-*
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
git -C "$CACHE_DIR" checkout --quiet "$REV"
fi
bash "${CACHE_DIR}/ci/build.sh" breadclip "$ROOT" "$@"

View file

@ -11,6 +11,11 @@ ExecStart=%h/.cargo/bin/breadclipd
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
# wl-paste/wl-copy live on the user's PATH (e.g. ~/.local/bin or
# ~/.cargo/bin via bakery/cargo install), which systemd user services don't
# inherit by default — without this, breadclipd starts but captures nothing.
Environment=PATH=%h/.local/bin:%h/.cargo/bin:/usr/local/bin:/usr/bin:/bin
# Forward stdout/stderr to the journal so `journalctl --user -u breadclipd` works # Forward stdout/stderr to the journal so `journalctl --user -u breadclipd` works
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal

View file

@ -0,0 +1,24 @@
# breadclip configuration — copy to
# $XDG_CONFIG_HOME/breadclip/config.toml (usually ~/.config/breadclip/config.toml)
#
# Every key is optional; a missing file, missing key, or out-of-range value
# falls back to the default shown here. A config file that fails to parse is
# backed up to config.toml.bak once before defaults are used.
[retention]
# Max non-pinned entries kept per kind; the oldest are trimmed automatically.
# 0 means "keep no (unpinned) entries of this kind". Pinned entries are never
# trimmed, regardless of these caps.
text = 200
images = 50
[panel]
# Popup panel width in pixels.
width = 520
[capture]
# Also watch the middle-click "primary" selection (wl-paste --watch --primary)
# and store those entries alongside regular clipboard entries with a
# "primary" badge. Off by default because primary selections tend to be
# transient and noisy.
primary = false