Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9137b4d7c2 | ||
|
|
b89e349342 | ||
|
|
ed371964c2 | ||
|
|
365072d65e | ||
|
|
c68214a6ab | ||
|
|
5d1b8918d3 | ||
|
|
84539f2fcd | ||
|
|
dad2a45d83 | ||
|
|
481d460a6e | ||
|
|
3adcfa3167 | ||
|
|
773972b711 | ||
|
|
d06d32b4e6 | ||
|
|
56aecfcbd6 | ||
|
|
70bb804883 | ||
|
|
802e03eecf | ||
|
|
56251a009c | ||
|
|
3b173a6765 | ||
|
|
86774ab1cf | ||
|
|
14672188b7 | ||
|
|
aad314c5ca | ||
|
|
5e4cbc83c9 | ||
|
|
08c27d1b22 | ||
|
|
1fb9770a77 |
19 changed files with 1164 additions and 130 deletions
24
.forgejo/workflows/check.yml
Normal file
24
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: check
|
||||||
|
|
||||||
|
# Fast-fail lint/test on short-lived work branches, before it ever reaches
|
||||||
|
# main and triggers a dev-track release build.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['feature/**', 'fix/**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --workspace --locked
|
||||||
75
.forgejo/workflows/dev-release.yml
Normal file
75
.forgejo/workflows/dev-release.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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' 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/breadshot/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
cp "src/target/release/breadshot" "${PKG_DIR}/breadshot-x86_64"
|
||||||
|
strip "${PKG_DIR}/breadshot-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/breadshot-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/breadshot-x86_64.sha256"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadshot/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}"
|
||||||
56
.forgejo/workflows/rc-release.yml
Normal file
56
.forgejo/workflows/rc-release.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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/breadshot/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
cp "src/target/release/breadshot" "${PKG_DIR}/breadshot-x86_64"
|
||||||
|
strip "${PKG_DIR}/breadshot-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/breadshot-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/breadshot-x86_64.sha256"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadshot/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}"
|
||||||
69
.forgejo/workflows/release.yml
Normal file
69
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ ! -f src/ci/build.sh ]; then
|
||||||
|
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd src && bash ci/build.sh cargo build --release --locked || {
|
||||||
|
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadshot/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
cp "src/target/release/breadshot" "${PKG_DIR}/breadshot-x86_64"
|
||||||
|
strip "${PKG_DIR}/breadshot-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/breadshot-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/breadshot-x86_64.sha256"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/breadshot/latest"
|
||||||
|
|
||||||
|
- name: regenerate index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
|
|
||||||
|
- name: upload to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadshot/${VERSION}"
|
||||||
|
gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadshot \
|
||||||
|
--title "breadshot v${VERSION}" --generate-notes 2>/dev/null || true
|
||||||
|
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadshot \
|
||||||
|
"${PKG_DIR}/breadshot-x86_64" \
|
||||||
|
"${PKG_DIR}/breadshot-x86_64.sha256" \
|
||||||
|
--clobber
|
||||||
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
|
|
@ -1,54 +0,0 @@
|
||||||
name: release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["v*"]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
DL_DIR: /srv/breadway-dl
|
|
||||||
ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: cargo build --release --locked
|
|
||||||
|
|
||||||
- name: prepare artifacts
|
|
||||||
run: |
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
PKG_DIR="${DL_DIR}/breadshot/${VERSION}"
|
|
||||||
mkdir -p "${PKG_DIR}"
|
|
||||||
cp "target/release/breadshot" "${PKG_DIR}/breadshot-x86_64"
|
|
||||||
strip "${PKG_DIR}/breadshot-x86_64"
|
|
||||||
sha256sum "${PKG_DIR}/breadshot-x86_64" | awk '{print $1}' \
|
|
||||||
> "${PKG_DIR}/breadshot-x86_64.sha256"
|
|
||||||
cp bakery.toml "${PKG_DIR}/bakery.toml"
|
|
||||||
ln -sfn "${VERSION}" "${DL_DIR}/breadshot/latest"
|
|
||||||
|
|
||||||
- name: ensure bread-ecosystem
|
|
||||||
run: |
|
|
||||||
rm -rf "${ECOSYSTEM_DIR}"
|
|
||||||
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
|
|
||||||
|
|
||||||
- name: regenerate index.json
|
|
||||||
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
|
|
||||||
|
|
||||||
- name: upload to GitHub Release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
PKG_DIR="${DL_DIR}/breadshot/${VERSION}"
|
|
||||||
gh release create "${GITHUB_REF_NAME}" \
|
|
||||||
--title "breadshot v${VERSION}" --generate-notes 2>/dev/null || true
|
|
||||||
gh release upload "${GITHUB_REF_NAME}" \
|
|
||||||
"${PKG_DIR}/breadshot-x86_64" \
|
|
||||||
"${PKG_DIR}/breadshot-x86_64.sha256" \
|
|
||||||
--clobber
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -29,3 +29,5 @@ logs/
|
||||||
# Runtime files
|
# Runtime files
|
||||||
*.sock
|
*.sock
|
||||||
*.pid
|
*.pid
|
||||||
|
|
||||||
|
# Local hygiene notes (not for commit)
|
||||||
|
|
|
||||||
43
AGENTS.md
Normal file
43
AGENTS.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# AGENTS.md — Repo hygiene
|
||||||
|
|
||||||
|
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not 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 — a manual "merge beta into main
|
||||||
|
monthly" step nobody reliably did across a dozen-plus repos. Collapsing to
|
||||||
|
one branch removes the class of bug; there's nothing left that can fall out
|
||||||
|
of sync.
|
||||||
|
|
||||||
|
## Remotes
|
||||||
|
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||||
|
- `github` — GitHub mirror. Push both when publishing.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
- `check.yml` — clippy + test on `feature/**` and `fix/**` (fast-fail before
|
||||||
|
a change reaches `main`).
|
||||||
|
- `dev-release.yml` triggers on `push: branches: ['main']`.
|
||||||
|
- `rc-release.yml` triggers on `push: tags: ['v*']` gated to *only* run for
|
||||||
|
`-rc.` tags.
|
||||||
|
- `release.yml` triggers on `push: tags: ['v*']` gated to skip any tag
|
||||||
|
containing `-rc.` — that's the signed stable release.
|
||||||
|
- No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the
|
||||||
|
dev-track workflow above. See bread-ecosystem's `docs/release-channels.md`
|
||||||
|
for the full track (stable/beta/dev) policy.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
|
- Don't merge this repo with `bread-screenshots` (the crate in
|
||||||
|
`bread-ecosystem`). Different jobs: breadshot is the user-facing
|
||||||
|
grim/slurp/wl-copy orchestrator; `bread-screenshots` is the capture
|
||||||
|
harness used by `bread-capture` to screenshot sibling apps in CI. They
|
||||||
|
share a grim backend and nothing else.
|
||||||
84
CONTRIBUTING.md
Normal file
84
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
`breadshot` — Screenshot utility for the bread ecosystem.
|
||||||
|
|
||||||
|
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
|
||||||
|
cargo test --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
77
Cargo.lock
generated
77
Cargo.lock
generated
|
|
@ -82,11 +82,34 @@ version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-shared"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-utils"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
|
dependencies = [
|
||||||
|
"bread-shared",
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "breadshot"
|
name = "breadshot"
|
||||||
version = "1.0.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"bread-utils",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
@ -105,9 +128,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.65"
|
version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
|
|
@ -134,9 +157,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.1"
|
version = "4.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
|
|
@ -144,9 +167,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.6.0"
|
version = "4.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
|
|
@ -219,21 +242,21 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
|
@ -335,9 +358,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.17"
|
version = "0.1.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
@ -359,9 +382,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.2"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
|
|
@ -436,9 +459,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -453,9 +476,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
|
|
@ -544,9 +567,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.118"
|
version = "2.0.119"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -575,9 +598,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.9"
|
version = "1.1.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
@ -898,6 +921,6 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadshot"
|
name = "breadshot"
|
||||||
version = "1.0.0"
|
version = "0.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Breadway"]
|
authors = ["Breadway"]
|
||||||
|
|
@ -16,6 +16,7 @@ serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
|
||||||
96
EVENTS.md
Normal file
96
EVENTS.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# breadshot — bread event integration
|
||||||
|
|
||||||
|
breadshot is a standalone Wayland screenshot orchestrator: it works
|
||||||
|
exactly the same with or without `breadd` running. When breadd *is*
|
||||||
|
present, a successful capture publishes into the shared bread automation
|
||||||
|
fabric. See the parent `bread` repo's `Documentation.md` — specifically
|
||||||
|
its "Namespaces" and "Integrating a bread\* app" sections — for the
|
||||||
|
general convention this follows.
|
||||||
|
|
||||||
|
This is a different job from `bread-screenshots` (the crate in
|
||||||
|
`bread-ecosystem`): that one is a capture harness for screenshotting
|
||||||
|
sibling apps in CI. Do not merge the two.
|
||||||
|
|
||||||
|
App id: **`shot`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
|
(feature `bread-client`). One-shot CLI invocations (`breadshot region`,
|
||||||
|
…) each `emit` on their own fire-and-forget connection (the same stance
|
||||||
|
`bread-emit` takes for occasional callers). Command verbs are only
|
||||||
|
received while `breadshot listen` is running — that process holds the
|
||||||
|
`bread.command.shot.**` subscription open.
|
||||||
|
|
||||||
|
## Events published (`bread.shot.*`)
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|-------|------|------|
|
||||||
|
| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": <string or null> }` | A capture completed successfully (grim + clipboard write both returned), whether triggered by the CLI or by `bread.command.shot.region` / `bread.command.shot.annotate`. Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. `breadshot annotate` publishes `mode: "region"`. |
|
||||||
|
| `bread.shot.region.done` | `{ "clipboard": true, "path": null }` | `bread.command.shot.region` was received and the region capture succeeded. |
|
||||||
|
| `bread.shot.region.failed` | `{ "error": "<message>" }` | `bread.command.shot.region` was received but the capture failed (cancelled slurp, missing dependency, grim/wl-copy error). |
|
||||||
|
| `bread.shot.annotate.done` | `{ "clipboard": true, "path": <string or null> }` | `bread.command.shot.annotate` was received and the region capture (plus optional satty/swappy pass) succeeded. `path` is the saved file, or `null` if the annotator exited without writing it. |
|
||||||
|
| `bread.shot.annotate.failed` | `{ "error": "<message>" }` | `bread.command.shot.annotate` was received but the capture failed (cancelled slurp, missing grim/slurp, annotator error). Missing satty/swappy is not a failure — breadshot warns and falls back to grim+slurp. |
|
||||||
|
|
||||||
|
`mode` is the CLI capture-mode name (`region`, `window`, `output`,
|
||||||
|
`active-window`, `active-output`) — not the `annotate` subcommand.
|
||||||
|
`clipboard` is whether the PNG was written to the clipboard — both current
|
||||||
|
capture paths do this (`save_and_copy` and `--clipboard-only`). `path` is
|
||||||
|
the saved file, or `null` when `--clipboard-only` was used (no file on
|
||||||
|
disk) or the annotator exited without writing one. The listen-triggered
|
||||||
|
region path is clipboard-only, so `path` is always `null` on
|
||||||
|
`bread.shot.region.done`.
|
||||||
|
|
||||||
|
The image bytes themselves are never included in the payload. The event
|
||||||
|
bus is a notification that a capture happened, not a channel for the
|
||||||
|
screenshot.
|
||||||
|
|
||||||
|
## Commands honored (`bread.command.shot.*`)
|
||||||
|
|
||||||
|
These are only received while `breadshot listen` is running. Publishing a
|
||||||
|
command with no subscriber is a silent no-op — that is the documented
|
||||||
|
bread convention, not a breadshot bug.
|
||||||
|
|
||||||
|
| Verb | Data | Effect |
|
||||||
|
|------|------|--------|
|
||||||
|
| `region` | none | Same interactive region capture as `breadshot region --clipboard-only`. Emits `bread.shot.region.done`/`.failed`. A successful capture also publishes `bread.shot.captured` the same way the CLI path does. |
|
||||||
|
| `annotate` | none | Same as `breadshot annotate`: region capture, then freeze the frame in `satty` (preferred) or `swappy` for arrows/text/rect. Emits `bread.shot.annotate.done`/`.failed`. A successful capture also publishes `bread.shot.captured` (`mode: "region"`). If neither annotator is installed, breadshot warns and saves the unannotated region shot. |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.spawn(function()
|
||||||
|
bread.emit("bread.command.shot.region")
|
||||||
|
bread.wait("bread.shot.region.done", { timeout = 30000 })
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.spawn(function()
|
||||||
|
bread.emit("bread.command.shot.annotate")
|
||||||
|
bread.wait("bread.shot.annotate.done", { timeout = 120000 })
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
A workflow that wants a file on disk (not just the clipboard) should
|
||||||
|
still shell out:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.exec("breadshot region")
|
||||||
|
bread.exec("breadshot active-output")
|
||||||
|
bread.exec("breadshot annotate")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
|
There is no `window`, `output`, `active-window`, `active-output`, `pin`,
|
||||||
|
`select`, or `edit` command verb. The CLI already covers the other
|
||||||
|
capture modes as synchronous one-shots. Annotation is the `annotate`
|
||||||
|
verb (a thin satty/swappy hand-off), not a built-in editor — do not
|
||||||
|
merge this with `bread-screenshots`. If/when another verb is needed,
|
||||||
|
add it at the same time, not stubbed as a no-op ahead of it.
|
||||||
|
|
||||||
|
## Fail-safe behavior
|
||||||
|
|
||||||
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
command subscription simply never receives anything — breadshot's
|
||||||
|
actual grim/slurp/wl-copy path 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 `breadshot listen` is needed.
|
||||||
|
- If `breadshot listen` is not running, commands are a graceful no-op at
|
||||||
|
the bus (no subscriber). The CLI still works, and one-shot invocations
|
||||||
|
still emit `bread.shot.captured` on their own short-lived connection.
|
||||||
40
README.md
40
README.md
|
|
@ -1,6 +1,20 @@
|
||||||
# breadshot
|
# breadshot
|
||||||
|
|
||||||
Wayland screenshot utility for the bread ecosystem. Wraps `grim`, `slurp`, and `wl-copy` with Hyprland-aware geometry resolution, clipboard integration, and desktop notifications.
|
Wayland screenshot **orchestrator** for the bread ecosystem — not a GUI editor.
|
||||||
|
It shells out to `grim`, `slurp`, and `wl-copy` with Hyprland-aware geometry
|
||||||
|
resolution, clipboard integration, and desktop notifications.
|
||||||
|
|
||||||
|
This is a different job from `bread-screenshots` (the crate in
|
||||||
|
`bread-ecosystem`): that one is a capture harness for screenshotting sibling
|
||||||
|
apps in CI. Do not merge the two.
|
||||||
|
|
||||||
|
On BOS, Hyprland binds `Super+Shift+S` / `C` / `P` to breadshot (not grimblast):
|
||||||
|
|
||||||
|
| Bind | Action | Command |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `Super+Shift+S` | Region → file (and clipboard) | `breadshot region` |
|
||||||
|
| `Super+Shift+C` | Region → clipboard only | `breadshot region --clipboard-only` |
|
||||||
|
| `Super+Shift+P` | Screen → file | `breadshot active-output` |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -14,6 +28,8 @@ Required (must be in `$PATH`):
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `hyprpicker` — screen freeze during selection (`--freeze`)
|
- `hyprpicker` — screen freeze during selection (`--freeze`)
|
||||||
|
- `satty` — freeze the captured frame and annotate (arrows/text/rect). Preferred for `--annotate`
|
||||||
|
- `swappy` — fallback annotator if `satty` is missing
|
||||||
- `notify-send` — desktop notifications (silently skipped if absent)
|
- `notify-send` — desktop notifications (silently skipped if absent)
|
||||||
|
|
||||||
## Build and install
|
## Build and install
|
||||||
|
|
@ -33,8 +49,16 @@ make install PREFIX=/usr
|
||||||
|
|
||||||
```
|
```
|
||||||
breadshot <mode> [options]
|
breadshot <mode> [options]
|
||||||
|
breadshot annotate [options]
|
||||||
|
breadshot listen
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`breadshot listen` is the long-running process that honors
|
||||||
|
`bread.command.shot.region` (clipboard-only region capture) and
|
||||||
|
`bread.command.shot.annotate` (region capture, then freeze-and-annotate)
|
||||||
|
on the bread event bus. See [EVENTS.md](EVENTS.md). Without it, the CLI
|
||||||
|
still works; bus commands are a silent no-op.
|
||||||
|
|
||||||
### Modes
|
### Modes
|
||||||
|
|
||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
|
|
@ -44,6 +68,7 @@ breadshot <mode> [options]
|
||||||
| `output` | Click to select a monitor |
|
| `output` | Click to select a monitor |
|
||||||
| `active-window` | Capture the currently focused window |
|
| `active-window` | Capture the currently focused window |
|
||||||
| `active-output` | Capture the monitor containing the active workspace |
|
| `active-output` | Capture the monitor containing the active workspace |
|
||||||
|
| `annotate` | Region capture, then freeze the frame for arrows/text/rect (requires `satty` or `swappy`) |
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
|
|
@ -52,10 +77,15 @@ breadshot <mode> [options]
|
||||||
| `--clipboard-only` | `-c` | Copy to clipboard only, do not save to disk |
|
| `--clipboard-only` | `-c` | Copy to clipboard only, do not save to disk |
|
||||||
| `--silent` | `-s` | Suppress notifications |
|
| `--silent` | `-s` | Suppress notifications |
|
||||||
| `--freeze` | `-z` | Freeze screen during selection (requires `hyprpicker`) |
|
| `--freeze` | `-z` | Freeze screen during selection (requires `hyprpicker`) |
|
||||||
|
| `--annotate` | `-a` | Freeze the captured frame and annotate (requires `satty` or `swappy`) |
|
||||||
| `--output-dir <DIR>` | `-o` | Override the save directory from config |
|
| `--output-dir <DIR>` | `-o` | Override the save directory from config |
|
||||||
| `--filename <NAME>` | `-f` | Override the output filename (without path) |
|
| `--filename <NAME>` | `-f` | Override the output filename (without path) |
|
||||||
| `--config <FILE>` | | Use a specific config file |
|
| `--config <FILE>` | | Use a specific config file |
|
||||||
|
|
||||||
|
If `--annotate` is set (or `breadshot annotate` is used) but neither
|
||||||
|
`satty` nor `swappy` is in `$PATH`, breadshot prints a warning and
|
||||||
|
falls back to the normal grim+slurp capture.
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -67,6 +97,11 @@ breadshot active-window --clipboard-only
|
||||||
|
|
||||||
# region selection with screen frozen, saved to a custom path
|
# region selection with screen frozen, saved to a custom path
|
||||||
breadshot region --freeze --output-dir ~/Desktop --filename capture.png
|
breadshot region --freeze --output-dir ~/Desktop --filename capture.png
|
||||||
|
|
||||||
|
# region capture, then freeze the frame and annotate
|
||||||
|
breadshot annotate
|
||||||
|
breadshot region --annotate
|
||||||
|
breadshot output --annotate
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
@ -85,6 +120,9 @@ silent = false
|
||||||
# Freeze screen during selection by default (requires hyprpicker)
|
# Freeze screen during selection by default (requires hyprpicker)
|
||||||
freeze = false
|
freeze = false
|
||||||
|
|
||||||
|
# Freeze the captured frame and annotate by default (requires satty or swappy)
|
||||||
|
annotate = false
|
||||||
|
|
||||||
# Notification display duration in milliseconds
|
# Notification display duration in milliseconds
|
||||||
notif_timeout = 5000
|
notif_timeout = 5000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ name = "breadshot"
|
||||||
description = "Wayland screenshot utility for the bread ecosystem — wraps grim/slurp/wl-copy with Hyprland-aware geometry"
|
description = "Wayland screenshot utility for the bread ecosystem — wraps grim/slurp/wl-copy with Hyprland-aware geometry"
|
||||||
binaries = ["breadshot"]
|
binaries = ["breadshot"]
|
||||||
system_deps = ["grim", "slurp", "wl-clipboard"]
|
system_deps = ["grim", "slurp", "wl-clipboard"]
|
||||||
optional_system_deps = ["hyprland", "hyprpicker", "libnotify"]
|
optional_system_deps = ["hyprland", "hyprpicker", "libnotify", "satty", "swappy"]
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
|
|
||||||
[config]
|
[config]
|
||||||
|
|
|
||||||
1
ci/bread-ecosystem.rev
Normal file
1
ci/bread-ecosystem.rev
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
620c5a1317a6b57276eabca961facdb78bf510db
|
||||||
20
ci/build.sh
Executable file
20
ci/build.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
|
||||||
|
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
|
||||||
|
# files now affect every product's release pipeline, so bumping the pin
|
||||||
|
# is a deliberate act instead of silent drift.
|
||||||
|
#
|
||||||
|
# Usage: ci/build.sh cargo build --release --locked
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
|
||||||
|
|
||||||
|
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-*
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
|
||||||
|
git -C "$CACHE_DIR" checkout --quiet "$REV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "${CACHE_DIR}/ci/build.sh" breadshot "$ROOT" "$@"
|
||||||
333
src/capture.rs
333
src/capture.rs
|
|
@ -1,5 +1,4 @@
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::ValueEnum;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{
|
use std::{
|
||||||
io::Write,
|
io::Write,
|
||||||
|
|
@ -10,7 +9,11 @@ use std::{
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|
||||||
#[derive(Debug, Clone, ValueEnum)]
|
/// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as
|
||||||
|
/// `bread.shot.*`. See `EVENTS.md`.
|
||||||
|
pub(crate) const APP_ID: &str = "shot";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
/// Select a region interactively
|
/// Select a region interactively
|
||||||
Region,
|
Region,
|
||||||
|
|
@ -19,22 +22,57 @@ pub enum Mode {
|
||||||
/// Click to select a monitor
|
/// Click to select a monitor
|
||||||
Output,
|
Output,
|
||||||
/// Capture the active window
|
/// Capture the active window
|
||||||
#[value(name = "active-window")]
|
|
||||||
ActiveWindow,
|
ActiveWindow,
|
||||||
/// Capture the active monitor
|
/// Capture the active monitor
|
||||||
#[value(name = "active-output")]
|
|
||||||
ActiveOutput,
|
ActiveOutput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
/// CLI / event-payload name (`region`, `active-window`, …).
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Region => "region",
|
||||||
|
Self::Window => "window",
|
||||||
|
Self::Output => "output",
|
||||||
|
Self::ActiveWindow => "active-window",
|
||||||
|
Self::ActiveOutput => "active-output",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Overrides {
|
pub struct Overrides {
|
||||||
pub clipboard_only: bool,
|
pub clipboard_only: bool,
|
||||||
pub silent: bool,
|
pub silent: bool,
|
||||||
pub freeze: bool,
|
pub freeze: bool,
|
||||||
|
pub annotate: bool,
|
||||||
pub output_dir: Option<PathBuf>,
|
pub output_dir: Option<PathBuf>,
|
||||||
pub filename: Option<String>,
|
pub filename: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
|
/// What a successful capture left behind. `path` is `None` when the user
|
||||||
|
/// asked for clipboard-only, cancelled the annotator without saving, or
|
||||||
|
/// the save file was never written.
|
||||||
|
pub struct CaptureOutcome {
|
||||||
|
pub clipboard: bool,
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Annotator {
|
||||||
|
Satty,
|
||||||
|
Swappy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Annotator {
|
||||||
|
fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Satty => "satty",
|
||||||
|
Self::Swappy => "swappy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<CaptureOutcome> {
|
||||||
check_deps()?;
|
check_deps()?;
|
||||||
|
|
||||||
let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir);
|
let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir);
|
||||||
|
|
@ -49,27 +87,61 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
|
||||||
let silent = overrides.silent || config.silent;
|
let silent = overrides.silent || config.silent;
|
||||||
let freeze = overrides.freeze || config.freeze;
|
let freeze = overrides.freeze || config.freeze;
|
||||||
let clipboard_only = overrides.clipboard_only;
|
let clipboard_only = overrides.clipboard_only;
|
||||||
|
let annotator = resolve_annotator(overrides.annotate || config.annotate);
|
||||||
|
|
||||||
let _freeze_guard = if freeze {
|
// Capture under the optional hyprpicker freeze, then drop it before
|
||||||
FreezeGuard::try_spawn()
|
// the annotator window appears so it can take the frozen frame.
|
||||||
.map_err(|e| tracing::warn!("freeze: {e}"))
|
let captured_png = {
|
||||||
.ok()
|
let _freeze_guard = if freeze {
|
||||||
} else {
|
FreezeGuard::try_spawn()
|
||||||
None
|
.map_err(|e| tracing::warn!("freeze: {e}"))
|
||||||
|
.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let geometry = geometry_for_mode(mode)?;
|
||||||
|
tracing::debug!("geometry: {geometry}");
|
||||||
|
|
||||||
|
if annotator.is_some() {
|
||||||
|
Some(grim_png(&geometry)?)
|
||||||
|
} else if clipboard_only {
|
||||||
|
copy_only(&geometry)?;
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
std::fs::create_dir_all(save_dir)
|
||||||
|
.with_context(|| format!("creating {}", save_dir.display()))?;
|
||||||
|
save_and_copy(&geometry, &save_path)?;
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let geometry = geometry_for_mode(mode)?;
|
if let (Some(tool), Some(png)) = (annotator, captured_png) {
|
||||||
tracing::debug!("geometry: {geometry}");
|
if !clipboard_only {
|
||||||
|
std::fs::create_dir_all(save_dir)
|
||||||
if clipboard_only {
|
.with_context(|| format!("creating {}", save_dir.display()))?;
|
||||||
copy_only(&geometry)?;
|
}
|
||||||
} else {
|
run_annotator(
|
||||||
std::fs::create_dir_all(save_dir)
|
tool,
|
||||||
.with_context(|| format!("creating {}", save_dir.display()))?;
|
&png,
|
||||||
save_and_copy(&geometry, &save_path)?;
|
(!clipboard_only).then_some(save_path.as_path()),
|
||||||
|
silent,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !silent {
|
// Both capture paths copy the PNG to the clipboard. `path` is null
|
||||||
|
// when the user asked for clipboard-only (no file on disk) or the
|
||||||
|
// annotator exited without writing the save file.
|
||||||
|
let path = if clipboard_only || !save_path.exists() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(save_path.as_path())
|
||||||
|
};
|
||||||
|
|
||||||
|
emit_captured(mode, true, path);
|
||||||
|
|
||||||
|
// The annotator owns its own copy/save notifications.
|
||||||
|
if !silent && annotator.is_none() {
|
||||||
let msg = if clipboard_only {
|
let msg = if clipboard_only {
|
||||||
"Copied to clipboard".to_string()
|
"Copied to clipboard".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -78,7 +150,10 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
|
||||||
send_notification("Screenshot", &msg, config.notif_timeout, &save_path);
|
send_notification("Screenshot", &msg, config.notif_timeout, &save_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(CaptureOutcome {
|
||||||
|
clipboard: true,
|
||||||
|
path: path.map(Path::to_path_buf),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- geometry ---
|
// --- geometry ---
|
||||||
|
|
@ -237,6 +312,103 @@ fn trim_geometry(geometry: &str) -> Result<String> {
|
||||||
|
|
||||||
// --- capture ---
|
// --- capture ---
|
||||||
|
|
||||||
|
fn grim_png(geometry: &str) -> Result<Vec<u8>> {
|
||||||
|
let out = Command::new("grim")
|
||||||
|
.args(["-g", geometry, "-"])
|
||||||
|
.output()
|
||||||
|
.context("running grim")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("grim exited with {}", out.status);
|
||||||
|
}
|
||||||
|
Ok(out.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_annotator(requested: bool) -> Option<Annotator> {
|
||||||
|
if !requested {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match find_annotator() {
|
||||||
|
Some(tool) => Some(tool),
|
||||||
|
None => {
|
||||||
|
eprintln!("breadshot: satty or swappy not found; capturing without annotation");
|
||||||
|
eprintln!(
|
||||||
|
"install satty (preferred) or swappy to freeze the frame and annotate (arrows/text/rect)"
|
||||||
|
);
|
||||||
|
tracing::warn!("annotate requested but satty/swappy missing");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_annotator() -> Option<Annotator> {
|
||||||
|
if in_path("satty") {
|
||||||
|
Some(Annotator::Satty)
|
||||||
|
} else if in_path("swappy") {
|
||||||
|
Some(Annotator::Swappy)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn annotator_args(tool: Annotator, save_path: Option<&Path>, silent: bool) -> Vec<String> {
|
||||||
|
match tool {
|
||||||
|
Annotator::Satty => {
|
||||||
|
let mut args = vec![
|
||||||
|
"--filename".into(),
|
||||||
|
"-".into(),
|
||||||
|
"--fullscreen".into(),
|
||||||
|
"--copy-command".into(),
|
||||||
|
"wl-copy".into(),
|
||||||
|
];
|
||||||
|
if let Some(path) = save_path {
|
||||||
|
args.push("--output-filename".into());
|
||||||
|
args.push(path.to_string_lossy().into_owned());
|
||||||
|
args.push("--save-after-copy".into());
|
||||||
|
}
|
||||||
|
if silent {
|
||||||
|
args.push("--disable-notifications".into());
|
||||||
|
}
|
||||||
|
// Last so a value-taking satty does not swallow the next flag.
|
||||||
|
args.push("--early-exit".into());
|
||||||
|
args
|
||||||
|
}
|
||||||
|
Annotator::Swappy => {
|
||||||
|
let mut args = vec!["-f".into(), "-".into()];
|
||||||
|
if let Some(path) = save_path {
|
||||||
|
args.push("-o".into());
|
||||||
|
args.push(path.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_annotator(
|
||||||
|
tool: Annotator,
|
||||||
|
png: &[u8],
|
||||||
|
save_path: Option<&Path>,
|
||||||
|
silent: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut child = Command::new(tool.name())
|
||||||
|
.args(annotator_args(tool, save_path, silent))
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawning {}", tool.name()))?;
|
||||||
|
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.context("annotator stdin")?
|
||||||
|
.write_all(png)
|
||||||
|
.context("piping screenshot to annotator")?;
|
||||||
|
|
||||||
|
let status = child.wait().context("waiting for annotator")?;
|
||||||
|
if !status.success() {
|
||||||
|
bail!("{} exited with {status}", tool.name());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_only(geometry: &str) -> Result<()> {
|
fn copy_only(geometry: &str) -> Result<()> {
|
||||||
let mut grim = Command::new("grim")
|
let mut grim = Command::new("grim")
|
||||||
.args(["-g", geometry, "-"])
|
.args(["-g", geometry, "-"])
|
||||||
|
|
@ -289,6 +461,26 @@ fn save_and_copy(geometry: &str, path: &Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publishes `bread.shot.captured` 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
|
||||||
|
/// breadshot's own capture path, only mean this one notification doesn't
|
||||||
|
/// go anywhere.
|
||||||
|
fn emit_captured(mode: &Mode, clipboard: bool, path: Option<&Path>) {
|
||||||
|
bread_utils::bread_client::BreadClient::connect(APP_ID).emit(
|
||||||
|
"bread.shot.captured",
|
||||||
|
captured_payload(mode, clipboard, path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn captured_payload(mode: &Mode, clipboard: bool, path: Option<&Path>) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"mode": mode.as_str(),
|
||||||
|
"clipboard": clipboard,
|
||||||
|
"path": path.map(|p| p.to_string_lossy().into_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) {
|
fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) {
|
||||||
let mut cmd = Command::new("notify-send");
|
let mut cmd = Command::new("notify-send");
|
||||||
cmd.args([title, msg, "-t", &timeout.to_string(), "-a", "breadshot"]);
|
cmd.args([title, msg, "-t", &timeout.to_string(), "-a", "breadshot"]);
|
||||||
|
|
@ -303,12 +495,13 @@ fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) {
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
fn hyprctl_json(subcmd: &str) -> Result<Value> {
|
fn hyprctl_json(subcmd: &str) -> Result<Value> {
|
||||||
let out = Command::new("hyprctl")
|
// Was a bare Command::new("hyprctl").output() with no timeout.
|
||||||
.args(["-j", subcmd])
|
bread_utils::proc::run_json(
|
||||||
.output()
|
"hyprctl",
|
||||||
.context("running hyprctl")?;
|
&["-j", subcmd],
|
||||||
serde_json::from_slice(&out.stdout)
|
std::time::Duration::from_secs(3),
|
||||||
.with_context(|| format!("parsing hyprctl {subcmd} output"))
|
)
|
||||||
|
.with_context(|| format!("running/parsing hyprctl {subcmd}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn slurp(args: &[&str]) -> Result<String> {
|
fn slurp(args: &[&str]) -> Result<String> {
|
||||||
|
|
@ -387,3 +580,87 @@ impl Drop for FreezeGuard {
|
||||||
let _ = self.child.wait();
|
let _ = self.child.wait();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_as_str_matches_cli_names() {
|
||||||
|
assert_eq!(Mode::Region.as_str(), "region");
|
||||||
|
assert_eq!(Mode::Window.as_str(), "window");
|
||||||
|
assert_eq!(Mode::Output.as_str(), "output");
|
||||||
|
assert_eq!(Mode::ActiveWindow.as_str(), "active-window");
|
||||||
|
assert_eq!(Mode::ActiveOutput.as_str(), "active-output");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_payload_clipboard_only_has_null_path() {
|
||||||
|
let v = captured_payload(&Mode::Region, true, None);
|
||||||
|
assert_eq!(v["mode"], "region");
|
||||||
|
assert_eq!(v["clipboard"], true);
|
||||||
|
assert!(v["path"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_payload_saved_file_includes_path() {
|
||||||
|
let path = Path::new("/tmp/shot.png");
|
||||||
|
let v = captured_payload(&Mode::ActiveOutput, true, Some(path));
|
||||||
|
assert_eq!(v["mode"], "active-output");
|
||||||
|
assert_eq!(v["clipboard"], true);
|
||||||
|
assert_eq!(v["path"], "/tmp/shot.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn satty_args_fullscreen_copy_and_early_exit() {
|
||||||
|
let args = annotator_args(Annotator::Satty, None, false);
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
[
|
||||||
|
"--filename",
|
||||||
|
"-",
|
||||||
|
"--fullscreen",
|
||||||
|
"--copy-command",
|
||||||
|
"wl-copy",
|
||||||
|
"--early-exit"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn satty_args_save_path_and_silent() {
|
||||||
|
let path = Path::new("/tmp/shot.png");
|
||||||
|
let args = annotator_args(Annotator::Satty, Some(path), true);
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
[
|
||||||
|
"--filename",
|
||||||
|
"-",
|
||||||
|
"--fullscreen",
|
||||||
|
"--copy-command",
|
||||||
|
"wl-copy",
|
||||||
|
"--output-filename",
|
||||||
|
"/tmp/shot.png",
|
||||||
|
"--save-after-copy",
|
||||||
|
"--disable-notifications",
|
||||||
|
"--early-exit"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swappy_args_stdin_and_optional_output() {
|
||||||
|
assert_eq!(annotator_args(Annotator::Swappy, None, true), ["-f", "-"]);
|
||||||
|
let path = Path::new("/tmp/shot.png");
|
||||||
|
assert_eq!(
|
||||||
|
annotator_args(Annotator::Swappy, Some(path), false),
|
||||||
|
["-f", "-", "-o", "/tmp/shot.png"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotator_prefers_satty_name() {
|
||||||
|
assert_eq!(Annotator::Satty.name(), "satty");
|
||||||
|
assert_eq!(Annotator::Swappy.name(), "swappy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ pub struct Config {
|
||||||
pub save_dir: PathBuf,
|
pub save_dir: PathBuf,
|
||||||
pub silent: bool,
|
pub silent: bool,
|
||||||
pub freeze: bool,
|
pub freeze: bool,
|
||||||
|
/// Open satty/swappy after capture to annotate the frozen frame.
|
||||||
|
pub annotate: bool,
|
||||||
pub notif_timeout: u32,
|
pub notif_timeout: u32,
|
||||||
pub date_format: String,
|
pub date_format: String,
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +22,7 @@ impl Default for Config {
|
||||||
.join("Screenshots"),
|
.join("Screenshots"),
|
||||||
silent: false,
|
silent: false,
|
||||||
freeze: false,
|
freeze: false,
|
||||||
|
annotate: false,
|
||||||
notif_timeout: 5000,
|
notif_timeout: 5000,
|
||||||
date_format: "%Y-%m-%d-%H%M%S".to_string(),
|
date_format: "%Y-%m-%d-%H%M%S".to_string(),
|
||||||
}
|
}
|
||||||
|
|
@ -36,10 +39,12 @@ impl Config {
|
||||||
tracing::debug!("no config at {}, using defaults", path.display());
|
tracing::debug!("no config at {}, using defaults", path.display());
|
||||||
return Ok(Self::default());
|
return Ok(Self::default());
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(path)
|
let content =
|
||||||
.with_context(|| format!("reading {}", path.display()))?;
|
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
toml::from_str(&content)
|
let mut config: Self =
|
||||||
.with_context(|| format!("parsing {}", path.display()))
|
toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
|
||||||
|
config.save_dir = expand_tilde(config.save_dir);
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,3 +54,56 @@ pub fn default_path() -> PathBuf {
|
||||||
.join("breadshot")
|
.join("breadshot")
|
||||||
.join("config.toml")
|
.join("config.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expand a leading `~` (or `~/...`) to the user's home directory, the way a
|
||||||
|
/// shell would. `PathBuf`'s `Deserialize` does no such expansion, so a
|
||||||
|
/// documented config value like `save_dir = "~/Pictures/Screenshots"` would
|
||||||
|
/// otherwise be taken literally and create a `./~/Pictures/Screenshots`
|
||||||
|
/// directory relative to the current working directory.
|
||||||
|
fn expand_tilde(path: PathBuf) -> PathBuf {
|
||||||
|
let Some(s) = path.to_str() else {
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
if s == "~" {
|
||||||
|
return dirs::home_dir().unwrap_or(path);
|
||||||
|
}
|
||||||
|
if let Some(rest) = s.strip_prefix("~/") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.join(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_prefix() {
|
||||||
|
let home = dirs::home_dir().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde(PathBuf::from("~/Pictures/Screenshots")),
|
||||||
|
home.join("Pictures/Screenshots")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_bare() {
|
||||||
|
let home = dirs::home_dir().unwrap();
|
||||||
|
assert_eq!(expand_tilde(PathBuf::from("~")), home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_absolute_untouched() {
|
||||||
|
let p = PathBuf::from("/var/tmp/shots");
|
||||||
|
assert_eq!(expand_tilde(p.clone()), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_no_expansion_mid_path() {
|
||||||
|
// Only a leading ~ is special, matching shell behavior.
|
||||||
|
let p = PathBuf::from("/home/user/~weird");
|
||||||
|
assert_eq!(expand_tilde(p.clone()), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
170
src/listen.rs
Normal file
170
src/listen.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
//! Long-running command subscription for `bread.command.shot.*`.
|
||||||
|
//!
|
||||||
|
//! `breadshot` is still a one-shot CLI by default. `breadshot listen` is the
|
||||||
|
//! optional persistent process that can honor bus commands. See `EVENTS.md`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||||
|
|
||||||
|
use crate::capture::{self, CaptureOutcome, Mode, Overrides, APP_ID};
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
/// Subscribe to `bread.command.shot.**` and block until the process is killed.
|
||||||
|
///
|
||||||
|
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
|
||||||
|
/// with backoff, and `on_event` simply isn't called until the daemon is up.
|
||||||
|
pub fn run(config: &Config) -> Result<()> {
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
if client.health().is_none() {
|
||||||
|
tracing::warn!("breadd unreachable; command subscription will connect when it comes back");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = config.clone();
|
||||||
|
let _commands = client.subscribe("bread.command.shot.**", move |event| {
|
||||||
|
handle_command(&event, &config);
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!("listening for bread.command.shot.**");
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to `bread.command.shot.*` verbs. Only `region` and `annotate` are
|
||||||
|
/// honored — other verbs are ignored, not stubbed as no-ops that pretend
|
||||||
|
/// to succeed.
|
||||||
|
///
|
||||||
|
/// Emits `bread.shot.<verb>.done` / `.failed` per the confirmation
|
||||||
|
/// convention in bread's Documentation.md.
|
||||||
|
fn handle_command(event: &BreadEvent, config: &Config) {
|
||||||
|
let Some(verb) = command_verb(&event.event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match verb {
|
||||||
|
"region" => handle_region(config),
|
||||||
|
"annotate" => handle_annotate(config),
|
||||||
|
other => {
|
||||||
|
tracing::debug!("ignoring unrecognized command verb '{other}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_region(config: &Config) {
|
||||||
|
// Clipboard-only matches the default region *bus* path: a Lua workflow
|
||||||
|
// that wants a file on disk can still `bread.exec("breadshot region")`.
|
||||||
|
let result = capture::run(
|
||||||
|
&Mode::Region,
|
||||||
|
config,
|
||||||
|
Overrides {
|
||||||
|
clipboard_only: true,
|
||||||
|
silent: false,
|
||||||
|
freeze: false,
|
||||||
|
annotate: false,
|
||||||
|
output_dir: None,
|
||||||
|
filename: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
match result {
|
||||||
|
Ok(_) => client.emit("bread.shot.region.done", region_done_payload()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("bread.command.shot.region failed: {e}");
|
||||||
|
client.emit("bread.shot.region.failed", command_failed_payload(&e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_annotate(config: &Config) {
|
||||||
|
// Interactive: satty/swappy get a default save path so the user can
|
||||||
|
// write the annotated frame. If the annotator is missing, this falls
|
||||||
|
// back to a normal region save (with a warning on stderr).
|
||||||
|
let result = capture::run(
|
||||||
|
&Mode::Region,
|
||||||
|
config,
|
||||||
|
Overrides {
|
||||||
|
clipboard_only: false,
|
||||||
|
silent: false,
|
||||||
|
freeze: false,
|
||||||
|
annotate: true,
|
||||||
|
output_dir: None,
|
||||||
|
filename: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
match result {
|
||||||
|
Ok(outcome) => client.emit("bread.shot.annotate.done", annotate_done_payload(&outcome)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("bread.command.shot.annotate failed: {e}");
|
||||||
|
client.emit("bread.shot.annotate.failed", command_failed_payload(&e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_verb(event_name: &str) -> Option<&str> {
|
||||||
|
event_name.strip_prefix("bread.command.shot.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn region_done_payload() -> serde_json::Value {
|
||||||
|
serde_json::json!({ "clipboard": true, "path": serde_json::Value::Null })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_failed_payload(error: &impl ToString) -> serde_json::Value {
|
||||||
|
serde_json::json!({ "error": error.to_string() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn annotate_done_payload(outcome: &CaptureOutcome) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"clipboard": outcome.clipboard,
|
||||||
|
"path": outcome.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_verb_strips_shot_prefix() {
|
||||||
|
assert_eq!(command_verb("bread.command.shot.region"), Some("region"));
|
||||||
|
assert_eq!(
|
||||||
|
command_verb("bread.command.shot.annotate"),
|
||||||
|
Some("annotate")
|
||||||
|
);
|
||||||
|
assert_eq!(command_verb("bread.command.shot.window"), Some("window"));
|
||||||
|
assert_eq!(command_verb("bread.command.clip.clear"), None);
|
||||||
|
assert_eq!(command_verb("bread.shot.captured"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn region_done_payload_is_clipboard_only() {
|
||||||
|
let v = region_done_payload();
|
||||||
|
assert_eq!(v["clipboard"], true);
|
||||||
|
assert!(v["path"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_failed_payload_includes_error() {
|
||||||
|
let v = command_failed_payload(&"selection cancelled");
|
||||||
|
assert_eq!(v["error"], "selection cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotate_done_payload_includes_saved_path() {
|
||||||
|
let v = annotate_done_payload(&CaptureOutcome {
|
||||||
|
clipboard: true,
|
||||||
|
path: Some(std::path::PathBuf::from("/tmp/shot.png")),
|
||||||
|
});
|
||||||
|
assert_eq!(v["clipboard"], true);
|
||||||
|
assert_eq!(v["path"], "/tmp/shot.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotate_done_payload_null_path_when_unsaved() {
|
||||||
|
let v = annotate_done_payload(&CaptureOutcome {
|
||||||
|
clipboard: true,
|
||||||
|
path: None,
|
||||||
|
});
|
||||||
|
assert_eq!(v["clipboard"], true);
|
||||||
|
assert!(v["path"].is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/main.rs
79
src/main.rs
|
|
@ -1,8 +1,9 @@
|
||||||
mod capture;
|
mod capture;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod listen;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::{Args, Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
|
@ -14,12 +15,19 @@ use config::Config;
|
||||||
name = "breadshot",
|
name = "breadshot",
|
||||||
version,
|
version,
|
||||||
about = "Screenshot utility for the bread ecosystem",
|
about = "Screenshot utility for the bread ecosystem",
|
||||||
disable_help_subcommand = true,
|
disable_help_subcommand = true
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Capture mode
|
#[command(subcommand)]
|
||||||
mode: Mode,
|
command: Command,
|
||||||
|
|
||||||
|
/// Path to config file
|
||||||
|
#[arg(long, value_name = "FILE", global = true)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct CaptureOpts {
|
||||||
/// Copy to clipboard only, don't save to disk
|
/// Copy to clipboard only, don't save to disk
|
||||||
#[arg(long, short = 'c')]
|
#[arg(long, short = 'c')]
|
||||||
clipboard_only: bool,
|
clipboard_only: bool,
|
||||||
|
|
@ -32,6 +40,10 @@ struct Cli {
|
||||||
#[arg(long, short = 'z')]
|
#[arg(long, short = 'z')]
|
||||||
freeze: bool,
|
freeze: bool,
|
||||||
|
|
||||||
|
/// Freeze the captured frame and annotate (requires satty or swappy)
|
||||||
|
#[arg(long, short = 'a')]
|
||||||
|
annotate: bool,
|
||||||
|
|
||||||
/// Override save directory from config
|
/// Override save directory from config
|
||||||
#[arg(long, short = 'o', value_name = "DIR")]
|
#[arg(long, short = 'o', value_name = "DIR")]
|
||||||
output_dir: Option<PathBuf>,
|
output_dir: Option<PathBuf>,
|
||||||
|
|
@ -39,10 +51,43 @@ struct Cli {
|
||||||
/// Override output filename (without path)
|
/// Override output filename (without path)
|
||||||
#[arg(long, short = 'f', value_name = "NAME")]
|
#[arg(long, short = 'f', value_name = "NAME")]
|
||||||
filename: Option<String>,
|
filename: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Path to config file
|
#[derive(Subcommand)]
|
||||||
#[arg(long, value_name = "FILE")]
|
enum Command {
|
||||||
config: Option<PathBuf>,
|
/// Select a region interactively
|
||||||
|
Region(CaptureOpts),
|
||||||
|
/// Click to select a window
|
||||||
|
Window(CaptureOpts),
|
||||||
|
/// Click to select a monitor
|
||||||
|
Output(CaptureOpts),
|
||||||
|
/// Capture the active window
|
||||||
|
#[command(name = "active-window")]
|
||||||
|
ActiveWindow(CaptureOpts),
|
||||||
|
/// Capture the active monitor
|
||||||
|
#[command(name = "active-output")]
|
||||||
|
ActiveOutput(CaptureOpts),
|
||||||
|
/// Capture a region, freeze the frame, and annotate
|
||||||
|
Annotate(CaptureOpts),
|
||||||
|
/// Subscribe to bread.command.shot.** and honor region/annotate captures
|
||||||
|
Listen,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
fn into_capture(self) -> Option<(Mode, CaptureOpts)> {
|
||||||
|
match self {
|
||||||
|
Self::Region(opts) => Some((Mode::Region, opts)),
|
||||||
|
Self::Window(opts) => Some((Mode::Window, opts)),
|
||||||
|
Self::Output(opts) => Some((Mode::Output, opts)),
|
||||||
|
Self::ActiveWindow(opts) => Some((Mode::ActiveWindow, opts)),
|
||||||
|
Self::ActiveOutput(opts) => Some((Mode::ActiveOutput, opts)),
|
||||||
|
Self::Annotate(mut opts) => {
|
||||||
|
opts.annotate = true;
|
||||||
|
Some((Mode::Region, opts))
|
||||||
|
}
|
||||||
|
Self::Listen => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
|
@ -58,15 +103,21 @@ fn main() -> Result<()> {
|
||||||
None => Config::load()?,
|
None => Config::load()?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let Some((mode, opts)) = cli.command.into_capture() else {
|
||||||
|
return listen::run(&config);
|
||||||
|
};
|
||||||
|
|
||||||
capture::run(
|
capture::run(
|
||||||
&cli.mode,
|
&mode,
|
||||||
&config,
|
&config,
|
||||||
Overrides {
|
Overrides {
|
||||||
clipboard_only: cli.clipboard_only,
|
clipboard_only: opts.clipboard_only,
|
||||||
silent: cli.silent,
|
silent: opts.silent,
|
||||||
freeze: cli.freeze,
|
freeze: opts.freeze,
|
||||||
output_dir: cli.output_dir,
|
annotate: opts.annotate,
|
||||||
filename: cli.filename,
|
output_dir: opts.output_dir,
|
||||||
|
filename: opts.filename,
|
||||||
},
|
},
|
||||||
)
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue