Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
26 changed files with 531 additions and 3011 deletions
|
|
@ -1,25 +0,0 @@
|
||||||
name: check
|
|
||||||
|
|
||||||
# Fast-fail lint/test on short-lived work branches, and on `main` itself —
|
|
||||||
# a push to main immediately triggers a dev-track release build, so it
|
|
||||||
# should be linted/tested first, not shipped unchecked.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['feature/**', 'fix/**', 'main']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
rm -rf src && mkdir src
|
|
||||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
|
||||||
|
|
||||||
- name: clippy
|
|
||||||
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
run: cd src && bash ci/build.sh cargo test --workspace --locked
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
name: dev release
|
|
||||||
|
|
||||||
# Publishes a dev-track build on every push to `main` (the trunk
|
|
||||||
# branch — there is no separate `dev` branch). See bread-ecosystem's
|
|
||||||
# docs/release-channels.md for the release-track policy this is part of.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['main']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
rm -rf src && mkdir src
|
|
||||||
git clone --branch main --depth 1 \
|
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
|
||||||
|
|
||||||
- name: compute dev version
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd src
|
|
||||||
# Base the dev version off the latest published stable tag,
|
|
||||||
# not Cargo.toml — Cargo.toml can go stale relative to the last
|
|
||||||
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
|
|
||||||
# breadpaper), which would make a dev build sort as OLDER than
|
|
||||||
# what's already installed and bakery would correctly refuse it.
|
|
||||||
LATEST_TAG="$(git ls-remote --tags --refs \
|
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
|
||||||
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
|
||||||
if [ -n "${LATEST_TAG}" ]; then
|
|
||||||
CUR="${LATEST_TAG}"
|
|
||||||
else
|
|
||||||
CUR="$(grep -m1 '^version' breadclip/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
|
||||||
fi
|
|
||||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
|
||||||
SHA="$(git rev-parse --short HEAD)"
|
|
||||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
|
||||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: prepare artifacts
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
PKG_DIR="/srv/breadway-dl/dev/breadclip/${VERSION}"
|
|
||||||
mkdir -p "${PKG_DIR}"
|
|
||||||
for bin in breadclip breadclipd; do
|
|
||||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
|
||||||
strip "${PKG_DIR}/${bin}-x86_64"
|
|
||||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
|
||||||
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
|
||||||
done
|
|
||||||
cp src/contrib/breadclipd.service "${PKG_DIR}/"
|
|
||||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
|
||||||
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadclip/latest"
|
|
||||||
|
|
||||||
# No GitHub Release upload — dev, like the other non-stable track,
|
|
||||||
# is only distributed via dl.breadway.dev/dev/.
|
|
||||||
- name: regenerate dev index.json
|
|
||||||
env:
|
|
||||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
|
||||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
|
||||||
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
|
||||||
# workflows run close together on the same self-hosted runner.
|
|
||||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
|
||||||
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
|
||||||
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
|
||||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
|
||||||
19
.forgejo/workflows/mirror.yml
Normal file
19
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
name: Mirror to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: Mirror to GitHub
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||||
|
cd repo.git
|
||||||
|
git push --prune \
|
||||||
|
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadclip.git" \
|
||||||
|
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
name: beta (rc) release
|
|
||||||
|
|
||||||
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
|
|
||||||
# pushed to `main` — there is no separate `beta` branch; "freezing" is
|
|
||||||
# just pausing pushes to main while an RC gets tested. See
|
|
||||||
# bread-ecosystem's docs/release-channels.md for the release-track policy.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ contains(github.ref_name, '-rc.') }}
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
rm -rf src && mkdir src
|
|
||||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
|
||||||
|
|
||||||
- name: prepare artifacts
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
PKG_DIR="/srv/breadway-dl/beta/breadclip/${VERSION}"
|
|
||||||
mkdir -p "${PKG_DIR}"
|
|
||||||
for bin in breadclip breadclipd; do
|
|
||||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
|
||||||
strip "${PKG_DIR}/${bin}-x86_64"
|
|
||||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
|
||||||
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
|
||||||
done
|
|
||||||
cp src/contrib/breadclipd.service "${PKG_DIR}/"
|
|
||||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
|
||||||
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadclip/latest"
|
|
||||||
|
|
||||||
# No GitHub Release upload — beta, like dev, is only distributed via
|
|
||||||
# dl.breadway.dev/beta/.
|
|
||||||
- name: regenerate beta index.json
|
|
||||||
env:
|
|
||||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
|
||||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
|
||||||
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
|
||||||
# workflows run close together on the same self-hosted runner.
|
|
||||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
|
||||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
|
||||||
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
|
||||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
|
||||||
|
|
@ -6,7 +6,6 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
|
|
@ -17,16 +16,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 && 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 +35,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 +48,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" \
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -29,9 +29,3 @@ logs/
|
||||||
# Runtime files
|
# Runtime files
|
||||||
*.sock
|
*.sock
|
||||||
*.pid
|
*.pid
|
||||||
|
|
||||||
# Local hygiene notes (not for commit)
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
|
||||||
graphify-out/
|
|
||||||
|
|
|
||||||
64
AGENTS.md
64
AGENTS.md
|
|
@ -1,64 +0,0 @@
|
||||||
# AGENTS.md — Repo hygiene
|
|
||||||
|
|
||||||
Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a
|
|
||||||
short map of the binaries. It is not user-facing project documentation.
|
|
||||||
|
|
||||||
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
|
|
||||||
— read and follow it for any git, branch, or release work here (the
|
|
||||||
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
|
|
||||||
etc). Don't improvise a different workflow. The short version: there is one
|
|
||||||
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
|
|
||||||
auto-publishes a dev-track build on every push. "Beta" and "stable" are both
|
|
||||||
just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track
|
|
||||||
build, push a plain `vX.Y.Z` tag to cut the signed stable release.
|
|
||||||
"Freezing" for stabilization means pausing pushes to `main`, not moving a
|
|
||||||
branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model
|
|
||||||
after `main` was found to have silently rotted out of sync with `dev`/`beta`
|
|
||||||
across most repos in this ecosystem.
|
|
||||||
|
|
||||||
When starting work on a new feature, create branch `feature/<feature-name>`.
|
|
||||||
When working on a bug or issue, create branch `fix/<issue you are fixing>`.
|
|
||||||
|
|
||||||
## Remotes
|
|
||||||
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
|
||||||
- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors.
|
|
||||||
|
|
||||||
## CI
|
|
||||||
- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`.
|
|
||||||
- `dev-release.yml` — triggers on push to `main`.
|
|
||||||
- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push.
|
|
||||||
- `release.yml` — triggers on any other `v*` tag push.
|
|
||||||
|
|
||||||
All four run on a self-hosted runner (`hestia`) inside a pinned Arch
|
|
||||||
container — not the host's native environment. The Containerfile/build
|
|
||||||
script are shared across bread-ecosystem products and live in
|
|
||||||
`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the
|
|
||||||
sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and
|
|
||||||
delegates to it. Nothing runs automatically on plain commits or PRs
|
|
||||||
beyond what's listed.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Three crates:
|
|
||||||
|
|
||||||
| Crate | Role |
|
|
||||||
|---|---|
|
|
||||||
| `breadclipd` | Clipboard-watch daemon; persists history to SQLite |
|
|
||||||
| `breadclip` | GTK4 Layer Shell popup (thin UI over the same DB) |
|
|
||||||
| `breadclip-core` | Shared history schema / DB access |
|
|
||||||
|
|
||||||
`--screenshot` (`breadclip/src/screenshot.rs`) captures the history panel
|
|
||||||
through `bread-screenshots`; do not rewrite it just to retarget the crate pin.
|
|
||||||
|
|
||||||
`EVENTS.md` is the bread-event contract. App id `clip`. Implemented:
|
|
||||||
`bread.clip.copied`, `bread.clip.clear.done`/`.failed`,
|
|
||||||
`bread.clip.pinned`/`bread.clip.pin.failed`, and commands
|
|
||||||
`bread.command.clip.clear` and `bread.command.clip.pin`. Pinning is real:
|
|
||||||
the history schema has a `pinned` column, the popup has a Ctrl+P toggle, and
|
|
||||||
trim exempts pinned rows. There is no `select` — the popup is a transient
|
|
||||||
process with no resident service to receive `bread.command.clip.select`;
|
|
||||||
do not invent it (or `bread.clip.selected`) ahead of a real product feature.
|
|
||||||
|
|
||||||
## Don't
|
|
||||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
|
||||||
- Don't invent `select` on the event bus. See `EVENTS.md`.
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
# Contributing
|
|
||||||
|
|
||||||
`breadclip` — Wayland clipboard history manager for Hyprland (daemon + GTK4 popup).
|
|
||||||
|
|
||||||
Part of the bread ecosystem; this repo follows the same branch/release
|
|
||||||
workflow as every other ecosystem product.
|
|
||||||
|
|
||||||
## Branches
|
|
||||||
|
|
||||||
There is one long-lived branch: **`main`**. All day-to-day work lands here.
|
|
||||||
Every push to `main` automatically builds and publishes a **dev-track**
|
|
||||||
build (see Tracks below) — a real install you can test before cutting
|
|
||||||
anything more formal.
|
|
||||||
|
|
||||||
New work — features and bug fixes alike — goes on a short-lived branch:
|
|
||||||
|
|
||||||
```
|
|
||||||
feature/<short-name>
|
|
||||||
fix/<issue-number-or-short-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
|
||||||
branches get deleted on merge — they never accumulate the kind of drift a
|
|
||||||
second long-lived branch does.
|
|
||||||
|
|
||||||
## The release cycle
|
|
||||||
|
|
||||||
There's no separate `beta` or release branch — "stable" and "beta" are both
|
|
||||||
just **tags** on `main`, not branches that need to be kept in sync:
|
|
||||||
|
|
||||||
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
|
||||||
auto-publishes a dev build — install it with `bakery track set dev` and
|
|
||||||
`bakery update --all`, then fix anything broken with another push.
|
|
||||||
2. When you want to stabilize before a real release, tag a release
|
|
||||||
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
|
||||||
both remotes). That tag alone triggers a beta-track build —
|
|
||||||
"freezing" is just pausing pushes to `main` while you test it, not a
|
|
||||||
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
|
||||||
3. Once an RC has gone without issues, tag the real release:
|
|
||||||
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
|
||||||
signed stable release build.
|
|
||||||
|
|
||||||
## Tracks, from a user's perspective
|
|
||||||
|
|
||||||
```
|
|
||||||
bakery track show # what you're currently on (defaults to stable)
|
|
||||||
bakery track set dev # or beta, or stable
|
|
||||||
bakery update --all # pull the latest build on your current track
|
|
||||||
```
|
|
||||||
|
|
||||||
| Track | What it is | Published from |
|
|
||||||
|--------|-----------|-----------------|
|
|
||||||
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
|
||||||
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
|
||||||
| `dev` | Bleeding edge | `main`, on every push |
|
|
||||||
|
|
||||||
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
|
||||||
latest published stable tag, so they always sort as newer than what you
|
|
||||||
have installed — no manual version bumping needed. Beta versions are just
|
|
||||||
the RC tag itself (already valid semver, already sorts below the real
|
|
||||||
release it's a candidate for).
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo build --release --workspace
|
|
||||||
cargo test --release --workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI
|
|
||||||
|
|
||||||
- `dev-release.yml` — triggered on push to `main`.
|
|
||||||
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
|
|
||||||
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
|
|
||||||
stable release.
|
|
||||||
|
|
||||||
All CI runs on a self-hosted runner; nothing runs automatically on plain
|
|
||||||
commits or PRs beyond the track builds above. See
|
|
||||||
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
|
|
||||||
for the full policy, including how a new product gets wired onto these tracks.
|
|
||||||
|
|
||||||
## Questions
|
|
||||||
|
|
||||||
Open an issue on this repo's Forgejo tracker.
|
|
||||||
600
Cargo.lock
generated
600
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
63
EVENTS.md
63
EVENTS.md
|
|
@ -1,63 +0,0 @@
|
||||||
# breadclip — bread event integration
|
|
||||||
|
|
||||||
breadclip is a standalone clipboard manager: it works exactly the same with
|
|
||||||
or without `breadd` running. When breadd *is* present, `breadclipd` publishes
|
|
||||||
events into the shared bread automation fabric and listens for a small set
|
|
||||||
of commands. See the parent `bread` repo's `Documentation.md` — specifically
|
|
||||||
its "Namespaces" and "Integrating a bread\* app" sections — for the general
|
|
||||||
convention this follows.
|
|
||||||
|
|
||||||
App id: **`clip`**. Transport: `bread-utils`'s `bread_client` module
|
|
||||||
(feature `bread-client`) — `breadclipd` links it directly rather than
|
|
||||||
shelling out, since it's a long-running process for the command-subscription
|
|
||||||
half (though each `emit` call is still its own short-lived connection, since
|
|
||||||
`capture_once()` — the actual clipboard-read code — runs as a fresh
|
|
||||||
per-clipboard-change process invocation, not inside a persistent loop).
|
|
||||||
|
|
||||||
## Events published (`bread.clip.*`)
|
|
||||||
|
|
||||||
| Event | Data | When |
|
|
||||||
|-------|------|------|
|
|
||||||
| `bread.clip.copied` | `{ "kind": "url" \| "error" \| "code" \| "path" \| "plain", "len": <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.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.
|
|
||||||
|
|
||||||
## Commands honored (`bread.command.clip.*`)
|
|
||||||
|
|
||||||
| Verb | Effect |
|
|
||||||
|------|--------|
|
|
||||||
| `clear` | Deletes all clipboard history (text entries, stored image files, pinned entries — it's a hard reset). Emits `bread.clip.clear.done`/`.failed`. |
|
|
||||||
| `pin` | Payload `{ "id": <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
|
|
||||||
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`
|
|
||||||
|
|
||||||
`select` (remote-activate a row from the bus, e.g. "paste entry 42 now")
|
|
||||||
is still deliberately not implemented: the popup is a short-lived process
|
|
||||||
with no resident service to receive that command, and the daemon has no
|
|
||||||
reason to talk to a transient popup. That's a real product decision for
|
|
||||||
breadclip itself — what should "select from the bus" even mean when the
|
|
||||||
popup isn't open? — not something to fabricate as a side effect of wiring
|
|
||||||
up the event bus. When breadclip grows that feature, the matching
|
|
||||||
`bread.command.clip.select` verb and `bread.clip.selected` event should be
|
|
||||||
added at the same time.
|
|
||||||
|
|
||||||
## Fail-safe behavior
|
|
||||||
|
|
||||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
|
||||||
(`BreadClient::emit` never blocks or errors the caller) and the
|
|
||||||
command subscription simply never receives anything — breadclip's actual
|
|
||||||
clipboard-history functionality is entirely unaffected either way.
|
|
||||||
- If breadd restarts, the command subscription reconnects automatically
|
|
||||||
(`BreadClient::subscribe`'s background thread has its own backoff loop);
|
|
||||||
no restart of breadclipd is needed.
|
|
||||||
36
README.md
36
README.md
|
|
@ -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,21 +92,9 @@ 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
|
|
||||||
|
|
||||||
- `history.db` and every file under `images/` are created with `0600` permissions (owner read/write only), regardless of your umask.
|
|
||||||
- breadclipd **never persists clipboard content flagged as sensitive**. `wl-paste --watch` reports copies made with `wl-copy --sensitive` — which also covers offers advertising the `x-kde-passwordManagerHint` MIME type, the convention used by KeePassXC, Bitwarden, and other password managers to mark content they own — via `CLIPBOARD_STATE=sensitive`, and those copies are skipped entirely and never reach the database.
|
|
||||||
- On top of that, breadclipd runs **best-effort ignore rules** that skip copies that *look* like secrets even when the app didn't flag them: one-time codes, Luhn-valid credit card numbers, private key blocks, `password:`-style credential lines, and well-known API token prefixes (see `breadclipd/src/ignore_rules.rs`). These are deliberately conservative and are a convenience, not a security boundary — the 0600/0700 permissions are the real protection.
|
|
||||||
- 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadclip-core"
|
name = "breadclip-core"
|
||||||
version = "0.2.4"
|
version = "0.1.0"
|
||||||
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", features = ["toml"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +1,6 @@
|
||||||
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::path::PathBuf;
|
||||||
use std::io::Write;
|
|
||||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// How many non-pinned text and image entries history keeps by default. The
|
|
||||||
/// popup fetches everything (see its `FETCH_ALL`), so entries the daemon
|
|
||||||
/// persists are always reachable from the UI; pinned entries are exempt from
|
|
||||||
/// trimming entirely and can exceed these caps.
|
|
||||||
pub const MAX_TEXT_ENTRIES: usize = 200;
|
|
||||||
pub const MAX_IMAGE_ENTRIES: usize = 50;
|
|
||||||
|
|
||||||
/// Retention caps for non-pinned entries. `0` is meaningful: it means
|
|
||||||
/// "keep no (unpinned) entries of this kind".
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct Retention {
|
|
||||||
pub text: usize,
|
|
||||||
pub images: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Retention {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
text: MAX_TEXT_ENTRIES,
|
|
||||||
images: MAX_IMAGE_ENTRIES,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a clipboard entry came from: the regular clipboard (Ctrl+C / copy)
|
|
||||||
/// or the middle-click primary selection.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum CaptureSource {
|
|
||||||
Clipboard,
|
|
||||||
Primary,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CaptureSource {
|
|
||||||
pub fn is_primary(self) -> bool {
|
|
||||||
matches!(self, CaptureSource::Primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ClipEntry {
|
pub struct ClipEntry {
|
||||||
|
|
@ -54,84 +10,17 @@ 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",
|
let conn = Connection::open(dir.join("history.db"))?;
|
||||||
path: dir.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
// The data dir holds clipboard secrets (history.db is plaintext).
|
|
||||||
// A 0700 parent also blocks traversal to whatever SQLite's
|
|
||||||
// -wal/-shm side files look like, regardless of their own mode.
|
|
||||||
restrict_dir(&dir, 0o700);
|
|
||||||
|
|
||||||
let db_path = dir.join("history.db");
|
|
||||||
let conn = Connection::open(&db_path)?;
|
|
||||||
// Clipboard events can spawn overlapping `--capture-once` processes
|
|
||||||
// and the popup opens the same DB — without a busy timeout a writer
|
|
||||||
// can hit SQLITE_BUSY and silently drop a capture.
|
|
||||||
conn.busy_timeout(Duration::from_secs(5))?;
|
|
||||||
// WAL: concurrent readers (popup) and the single writer (daemon) no
|
|
||||||
// longer block each other, and readers see a consistent snapshot.
|
|
||||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
|
||||||
conn.execute_batch(
|
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,143 +28,66 @@ 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.
|
Ok(Self { conn })
|
||||||
ensure_column(
|
|
||||||
&conn,
|
|
||||||
"pinned",
|
|
||||||
"ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
ensure_column(
|
|
||||||
&conn,
|
|
||||||
"is_primary",
|
|
||||||
"ALTER TABLE history ADD COLUMN is_primary INTEGER NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
// history.db can contain plaintext secrets copied to the clipboard
|
|
||||||
// (passwords, tokens, TOTP codes); restrict it to owner-only, every open.
|
|
||||||
restrict_permissions(&db_path);
|
|
||||||
let db = Self { conn, retention };
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
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| {
|
Ok(ClipEntry {
|
||||||
Ok(ClipEntry {
|
id: row.get(0)?,
|
||||||
id: row.get(0)?,
|
timestamp: row.get(1)?,
|
||||||
timestamp: row.get(1)?,
|
mime_type: row.get(2)?,
|
||||||
mime_type: row.get(2)?,
|
content: row.get(3)?,
|
||||||
content: row.get(3)?,
|
image_path: row.get(4)?,
|
||||||
image_path: row.get(4)?,
|
content_hash: row.get(5)?,
|
||||||
content_hash: row.get(5)?,
|
})
|
||||||
pinned: row.get(6)?,
|
})?
|
||||||
is_primary: row.get(7)?,
|
.collect::<SqlResult<Vec<_>>>();
|
||||||
})
|
rows
|
||||||
})?
|
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes every history entry and every stored image file — a hard
|
pub fn delete_entry(&self, id: i64) -> SqlResult<()> {
|
||||||
/// reset that clears pinned entries too. Used by the
|
// Clean up image file if present
|
||||||
/// `bread.command.clip.clear` handler (see breadclipd's bread-client
|
|
||||||
/// subscription) as well as anything else that wants a full wipe.
|
|
||||||
pub fn clear_all(&self) -> Result<(), HistoryError> {
|
|
||||||
let image_paths: Vec<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(0))?
|
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
||||||
paths
|
|
||||||
};
|
|
||||||
// Delete rows first, then files — the reverse order would leave rows
|
|
||||||
// pointing at already-removed files if the DELETE failed halfway.
|
|
||||||
self.conn.execute("DELETE FROM history", [])?;
|
|
||||||
for path in image_paths {
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_entry(&self, id: i64) -> Result<(), HistoryError> {
|
|
||||||
// Clean up image file if present — after the row is gone, so a
|
|
||||||
// failed DELETE never leaves a row pointing at a deleted file.
|
|
||||||
let image_path: Option<String> = self
|
let image_path: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
|
|
@ -285,161 +97,54 @@ 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([max_images as i64], |row| row.get(0))?
|
||||||
.query_map([self.retention.images as i64], |row| row.get(0))?
|
.collect::<SqlResult<Vec<_>>>()?;
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
||||||
paths
|
paths
|
||||||
};
|
};
|
||||||
self.conn.execute(
|
|
||||||
"DELETE FROM history
|
|
||||||
WHERE mime_type LIKE 'image/%'
|
|
||||||
AND pinned = 0
|
|
||||||
AND id NOT IN (
|
|
||||||
SELECT id FROM history WHERE mime_type LIKE 'image/%' AND pinned = 0
|
|
||||||
ORDER BY timestamp DESC, id DESC LIMIT ?1
|
|
||||||
)",
|
|
||||||
[self.retention.images as i64],
|
|
||||||
)?;
|
|
||||||
for path in old_paths {
|
for path in old_paths {
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM history
|
||||||
|
WHERE mime_type = 'image/png'
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM history WHERE mime_type = 'image/png'
|
||||||
|
ORDER BY timestamp DESC LIMIT ?1
|
||||||
|
)",
|
||||||
|
[max_images as i64],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add `column` to the history table if it isn't there (migration for
|
|
||||||
/// databases created by older versions). Table/column names are our own
|
|
||||||
/// constants, never user input.
|
|
||||||
fn ensure_column(conn: &Connection, column: &str, add_ddl: &str) -> rusqlite::Result<()> {
|
|
||||||
let mut stmt = conn.prepare("PRAGMA table_info(history)")?;
|
|
||||||
let names = stmt.query_map([], |row| row.get::<_, String>(1))?;
|
|
||||||
for name in names {
|
|
||||||
if name? == column {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conn.execute_batch(add_ddl)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restrict a file to owner-only read/write (0600). Clipboard history can
|
|
||||||
/// contain passwords and other secrets, so this must not be world/group
|
|
||||||
/// readable regardless of the process umask.
|
|
||||||
fn restrict_permissions(path: &Path) {
|
|
||||||
if let Ok(meta) = std::fs::metadata(path) {
|
|
||||||
let mut perms = meta.permissions();
|
|
||||||
perms.set_mode(0o600);
|
|
||||||
let _ = std::fs::set_permissions(path, perms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
||||||
|
|
@ -447,13 +152,9 @@ pub fn sha256_hex(data: &[u8]) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_dir() -> PathBuf {
|
pub fn data_dir() -> PathBuf {
|
||||||
// Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))`
|
dirs::data_local_dir()
|
||||||
// — PathBuf/std::fs never expand `~`, so on the rare box where `dirs`
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
// can't resolve a home directory, that fallback silently resolved to a
|
.join("breadclip")
|
||||||
// directory literally named `~` under the current working directory
|
|
||||||
// instead of the user's actual home. bread_utils::xdg resolves a real
|
|
||||||
// $HOME before ever falling back.
|
|
||||||
bread_utils::xdg::data_dir("breadclip")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unix_now() -> i64 {
|
fn unix_now() -> i64 {
|
||||||
|
|
@ -462,297 +163,3 @@ fn unix_now() -> i64 {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.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)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn open_test_db() -> (std::sync::MutexGuard<'static, ()>, tempfile::TempDir, HistoryDb) {
|
|
||||||
let guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner());
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
std::env::set_var("XDG_DATA_HOME", dir.path());
|
|
||||||
let db = HistoryDb::open().expect("open history db");
|
|
||||||
(guard, dir, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_all_removes_every_entry_and_image_file() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
db.insert_text("first", CaptureSource::Clipboard).unwrap();
|
|
||||||
db.insert_text("second", CaptureSource::Clipboard).unwrap();
|
|
||||||
db.insert_image(b"not really a png, just bytes for the test", "image/png", CaptureSource::Clipboard)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let before = db.list_entries(10).unwrap();
|
|
||||||
assert_eq!(before.len(), 3);
|
|
||||||
let image_path = before
|
|
||||||
.iter()
|
|
||||||
.find_map(|e| e.image_path.clone())
|
|
||||||
.expect("one entry should be the image");
|
|
||||||
assert!(Path::new(&image_path).exists());
|
|
||||||
|
|
||||||
db.clear_all().unwrap();
|
|
||||||
|
|
||||||
let after = db.list_entries(10).unwrap();
|
|
||||||
assert!(after.is_empty(), "expected no entries after clear_all");
|
|
||||||
assert!(
|
|
||||||
!Path::new(&image_path).exists(),
|
|
||||||
"image file should be removed by clear_all"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_all_on_empty_history_is_a_harmless_no_op() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
db.clear_all().unwrap();
|
|
||||||
assert!(db.list_entries(10).unwrap().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_uses_wal_journal_mode() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
let mode: String = db
|
|
||||||
.conn
|
|
||||||
.pragma_query_value(None, "journal_mode", |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(mode, "wal");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entries_with_equal_timestamps_order_by_id_desc() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
db.insert_text("first", CaptureSource::Clipboard).unwrap();
|
|
||||||
db.insert_text("second", CaptureSource::Clipboard).unwrap();
|
|
||||||
let entries = db.list_entries(10).unwrap();
|
|
||||||
assert_eq!(entries[0].content.as_deref(), Some("second"));
|
|
||||||
assert_eq!(entries[1].content.as_deref(), Some("first"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trim_caps_text_and_images_at_retention_limits() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
for i in 0..210 {
|
|
||||||
db.insert_text(&format!("text-{i}"), CaptureSource::Clipboard).unwrap();
|
|
||||||
}
|
|
||||||
for i in 0..55 {
|
|
||||||
db.insert_image(format!("img-{i}").as_bytes(), "image/png", CaptureSource::Clipboard)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let entries = db.list_entries(1000).unwrap();
|
|
||||||
let texts = entries
|
|
||||||
.iter()
|
|
||||||
.filter(|e| e.mime_type == "text/plain")
|
|
||||||
.count();
|
|
||||||
let images = entries
|
|
||||||
.iter()
|
|
||||||
.filter(|e| e.mime_type.starts_with("image/"))
|
|
||||||
.count();
|
|
||||||
assert_eq!(texts, MAX_TEXT_ENTRIES);
|
|
||||||
assert_eq!(images, MAX_IMAGE_ENTRIES);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trim_respects_configured_retention() {
|
|
||||||
let (_guard, _dir, _db) = open_test_db(); // holds the env lock
|
|
||||||
// Reopen with a custom retention on a fresh dir.
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
std::env::set_var("XDG_DATA_HOME", dir.path());
|
|
||||||
let db = HistoryDb::open_with(Retention { text: 3, images: 2 }).expect("open db");
|
|
||||||
for i in 0..10 {
|
|
||||||
db.insert_text(&format!("t{i}"), CaptureSource::Clipboard).unwrap();
|
|
||||||
}
|
|
||||||
for i in 0..5 {
|
|
||||||
db.insert_image(format!("img-{i}").as_bytes(), "image/png", CaptureSource::Clipboard)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let entries = db.list_entries(100).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
entries.iter().filter(|e| e.mime_type == "text/plain").count(),
|
|
||||||
3
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
entries.iter().filter(|e| e.mime_type.starts_with("image/")).count(),
|
|
||||||
2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pinned_entries_survive_trim() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
for i in 0..205 {
|
|
||||||
db.insert_text(&format!("t{i}"), CaptureSource::Clipboard).unwrap();
|
|
||||||
}
|
|
||||||
let entries = db.list_entries(1000).unwrap();
|
|
||||||
let oldest_id = entries.last().unwrap().id;
|
|
||||||
let oldest_text = entries.last().unwrap().content.clone().unwrap();
|
|
||||||
db.set_pinned(oldest_id, true).unwrap();
|
|
||||||
|
|
||||||
// Push past the cap: the pinned entry must survive while the rest
|
|
||||||
// stay capped.
|
|
||||||
for i in 0..10 {
|
|
||||||
db.insert_text(&format!("more{i}"), CaptureSource::Clipboard).unwrap();
|
|
||||||
}
|
|
||||||
let entries = db.list_entries(1000).unwrap();
|
|
||||||
assert!(
|
|
||||||
entries.iter().any(|e| e.id == oldest_id && e.pinned),
|
|
||||||
"a pinned entry must never be trimmed"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.filter(|e| !e.pinned && e.mime_type == "text/plain")
|
|
||||||
.count(),
|
|
||||||
MAX_TEXT_ENTRIES
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unpinning lets it get trimmed again like any other entry.
|
|
||||||
db.set_pinned(oldest_id, false).unwrap();
|
|
||||||
for i in 0..10 {
|
|
||||||
db.insert_text(&format!("final{i}"), CaptureSource::Clipboard).unwrap();
|
|
||||||
}
|
|
||||||
let entries = db.list_entries(1000).unwrap();
|
|
||||||
assert!(
|
|
||||||
!entries.iter().any(|e| e.content.as_deref() == Some(oldest_text.as_str())),
|
|
||||||
"an unpinned entry falls back under normal trimming"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pinned_entries_sort_first() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
db.insert_text("a", CaptureSource::Clipboard).unwrap();
|
|
||||||
db.insert_text("b", CaptureSource::Clipboard).unwrap();
|
|
||||||
let second_id = db.list_entries(10).unwrap()[1].id;
|
|
||||||
|
|
||||||
db.set_pinned(second_id, true).unwrap();
|
|
||||||
|
|
||||||
let entries = db.list_entries(10).unwrap();
|
|
||||||
assert_eq!(entries[0].id, second_id, "pinned rows sort before unpinned");
|
|
||||||
assert!(entries[0].pinned);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn primary_source_is_recorded() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
db.insert_text("primary text", CaptureSource::Primary).unwrap();
|
|
||||||
db.insert_image(b"png", "image/png", CaptureSource::Clipboard).unwrap();
|
|
||||||
|
|
||||||
let entries = db.list_entries(10).unwrap();
|
|
||||||
let text = entries.iter().find(|e| e.mime_type == "text/plain").unwrap();
|
|
||||||
assert!(text.is_primary, "primary-selection text is tagged");
|
|
||||||
let img = entries
|
|
||||||
.iter()
|
|
||||||
.find(|e| e.mime_type.starts_with("image/"))
|
|
||||||
.unwrap();
|
|
||||||
assert!(!img.is_primary, "clipboard image is not tagged primary");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn insert_image_jpeg_stores_jpg_file_and_trims_with_other_images() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
db.insert_image(b"\xff\xd8\xff fake jpeg", "image/jpeg", CaptureSource::Clipboard)
|
|
||||||
.unwrap();
|
|
||||||
let jpeg_path = db
|
|
||||||
.list_entries(10)
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.find(|e| e.mime_type == "image/jpeg")
|
|
||||||
.expect("jpeg entry")
|
|
||||||
.image_path
|
|
||||||
.unwrap();
|
|
||||||
assert!(jpeg_path.ends_with(".jpg"));
|
|
||||||
assert!(Path::new(&jpeg_path).exists());
|
|
||||||
|
|
||||||
// 49 more images: the jpeg is still within the newest 50.
|
|
||||||
for i in 0..49 {
|
|
||||||
db.insert_image(format!("img-{i}").as_bytes(), "image/png", CaptureSource::Clipboard)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let entries = db.list_entries(100).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.filter(|e| e.mime_type.starts_with("image/"))
|
|
||||||
.count(),
|
|
||||||
MAX_IMAGE_ENTRIES
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
entries.iter().any(|e| e.mime_type == "image/jpeg"),
|
|
||||||
"jpeg is among the newest 50 images, so it must survive trim"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Push it past the cap: the jpeg row and its file both go.
|
|
||||||
for i in 0..10 {
|
|
||||||
db.insert_image(format!("late-{i}").as_bytes(), "image/png", CaptureSource::Clipboard)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let entries = db.list_entries(100).unwrap();
|
|
||||||
assert!(!entries.iter().any(|e| e.mime_type == "image/jpeg"));
|
|
||||||
assert!(
|
|
||||||
!Path::new(&jpeg_path).exists(),
|
|
||||||
"trim must remove the file of a trimmed image row"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn insert_image_propagates_write_failure_without_leaving_a_row() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
// Replace the images dir with a regular file so create_dir_all fails.
|
|
||||||
let images_dir = data_dir().join("images");
|
|
||||||
std::fs::create_dir_all(&images_dir).unwrap();
|
|
||||||
std::fs::remove_dir_all(&images_dir).unwrap();
|
|
||||||
std::fs::write(&images_dir, b"not a directory").unwrap();
|
|
||||||
|
|
||||||
let result = db.insert_image(b"png bytes", "image/png", CaptureSource::Clipboard);
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"a failed image write must surface as an error"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
db.list_entries(10).unwrap().is_empty(),
|
|
||||||
"a failed image write must not leave a broken row behind"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_file(&images_dir).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gc_orphaned_images_removes_unreferenced_but_keeps_referenced_files() {
|
|
||||||
let (_guard, _dir, db) = open_test_db();
|
|
||||||
db.insert_image(b"png-a", "image/png", CaptureSource::Clipboard).unwrap();
|
|
||||||
let referenced = db
|
|
||||||
.list_entries(10)
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.unwrap()
|
|
||||||
.image_path
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// An orphan: an image file no history row references. Fake an old
|
|
||||||
// mtime so the age guard (which protects concurrent in-flight
|
|
||||||
// writes) doesn't skip it.
|
|
||||||
let orphan = data_dir().join("images/orphan.png");
|
|
||||||
std::fs::write(&orphan, b"orphan").unwrap();
|
|
||||||
let file = std::fs::File::options().write(true).open(&orphan).unwrap();
|
|
||||||
let old = std::time::SystemTime::now() - Duration::from_secs(7200);
|
|
||||||
file.set_modified(old).unwrap();
|
|
||||||
|
|
||||||
db.gc_orphaned_images().unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
Path::new(&referenced).exists(),
|
|
||||||
"a referenced image must survive GC"
|
|
||||||
);
|
|
||||||
assert!(!orphan.exists(), "an unreferenced image must be swept");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadclip"
|
name = "breadclip"
|
||||||
version = "0.2.4"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
@ -9,12 +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://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
|
||||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
|
|
||||||
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
|
||||||
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
|
||||||
anyhow = "1"
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
mod css;
|
mod css;
|
||||||
mod screenshot;
|
mod position;
|
||||||
|
|
||||||
use breadclip_core::{ClipEntry, HistoryDb};
|
use breadclip_core::{ClipEntry, HistoryDb};
|
||||||
use bread_theme::{load_palette};
|
use bread_theme::{load_palette};
|
||||||
|
|
@ -7,24 +7,25 @@ use gtk4::{
|
||||||
glib,
|
glib,
|
||||||
pango::EllipsizeMode,
|
pango::EllipsizeMode,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
Application, Box as GBox, Button, ContentFit, EventControllerKey, Label,
|
Application, ApplicationWindow, Box as GBox, Button, ContentFit, EventControllerKey, Label,
|
||||||
ListBox, Orientation, Picture, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
ListBox, Orientation, Picture, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
||||||
};
|
};
|
||||||
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
use std::{
|
use std::{
|
||||||
cell::Cell,
|
cell::Cell,
|
||||||
|
env,
|
||||||
fs,
|
fs,
|
||||||
io::Write,
|
io::Write,
|
||||||
|
path::PathBuf,
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
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 +145,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,68 +156,134 @@ 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", "image/png"])
|
||||||
.args(["--type", entry.mime_type.as_str()])
|
.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())
|
.stdout(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.spawn()
|
||||||
.spawn()
|
{
|
||||||
{
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
let _ = stdin.write_all(content.as_bytes());
|
||||||
let _ = stdin.write_all(content.as_bytes());
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PID file toggle (single-instance, matches breadbox pattern) -------------
|
||||||
|
|
||||||
|
fn pid_file() -> PathBuf {
|
||||||
|
env::var("XDG_RUNTIME_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||||
|
.join("breadclip.pid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_or_continue() -> bool {
|
||||||
|
let pf = pid_file();
|
||||||
|
if let Ok(content) = fs::read_to_string(&pf) {
|
||||||
|
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||||
|
let alive = fs::read_to_string(format!("/proc/{}/comm", pid))
|
||||||
|
.map(|s| s.trim() == "breadclip")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if alive {
|
||||||
|
let _ = Command::new("kill").arg(pid.to_string()).status();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::write(&pf, std::process::id().to_string());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_pid() {
|
||||||
|
let _ = fs::remove_file(pid_file());
|
||||||
|
}
|
||||||
|
|
||||||
// ---- UI ---------------------------------------------------------------------
|
// ---- UI ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
fn run_ui(entries: Vec<ClipEntry>) {
|
||||||
// Live snapshot of the entries backing the list — pinning re-reads the
|
let app = Application::builder()
|
||||||
// DB (pinned rows sort first) and rebuilds the rows from this. Built
|
.application_id("com.breadway.breadclip")
|
||||||
// outside connect_activate because that handler is `Fn` (runs once per
|
.build();
|
||||||
// activation) and can't consume the entries vector.
|
|
||||||
let entries_rc: Rc<std::cell::RefCell<Vec<ClipEntry>>> =
|
|
||||||
Rc::new(std::cell::RefCell::new(entries));
|
|
||||||
|
|
||||||
let mut builder = Application::builder().application_id("com.breadway.breadclip");
|
|
||||||
if screenshot_req.is_some() {
|
|
||||||
// GApplication is single-instance by default; this machine typically
|
|
||||||
// already has a real breadclip instance, so without this a
|
|
||||||
// screenshot run would just message the *existing* instance instead
|
|
||||||
// of starting a fresh one that ever sees `screenshot_req`.
|
|
||||||
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
|
|
||||||
}
|
|
||||||
let app = builder.build();
|
|
||||||
let is_screenshot_run = screenshot_req.is_some();
|
|
||||||
|
|
||||||
app.connect_activate(move |app| {
|
app.connect_activate(move |app| {
|
||||||
bread_theme::gtk::apply_shared();
|
bread_theme::gtk::apply_shared();
|
||||||
bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette()));
|
bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette()));
|
||||||
|
|
||||||
// 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 = ApplicationWindow::builder().application(app).build();
|
||||||
bread_theme::gtk::bind_window_auto(&window);
|
window.init_layer_shell();
|
||||||
|
window.set_namespace(Some("breadclip"));
|
||||||
|
window.set_layer(Layer::Overlay);
|
||||||
|
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
||||||
|
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
|
||||||
|
window.set_anchor(edge, true);
|
||||||
|
}
|
||||||
|
window.set_exclusive_zone(0);
|
||||||
|
|
||||||
|
// ---- Position panel relative to the active window ----
|
||||||
|
// If a non-fullscreen window is focused, anchor the panel just below it.
|
||||||
|
// Otherwise, centre the panel on screen.
|
||||||
|
let active_win = position::get_active_window();
|
||||||
|
let monitor = position::get_focused_monitor();
|
||||||
|
|
||||||
let panel_width = breadclip_core::config::load().panel_width;
|
|
||||||
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 +319,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 +330,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));
|
||||||
|
|
@ -339,6 +337,7 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
let close_all: Rc<dyn Fn()> = Rc::new({
|
let close_all: Rc<dyn Fn()> = Rc::new({
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
move || {
|
move || {
|
||||||
|
cleanup_pid();
|
||||||
w.close();
|
w.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -384,11 +383,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 +400,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() {
|
||||||
|
|
@ -431,51 +421,34 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Down => {
|
Key::Down => {
|
||||||
bread_utils::gtk_popup::select_next_visible(&list_k);
|
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
|
||||||
|
let mut i = cur + 1;
|
||||||
|
loop {
|
||||||
|
match list_k.row_at_index(i) {
|
||||||
|
Some(r) if r.is_visible() => {
|
||||||
|
list_k.select_row(Some(&r));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(_) => i += 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Up => {
|
Key::Up => {
|
||||||
bread_utils::gtk_popup::select_prev_visible(&list_k);
|
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
|
||||||
glib::Propagation::Stop
|
let mut i = cur - 1;
|
||||||
}
|
loop {
|
||||||
// Pin/unpin the selected entry. Pinned rows sort to the
|
if i < 0 {
|
||||||
// top and survive trimming, so re-read the DB and rebuild
|
break;
|
||||||
// the list, then re-select the row we just toggled.
|
}
|
||||||
Key::P if state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) => {
|
match list_k.row_at_index(i) {
|
||||||
if let Some(row) = list_k.selected_row() {
|
Some(r) if r.is_visible() => {
|
||||||
if let Some(entry) = get_row_entry(&row) {
|
list_k.select_row(Some(&r));
|
||||||
if let Ok(db) = HistoryDb::open() {
|
break;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
Some(_) => i -= 1,
|
||||||
|
None => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
|
|
@ -500,59 +473,41 @@ fn run_ui(entries: Vec<ClipEntry>, screenshot_req: Option<screenshot::Screenshot
|
||||||
// ---- Click outside panel → close (same pattern as breadbox) ----
|
// ---- Click outside panel → close (same pattern as breadbox) ----
|
||||||
{
|
{
|
||||||
let close_outside = Rc::clone(&close_all);
|
let close_outside = Rc::clone(&close_all);
|
||||||
bread_utils::gtk_popup::close_on_outside_click(&window, &panel, move || close_outside());
|
let panel_ref = panel.clone();
|
||||||
}
|
let win_ref = window.clone();
|
||||||
|
let outside_click = gtk4::GestureClick::new();
|
||||||
if let Some(req) = screenshot_req.clone() {
|
outside_click.connect_pressed(move |_, _, x, y| {
|
||||||
screenshot::dispatch(&window, req);
|
if let Some(b) = panel_ref.compute_bounds(&win_ref) {
|
||||||
|
let outside = x < b.x() as f64
|
||||||
|
|| x > (b.x() + b.width()) as f64
|
||||||
|
|| y < b.y() as f64
|
||||||
|
|| y > (b.y() + b.height()) as f64;
|
||||||
|
if outside {
|
||||||
|
close_outside();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.add_controller(outside_click);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.connect_destroy(|_| cleanup_pid());
|
||||||
window.present();
|
window.present();
|
||||||
search.grab_focus();
|
search.grab_focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
if is_screenshot_run {
|
app.run();
|
||||||
// GLib's own option parser otherwise rejects --screenshot/--output
|
|
||||||
// before clap ever sees them (`Cli::parse()` already ran in `main`,
|
|
||||||
// over the real argv).
|
|
||||||
app.run_with_args(&[] as &[&str]);
|
|
||||||
} else {
|
|
||||||
app.run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Main -------------------------------------------------------------------
|
// ---- Main -------------------------------------------------------------------
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
use clap::Parser;
|
if !toggle_or_continue() {
|
||||||
let cli = screenshot::Cli::parse();
|
return;
|
||||||
let screenshot_req = cli.screenshot_request();
|
}
|
||||||
|
|
||||||
// `toggle_or_kill` kills whatever's holding the single-instance lock —
|
|
||||||
// a real, already-running breadclip included. A screenshot run must
|
|
||||||
// never touch it: it's a separate, disposable instance by design (same
|
|
||||||
// reasoning as breadbar's `allow_multiple_instances`), not a toggle of
|
|
||||||
// the operator's real clipboard panel.
|
|
||||||
//
|
|
||||||
// Kept alive for the rest of `main` — dropping it releases the
|
|
||||||
// single-instance lock and removes the pid file, which happens
|
|
||||||
// naturally once `run_ui` returns (after the window closes).
|
|
||||||
let _singleton_guard = if screenshot_req.is_some() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
match bread_utils::singleton::toggle_or_kill("breadclip") {
|
|
||||||
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
|
||||||
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
breadclip/src/position.rs
Normal file
71
breadclip/src/position.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
use std::{env, io::{Read, Write}, os::unix::net::UnixStream};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct WindowInfo {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct MonitorInfo {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query Hyprland for the currently active (focused) window via its IPC socket.
|
||||||
|
/// Returns `None` if the window is fullscreen or no window is focused.
|
||||||
|
pub fn get_active_window() -> Option<WindowInfo> {
|
||||||
|
let json = hyprctl_json("j/activewindow")?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&json).ok()?;
|
||||||
|
|
||||||
|
// Fullscreen windows should cause centred fallback positioning
|
||||||
|
if v["fullscreen"].as_i64().unwrap_or(0) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// "class" is empty when no window is focused
|
||||||
|
if v["class"].as_str().unwrap_or("").is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = v["at"][0].as_i64()? as i32;
|
||||||
|
let y = v["at"][1].as_i64()? as i32;
|
||||||
|
let w = v["size"][0].as_i64()? as i32;
|
||||||
|
let h = v["size"][1].as_i64()? as i32;
|
||||||
|
Some(WindowInfo { x, y, width: w, height: h })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query Hyprland for the focused monitor's dimensions.
|
||||||
|
pub fn get_focused_monitor() -> Option<MonitorInfo> {
|
||||||
|
let json = hyprctl_json("j/monitors")?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&json).ok()?;
|
||||||
|
let monitors = v.as_array()?;
|
||||||
|
let m = monitors
|
||||||
|
.iter()
|
||||||
|
.find(|m| m["focused"].as_bool().unwrap_or(false))
|
||||||
|
.or_else(|| monitors.first())?;
|
||||||
|
Some(MonitorInfo {
|
||||||
|
x: m["x"].as_i64()? as i32,
|
||||||
|
y: m["y"].as_i64()? as i32,
|
||||||
|
width: m["width"].as_i64()? as i32,
|
||||||
|
height: m["height"].as_i64()? as i32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request to Hyprland's IPC socket and return the response string.
|
||||||
|
fn hyprctl_json(request: &str) -> Option<String> {
|
||||||
|
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||||
|
let rt = env::var("XDG_RUNTIME_DIR").ok()?;
|
||||||
|
let socket = format!("{}/hypr/{}/.socket.sock", rt, sig);
|
||||||
|
|
||||||
|
let mut stream = UnixStream::connect(&socket).ok()?;
|
||||||
|
stream.write_all(request.as_bytes()).ok()?;
|
||||||
|
stream.shutdown(std::net::Shutdown::Write).ok()?;
|
||||||
|
|
||||||
|
let mut buf = String::new();
|
||||||
|
stream.read_to_string(&mut buf).ok()?;
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
//! `--screenshot` CLI mode: render breadclip's clipboard-history panel,
|
|
||||||
//! capture it via `bread-screenshots`, then exit — driven by
|
|
||||||
//! `bread-ecosystem`'s `bread-capture` orchestrator, or run standalone for
|
|
||||||
//! one-off captures.
|
|
||||||
//!
|
|
||||||
//! breadclip has one view worth capturing: the history panel itself. Like
|
|
||||||
//! breadbox, it's a `halign`/`valign`-positioned panel over a full-screen
|
|
||||||
//! transparent overlay rather than its own layer surface, so a full
|
|
||||||
//! known-size canvas capture is the simplest reliable option. In screenshot
|
|
||||||
//! mode there's never a focused window to anchor the panel next to (see
|
|
||||||
//! `bread_utils::hypr::active_window` in `main.rs`, which the isolated
|
|
||||||
//! capture environment always reports `None` for — deliberately, see
|
|
||||||
//! `bread-capture`'s isolation module), so the panel always falls back to
|
|
||||||
//! centered, which is exactly what we want for a consistent screenshot.
|
|
||||||
|
|
||||||
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY};
|
|
||||||
use clap::Parser;
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(name = "breadclip")]
|
|
||||||
pub struct Cli {
|
|
||||||
/// Render the named view, capture it, then exit instead of running
|
|
||||||
/// normally. Known views: "history".
|
|
||||||
#[arg(long)]
|
|
||||||
pub screenshot: Option<String>,
|
|
||||||
|
|
||||||
/// PNG path to write the capture to. Required together with --screenshot.
|
|
||||||
#[arg(long)]
|
|
||||||
pub output: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Capture canvas width — matches the isolated compositor's output width
|
|
||||||
/// (`bread-capture --isolate-width`).
|
|
||||||
#[arg(long, default_value_t = DEFAULT_WIDTH)]
|
|
||||||
pub width: u32,
|
|
||||||
|
|
||||||
/// Capture canvas height — see `width`.
|
|
||||||
#[arg(long, default_value_t = DEFAULT_HEIGHT)]
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ScreenshotRequest {
|
|
||||||
pub view: String,
|
|
||||||
pub output: PathBuf,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cli {
|
|
||||||
/// `None` for a normal run. Exits the process with an error if the
|
|
||||||
/// `--screenshot` / `--output` pair is incomplete, before any GTK setup
|
|
||||||
/// happens.
|
|
||||||
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
|
|
||||||
if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) {
|
|
||||||
eprintln!("breadclip: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
Some(ScreenshotRequest {
|
|
||||||
view: self.screenshot.clone()?,
|
|
||||||
output: self.output.clone()?,
|
|
||||||
width: self.width,
|
|
||||||
height: self.height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wire up the given view's screenshot sequence against an already-built,
|
|
||||||
/// not-yet-presented window. Every path here ends by exiting the process —
|
|
||||||
/// it never returns control to the normal history-panel UI.
|
|
||||||
pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) {
|
|
||||||
match req.view.as_str() {
|
|
||||||
"history" => {
|
|
||||||
let output = req.output;
|
|
||||||
let (width, height) = (req.width as i32, req.height as i32);
|
|
||||||
window.connect_map(move |_| {
|
|
||||||
let output = output.clone();
|
|
||||||
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
|
|
||||||
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
eprintln!("breadclip: unknown screenshot view '{other}' (known: history)");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(result: anyhow::Result<()>) {
|
|
||||||
match result {
|
|
||||||
Ok(()) => std::process::exit(0),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadclip: screenshot capture failed: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadclipd"
|
name = "breadclipd"
|
||||||
version = "0.2.4"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
@ -9,8 +9,3 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
breadclip-core = { path = "../breadclip-core" }
|
breadclip-core = { path = "../breadclip-core" }
|
||||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
|
||||||
serde_json = "1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
|
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
//! Heuristic classification of clipboard text, used to tag the
|
|
||||||
//! `bread.clip.copied` event's `kind` field (see `bread-shared`'s namespace
|
|
||||||
//! docs). Deliberately simple pattern matching, not a real parser or ML
|
|
||||||
//! classifier — good enough to auto-tag "you just copied a stack trace" or
|
|
||||||
//! "you just copied a URL" for a Lua automation module to react to, not a
|
|
||||||
//! source of truth anyone should build a security decision on.
|
|
||||||
|
|
||||||
/// Checked in this order because a false-not-`error` or false-not-`url` is
|
|
||||||
/// cheap to live with, while classifying a URL as generic `code` because it
|
|
||||||
/// contains a `/` would be a worse user-facing miss.
|
|
||||||
pub fn detect(text: &str) -> &'static str {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return "plain";
|
|
||||||
}
|
|
||||||
if looks_like_error(trimmed) {
|
|
||||||
"error"
|
|
||||||
} else if looks_like_url(trimmed) {
|
|
||||||
"url"
|
|
||||||
} else if looks_like_path(trimmed) {
|
|
||||||
"path"
|
|
||||||
} else if looks_like_code(trimmed) {
|
|
||||||
"code"
|
|
||||||
} else {
|
|
||||||
"plain"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn looks_like_url(text: &str) -> bool {
|
|
||||||
// Single "word" (no internal whitespace) is required — a sentence that
|
|
||||||
// merely contains a URL should classify as plain/error/code on its own
|
|
||||||
// merits, not get tagged "url" just because a link appears in it.
|
|
||||||
if text.split_whitespace().count() != 1 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let lower = text.to_ascii_lowercase();
|
|
||||||
lower.starts_with("http://")
|
|
||||||
|| lower.starts_with("https://")
|
|
||||||
|| lower.starts_with("ftp://")
|
|
||||||
|| lower.starts_with("file://")
|
|
||||||
|| lower.starts_with("mailto:")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn looks_like_error(text: &str) -> bool {
|
|
||||||
const MARKERS: [&str; 8] = [
|
|
||||||
"traceback (most recent call last)",
|
|
||||||
"panicked at",
|
|
||||||
"exception in thread",
|
|
||||||
"unhandled exception",
|
|
||||||
"stack trace:",
|
|
||||||
"at java.",
|
|
||||||
"uncaught exception",
|
|
||||||
"fatal error:",
|
|
||||||
];
|
|
||||||
let lower = text.to_ascii_lowercase();
|
|
||||||
if MARKERS.iter().any(|m| lower.contains(m)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// A line starting "Error:"/"error:" combined with more than one line is
|
|
||||||
// a common shape for compiler/runtime error output; a single bare line
|
|
||||||
// like "error: 42" out of context is more likely just plain text.
|
|
||||||
let first_line = lower.lines().next().unwrap_or("");
|
|
||||||
text.lines().count() > 1
|
|
||||||
&& (first_line.starts_with("error:") || first_line.starts_with("error["))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn looks_like_path(text: &str) -> bool {
|
|
||||||
if text.split_whitespace().count() != 1 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let has_extension_or_depth = text
|
|
||||||
.rsplit('/')
|
|
||||||
.next()
|
|
||||||
.map(|s| s.contains('.'))
|
|
||||||
.unwrap_or(false)
|
|
||||||
|| text.matches('/').count() >= 2;
|
|
||||||
(text.starts_with('/') || text.starts_with("~/") || text.starts_with("./"))
|
|
||||||
&& has_extension_or_depth
|
|
||||||
}
|
|
||||||
|
|
||||||
fn looks_like_code(text: &str) -> bool {
|
|
||||||
const CODE_TOKENS: [&str; 14] = [
|
|
||||||
"fn ",
|
|
||||||
"function ",
|
|
||||||
"def ",
|
|
||||||
"class ",
|
|
||||||
"const ",
|
|
||||||
"let ",
|
|
||||||
"import ",
|
|
||||||
"#include",
|
|
||||||
"public ",
|
|
||||||
"private ",
|
|
||||||
"=> {",
|
|
||||||
"};",
|
|
||||||
"</",
|
|
||||||
"><",
|
|
||||||
];
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
let token_hits = CODE_TOKENS.iter().filter(|t| text.contains(*t)).count();
|
|
||||||
let brace_or_semicolon_lines = lines
|
|
||||||
.iter()
|
|
||||||
.filter(|l| {
|
|
||||||
let t = l.trim_end();
|
|
||||||
t.ends_with(';') || t.ends_with('{') || t.ends_with('}')
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
|
|
||||||
// Multi-line with a meaningful fraction of "code-shaped" lines, or a
|
|
||||||
// couple of code tokens anywhere, counts as code.
|
|
||||||
if lines.len() > 1 {
|
|
||||||
return token_hits >= 2 || brace_or_semicolon_lines * 2 >= lines.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
// A single line only counts as code if it actually *looks* like a code
|
|
||||||
// statement — a prose sentence that merely contains a keyword ("let me
|
|
||||||
// show you", "function of time", "class is a concept") must stay plain.
|
|
||||||
if lines.len() == 1 && token_hits >= 1 && text.len() < 200 {
|
|
||||||
let trimmed = text.trim_start();
|
|
||||||
let import_like = trimmed.starts_with("import ") || trimmed.starts_with("#include");
|
|
||||||
let code_shaped = trimmed.ends_with('{')
|
|
||||||
|| trimmed.ends_with('}')
|
|
||||||
|| trimmed.ends_with(';')
|
|
||||||
|| trimmed.ends_with('(')
|
|
||||||
|| trimmed.ends_with(')')
|
|
||||||
|| trimmed.contains("=>")
|
|
||||||
|| (trimmed.contains('(') && trimmed.contains(')'))
|
|
||||||
|| trimmed.contains(" = ");
|
|
||||||
return import_like || code_shaped;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_url() {
|
|
||||||
assert_eq!(detect("https://example.com/path?query=1"), "url");
|
|
||||||
assert_eq!(detect("http://localhost:8080"), "url");
|
|
||||||
assert_eq!(detect(" https://example.com "), "url");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn does_not_tag_sentence_containing_a_url_as_url() {
|
|
||||||
assert_eq!(detect("check out https://example.com later"), "plain");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_python_traceback() {
|
|
||||||
let text = "Traceback (most recent call last):\n File \"x.py\", line 1\nValueError: bad";
|
|
||||||
assert_eq!(detect(text), "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_rust_panic() {
|
|
||||||
assert_eq!(
|
|
||||||
detect("thread 'main' panicked at src/main.rs:10:5:\nindex out of bounds"),
|
|
||||||
"error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_multiline_error_prefixed_output() {
|
|
||||||
assert_eq!(detect("error: expected `;`\n --> src/main.rs:2:1"), "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_line_error_word_without_context_is_plain() {
|
|
||||||
assert_eq!(detect("error: something"), "plain");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_absolute_path() {
|
|
||||||
assert_eq!(detect("/home/user/.config/bread/init.lua"), "path");
|
|
||||||
assert_eq!(detect("~/Projects/bread/README.md"), "path");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_multiline_code() {
|
|
||||||
let text = "fn main() {\n let x = 1;\n println!(\"{}\", x);\n}";
|
|
||||||
assert_eq!(detect(text), "code");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_single_line_import() {
|
|
||||||
assert_eq!(detect("import numpy as np"), "code");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_line_prose_containing_a_keyword_is_plain() {
|
|
||||||
assert_eq!(detect("let me show you something"), "plain");
|
|
||||||
assert_eq!(detect("function of time"), "plain");
|
|
||||||
assert_eq!(detect("class is a concept"), "plain");
|
|
||||||
assert_eq!(detect("const means constant"), "plain");
|
|
||||||
assert_eq!(detect("def is short for define"), "plain");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plain_prose_is_plain() {
|
|
||||||
assert_eq!(
|
|
||||||
detect("just a normal sentence someone copied from an email"),
|
|
||||||
"plain"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_or_whitespace_is_plain() {
|
|
||||||
assert_eq!(detect(""), "plain");
|
|
||||||
assert_eq!(detect(" \n "), "plain");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
//! Best-effort content heuristics for never persisting obvious secrets.
|
|
||||||
//!
|
|
||||||
//! The password-manager path (`CLIPBOARD_STATE=sensitive` / the
|
|
||||||
//! `x-kde-passwordManagerHint` MIME type) only catches copies an app
|
|
||||||
//! deliberately flagged. These rules catch copies that *look* like secrets
|
|
||||||
//! even when the app didn't flag them — one-time codes, credit card
|
|
||||||
//! numbers, private keys, credential lines. They are deliberately
|
|
||||||
//! conservative (a false "skip" is cheaper than a leaked password) and they
|
|
||||||
//! are a convenience, not a security boundary: the 0600 file permissions
|
|
||||||
//! and 0700 data dir are the real protection.
|
|
||||||
|
|
||||||
/// Would persisting this text be a bad idea? Skipped copies never reach the
|
|
||||||
/// database (or the event bus), like the password-manager path.
|
|
||||||
pub fn is_sensitive(text: &str) -> bool {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
looks_like_private_key(trimmed)
|
|
||||||
|| looks_like_credential_line(trimmed)
|
|
||||||
|| looks_like_otp(trimmed)
|
|
||||||
|| contains_card_number(trimmed)
|
|
||||||
|| contains_api_token(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PEM/OpenSSH private key blocks (and PGP blocks, which are just as
|
|
||||||
/// sensitive).
|
|
||||||
fn looks_like_private_key(text: &str) -> bool {
|
|
||||||
text.contains("-----BEGIN") || text.contains("PRIVATE KEY")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A line that labels a credential directly: `password: hunter2`,
|
|
||||||
/// `passwd = hunter2`, etc.
|
|
||||||
fn looks_like_credential_line(text: &str) -> bool {
|
|
||||||
let first = text.lines().next().unwrap_or("").trim().to_ascii_lowercase();
|
|
||||||
const PREFIXES: [&str; 8] = [
|
|
||||||
"password:",
|
|
||||||
"password =",
|
|
||||||
"password=",
|
|
||||||
"passwd:",
|
|
||||||
"passwd =",
|
|
||||||
"passwd=",
|
|
||||||
"pw:",
|
|
||||||
"pass:",
|
|
||||||
];
|
|
||||||
PREFIXES.iter().any(|p| first.starts_with(p))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A one-time code: a short copy that mentions a code keyword and contains
|
|
||||||
/// a 6–8 digit run. A *bare* 6-digit number is deliberately not flagged —
|
|
||||||
/// too many legitimate numbers have that shape.
|
|
||||||
fn looks_like_otp(text: &str) -> bool {
|
|
||||||
if text.len() > 60 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let lower = text.to_ascii_lowercase();
|
|
||||||
const LABELS: [&str; 8] = [
|
|
||||||
"code", "otp", "verification", "verif", "2fa", "passcode", "one-time", "one time",
|
|
||||||
];
|
|
||||||
if !LABELS.iter().any(|k| lower.contains(k)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
has_digit_run(text, 6, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Credit card numbers: 13–19 digit runs (allowing spaces/dashes between
|
|
||||||
/// groups) that pass the Luhn check.
|
|
||||||
fn contains_card_number(text: &str) -> bool {
|
|
||||||
let mut run = String::new();
|
|
||||||
for c in text.chars() {
|
|
||||||
if c.is_ascii_digit() {
|
|
||||||
run.push(c);
|
|
||||||
} else if (c == ' ' || c == '-') && !run.is_empty() {
|
|
||||||
run.push(' '); // keep one run across group separators
|
|
||||||
} else {
|
|
||||||
if card_like(&run) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
run.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
card_like(&run)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn card_like(run: &str) -> bool {
|
|
||||||
let digits: String = run.chars().filter(|c| c.is_ascii_digit()).collect();
|
|
||||||
(13..=19).contains(&digits.len()) && luhn_valid(&digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn luhn_valid(digits: &str) -> bool {
|
|
||||||
let mut sum: u32 = 0;
|
|
||||||
let mut double = false;
|
|
||||||
for b in digits.bytes().rev() {
|
|
||||||
let mut d = (b - b'0') as u32;
|
|
||||||
if double {
|
|
||||||
d *= 2;
|
|
||||||
if d > 9 {
|
|
||||||
d -= 9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sum += d;
|
|
||||||
double = !double;
|
|
||||||
}
|
|
||||||
sum.is_multiple_of(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Well-known API token prefixes (OpenAI, GitHub, Slack, AWS access keys).
|
|
||||||
fn contains_api_token(text: &str) -> bool {
|
|
||||||
const PREFIXES: [&str; 7] = ["sk-", "sk-proj-", "ghp_", "gho_", "xoxb-", "xoxp-", "AKIA"];
|
|
||||||
PREFIXES.iter().any(|p| text.contains(p))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_digit_run(text: &str, min: usize, max: usize) -> bool {
|
|
||||||
let mut run = 0usize;
|
|
||||||
for c in text.chars() {
|
|
||||||
if c.is_ascii_digit() {
|
|
||||||
run += 1;
|
|
||||||
} else {
|
|
||||||
if run >= min && run <= max {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
run = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run >= min && run <= max
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn private_key_blocks_are_sensitive() {
|
|
||||||
assert!(is_sensitive(
|
|
||||||
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA..."
|
|
||||||
));
|
|
||||||
assert!(is_sensitive("-----BEGIN PGP MESSAGE-----"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credential_labeled_lines_are_sensitive() {
|
|
||||||
assert!(is_sensitive("password: hunter2"));
|
|
||||||
assert!(is_sensitive("Password = correct horse battery staple"));
|
|
||||||
assert!(is_sensitive("passwd=hunter2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn labeled_one_time_codes_are_sensitive() {
|
|
||||||
assert!(is_sensitive("Your verification code is 483920"));
|
|
||||||
assert!(is_sensitive("483920 is your code"));
|
|
||||||
assert!(is_sensitive("OTP: 12345678"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bare_numbers_are_not_sensitive() {
|
|
||||||
// A bare 6-digit number is too ambiguous to drop on purpose.
|
|
||||||
assert!(!is_sensitive("483920"));
|
|
||||||
assert!(!is_sensitive("The answer is 42"));
|
|
||||||
// A long random number that happens to be 16 digits but fails Luhn.
|
|
||||||
assert!(!is_sensitive("1234567890123456"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn luhn_valid_card_numbers_are_sensitive() {
|
|
||||||
assert!(is_sensitive("4111 1111 1111 1111"));
|
|
||||||
assert!(is_sensitive("card number: 4111-1111-1111-1111"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn api_token_prefixes_are_sensitive() {
|
|
||||||
assert!(is_sensitive("sk-proj-abc123def456"));
|
|
||||||
assert!(is_sensitive("ghp_1234567890abcdefghijklmnopqrstuvwxyz"));
|
|
||||||
assert!(is_sensitive("AKIAIOSFODNN7EXAMPLE"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normal_prose_is_not_sensitive() {
|
|
||||||
assert!(!is_sensitive("just a normal sentence someone copied"));
|
|
||||||
assert!(!is_sensitive("here is the plan for next week"));
|
|
||||||
assert!(!is_sensitive("the password field in the form is empty"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +1,9 @@
|
||||||
mod content_kind;
|
use breadclip_core::{sha256_hex, HistoryDb};
|
||||||
mod ignore_rules;
|
use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration};
|
||||||
|
|
||||||
use bread_utils::bread_client::BreadClient;
|
fn get_available_types() -> Vec<String> {
|
||||||
use bread_utils::singleton::{try_acquire, Acquire};
|
Command::new("wl-paste")
|
||||||
use breadclip_core::{CaptureSource, HistoryDb};
|
.args(["--list-types"])
|
||||||
use serde_json::Value;
|
|
||||||
use std::{env, io::Read, process::Command, thread, time::Duration};
|
|
||||||
|
|
||||||
/// This app's id in bread's sibling-app namespace registry
|
|
||||||
/// (`bread_shared::apps::KNOWN_APPS`) — events are published as
|
|
||||||
/// `bread.clip.*`, commands are received on `bread.command.clip.*`.
|
|
||||||
const APP_ID: &str = "clip";
|
|
||||||
|
|
||||||
/// Argument used to invoke this same binary as the one-shot handler for
|
|
||||||
/// `wl-paste --watch`. Kept as a plain positional arg (no clap dependency
|
|
||||||
/// needed for a single internal flag).
|
|
||||||
const CAPTURE_FLAG: &str = "--capture-once";
|
|
||||||
|
|
||||||
/// Second positional arg distinguishing a capture spawned by the primary-
|
|
||||||
/// selection watcher from one spawned by the regular-clipboard watcher.
|
|
||||||
const PRIMARY_FLAG: &str = "--primary";
|
|
||||||
|
|
||||||
/// MIME type convention (originating with KDE's Klipper) that password
|
|
||||||
/// managers such as KeePassXC and Bitwarden set on clipboard content they
|
|
||||||
/// own, signaling "don't persist this". Only reachable on the fallback
|
|
||||||
/// capture path — when invoked through `wl-paste --watch`, `CLIPBOARD_STATE`
|
|
||||||
/// carries the same information from the same event (see `capture_once`).
|
|
||||||
const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint";
|
|
||||||
|
|
||||||
/// Build a `wl-paste` command with a hard deadline. Without the timeout, a
|
|
||||||
/// stalled selection offer (source app died mid-transfer, compositor never
|
|
||||||
/// completes the handoff) leaves `wl-paste` blocked forever reading a pipe
|
|
||||||
/// that never closes — and a forever-blocked fallback capture would eat a
|
|
||||||
/// file descriptor per event over a long session. `timeout -k` guarantees a
|
|
||||||
/// SIGKILL if the initial SIGTERM doesn't land. Only used by the manual
|
|
||||||
/// fallback path (`capture_via_wl_paste`); the `--watch` path reads the
|
|
||||||
/// content from stdin and so has nothing that can hang.
|
|
||||||
fn wl_paste_cmd(primary: bool) -> Command {
|
|
||||||
let mut cmd = Command::new("timeout");
|
|
||||||
cmd.args(["-k", "2", "5", "wl-paste"]);
|
|
||||||
if primary {
|
|
||||||
cmd.arg("--primary");
|
|
||||||
}
|
|
||||||
cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `wl-paste --watch` sets this in the spawned command's environment for
|
|
||||||
/// every clipboard event (see wl-paste(1)): `data` (read the content from
|
|
||||||
/// stdin), `nil` (empty clipboard), `clear` (explicitly cleared), or
|
|
||||||
/// `sensitive` (a password manager flagged the selection). Unset or
|
|
||||||
/// unrecognized means we were not invoked by `--watch` (e.g. a manual
|
|
||||||
/// `--capture-once` run) and we fall back to querying wl-paste directly.
|
|
||||||
const CLIPBOARD_STATE: &str = "CLIPBOARD_STATE";
|
|
||||||
|
|
||||||
/// Open the history DB with the retention caps from the user's config. The
|
|
||||||
/// `--capture-once` processes run per clipboard event, so they load the
|
|
||||||
/// config fresh rather than inheriting anything from the long-lived daemon.
|
|
||||||
fn open_db() -> Result<HistoryDb, 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 +12,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,284 +25,47 @@ 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);
|
||||||
match db.insert_text(text, source(primary)) {
|
if alive {
|
||||||
Ok(()) => emit_copied(content_kind::detect(text), text.len()),
|
eprintln!("breadclipd: already running (pid {})", pid);
|
||||||
Err(e) => eprintln!("breadclipd: insert text: {e}"),
|
return false;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_image(bytes: Vec<u8>, mime: &str, primary: bool) {
|
|
||||||
let db = match open_db() {
|
|
||||||
Ok(db) => db,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadclipd: failed to open database: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match db.insert_image(&bytes, mime, source(primary)) {
|
|
||||||
Ok(()) => emit_copied("image", bytes.len()),
|
|
||||||
Err(e) => eprintln!("breadclipd: insert image: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capture path for `wl-paste --watch` invocations: the clipboard content
|
|
||||||
/// arrives on our stdin together with its `CLIPBOARD_STATE` in one event, so
|
|
||||||
/// the sensitive check and the data read can't race each other (two separate
|
|
||||||
/// `wl-paste` calls could straddle a clipboard change mid-capture).
|
|
||||||
fn capture_from_stdin(primary: bool) {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
if std::io::stdin().read_to_end(&mut buf).is_err() || buf.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(mime) = sniff_image_mime(&buf) {
|
|
||||||
store_image(buf, mime, primary);
|
|
||||||
} else if let Ok(text) = String::from_utf8(buf) {
|
|
||||||
if !text.trim().is_empty() && !ignore_rules::is_sensitive(&text) {
|
|
||||||
store_text(&text, primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fallback capture path for manual `--capture-once` invocations (no
|
|
||||||
/// `CLIPBOARD_STATE` was set by wl-paste): ask wl-paste directly. Same
|
|
||||||
/// behavior as the stdin path, including requesting whichever image type is
|
|
||||||
/// actually offered rather than assuming PNG.
|
|
||||||
fn capture_via_wl_paste(primary: bool) {
|
|
||||||
let types = get_available_types(primary);
|
|
||||||
|
|
||||||
if types.iter().any(|t| t == PASSWORD_HINT_MIME) {
|
|
||||||
// Password manager (KeePassXC, Bitwarden, etc.) marked this copy as
|
|
||||||
// sensitive via the x-kde-passwordManagerHint convention — never
|
|
||||||
// persist it.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg");
|
|
||||||
let has_text = types.iter().any(|t| t.starts_with("text/"));
|
|
||||||
|
|
||||||
if has_image && !has_text {
|
|
||||||
let mime = if types.iter().any(|t| t == "image/png") {
|
|
||||||
"image/png"
|
|
||||||
} else {
|
|
||||||
"image/jpeg"
|
|
||||||
};
|
|
||||||
if let Some(bytes) = get_clipboard_bytes(mime, primary) {
|
|
||||||
store_image(bytes, mime, primary);
|
|
||||||
}
|
|
||||||
} else if has_text {
|
|
||||||
if let Some(text) = get_clipboard_text(primary) {
|
|
||||||
if !ignore_rules::is_sensitive(&text) {
|
|
||||||
store_text(&text, primary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
let _ = fs::write(&path, std::process::id().to_string());
|
||||||
|
true
|
||||||
/// Invoked once per clipboard-change event (as the handler command for
|
|
||||||
/// `wl-paste --watch`). Stores a text or image entry to the history DB,
|
|
||||||
/// unless the selection was flagged as sensitive — in which case nothing is
|
|
||||||
/// ever persisted.
|
|
||||||
fn capture_once(primary: bool) {
|
|
||||||
match env::var(CLIPBOARD_STATE).as_deref() {
|
|
||||||
// Password manager (KeePassXC, Bitwarden, ...) flagged this selection
|
|
||||||
// as sensitive via `wl-copy --sensitive` — never persist it.
|
|
||||||
Ok("sensitive") => {}
|
|
||||||
// Empty clipboard (nil) or an explicit clear — nothing to store.
|
|
||||||
Ok("nil") | Ok("clear") => {}
|
|
||||||
Ok("data") => capture_from_stdin(primary),
|
|
||||||
// Unset or unrecognized: not invoked by `--watch`. Fall back to the
|
|
||||||
// direct wl-paste path rather than blocking on whatever stdin is.
|
|
||||||
_ => capture_via_wl_paste(primary),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publishes `bread.clip.copied` into the bread event fabric. Fire-and-forget
|
|
||||||
/// and non-fatal by design (`BreadClient::emit` never blocks or errors this
|
|
||||||
/// caller) — breadd being absent or not installed must never affect
|
|
||||||
/// breadclip's own clipboard-history functionality, only mean this one
|
|
||||||
/// notification doesn't go anywhere.
|
|
||||||
fn emit_copied(kind: &str, len: usize) {
|
|
||||||
BreadClient::connect(APP_ID).emit(
|
|
||||||
"bread.clip.copied",
|
|
||||||
serde_json::json!({ "kind": kind, "len": len }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reacts to `bread.command.clip.*` verbs. Only `clear` and `pin` map to
|
|
||||||
/// real, existing breadclip functionality today — `select` would need a
|
|
||||||
/// "remote-activate a row" concept the popup doesn't expose over the bus,
|
|
||||||
/// which is a real product decision for breadclip itself, not something to
|
|
||||||
/// fabricate as a side effect of wiring up the event bus. See
|
|
||||||
/// `breadclip/EVENTS.md` for the honest current status.
|
|
||||||
///
|
|
||||||
/// Emits `bread.clip.<verb>.done`/`bread.clip.pinned`/`.failed` per the
|
|
||||||
/// confirmation convention in bread's Documentation.md — a module that
|
|
||||||
/// started this command via `bread.wait`/`bread.wait_any` can await the real
|
|
||||||
/// outcome instead of assuming success the moment it publishes the command.
|
|
||||||
fn handle_command(event_name: &str, data: &Value) {
|
|
||||||
let Some(verb) = event_name.strip_prefix("bread.command.clip.") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match verb {
|
|
||||||
"clear" => match HistoryDb::open().and_then(|db| db.clear_all()) {
|
|
||||||
Ok(()) => {
|
|
||||||
eprintln!("breadclipd: cleared history via bread.command.clip.clear");
|
|
||||||
BreadClient::connect(APP_ID).emit("bread.clip.clear.done", serde_json::json!({}));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadclipd: bread.command.clip.clear failed: {e}");
|
|
||||||
BreadClient::connect(APP_ID).emit(
|
|
||||||
"bread.clip.clear.failed",
|
|
||||||
serde_json::json!({ "error": e.to_string() }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pin" => match data.get("id").and_then(Value::as_i64) {
|
|
||||||
Some(id) => {
|
|
||||||
let pin = data.get("pin").and_then(Value::as_bool).unwrap_or(true);
|
|
||||||
match HistoryDb::open().and_then(|db| db.set_pinned(id, pin)) {
|
|
||||||
Ok(()) => {
|
|
||||||
eprintln!(
|
|
||||||
"breadclipd: {} entry {id} via bread.command.clip.pin",
|
|
||||||
if pin { "pinned" } else { "unpinned" }
|
|
||||||
);
|
|
||||||
BreadClient::connect(APP_ID).emit(
|
|
||||||
"bread.clip.pinned",
|
|
||||||
serde_json::json!({ "id": id, "pinned": pin }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadclipd: bread.command.clip.pin failed: {e}");
|
|
||||||
BreadClient::connect(APP_ID).emit(
|
|
||||||
"bread.clip.pin.failed",
|
|
||||||
serde_json::json!({ "error": e.to_string() }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
eprintln!("breadclipd: bread.command.clip.pin missing 'id'");
|
|
||||||
BreadClient::connect(APP_ID).emit(
|
|
||||||
"bread.clip.pin.failed",
|
|
||||||
serde_json::json!({ "error": "missing 'id'" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
other => {
|
|
||||||
eprintln!("breadclipd: ignoring unrecognized command verb '{other}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backoff for the `wl-paste --watch` restart loops: 2s → 4s → 8s → 16s →
|
|
||||||
/// 32s, capped at 30s. A dead compositor keeps the daemon alive without
|
|
||||||
/// hammering either wl-paste or the journal.
|
|
||||||
fn restart_delay(consecutive_failures: u32) -> Duration {
|
|
||||||
Duration::from_secs((2u64 << consecutive_failures.saturating_sub(1).min(4)).min(30))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log a watch-loop restart, but only on state changes — the first failure
|
|
||||||
/// announces it, then every 8th attempt. Otherwise a persistent failure
|
|
||||||
/// would spam the journal every two seconds forever.
|
|
||||||
fn log_restart(what: &str, detail: &str, attempt: u32) {
|
|
||||||
if attempt == 1 || attempt.is_multiple_of(8) {
|
|
||||||
eprintln!("breadclipd: {what} ({detail}), restarting (attempt {attempt})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs one `wl-paste --watch` watcher for the daemon's whole lifetime,
|
|
||||||
/// restarting it with capped backoff whenever it exits (e.g. the compositor
|
|
||||||
/// connection dropped). `primary` selects the middle-click primary selection
|
|
||||||
/// watcher (`--watch --primary`) instead of the regular clipboard watcher.
|
|
||||||
fn watch_loop(exe: std::path::PathBuf, primary: bool) {
|
|
||||||
let label = if primary {
|
|
||||||
"wl-paste --watch (primary)"
|
|
||||||
} else {
|
|
||||||
"wl-paste --watch"
|
|
||||||
};
|
|
||||||
let mut consecutive_failures: u32 = 0;
|
|
||||||
loop {
|
|
||||||
// `wl-paste --watch <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();
|
if !acquire_lock() {
|
||||||
if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) {
|
std::process::exit(1);
|
||||||
capture_once(args.iter().any(|a| a == PRIMARY_FLAG));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// flock(2)-based singleton (bread_utils::singleton): lock ownership is
|
|
||||||
// kernel-atomic and released automatically the instant this process
|
|
||||||
// dies, so there's no stale-pid-file case to reason about.
|
|
||||||
let _singleton = match try_acquire("breadclipd") {
|
|
||||||
Ok(Acquire::Acquired(guard)) => Some(guard),
|
|
||||||
Ok(Acquire::HeldByOther(pid)) => {
|
|
||||||
eprintln!("breadclipd: already running (pid {:?})", pid);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadclipd: single-instance lock unavailable ({e}); continuing without it");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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;
|
||||||
while env::var("WAYLAND_DISPLAY").is_err() && retries < 20 {
|
while env::var("WAYLAND_DISPLAY").is_err() && retries < 20 {
|
||||||
|
|
@ -378,174 +74,52 @@ 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();
|
let db = match HistoryDb::open() {
|
||||||
|
Ok(db) => db,
|
||||||
// Fail fast (before we start watching) if the DB can't be opened.
|
|
||||||
if let Err(e) = HistoryDb::open_with(cfg.retention) {
|
|
||||||
eprintln!("breadclipd: failed to open database: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let exe = match env::current_exe() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("breadclipd: could not resolve own executable path: {e}");
|
eprintln!("breadclipd: failed to open database: {e}");
|
||||||
|
let _ = fs::remove_file(lock_file());
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eprintln!("breadclipd: started (pid {})", std::process::id());
|
eprintln!("breadclipd: started (pid {})", std::process::id());
|
||||||
|
|
||||||
// Long-lived, so this uses BreadClient::subscribe (a persistent
|
let mut last_text_hash: Option<String> = None;
|
||||||
// background thread with its own reconnect/backoff loop) rather than a
|
let mut last_image_hash: Option<String> = None;
|
||||||
// one-shot connection — the same client type capture_once() uses for
|
|
||||||
// `emit`, just the other half of it. breadd being absent or restarting
|
|
||||||
// is transparent here too: the subscription just quietly stops
|
|
||||||
// delivering commands until it reconnects.
|
|
||||||
let command_client = BreadClient::connect(APP_ID);
|
|
||||||
let _commands = command_client.subscribe("bread.command.clip.**", |event| {
|
|
||||||
handle_command(&event.event, &event.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Regular-clipboard watcher, always on.
|
|
||||||
let clipboard_exe = exe.clone();
|
|
||||||
thread::spawn(move || watch_loop(clipboard_exe, false));
|
|
||||||
// Primary-selection watcher (middle-click), opt-in via config.
|
|
||||||
if cfg.capture_primary {
|
|
||||||
eprintln!("breadclipd: watching primary selection");
|
|
||||||
thread::spawn(move || watch_loop(exe, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The watcher threads own the daemon's lifetime; this thread just keeps
|
|
||||||
// the process (and the singleton guard above) alive.
|
|
||||||
loop {
|
loop {
|
||||||
thread::sleep(Duration::from_secs(3600));
|
let types = get_available_types();
|
||||||
}
|
let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg");
|
||||||
}
|
let has_text = types.iter().any(|t| t.starts_with("text/"));
|
||||||
|
|
||||||
#[cfg(test)]
|
if has_image && !has_text {
|
||||||
mod tests {
|
if let Some(bytes) = get_clipboard_image() {
|
||||||
use super::*;
|
let hash = sha256_hex(&bytes);
|
||||||
|
if Some(&hash) != last_image_hash.as_ref() {
|
||||||
// Isolates HistoryDb::open() to a fresh temp dir per test. `cargo test`
|
last_image_hash = Some(hash);
|
||||||
// runs tests in parallel threads within one process by default, and
|
last_text_hash = None;
|
||||||
// `XDG_DATA_HOME` is process-global — three tests in this module all
|
if let Err(e) = db.insert_image(&bytes) {
|
||||||
// set it, so without this lock they race each other (confirmed: an
|
eprintln!("breadclipd: insert image: {e}");
|
||||||
// earlier version of this file had that exact bug, caught by a flaky
|
}
|
||||||
// `len() == 2` instead of `1` failure).
|
}
|
||||||
fn env_test_lock() -> &'static std::sync::Mutex<()> {
|
}
|
||||||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
} else if has_text {
|
||||||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
if let Some(text) = get_clipboard_text() {
|
||||||
}
|
let hash = sha256_hex(text.as_bytes());
|
||||||
|
if Some(&hash) != last_text_hash.as_ref() {
|
||||||
fn with_isolated_history<F: FnOnce()>(f: F) {
|
last_text_hash = Some(hash);
|
||||||
let _guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner());
|
if let Err(e) = db.insert_text(&text) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
eprintln!("breadclipd: insert text: {e}");
|
||||||
std::env::set_var("XDG_DATA_HOME", dir.path());
|
}
|
||||||
f();
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
#[test]
|
|
||||||
fn sniff_image_mime_detects_png_and_jpeg() {
|
thread::sleep(Duration::from_millis(500));
|
||||||
assert_eq!(sniff_image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png"));
|
|
||||||
assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
|
|
||||||
assert_eq!(sniff_image_mime(b"plain text"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restart_delay_backs_off_and_caps() {
|
|
||||||
assert_eq!(restart_delay(1), Duration::from_secs(2));
|
|
||||||
assert_eq!(restart_delay(2), Duration::from_secs(4));
|
|
||||||
assert_eq!(restart_delay(3), Duration::from_secs(8));
|
|
||||||
assert_eq!(restart_delay(4), Duration::from_secs(16));
|
|
||||||
assert_eq!(restart_delay(100), Duration::from_secs(30));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handle_command_clear_empties_history_even_with_no_daemon_reachable() {
|
|
||||||
// The point of this test: handle_command's actual effect (clearing
|
|
||||||
// the DB) must not depend on breadd being reachable — `emit` inside
|
|
||||||
// it is fire-and-forget and must never gate the real work. No test
|
|
||||||
// daemon is running here, so if clearing the DB were accidentally
|
|
||||||
// made contingent on the emit succeeding, this test would fail.
|
|
||||||
with_isolated_history(|| {
|
|
||||||
let db = HistoryDb::open().expect("open history db");
|
|
||||||
db.insert_text("something to clear", CaptureSource::Clipboard).unwrap();
|
|
||||||
assert_eq!(db.list_entries(10).unwrap().len(), 1);
|
|
||||||
|
|
||||||
handle_command("bread.command.clip.clear", &serde_json::json!({}));
|
|
||||||
|
|
||||||
let db = HistoryDb::open().expect("reopen history db");
|
|
||||||
assert!(db.list_entries(10).unwrap().is_empty());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handle_command_pin_toggles_pinned_state() {
|
|
||||||
with_isolated_history(|| {
|
|
||||||
let db = HistoryDb::open().expect("open history db");
|
|
||||||
db.insert_text("to pin", CaptureSource::Clipboard).unwrap();
|
|
||||||
let id = db.list_entries(10).unwrap()[0].id;
|
|
||||||
assert!(!db.list_entries(10).unwrap()[0].pinned);
|
|
||||||
|
|
||||||
handle_command(
|
|
||||||
"bread.command.clip.pin",
|
|
||||||
&serde_json::json!({ "id": id, "pin": true }),
|
|
||||||
);
|
|
||||||
assert!(db.list_entries(10).unwrap()[0].pinned);
|
|
||||||
|
|
||||||
handle_command(
|
|
||||||
"bread.command.clip.pin",
|
|
||||||
&serde_json::json!({ "id": id, "pin": false }),
|
|
||||||
);
|
|
||||||
assert!(!db.list_entries(10).unwrap()[0].pinned);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handle_command_pin_without_id_is_a_no_op() {
|
|
||||||
with_isolated_history(|| {
|
|
||||||
let db = HistoryDb::open().expect("open history db");
|
|
||||||
db.insert_text("untouched", CaptureSource::Clipboard).unwrap();
|
|
||||||
|
|
||||||
handle_command("bread.command.clip.pin", &serde_json::json!({}));
|
|
||||||
|
|
||||||
assert!(!db.list_entries(10).unwrap()[0].pinned);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handle_command_ignores_unrecognized_verb() {
|
|
||||||
with_isolated_history(|| {
|
|
||||||
let db = HistoryDb::open().expect("open history db");
|
|
||||||
db.insert_text("should survive", CaptureSource::Clipboard).unwrap();
|
|
||||||
|
|
||||||
handle_command("bread.command.clip.select", &serde_json::json!({}));
|
|
||||||
|
|
||||||
let db = HistoryDb::open().expect("reopen history db");
|
|
||||||
assert_eq!(
|
|
||||||
db.list_entries(10).unwrap().len(),
|
|
||||||
1,
|
|
||||||
"an unrecognized verb must not touch history"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handle_command_ignores_events_outside_its_own_command_namespace() {
|
|
||||||
with_isolated_history(|| {
|
|
||||||
let db = HistoryDb::open().expect("open history db");
|
|
||||||
db.insert_text("should survive", CaptureSource::Clipboard).unwrap();
|
|
||||||
|
|
||||||
// Not a `bread.command.clip.*` event at all — must be a no-op.
|
|
||||||
handle_command("bread.command.pad.clear", &serde_json::json!({}));
|
|
||||||
handle_command("bread.clip.copied", &serde_json::json!({}));
|
|
||||||
|
|
||||||
let db = HistoryDb::open().expect("reopen history db");
|
|
||||||
assert_eq!(db.list_entries(10).unwrap().len(), 1);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
620c5a1317a6b57276eabca961facdb78bf510db
|
|
||||||
20
ci/build.sh
20
ci/build.sh
|
|
@ -1,20 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
|
|
||||||
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
|
|
||||||
# files now affect every product's release pipeline, so bumping the pin
|
|
||||||
# is a deliberate act instead of silent drift.
|
|
||||||
#
|
|
||||||
# Usage: ci/build.sh cargo build --release --locked
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
|
|
||||||
|
|
||||||
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
|
|
||||||
if [ ! -d "$CACHE_DIR" ]; then
|
|
||||||
rm -rf /tmp/bread-ecosystem-ci-*
|
|
||||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
|
|
||||||
git -C "$CACHE_DIR" checkout --quiet "$REV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
bash "${CACHE_DIR}/ci/build.sh" breadclip "$ROOT" "$@"
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue