Compare commits

..

No commits in common. "main" and "v0.2.2" have entirely different histories.
main ... v0.2.2

18 changed files with 382 additions and 1678 deletions

View file

@ -1,11 +1,10 @@
name: check name: check
# Fast-fail lint/test on short-lived work branches, and on `main` itself — # Fast-fail lint/test on short-lived work branches, before it ever reaches
# a push to main immediately triggers a dev-track release build, so it # main and triggers a dev-track release build.
# should be linted/tested first, not shipped unchecked.
on: on:
push: push:
branches: ['feature/**', 'fix/**', 'main'] branches: ['feature/**', 'fix/**']
jobs: jobs:
check: check:

View file

@ -17,16 +17,7 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: | run: cd src && bash ci/build.sh cargo build --release --locked
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: |
@ -45,14 +36,8 @@ 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
@ -64,9 +49,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 "${GITHUB_REPOSITORY}" \ gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadclip \
--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 "${GITHUB_REPOSITORY}" \ gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadclip \
"${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" \

View file

@ -51,14 +51,12 @@ Three crates:
through `bread-screenshots`; do not rewrite it just to retarget the crate pin. through `bread-screenshots`; do not rewrite it just to retarget the crate pin.
`EVENTS.md` is the bread-event contract. App id `clip`. Implemented: `EVENTS.md` is the bread-event contract. App id `clip`. Implemented:
`bread.clip.copied`, `bread.clip.clear.done`/`.failed`, `bread.clip.copied`, `bread.clip.clear.done`/`.failed`, and command
`bread.clip.pinned`/`bread.clip.pin.failed`, and commands `bread.command.clip.clear`. There is no pin/select — the history schema has
`bread.command.clip.clear` and `bread.command.clip.pin`. Pinning is real: no pinned column and the popup has no pin UI. Do not invent
the history schema has a `pinned` column, the popup has a Ctrl+P toggle, and `bread.command.clip.pin`/`.select` (or matching events) ahead of a real
trim exempts pinned rows. There is no `select` — the popup is a transient product feature.
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
- Don't embed credentials in remote URLs — SSH or a credential helper only. - Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't invent `select` on the event bus. See `EVENTS.md`. - Don't invent pin/select on the event bus. See `EVENTS.md`.

166
Cargo.lock generated
View file

@ -114,8 +114,8 @@ dependencies = [
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.7.4" version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [ dependencies = [
"dirs", "dirs",
"gtk4", "gtk4",
@ -134,12 +134,11 @@ dependencies = [
"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.4" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-screenshots", "bread-screenshots",
@ -154,19 +153,19 @@ dependencies = [
[[package]] [[package]]
name = "breadclip-core" name = "breadclip-core"
version = "0.2.4" version = "0.2.2"
dependencies = [ dependencies = [
"bread-utils", "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.4" version = "0.2.2"
dependencies = [ dependencies = [
"bread-utils", "bread-utils",
"breadclip-core", "breadclip-core",
@ -194,14 +193,14 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.3" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -225,9 +224,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.6" version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -235,9 +234,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.6" version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -349,9 +348,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
@ -365,30 +364,30 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.11" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [ dependencies = [
"futures-core", "futures-core",
] ]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@ -397,32 +396,32 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.3", "syn 2.0.119",
] ]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-macro", "futures-macro",
@ -453,7 +452,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -486,7 +485,7 @@ dependencies = [
"libc", "libc",
"pango-sys", "pango-sys",
"pkg-config", "pkg-config",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -547,7 +546,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps 7.0.8", "system-deps",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -611,7 +610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [ dependencies = [
"libc", "libc",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -622,7 +621,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -643,7 +642,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -674,7 +673,7 @@ dependencies = [
"graphene-sys", "graphene-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -700,9 +699,9 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell" name = "gtk4-layer-shell"
version = "0.8.1" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"gdk4", "gdk4",
@ -715,15 +714,15 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell-sys" name = "gtk4-layer-shell-sys"
version = "0.6.1" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9"
dependencies = [ dependencies = [
"gdk4-sys", "gdk4-sys",
"glib-sys", "glib-sys",
"gtk4-sys", "gtk4-sys",
"libc", "libc",
"system-deps 8.0.0", "system-deps",
] ]
[[package]] [[package]]
@ -754,7 +753,7 @@ dependencies = [
"gsk4-sys", "gsk4-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -823,15 +822,15 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.189" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.20" version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@ -912,7 +911,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps 7.0.8", "system-deps",
] ]
[[package]] [[package]]
@ -923,9 +922,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
@ -938,18 +937,18 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -1015,9 +1014,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive", "serde_derive",
@ -1025,29 +1024,29 @@ dependencies = [
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.229" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.229" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.3", "syn 2.0.119",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.151" version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -1140,20 +1139,7 @@ dependencies = [
"cfg-expr", "cfg-expr",
"heck", "heck",
"pkg-config", "pkg-config",
"toml 1.1.4+spec-1.1.0", "toml 1.1.3+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",
] ]
@ -1210,9 +1196,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.1.4+spec-1.1.0" version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
@ -1269,9 +1255,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.3+spec-1.1.0" version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow 1.0.4", "winnow 1.0.4",
] ]
@ -1462,24 +1448,24 @@ dependencies = [
[[package]] [[package]]
name = "xml-rs" name = "xml-rs"
version = "0.8.29" version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.56" version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.56" version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -21,8 +21,6 @@ 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.
@ -30,27 +28,19 @@ Content is never included in the payload — only its detected kind and length.
| Verb | Effect | | Verb | Effect |
|------|--------| |------|--------|
| `clear` | Deletes all clipboard history (text entries, stored image files, pinned entries — it's a hard reset). Emits `bread.clip.clear.done`/`.failed`. | | `clear` | Deletes all clipboard history (text entries and stored image files). 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`. |
Pinned entries are exempt from the daemon's trimming and sort to the top of ### Not implemented: `pin` / `select`
the history list. The popup's Ctrl+P toggle writes the same `pinned` column
directly (it doesn't round-trip through the bus), and also emits
`bread.clip.pinned`, so automation can observe either path. Entry `id`s come
from `list_entries` — there is currently no read API on the bus for the
history contents.
### Not implemented: `select` An earlier draft of this integration planned `pin`/`select` verbs, but
breadclip's history schema has no "pinned" concept at all today — there's no
`select` (remote-activate a row from the bus, e.g. "paste entry 42 now") column for it, and the GTK popup UI has no corresponding affordance. Adding
is still deliberately not implemented: the popup is a short-lived process real pin/select support is a product decision for breadclip itself (does it
with no resident service to receive that command, and the daemon has no want pinning, and what should the UI look like?), not something to fabricate
reason to talk to a transient popup. That's a real product decision for as a side effect of wiring up the event bus. If/when breadclip grows that
breadclip itself — what should "select from the bus" even mean when the feature, the corresponding `bread.command.clip.pin`/`.select` verbs (and
popup isn't open? — not something to fabricate as a side effect of wiring matching `bread.clip.pinned`/`.selected` events) should be added at the same
up the event bus. When breadclip grows that feature, the matching time, not stubbed out ahead of it.
`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,7 +77,6 @@ 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.
@ -86,25 +85,6 @@ Clicking an entry copies it and closes the popup. Clicking outside the panel clo
The popup has three filter chips — **All**, **Text**, **Images** — and a search box. The search box filters text entries by content; image entries only appear under the **Images** filter. 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/`):
@ -112,20 +92,14 @@ 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/JPEG files for image entries | | `images/` | PNG files for image entries |
The daemon trims the oldest non-pinned entries automatically, keeping at The daemon keeps at most 200 text entries and 50 image entries, trimming oldest entries automatically.
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**. `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. - 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.
- 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,14 @@
[package] [package]
name = "breadclip-core" name = "breadclip-core"
version = "0.2.4" version = "0.2.2"
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"
toml_edit = "0.22" dirs = "5"
# `toml` for bread-utils' non-destructive config load/save discipline (config.rs). bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
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

@ -1,175 +0,0 @@
//! breadclip's user configuration.
//!
//! Read from `$XDG_CONFIG_HOME/breadclip/config.toml` (or
//! `~/.config/breadclip/config.toml`). Every key has a sensible default, so
//! the file is entirely optional. A file that *exists* but fails to parse is
//! backed up to `config.toml.bak` (once) and defaults are used, matching
//! bread-utils' non-destructive TOML discipline (see
//! `bread_utils::tomlcfg::load_doc`).
//!
//! ```toml
//! [retention]
//! text = 200 # max non-pinned text entries (0 = keep none)
//! images = 50 # max non-pinned image entries (0 = keep none)
//!
//! [panel]
//! width = 520 # popup panel width, px
//!
//! [capture]
//! primary = false # also watch the middle-click primary selection
//! ```
use crate::Retention;
use std::path::PathBuf;
use toml_edit::{DocumentMut, Item};
/// Resolved configuration. `Config::default()` matches the built-in
/// behavior before config files existed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Config {
pub retention: Retention,
pub panel_width: i32,
pub capture_primary: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
retention: Retention::default(),
panel_width: 520,
capture_primary: false,
}
}
}
/// Clamp bounds. Retention can legitimately be `0` ("keep no unpinned
/// entries of this kind") but never huge; the panel width is bounded to
/// something that still fits on a screen.
const MAX_RETENTION: i64 = 10_000;
const MIN_PANEL_WIDTH: i64 = 300;
const MAX_PANEL_WIDTH: i64 = 2000;
pub fn config_path() -> PathBuf {
bread_utils::xdg::config_dir("breadclip").join("config.toml")
}
/// Load configuration, falling back to defaults for anything missing,
/// unparseable, or out of range.
pub fn load() -> Config {
let doc = bread_utils::tomlcfg::load_doc("breadclip", &config_path());
Config {
retention: Retention {
text: int(&doc, "retention", "text", Retention::default().text as i64)
.clamp(0, MAX_RETENTION) as usize,
images: int(&doc, "retention", "images", Retention::default().images as i64)
.clamp(0, MAX_RETENTION) as usize,
},
panel_width: int(&doc, "panel", "width", 520)
.clamp(MIN_PANEL_WIDTH, MAX_PANEL_WIDTH) as i32,
capture_primary: boolean(&doc, "capture", "primary", false),
}
}
fn int(doc: &DocumentMut, section: &str, key: &str, default: i64) -> i64 {
doc.get(section)
.and_then(Item::as_table)
.and_then(|t| t.get(key))
.and_then(Item::as_integer)
.unwrap_or(default)
}
fn boolean(doc: &DocumentMut, section: &str, key: &str, default: bool) -> bool {
doc.get(section)
.and_then(Item::as_table)
.and_then(|t| t.get(key))
.and_then(Item::as_bool)
.unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
fn with_config_dir<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,50 +1,7 @@
pub mod config; use rusqlite::{params, Connection, Result as SqlResult};
use rusqlite::{params, Connection};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fmt; use std::os::unix::fs::PermissionsExt;
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 {
@ -54,84 +11,18 @@ 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 {
/// Open with the default retention caps. pub fn open() -> SqlResult<Self> {
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).map_err(|source| HistoryError::Io { std::fs::create_dir_all(&dir).ok();
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,
@ -139,99 +30,53 @@ 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, id DESC);", CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp 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);
let db = Self { conn, retention }; Ok(Self { conn })
db.gc_orphaned_images()?;
Ok(db)
} }
pub fn insert_text(&self, text: &str, source: CaptureSource) -> Result<(), HistoryError> { pub fn insert_text(&self, text: &str) -> SqlResult<()> {
let hash = sha256_hex(text.as_bytes()); let 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, is_primary) "INSERT INTO history (timestamp, mime_type, content, content_hash)
VALUES (?1, 'text/plain', ?2, ?3, ?4) VALUES (?1, 'text/plain', ?2, ?3)
ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1, is_primary = ?4", ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1",
params![ts, text, hash, source.is_primary() as i64], params![ts, text, hash],
)?; )?;
self.trim() self.trim(200, 50)
} }
/// Store an image. `mime_type` is persisted with the row and drives the pub fn insert_image(&self, png_bytes: &[u8]) -> SqlResult<()> {
/// file extension, so a JPEG capture is stored as a `.jpg` offered as let hash = sha256_hex(png_bytes);
/// `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).map_err(|source| HistoryError::Io { std::fs::create_dir_all(&images_dir).ok();
action: "create images directory", // Use first 16 hex chars for the filename (collision-safe for 50 images)
path: images_dir.clone(), let path = images_dir.join(format!("{}.png", &hash[..16]));
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() {
write_image_file(&path, bytes)?; std::fs::write(&path, png_bytes).ok();
restrict_permissions(&path);
} }
let path_str = path.to_string_lossy().to_string(); let 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, is_primary) "INSERT INTO history (timestamp, mime_type, image_path, content_hash)
VALUES (?1, ?2, ?3, ?4, ?5) VALUES (?1, 'image/png', ?2, ?3)
ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1, is_primary = ?5", ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1",
params![ts, mime_type, path_str, hash, source.is_primary() as i64], params![ts, path_str, hash],
)?; )?;
self.trim() self.trim(200, 50)
} }
/// Pin (or unpin) an entry. Pinned entries are exempt from trimming and pub fn list_entries(&self, limit: usize) -> SqlResult<Vec<ClipEntry>> {
/// 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, pinned, is_primary "SELECT id, timestamp, mime_type, content, image_path, content_hash
FROM history ORDER BY pinned DESC, timestamp DESC, id DESC LIMIT ?1", FROM history ORDER BY timestamp DESC LIMIT ?1",
)?; )?;
let rows = stmt let rows = stmt
.query_map([limit as i64], |row| { .query_map([limit as i64], |row| {
@ -242,40 +87,35 @@ 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::<rusqlite::Result<Vec<_>>>()?; .collect::<SqlResult<Vec<_>>>();
Ok(rows) rows
} }
/// Deletes every history entry and every stored image file — a hard /// Deletes every history entry and every stored image file. Used by the
/// 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 full wipe. /// subscription) as well as anything else that wants a hard reset of
pub fn clear_all(&self) -> Result<(), HistoryError> { /// clipboard history.
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::<rusqlite::Result<Vec<_>>>()?; .collect::<SqlResult<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) -> Result<(), HistoryError> { pub fn delete_entry(&self, id: i64) -> SqlResult<()> {
// Clean up image file if present — after the row is gone, so a // Clean up image file if present
// 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(
@ -285,114 +125,56 @@ 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(())
} }
/// Sweep image files on disk that no history row references anymore fn trim(&self, max_text: usize, max_images: usize) -> SqlResult<()> {
/// (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' AND pinned = 0 WHERE mime_type = 'text/plain'
AND id NOT IN ( AND id NOT IN (
SELECT id FROM history WHERE mime_type = 'text/plain' AND pinned = 0 SELECT id FROM history WHERE mime_type = 'text/plain'
ORDER BY timestamp DESC, id DESC LIMIT ?1 ORDER BY timestamp DESC LIMIT ?1
)", )",
[self.retention.text as i64], [max_text as i64],
)?; )?;
// Collect the image files that are about to be trimmed, delete the // Collect old image paths before deleting rows
// 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 LIKE 'image/%' WHERE mime_type = 'image/png'
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 LIKE 'image/%' AND pinned = 0 SELECT id FROM history WHERE mime_type = 'image/png'
ORDER BY timestamp DESC, id DESC LIMIT ?1 ORDER BY timestamp DESC LIMIT ?1
)", )",
)?; )?;
let paths = stmt let paths = stmt
.query_map([self.retention.images as i64], |row| row.get(0))? .query_map([max_images as i64], |row| row.get(0))?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<SqlResult<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.
@ -404,42 +186,6 @@ fn restrict_permissions(path: &Path) {
} }
} }
/// Restrict a directory to owner-only (0700). Used for the data dir and the
/// images dir, both of which contain clipboard secrets.
fn restrict_dir(path: &Path, mode: u32) {
if let Ok(meta) = std::fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(mode);
let _ = std::fs::set_permissions(path, perms);
}
}
/// Write an image file, created 0600 from the very first syscall — no window
/// where a clipboard image (possibly a screenshot with sensitive pixels) is
/// world-readable before a chmod lands, and no dependence on the umask.
fn write_image_file(path: &Path, bytes: &[u8]) -> Result<(), HistoryError> {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
{
Ok(mut f) => f.write_all(bytes).map_err(|source| HistoryError::Io {
action: "write image file",
path: path.to_path_buf(),
source,
}),
// Same hash filename means identical content — a concurrent
// `--capture-once` already wrote it.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(source) => Err(HistoryError::Io {
action: "create image file",
path: path.to_path_buf(),
source,
}),
}
}
pub fn sha256_hex(data: &[u8]) -> String { pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(data); hasher.update(data);
@ -463,33 +209,28 @@ 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::*;
fn open_test_db() -> (std::sync::MutexGuard<'static, ()>, tempfile::TempDir, HistoryDb) { // `HistoryDb::open` resolves its path via `data_dir()`, which follows
let guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner()); // `$XDG_DATA_HOME` — redirecting it to a fresh temp dir per test keeps
// this isolated from a real `~/.local/share/breadclip` and from other
// tests. Safe without a lock: this is currently the only test in the
// crate that touches XDG_DATA_HOME.
fn open_test_db() -> (tempfile::TempDir, HistoryDb) {
let dir = tempfile::tempdir().expect("tempdir"); 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");
(guard, dir, db) (dir, db)
} }
#[test] #[test]
fn clear_all_removes_every_entry_and_image_file() { fn clear_all_removes_every_entry_and_image_file() {
let (_guard, _dir, db) = open_test_db(); let (_dir, db) = open_test_db();
db.insert_text("first", CaptureSource::Clipboard).unwrap(); db.insert_text("first").unwrap();
db.insert_text("second", CaptureSource::Clipboard).unwrap(); db.insert_text("second").unwrap();
db.insert_image(b"not really a png, just bytes for the test", "image/png", CaptureSource::Clipboard) db.insert_image(b"not really a png, just bytes for the test")
.unwrap(); .unwrap();
let before = db.list_entries(10).unwrap(); let before = db.list_entries(10).unwrap();
@ -512,247 +253,8 @@ 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 (_guard, _dir, db) = open_test_db(); let (_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.4" version = "0.2.2"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@ -9,7 +9,7 @@ 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.7.4", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs. # Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }

View file

@ -18,13 +18,11 @@ use std::{
rc::Rc, rc::Rc,
}; };
// Pinned entries are exempt from trimming, so the fetch limit can't be const PANEL_WIDTH: i32 = 520;
// derived from the retention caps alone. History stays tiny in practice — const MAX_ENTRIES: usize = 200;
// 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
// Fallback panel height, used only if measuring the panel before it's shown // Worst-case panel height (search + chips + full-height list), used to keep
// reports nothing sensible. // the panel fully on-screen before its natural size is known.
const PANEL_HEIGHT_ESTIMATE: i32 = 580; const PANEL_HEIGHT_ESTIMATE: i32 = 580;
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
@ -144,21 +142,6 @@ 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);
@ -170,21 +153,20 @@ fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow {
} }
fn do_copy(entry: &ClipEntry) { fn do_copy(entry: &ClipEntry) {
if entry.mime_type.starts_with("image/") { match entry.mime_type.as_str() {
// Re-paste with the same type we stored — a JPEG row must be offered "image/png" => {
// as image/jpeg, not relabeled as image/png.
if let Some(ref path) = entry.image_path { if let Some(ref path) = entry.image_path {
if let Ok(file) = fs::File::open(path) { if let Ok(file) = fs::File::open(path) {
let _ = Command::new("wl-copy") let _ = Command::new("wl-copy")
.args(["--type", entry.mime_type.as_str()]) .args(["--type", "image/png"])
.stdin(Stdio::from(file)) .stdin(Stdio::from(file))
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.spawn(); .spawn();
} }
} }
return;
} }
_ => {
if let Some(ref content) = entry.content { if let Some(ref content) = entry.content {
if let Ok(mut child) = Command::new("wl-copy") if let Ok(mut child) = Command::new("wl-copy")
.stdin(Stdio::piped()) .stdin(Stdio::piped())
@ -198,17 +180,12 @@ fn do_copy(entry: &ClipEntry) {
} }
} }
} }
}
}
// ---- UI --------------------------------------------------------------------- // ---- UI ---------------------------------------------------------------------
fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::ScreenshotRequest>) { fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::ScreenshotRequest>) {
// Live snapshot of the entries backing the list — pinning re-reads the
// DB (pinned rows sort first) and rebuilds the rows from this. Built
// outside connect_activate because that handler is `Fn` (runs once per
// activation) and can't consume the entries vector.
let entries_rc: Rc<std::cell::RefCell<Vec<ClipEntry>>> =
Rc::new(std::cell::RefCell::new(entries));
let mut builder = Application::builder().application_id("com.breadway.breadclip"); let mut builder = Application::builder().application_id("com.breadway.breadclip");
if screenshot_req.is_some() { if screenshot_req.is_some() {
// GApplication is single-instance by default; this machine typically // GApplication is single-instance by default; this machine typically
@ -226,12 +203,53 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
// 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);
let panel_width = breadclip_core::config::load().panel_width; // ---- Position panel relative to the active window ----
// If a non-fullscreen window is focused, anchor the panel just below it.
// Otherwise, centre the panel on screen.
let active_win = 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();
@ -267,7 +285,7 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
let list = ListBox::new(); let list = ListBox::new();
list.set_selection_mode(SelectionMode::Browse); list.set_selection_mode(SelectionMode::Browse);
for entry in entries_rc.borrow().iter() { for entry in &entries {
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) {
@ -278,60 +296,6 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
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));
@ -384,11 +348,7 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
{ {
let close_k = Rc::clone(&close_all); let close_k = Rc::clone(&close_all);
let list_k = list.clone(); let list_k = list.clone();
let search_k = search.clone(); key_ctrl.connect_key_pressed(move |_, key, _, _| {
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 => {
@ -405,11 +365,6 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
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() {
@ -438,48 +393,6 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
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,
} }
}); });
@ -551,7 +464,7 @@ fn main() {
}; };
let entries = HistoryDb::open() let entries = HistoryDb::open()
.and_then(|db| db.list_entries(FETCH_ALL)) .and_then(|db| db.list_entries(MAX_ENTRIES))
.unwrap_or_default(); .unwrap_or_default();
run_ui(entries, screenshot_req); run_ui(entries, screenshot_req);

View file

@ -13,10 +13,15 @@
//! `bread-capture`'s isolation module), so the panel always falls back to //! `bread-capture`'s isolation module), so the panel always falls back to
//! centered, which is exactly what we want for a consistent screenshot. //! 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 clap::Parser;
use gtk4::prelude::*; use gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
/// Extra settle time after `map` for the first frame to actually paint
/// before grim runs — `map` fires once the surface exists, not once
/// anything has been drawn into it.
const SETTLE_DELAY: Duration = Duration::from_millis(300);
#[derive(Parser)] #[derive(Parser)]
#[command(name = "breadclip")] #[command(name = "breadclip")]
@ -32,11 +37,11 @@ pub struct Cli {
/// Capture canvas width — matches the isolated compositor's output width /// Capture canvas width — matches the isolated compositor's output width
/// (`bread-capture --isolate-width`). /// (`bread-capture --isolate-width`).
#[arg(long, default_value_t = DEFAULT_WIDTH)] #[arg(long, default_value_t = 1920)]
pub width: u32, pub width: u32,
/// Capture canvas height — see `width`. /// Capture canvas height — see `width`.
#[arg(long, default_value_t = DEFAULT_HEIGHT)] #[arg(long, default_value_t = 1080)]
pub height: u32, pub height: u32,
} }
@ -49,20 +54,16 @@ pub struct ScreenshotRequest {
} }
impl Cli { impl Cli {
/// `None` for a normal run. Exits the process with an error if the /// `None` for a normal run. Exits the process with an error if
/// `--screenshot` / `--output` pair is incomplete, before any GTK setup /// `--screenshot` was given without `--output`, before any GTK setup
/// happens. /// happens.
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> { pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) { let view = self.screenshot.clone()?;
eprintln!("breadclip: {e}"); let Some(output) = self.output.clone() else {
eprintln!("breadclip: --screenshot requires --output");
std::process::exit(1); std::process::exit(1);
} };
Some(ScreenshotRequest { Some(ScreenshotRequest { view, output, width: self.width, height: self.height })
view: self.screenshot.clone()?,
output: self.output.clone()?,
width: self.width,
height: self.height,
})
} }
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadclipd" name = "breadclipd"
version = "0.2.4" version = "0.2.2"
edition = "2021" edition = "2021"
[[bin]] [[bin]]

