diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml deleted file mode 100644 index b547c34..0000000 --- a/.forgejo/workflows/check.yml +++ /dev/null @@ -1,24 +0,0 @@ -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 diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml deleted file mode 100644 index 022c233..0000000 --- a/.forgejo/workflows/dev-release.yml +++ /dev/null @@ -1,75 +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' 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}" diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml deleted file mode 100644 index bad1b67..0000000 --- a/.forgejo/workflows/rc-release.yml +++ /dev/null @@ -1,56 +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/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}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index f8ef6b9..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,69 +0,0 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..925331a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +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 diff --git a/.gitignore b/.gitignore index 0a33787..36f7f5b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,3 @@ logs/ # Runtime files *.sock *.pid - -# Local hygiene notes (not for commit) diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c8c782d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,43 +0,0 @@ -# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b8a86dc..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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/ -fix/ -``` - -Branch off `main`, open a PR/push back into `main` when ready. Short-lived -branches get deleted on merge — they never accumulate the kind of drift a -second long-lived branch does. - -## The release cycle - -There's no separate `beta` or release branch — "stable" and "beta" are both -just **tags** on `main`, not branches that need to be kept in sync: - -1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push - auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then fix anything broken with another push. -2. When you want to stabilize before a real release, tag a release - candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to - both remotes). That tag alone triggers a beta-track build — - "freezing" is just pausing pushes to `main` while you test it, not a - branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. -3. Once an RC has gone without issues, tag the real release: - `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the - signed stable release build. - -## Tracks, from a user's perspective - -``` -bakery track show # what you're currently on (defaults to stable) -bakery track set dev # or beta, or stable -bakery update --all # pull the latest build on your current track -``` - -| Track | What it is | Published from | -|--------|-----------|-----------------| -| `stable` | The last tagged release | a `vX.Y.Z` tag | -| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | -| `dev` | Bleeding edge | `main`, on every push | - -Dev versions are auto-computed (`X.Y.Z-dev.+`) from the -latest published stable tag, so they always sort as newer than what you -have installed — no manual version bumping needed. Beta versions are just -the RC tag itself (already valid semver, already sorts below the real -release it's a candidate for). - -## Local development - -```sh -cargo build --release -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. diff --git a/Cargo.lock b/Cargo.lock index 6cf4d70..8dbb11a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,34 +82,11 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "breadshot" -version = "0.1.2" +version = "1.0.0" dependencies = [ "anyhow", - "bread-utils", "chrono", "clap", "dirs", @@ -128,9 +105,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cc" -version = "1.3.0" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -157,9 +134,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -167,9 +144,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -242,21 +219,21 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-task", @@ -358,9 +335,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -382,9 +359,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "nu-ansi-term" @@ -459,9 +436,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -476,9 +453,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "serde" @@ -567,9 +544,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.119" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -598,9 +575,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] @@ -921,6 +898,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index e156f4b..5df1443 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadshot" -version = "0.1.2" +version = "1.0.0" edition = "2021" license = "MIT" authors = ["Breadway"] @@ -16,7 +16,6 @@ serde_json = "1" toml = "0.8" tracing = "0.1" 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] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md deleted file mode 100644 index a74da00..0000000 --- a/EVENTS.md +++ /dev/null @@ -1,96 +0,0 @@ -# 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": }` | 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": "" }` | `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": }` | `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": "" }` | `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. diff --git a/README.md b/README.md index 77a7575..89cbf0f 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,6 @@ # breadshot -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` | +Wayland screenshot utility for the bread ecosystem. Wraps `grim`, `slurp`, and `wl-copy` with Hyprland-aware geometry resolution, clipboard integration, and desktop notifications. ## Requirements @@ -28,8 +14,6 @@ Required (must be in `$PATH`): Optional: - `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) ## Build and install @@ -49,16 +33,8 @@ make install PREFIX=/usr ``` breadshot [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 | Mode | Description | @@ -68,7 +44,6 @@ still works; bus commands are a silent no-op. | `output` | Click to select a monitor | | `active-window` | Capture the currently focused window | | `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 @@ -77,15 +52,10 @@ still works; bus commands are a silent no-op. | `--clipboard-only` | `-c` | Copy to clipboard only, do not save to disk | | `--silent` | `-s` | Suppress notifications | | `--freeze` | `-z` | Freeze screen during selection (requires `hyprpicker`) | -| `--annotate` | `-a` | Freeze the captured frame and annotate (requires `satty` or `swappy`) | | `--output-dir ` | `-o` | Override the save directory from config | | `--filename ` | `-f` | Override the output filename (without path) | | `--config ` | | 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 ```sh @@ -97,11 +67,6 @@ breadshot active-window --clipboard-only # region selection with screen frozen, saved to a custom path 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 @@ -120,9 +85,6 @@ silent = false # Freeze screen during selection by default (requires hyprpicker) freeze = false -# Freeze the captured frame and annotate by default (requires satty or swappy) -annotate = false - # Notification display duration in milliseconds notif_timeout = 5000 diff --git a/bakery.toml b/bakery.toml index abcd841..616d788 100644 --- a/bakery.toml +++ b/bakery.toml @@ -2,7 +2,7 @@ name = "breadshot" description = "Wayland screenshot utility for the bread ecosystem — wraps grim/slurp/wl-copy with Hyprland-aware geometry" binaries = ["breadshot"] system_deps = ["grim", "slurp", "wl-clipboard"] -optional_system_deps = ["hyprland", "hyprpicker", "libnotify", "satty", "swappy"] +optional_system_deps = ["hyprland", "hyprpicker", "libnotify"] bread_deps = [] [config] diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev deleted file mode 100644 index 474f1fd..0000000 --- a/ci/bread-ecosystem.rev +++ /dev/null @@ -1 +0,0 @@ -620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh deleted file mode 100755 index e4424e0..0000000 --- a/ci/build.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Delegates to bread-ecosystem's shared CI build image/script, pinned to -# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI -# files now affect every product's release pipeline, so bumping the pin -# is a deliberate act instead of silent drift. -# -# Usage: ci/build.sh cargo build --release --locked -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" - -CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" -if [ ! -d "$CACHE_DIR" ]; then - rm -rf /tmp/bread-ecosystem-ci-* - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" - git -C "$CACHE_DIR" checkout --quiet "$REV" -fi - -bash "${CACHE_DIR}/ci/build.sh" breadshot "$ROOT" "$@" diff --git a/src/capture.rs b/src/capture.rs index 1422359..08dda97 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context, Result}; +use clap::ValueEnum; use serde_json::Value; use std::{ io::Write, @@ -9,11 +10,7 @@ use std::{ use crate::config::Config; -/// 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)] +#[derive(Debug, Clone, ValueEnum)] pub enum Mode { /// Select a region interactively Region, @@ -22,57 +19,22 @@ pub enum Mode { /// Click to select a monitor Output, /// Capture the active window + #[value(name = "active-window")] ActiveWindow, /// Capture the active monitor + #[value(name = "active-output")] 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 clipboard_only: bool, pub silent: bool, pub freeze: bool, - pub annotate: bool, pub output_dir: Option, pub filename: Option, } -/// 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, -} - -#[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 { +pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { check_deps()?; let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir); @@ -87,61 +49,27 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result Result Result { // --- capture --- -fn grim_png(geometry: &str) -> Result> { - 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 { - 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 { - 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 { - 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<()> { let mut grim = Command::new("grim") .args(["-g", geometry, "-"]) @@ -461,26 +289,6 @@ fn save_and_copy(geometry: &str, path: &Path) -> Result<()> { 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) { let mut cmd = Command::new("notify-send"); cmd.args([title, msg, "-t", &timeout.to_string(), "-a", "breadshot"]); @@ -495,13 +303,12 @@ fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) { // --- helpers --- fn hyprctl_json(subcmd: &str) -> Result { - // Was a bare Command::new("hyprctl").output() with no timeout. - bread_utils::proc::run_json( - "hyprctl", - &["-j", subcmd], - std::time::Duration::from_secs(3), - ) - .with_context(|| format!("running/parsing hyprctl {subcmd}")) + let out = Command::new("hyprctl") + .args(["-j", subcmd]) + .output() + .context("running hyprctl")?; + serde_json::from_slice(&out.stdout) + .with_context(|| format!("parsing hyprctl {subcmd} output")) } fn slurp(args: &[&str]) -> Result { @@ -580,87 +387,3 @@ impl Drop for FreezeGuard { 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"); - } -} diff --git a/src/config.rs b/src/config.rs index 5cbb6cf..8f8ffd8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,8 +8,6 @@ pub struct Config { pub save_dir: PathBuf, pub silent: bool, pub freeze: bool, - /// Open satty/swappy after capture to annotate the frozen frame. - pub annotate: bool, pub notif_timeout: u32, pub date_format: String, } @@ -22,7 +20,6 @@ impl Default for Config { .join("Screenshots"), silent: false, freeze: false, - annotate: false, notif_timeout: 5000, date_format: "%Y-%m-%d-%H%M%S".to_string(), } @@ -39,12 +36,10 @@ impl Config { tracing::debug!("no config at {}, using defaults", path.display()); return Ok(Self::default()); } - let content = - std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; - let mut config: Self = - toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?; - config.save_dir = expand_tilde(config.save_dir); - Ok(config) + let content = std::fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display()))?; + toml::from_str(&content) + .with_context(|| format!("parsing {}", path.display())) } } @@ -54,56 +49,3 @@ pub fn default_path() -> PathBuf { .join("breadshot") .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); - } -} diff --git a/src/listen.rs b/src/listen.rs deleted file mode 100644 index d4ed7aa..0000000 --- a/src/listen.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! 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..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()); - } -} diff --git a/src/main.rs b/src/main.rs index d80cb4e..8e79de6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,8 @@ mod capture; mod config; -mod listen; use anyhow::Result; -use clap::{Args, Parser, Subcommand}; +use clap::Parser; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -15,19 +14,12 @@ use config::Config; name = "breadshot", version, about = "Screenshot utility for the bread ecosystem", - disable_help_subcommand = true + disable_help_subcommand = true, )] struct Cli { - #[command(subcommand)] - command: Command, + /// Capture mode + mode: Mode, - /// Path to config file - #[arg(long, value_name = "FILE", global = true)] - config: Option, -} - -#[derive(Args)] -struct CaptureOpts { /// Copy to clipboard only, don't save to disk #[arg(long, short = 'c')] clipboard_only: bool, @@ -40,10 +32,6 @@ struct CaptureOpts { #[arg(long, short = 'z')] freeze: bool, - /// Freeze the captured frame and annotate (requires satty or swappy) - #[arg(long, short = 'a')] - annotate: bool, - /// Override save directory from config #[arg(long, short = 'o', value_name = "DIR")] output_dir: Option, @@ -51,43 +39,10 @@ struct CaptureOpts { /// Override output filename (without path) #[arg(long, short = 'f', value_name = "NAME")] filename: Option, -} -#[derive(Subcommand)] -enum Command { - /// 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, - } - } + /// Path to config file + #[arg(long, value_name = "FILE")] + config: Option, } fn main() -> Result<()> { @@ -103,21 +58,15 @@ fn main() -> Result<()> { None => Config::load()?, }; - let Some((mode, opts)) = cli.command.into_capture() else { - return listen::run(&config); - }; - capture::run( - &mode, + &cli.mode, &config, Overrides { - clipboard_only: opts.clipboard_only, - silent: opts.silent, - freeze: opts.freeze, - annotate: opts.annotate, - output_dir: opts.output_dir, - filename: opts.filename, + clipboard_only: cli.clipboard_only, + silent: cli.silent, + freeze: cli.freeze, + output_dir: cli.output_dir, + filename: cli.filename, }, - )?; - Ok(()) + ) }