View file

@ -105,29 +105,12 @@ fn looks_like_code(text: &str) -> bool {
}) })
.count(); .count();
// Multi-line with a meaningful fraction of "code-shaped" lines, or a // Multi-line with a meaningful fraction of "code-shaped" lines, or an
// couple of code tokens anywhere, counts as code. // unambiguous single-line token (import/#include/fn signature), counts
if lines.len() > 1 { // as code. A single short line of prose won't hit either bar.
return token_hits >= 2 || brace_or_semicolon_lines * 2 >= lines.len(); token_hits >= 2
} || (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)]
@ -187,15 +170,6 @@ 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

@ -1,182 +0,0 @@
//! Best-effort content heuristics for never persisting obvious secrets.
//!
//! The password-manager path (`CLIPBOARD_STATE=sensitive` / the
//! `x-kde-passwordManagerHint` MIME type) only catches copies an app
//! deliberately flagged. These rules catch copies that *look* like secrets
//! even when the app didn't flag them — one-time codes, credit card
//! numbers, private keys, credential lines. They are deliberately
//! conservative (a false "skip" is cheaper than a leaked password) and they
//! are a convenience, not a security boundary: the 0600 file permissions
//! and 0700 data dir are the real protection.
/// Would persisting this text be a bad idea? Skipped copies never reach the
/// database (or the event bus), like the password-manager path.
pub fn is_sensitive(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
looks_like_private_key(trimmed)
|| looks_like_credential_line(trimmed)
|| looks_like_otp(trimmed)
|| contains_card_number(trimmed)
|| contains_api_token(trimmed)
}
/// PEM/OpenSSH private key blocks (and PGP blocks, which are just as
/// sensitive).
fn looks_like_private_key(text: &str) -> bool {
text.contains("-----BEGIN") || text.contains("PRIVATE KEY")
}
/// A line that labels a credential directly: `password: hunter2`,
/// `passwd = hunter2`, etc.
fn looks_like_credential_line(text: &str) -> bool {
let first = text.lines().next().unwrap_or("").trim().to_ascii_lowercase();
const PREFIXES: [&str; 8] = [
"password:",
"password =",
"password=",
"passwd:",
"passwd =",
"passwd=",
"pw:",
"pass:",
];
PREFIXES.iter().any(|p| first.starts_with(p))
}
/// A one-time code: a short copy that mentions a code keyword and contains
/// a 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,11 +1,8 @@
mod content_kind; mod content_kind;
mod ignore_rules;
use bread_utils::bread_client::BreadClient; use bread_utils::bread_client::BreadClient;
use bread_utils::singleton::{try_acquire, Acquire}; use breadclip_core::HistoryDb;
use breadclip_core::{CaptureSource, HistoryDb}; use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration};
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
@ -17,52 +14,15 @@ 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". Only reachable on the fallback /// own, signaling "don't persist this". Any offer advertising it is skipped
/// capture path — when invoked through `wl-paste --watch`, `CLIPBOARD_STATE` /// entirely.
/// 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";
/// Build a `wl-paste` command with a hard deadline. Without the timeout, a fn get_available_types() -> Vec<String> {
/// stalled selection offer (source app died mid-transfer, compositor never Command::new("wl-paste")
/// completes the handoff) leaves `wl-paste` blocked forever reading a pipe .args(["--list-types"])
/// 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())
@ -71,16 +31,8 @@ fn get_available_types(primary: bool) -> Vec<String> {
.unwrap_or_default() .unwrap_or_default()
} }
fn get_clipboard_bytes(mime: &str, primary: bool) -> Option<Vec<u8>> { fn get_clipboard_text() -> Option<String> {
let output = wl_paste_cmd(primary).args(["--type", mime]).output().ok()?; let output = Command::new("wl-paste")
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()?;
@ -92,80 +44,48 @@ fn get_clipboard_text(primary: bool) -> Option<String> {
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
} }
/// Identify an image payload by its magic bytes. `wl-paste --watch` hands us fn get_clipboard_image() -> Option<Vec<u8>> {
/// the content on stdin without saying which offered type it picked, so the let output = Command::new("wl-paste")
/// bytes are the only reliable signal — and they're exact, because watch .args(["--type", "image/png"])
/// mode never appends a trailing newline. .output()
fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> { .ok()?;
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { if !output.status.success() || output.stdout.is_empty() {
return Some("image/png"); return None;
} }
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { Some(output.stdout)
return Some("image/jpeg");
}
None
} }
fn source(primary: bool) -> CaptureSource { fn lock_file() -> PathBuf {
if primary { env::var("XDG_RUNTIME_DIR")
CaptureSource::Primary .map(PathBuf::from)
} else { .unwrap_or_else(|_| PathBuf::from("/tmp"))
CaptureSource::Clipboard .join("breadclipd.lock")
}
} }
fn store_text(text: &str, primary: bool) { // Returns false if another instance is already running.
let db = match open_db() { fn acquire_lock() -> bool {
Ok(db) => db, let path = lock_file();
Err(e) => { if let Ok(content) = fs::read_to_string(&path) {
eprintln!("breadclipd: failed to open database: {e}"); if let Ok(pid) = content.trim().parse::<u32>() {
return; let alive = fs::read_to_string(format!("/proc/{}/comm", pid))
.map(|s| s.trim() == "breadclipd")
.unwrap_or(false);
if alive {
eprintln!("breadclipd: already running (pid {})", pid);
return false;
} }
};
match db.insert_text(text, source(primary)) {
Ok(()) => emit_copied(content_kind::detect(text), text.len()),
Err(e) => eprintln!("breadclipd: insert text: {e}"),
} }
} }
let _ = fs::write(&path, std::process::id().to_string());
true
}
fn store_image(bytes: Vec<u8>, mime: &str, primary: bool) { /// Invoked once per clipboard-change event (as the handler command for
let db = match open_db() { /// `wl-paste --watch`). Reads whatever is on the clipboard right now, skips
Ok(db) => db, /// it entirely if a password manager flagged it as sensitive, and otherwise
Err(e) => { /// persists a text or image entry to the history DB.
eprintln!("breadclipd: failed to open database: {e}"); fn capture_once() {
return; let types = get_available_types();
}
};
match db.insert_image(&bytes, mime, source(primary)) {
Ok(()) => emit_copied("image", bytes.len()),
Err(e) => eprintln!("breadclipd: insert image: {e}"),
}
}
/// Capture path for `wl-paste --watch` invocations: the clipboard content
/// arrives on our stdin together with its `CLIPBOARD_STATE` in one event, so
/// the sensitive check and the data read can't race each other (two separate
/// `wl-paste` calls could straddle a clipboard change mid-capture).
fn capture_from_stdin(primary: bool) {
let mut buf = Vec::new();
if std::io::stdin().read_to_end(&mut buf).is_err() || buf.is_empty() {
return;
}
if let Some(mime) = sniff_image_mime(&buf) {
store_image(buf, mime, primary);
} else if let Ok(text) = String::from_utf8(buf) {
if !text.trim().is_empty() && !ignore_rules::is_sensitive(&text) {
store_text(&text, primary);
}
}
}
/// Fallback capture path for manual `--capture-once` invocations (no
/// `CLIPBOARD_STATE` was set by wl-paste): ask wl-paste directly. Same
/// behavior as the stdin path, including requesting whichever image type is
/// actually offered rather than assuming PNG.
fn capture_via_wl_paste(primary: bool) {
let types = get_available_types(primary);
if types.iter().any(|t| t == PASSWORD_HINT_MIME) { if types.iter().any(|t| t == PASSWORD_HINT_MIME) {
// Password manager (KeePassXC, Bitwarden, etc.) marked this copy as // Password manager (KeePassXC, Bitwarden, etc.) marked this copy as
@ -177,42 +97,31 @@ fn capture_via_wl_paste(primary: bool) {
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/"));
if has_image && !has_text { let db = match HistoryDb::open() {
let mime = if types.iter().any(|t| t == "image/png") { Ok(db) => db,
"image/png" Err(e) => {
} else { eprintln!("breadclipd: failed to open database: {e}");
"image/jpeg" return;
}
}; };
if let Some(bytes) = get_clipboard_bytes(mime, primary) {
store_image(bytes, mime, primary); if has_image && !has_text {
if let Some(bytes) = get_clipboard_image() {
match db.insert_image(&bytes) {
Ok(()) => emit_copied("image", bytes.len()),
Err(e) => eprintln!("breadclipd: insert image: {e}"),
}
} }
} else if has_text { } else if has_text {
if let Some(text) = get_clipboard_text(primary) { if let Some(text) = get_clipboard_text() {
if !ignore_rules::is_sensitive(&text) { match db.insert_text(&text) {
store_text(&text, primary); Ok(()) => emit_copied(content_kind::detect(&text), text.len()),
Err(e) => eprintln!("breadclipd: insert text: {e}"),
} }
} }
} }
} }
/// Invoked once per clipboard-change event (as the handler command for
/// `wl-paste --watch`). Stores a text or image entry to the history DB,
/// unless the selection was flagged as sensitive — in which case nothing is
/// ever persisted.
fn capture_once(primary: bool) {
match env::var(CLIPBOARD_STATE).as_deref() {
// Password manager (KeePassXC, Bitwarden, ...) flagged this selection
// as sensitive via `wl-copy --sensitive` — never persist it.
Ok("sensitive") => {}
// Empty clipboard (nil) or an explicit clear — nothing to store.
Ok("nil") | Ok("clear") => {}
Ok("data") => capture_from_stdin(primary),
// Unset or unrecognized: not invoked by `--watch`. Fall back to the
// direct wl-paste path rather than blocking on whatever stdin is.
_ => capture_via_wl_paste(primary),
}
}
/// 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
@ -225,18 +134,19 @@ fn emit_copied(kind: &str, len: usize) {
); );
} }
/// Reacts to `bread.command.clip.*` verbs. Only `clear` and `pin` map to /// Reacts to `bread.command.clip.*` verbs. Only `clear` maps to real,
/// real, existing breadclip functionality today — `select` would need a /// existing breadclip functionality today — `pin`/`select` would need a new
/// "remote-activate a row" concept the popup doesn't expose over the bus, /// "pinned" concept that doesn't exist anywhere in the history DB schema,
/// which is a real product decision for breadclip itself, not something to /// which is a real product decision for breadclip itself (does it want
/// fabricate as a side effect of wiring up the event bus. See /// pinning at all, and what would the GTK UI for it look like?), not
/// something to fabricate as a side effect of wiring up the event bus. See
/// `breadclip/EVENTS.md` for the honest current status. /// `breadclip/EVENTS.md` for the honest current status.
/// ///
/// Emits `bread.clip.<verb>.done`/`bread.clip.pinned`/`.failed` per the /// Emits `bread.clip.<verb>.done`/`.failed` per the confirmation convention
/// confirmation convention in bread's Documentation.md — a module that /// in bread's Documentation.md — a module that started this command via
/// started this command via `bread.wait`/`bread.wait_any` can await the real /// `bread.wait`/`bread.wait_any` can await the real outcome instead of
/// outcome instead of assuming success the moment it publishes the command. /// assuming success the moment it publishes the command.
fn handle_command(event_name: &str, data: &Value) { fn handle_command(event_name: &str) {
let Some(verb) = event_name.strip_prefix("bread.command.clip.") else { let Some(verb) = event_name.strip_prefix("bread.command.clip.") else {
return; return;
}; };
@ -254,121 +164,22 @@ fn handle_command(event_name: &str, data: &Value) {
); );
} }
}, },
"pin" => match data.get("id").and_then(Value::as_i64) {
Some(id) => {
let pin = data.get("pin").and_then(Value::as_bool).unwrap_or(true);
match HistoryDb::open().and_then(|db| db.set_pinned(id, pin)) {
Ok(()) => {
eprintln!(
"breadclipd: {} entry {id} via bread.command.clip.pin",
if pin { "pinned" } else { "unpinned" }
);
BreadClient::connect(APP_ID).emit(
"bread.clip.pinned",
serde_json::json!({ "id": id, "pinned": pin }),
);
}
Err(e) => {
eprintln!("breadclipd: bread.command.clip.pin failed: {e}");
BreadClient::connect(APP_ID).emit(
"bread.clip.pin.failed",
serde_json::json!({ "error": e.to_string() }),
);
}
}
}
None => {
eprintln!("breadclipd: bread.command.clip.pin missing 'id'");
BreadClient::connect(APP_ID).emit(
"bread.clip.pin.failed",
serde_json::json!({ "error": "missing 'id'" }),
);
}
},
other => { 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(args.iter().any(|a| a == PRIMARY_FLAG)); capture_once();
return; return;
} }
// flock(2)-based singleton (bread_utils::singleton): lock ownership is if !acquire_lock() {
// kernel-atomic and released automatically the instant this process
// dies, so there's no stale-pid-file case to reason about.
let _singleton = match try_acquire("breadclipd") {
Ok(Acquire::Acquired(guard)) => Some(guard),
Ok(Acquire::HeldByOther(pid)) => {
eprintln!("breadclipd: already running (pid {:?})", pid);
std::process::exit(1); std::process::exit(1);
} }
Err(e) => {
eprintln!("breadclipd: single-instance lock unavailable ({e}); continuing without it");
None
}
};
// Wait briefly for WAYLAND_DISPLAY — common when started early in the session // Wait briefly for WAYLAND_DISPLAY — common when started early in the session
let mut retries = 0; let mut retries = 0;
@ -378,14 +189,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_with(cfg.retention) { if let Err(e) = HistoryDb::open() {
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);
} }
@ -393,6 +204,7 @@ 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);
} }
}; };
@ -407,22 +219,29 @@ 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, &event.data); handle_command(&event.event);
}); });
// Regular-clipboard watcher, always on. // `wl-paste --watch <cmd>` runs <cmd> once per clipboard-change event
let clipboard_exe = exe.clone(); // instead of polling — zero idle cost between changes, and no more
thread::spawn(move || watch_loop(clipboard_exe, false)); // forking wl-paste 4-6 times a second. Each event re-invokes this same
// Primary-selection watcher (middle-click), opt-in via config. // binary with --capture-once to do a single read-and-store pass.
if cfg.capture_primary {
eprintln!("breadclipd: watching primary selection");
thread::spawn(move || watch_loop(exe, true));
}
// The watcher threads own the daemon's lifetime; this thread just keeps
// the process (and the singleton guard above) alive.
loop { loop {
thread::sleep(Duration::from_secs(3600)); let status = Command::new("wl-paste")
.args(["--watch"])
.arg(&exe)
.arg(CAPTURE_FLAG)
.status();
match status {
Ok(s) => {
eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s");
}
Err(e) => {
eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s");
}
}
thread::sleep(Duration::from_secs(2));
} }
} }
@ -448,22 +267,6 @@ mod tests {
f(); 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
@ -473,57 +276,23 @@ 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", CaptureSource::Clipboard).unwrap(); db.insert_text("something to clear").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", &serde_json::json!({})); handle_command("bread.command.clip.clear");
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", CaptureSource::Clipboard).unwrap(); db.insert_text("should survive").unwrap();
handle_command("bread.command.clip.select", &serde_json::json!({})); handle_command("bread.command.clip.pin");
let db = HistoryDb::open().expect("reopen history db"); let db = HistoryDb::open().expect("reopen history db");
assert_eq!( assert_eq!(
@ -538,11 +307,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", CaptureSource::Clipboard).unwrap(); db.insert_text("should survive").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", &serde_json::json!({})); handle_command("bread.command.pad.clear");
handle_command("bread.clip.copied", &serde_json::json!({})); handle_command("bread.clip.copied");
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);

View file

@ -11,11 +11,6 @@ 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

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