From 1fb9770a77f3ecd00e997efbdd9f4233824a4573 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 14:10:13 +0800 Subject: [PATCH 01/22] CI: migrate release workflow from GitHub Actions to Forgejo Actions GitHub Actions self-hosted runners need per-repo registration on a personal account; Forgejo Actions' runner already serves every repo with zero setup. Moves release publishing there (dl.breadway.dev stays the primary bakery target; GitHub release upload is kept as the fallback via an explicit token, since Forgejo Actions has no ambient GITHUB_TOKEN) and adds a mirror workflow to keep GitHub in sync automatically. --- .forgejo/workflows/mirror.yml | 19 ++++++++++++ .forgejo/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 54 ---------------------------------- 3 files changed, 72 insertions(+), 54 deletions(-) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/release.yml delete mode 100644 .github/workflows/release.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..cfd402d --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,19 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - name: Mirror to GitHub + run: | + set -euo pipefail + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + git push --prune \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadshot.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..75ae32d --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,53 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + build: + 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 && cargo build --release --locked + + - 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 + run: | + set -euo pipefail + 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 deleted file mode 100644 index 925331a..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -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 From 08c27d1b22ae280da01832d510f7024f305c2990 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 03:19:50 +0800 Subject: [PATCH 02/22] breadshot: expand ~ in save_dir, align Cargo.toml version with tag - config.rs: expand a leading ~ in the save_dir config value on load. The documented example config (save_dir = "~/Pictures/Screenshots") was previously taken literally, silently creating ./~/Pictures/... in the current working directory. Added unit tests. - Cargo.toml: 1.0.0 -> 0.1.0, matching the only existing tag (v0.1.0). breadshot --version previously reported nine releases ahead of the actual release history. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/config.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dbb11a..258d335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "breadshot" -version = "1.0.0" +version = "0.1.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5df1443..87b2785 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadshot" -version = "1.0.0" +version = "0.1.0" edition = "2021" license = "MIT" authors = ["Breadway"] diff --git a/src/config.rs b/src/config.rs index 8f8ffd8..eb89f47 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,8 +38,10 @@ impl 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())) + let mut config: Self = toml::from_str(&content) + .with_context(|| format!("parsing {}", path.display()))?; + config.save_dir = expand_tilde(config.save_dir); + Ok(config) } } @@ -49,3 +51,56 @@ 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); + } +} From 5e4cbc83c95e58cf1688dc8cb92c991b8782a08b Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 09:53:54 +0800 Subject: [PATCH 03/22] Timeout-guard hyprctl JSON queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hyprctl_json used a bare Command::new("hyprctl").output() with no timeout, used by every geometry_* helper (region/output/window/active_window/ active_output selection) — an unresponsive hyprctl could block screenshot capture indefinitely. Switched to bread_utils::proc::run_json (path dependency for now, see the TODO in Cargo.toml). grim/slurp/wl-copy calls deliberately left untouched: several pipe binary image data through stdin/stdout (e.g. grim -> wl-copy), which bread_utils::proc's current run_with_stdin only accepts as &str — adapting those safely would need a bytes-flavored variant, out of scope for this pass to avoid risking a regression in image piping. --- Cargo.lock | 10 ++++++++++ Cargo.toml | 2 ++ src/capture.rs | 9 +++------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 258d335..1054183 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,11 +82,21 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bread-utils" +version = "0.2.3" +dependencies = [ + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadshot" version = "0.1.0" dependencies = [ "anyhow", + "bread-utils", "chrono", "clap", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 87b2785..e3b75ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ serde_json = "1" toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern +bread-utils = { path = "../bread-ecosystem-fix-worktree/bread-utils" } [profile.release] lto = "thin" diff --git a/src/capture.rs b/src/capture.rs index 08dda97..349dde9 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -303,12 +303,9 @@ fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) { // --- helpers --- fn hyprctl_json(subcmd: &str) -> Result { - 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")) + // 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}")) } fn slurp(args: &[&str]) -> Result { From aad314c5ca5dba38a2e34c0243f0a0c7ecc7ca24 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 03:52:53 +0800 Subject: [PATCH 04/22] Switch to tag-pinned bread-ecosystem deps; bump version to v0.1.1 --- Cargo.lock | 57 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 5 ++--- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1054183..c1e5eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,8 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bread-utils" -version = "0.2.3" +version = "0.3.0" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" dependencies = [ "dirs", "serde", @@ -93,7 +94,7 @@ dependencies = [ [[package]] name = "breadshot" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "bread-utils", @@ -115,9 +116,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cc" -version = "1.2.65" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -144,9 +145,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -154,9 +155,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -229,21 +230,21 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-task", @@ -345,9 +346,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -369,9 +370,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nu-ansi-term" @@ -446,9 +447,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -463,9 +464,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "serde" @@ -554,9 +555,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -585,9 +586,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -908,6 +909,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index e3b75ea..a8e59a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadshot" -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT" authors = ["Breadway"] @@ -16,8 +16,7 @@ serde_json = "1" toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern -bread-utils = { path = "../bread-ecosystem-fix-worktree/bread-utils" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } [profile.release] lto = "thin" From 14672188b75bfe1b1602385b4347b20ef5e880ff Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:18:09 +0800 Subject: [PATCH 05/22] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index cfd402d..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadshot.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 86774ab1cf8084cece372556399bb8d6c7bbb4df Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:56:49 +0800 Subject: [PATCH 06/22] ci: add dev/beta build track workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dev-release.yml (publishes on every push to dev) and beta-release.yml (publishes on a beta-v* tag), mirroring the pattern landing in bread-ecosystem/bread. Also creates the dev branch for this repo, which didn't exist before — see bread-ecosystem/docs/release-channels.md for the three-track policy. --- .forgejo/workflows/beta-release.yml | 53 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 62 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .forgejo/workflows/beta-release.yml create mode 100644 .forgejo/workflows/dev-release.yml diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml new file mode 100644 index 0000000..3a89036 --- /dev/null +++ b/.forgejo/workflows/beta-release.yml @@ -0,0 +1,53 @@ +name: beta release + +# Publishes a beta-track build when a `beta-v*` tag is pushed — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + 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 && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-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 the other non-stable track, + # 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 + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..fbf6fea --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,62 @@ +name: dev release + +# Publishes a dev-track build on every push to `dev` — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + branches: ['dev'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch dev --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + 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 + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 3b173a67650093966030440f188a88f69fbe351b Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:10:22 +0800 Subject: [PATCH 07/22] ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set From 56251a009c23b4cdddf2c24f3e55da39805ca5ed Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:24:54 +0800 Subject: [PATCH 08/22] ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta workflows run close together on the same self-hosted runner — one job's rm -rf/clone can stomp another's in-progress checkout, causing the regenerate-index step to fail intermittently. Switch to mktemp -d. --- .forgejo/workflows/beta-release.yml | 12 +++++++----- .forgejo/workflows/dev-release.yml | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 3a89036..52cf657 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -46,8 +46,10 @@ jobs: 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 - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + 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 dev 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/dev-release.yml b/.forgejo/workflows/dev-release.yml index fbf6fea..05b652a 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -55,8 +55,10 @@ jobs: 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 - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + 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 dev 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}" From 802e03eecf088065d8f2a4a48d0e6d7a4481e63e Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:57:21 +0800 Subject: [PATCH 09/22] random commit message, read it yourself --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 36f7f5b..8d2803a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ logs/ # Runtime files *.sock *.pid + +# Local hygiene notes (not for commit) +CLAUDE.md From 70bb804883cdcf8e4f5b6fc3ce30e7a10a7c6b1f Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 13:53:13 +0800 Subject: [PATCH 10/22] ci: base dev version on the latest published tag, not Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml can drift stale relative to the actual last release (observed on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped dev version sort as OLDER than what's already installed — bakery's semver check correctly refused those "updates". Deriving the base version from git ls-remote --tags instead is self-healing regardless of Cargo.toml drift, with a Cargo.toml fallback only for a repo with no tags yet. --- .forgejo/workflows/dev-release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 05b652a..1f149e6 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -26,7 +26,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # 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//' | 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)" From 56aecfcbd6b29440935e86a116086bd3946465da Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:37:53 +0800 Subject: [PATCH 11/22] ci: make beta a branch-triggered freeze track, not a one-off tag Beta is now a real stabilization branch: publishes on every push to `beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.+, base version from the latest published tag) instead of a manual beta-v* tag. Fixes made during the freeze land via fix/ branches merged into `beta` directly. The gen-index.sh clone for beta pulls bread-ecosystem's default branch (main) rather than pinning to dev, since beta is the more stable track and main now carries the TRACK-aware script. --- .forgejo/workflows/beta-release.yml | 41 ++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 52cf657..ea7ef7e 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -1,12 +1,12 @@ name: beta release -# Publishes a beta-track build when a `beta-v*` tag is pushed — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See bread-ecosystem's docs/release-channels.md for the three-track policy. on: push: - tags: ['beta-v*'] + branches: ['beta'] jobs: build: @@ -16,16 +16,37 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta 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 beta 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//' | 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))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/breadshot/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadshot" "${PKG_DIR}/breadshot-x86_64" @@ -35,8 +56,8 @@ jobs: cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadshot/latest" - # No GitHub Release upload — beta, like the other non-stable track, - # is only distributed via dl.breadway.dev/beta/. + # 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 }} @@ -50,6 +71,6 @@ jobs: # 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 dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + 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}" From d06d32b4e63c16b1f1431be63ed009e6afc21cbb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:42:19 +0800 Subject: [PATCH 12/22] docs: add CONTRIBUTING.md Documents the dev/beta/main branch and release-track workflow shared across the bread ecosystem. See bread-ecosystem's docs/release-channels.md for the full policy this implements. --- CONTRIBUTING.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..70eba86 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +# 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 + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` 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 report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## 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 | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +cargo build --release +cargo test --release +``` + +## CI + +- `dev-release.yml` — triggered on push to `dev`. +- `beta-release.yml` — triggered on push to `beta`. +- `release.yml` — triggered on a `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. From 773972b7119eda4b32d65da6d2469d3453477b01 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:05:29 +0800 Subject: [PATCH 13/22] =?UTF-8?q?CI:=20single-trunk=20model=20=E2=80=94=20?= =?UTF-8?q?dev=20triggers=20on=20main,=20beta=20becomes=20RC-tag-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev/beta branch split with one trunk (main): dev-track builds still publish on every push, but the beta track now publishes from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained beta branch. Removes the branch nobody reliably kept in sync. --- .forgejo/workflows/dev-release.yml | 15 ++++---- .../{beta-release.yml => rc-release.yml} | 38 +++++-------------- .forgejo/workflows/release.yml | 1 + 3 files changed, 17 insertions(+), 37 deletions(-) rename .forgejo/workflows/{beta-release.yml => rc-release.yml} (57%) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 1f149e6..34698d2 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -1,12 +1,11 @@ name: dev release -# Publishes a dev-track build on every push to `dev` — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# 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: ['dev'] + branches: ['main'] jobs: build: @@ -16,7 +15,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -33,7 +32,7 @@ jobs: # 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//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else @@ -71,6 +70,6 @@ jobs: # 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 dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + 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/beta-release.yml b/.forgejo/workflows/rc-release.yml similarity index 57% rename from .forgejo/workflows/beta-release.yml rename to .forgejo/workflows/rc-release.yml index ea7ef7e..259de81 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -1,52 +1,32 @@ -name: beta release +name: beta (rc) release -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See bread-ecosystem's docs/release-channels.md for the three-track policy. +# 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: - branches: ['beta'] + 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 beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta 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 beta 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//' | 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))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - 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" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 75ae32d..076e46c 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout From 3adcfa316792044c84be9d15f3333fe57a2e947a Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:08:41 +0800 Subject: [PATCH 14/22] CONTRIBUTING.md: document single-trunk + RC-tag release model --- CONTRIBUTING.md | 70 ++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70eba86..b8a86dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,10 @@ workflow as every other ecosystem product. ## Branches -- **`main`** — release branch, always tag-ready. Nothing is committed to it - directly; it only moves forward via a `beta` merge (see below). -- **`dev`** — integration branch. All day-to-day work lands here first. - Every push to `dev` automatically builds and publishes a **dev-track** - build (see Tracks below) — use this to test your change in a real install - before it goes any further. -- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. - Every push to `beta` automatically builds and publishes a **beta-track** - build. While a freeze is active, only fixes for issues found *in that - freeze* should land on `beta`. +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: @@ -25,28 +19,26 @@ feature/ fix/ ``` -Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing -something reported against an active `beta` freeze, branch off `beta` -instead, merge the fix there to unblock testers, and also forward the same -fix into `dev` so it doesn't quietly reappear next cycle. +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 -1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push +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 report or fix anything broken with another - push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut - fresh from `dev`'s current tip. This freezes it as the stabilization - target — `dev` keeps moving independently starting the next cycle. -3. `beta` is open for anyone to test: `bakery track set beta` and - `bakery update --all`. **File issues against anything you find on this - repo's Forgejo issue tracker.** Fixes land via `fix/` branches - merged into `beta`. -4. Once `beta` has gone roughly **a month** without new issues, it's merged - into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the - stable release build. `beta` is then reset from `dev` to start the next - cycle. + `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 @@ -58,14 +50,15 @@ bakery update --all # pull the latest build on your current track | Track | What it is | Published from | |--------|-----------|-----------------| -| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | -| `beta` | Current stabilization freeze | `beta`, on every push | -| `dev` | Bleeding edge | `dev`, on every push | +| `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/beta versions are auto-computed (`X.Y.Z-dev.+` / -`-beta.…`) from the latest published stable tag, so they always sort as -newer than what you have installed — no manual version bumping needed when -pushing to `dev` or `beta`. +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 @@ -76,9 +69,10 @@ cargo test --release ## CI -- `dev-release.yml` — triggered on push to `dev`. -- `beta-release.yml` — triggered on push to `beta`. -- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. +- `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 From 481d460a6ea4e3ae79db77308d44cefbb403c530 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 14:03:18 +0800 Subject: [PATCH 15/22] ci: build against bread-ecosystem's shared Arch CI image, add check.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as breadpad: build inside the shared pinned Arch container (bread-ecosystem/ci/, cloned at the sha in ci/bread-ecosystem.rev) instead of building natively against whatever's on the runner host. Adds check.yml (clippy + test on feature/**/fix/**) as a fast-fail gate before anything reaches main. Verified locally: build, clippy, and test all pass through the new container path — no pre-existing lint/test debt found here. --- .forgejo/workflows/check.yml | 24 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 2 +- .forgejo/workflows/rc-release.yml | 2 +- .forgejo/workflows/release.yml | 2 +- ci/bread-ecosystem.rev | 1 + ci/build.sh | 20 ++++++++++++++++++++ 6 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/check.yml create mode 100644 ci/bread-ecosystem.rev create mode 100755 ci/build.sh diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..b547c34 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -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 diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 34698d2..022c233 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -19,7 +19,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: compute dev version run: | diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml index 259de81..bad1b67 100644 --- a/.forgejo/workflows/rc-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -21,7 +21,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 076e46c..9e72233 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..474f1fd --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..e4424e0 --- /dev/null +++ b/ci/build.sh @@ -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" "$@" From 84539f2fcd05ac12980902cfa84833855a76a0ed Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:36:05 +0800 Subject: [PATCH 16/22] Pin bread-utils to v0.7.1; document BOS screenshot binds bread-utils moves from bread-ecosystem tag v0.3.0 to v0.7.1. README states breadshot is the grim/slurp/wl-copy orchestrator BOS binds Super+Shift+S/C/P to (not grimblast), not a GUI editor, and not bread-screenshots. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- README.md | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1e5eb6..c1bb34d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,8 +84,8 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bread-utils" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs", "serde", diff --git a/Cargo.toml b/Cargo.toml index a8e59a4..a747e4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ 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.3.0" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } [profile.release] lto = "thin" diff --git a/README.md b/README.md index 89cbf0f..cf01f85 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ # 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 From 5d1b8918d30c82696b4ddc555a48377f4551c335 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 17/22] Track AGENTS.md --- .gitignore | 1 - AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 8d2803a..0a33787 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,3 @@ logs/ *.pid # Local hygiene notes (not for commit) -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c8c782d --- /dev/null +++ b/AGENTS.md @@ -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. From c68214a6ab92d1e4144cebcbe0a22352d42645b7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:15:03 +0800 Subject: [PATCH 18/22] Wire breadshot into the bread event bus (app id shot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit bread.shot.captured after a successful grim/wl-copy capture. Fail-silent when breadd is down. No command verbs — breadshot is a one-shot CLI; Lua workflows should bread.exec("breadshot …"). --- Cargo.lock | 12 ++++++++ Cargo.toml | 2 +- EVENTS.md | 66 +++++++++++++++++++++++++++++++++++++++++ src/capture.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 EVENTS.md diff --git a/Cargo.lock b/Cargo.lock index c1bb34d..2f2acd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,11 +82,23 @@ 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.3.1" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ + "bread-shared", "dirs", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a747e4d..5d7286c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ 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.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..abc2eb8 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,66 @@ +# breadshot — bread event integration + +breadshot is a standalone, one-shot Wayland screenshot orchestrator: it +works exactly the same with or without `breadd` running. When breadd *is* +present, a successful capture publishes one event 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`). breadshot is a short-lived CLI, not a daemon — +each `emit` is its own fire-and-forget connection (the same stance +`bread-emit` takes for occasional callers). There is no process to hold a +command 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). Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | + +`mode` is the CLI mode name (same strings `breadshot ` accepts). +`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). + +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.*`) + +None. breadshot is a one-shot CLI with no persistent process to subscribe +to `bread.command.shot.*`. A Lua workflow that wants a screenshot should +shell out: + +```lua +bread.exec("breadshot region") +-- or +bread.exec("breadshot region --clipboard-only") +bread.exec("breadshot active-output") +``` + +The outcome of that exec is the same `bread.shot.captured` event the +keybind path already publishes — `bread.wait("bread.shot.captured")` +inside a spawned coroutine if the workflow needs to react to the file. + +There is no `pin`, `select`, `edit`, or other command verb. breadshot has +no editor, no history, and no concept those verbs could hang on. +`bread.exec("breadshot …")` is the whole command surface. If/when a +long-running piece exists, verbs should be added then, not stubbed as +no-ops 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) — breadshot's + actual grim/slurp/wl-copy path is entirely unaffected either way. +- There is no command subscription, so a breadd restart cannot drop one. + The next `breadshot` invocation emits (or silently doesn't) on its own + short-lived connection. diff --git a/src/capture.rs b/src/capture.rs index 349dde9..dd35f2f 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -10,6 +10,10 @@ use std::{ use crate::config::Config; +/// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as +/// `bread.shot.*`. See `EVENTS.md`. +const APP_ID: &str = "shot"; + #[derive(Debug, Clone, ValueEnum)] pub enum Mode { /// Select a region interactively @@ -26,6 +30,19 @@ pub enum Mode { 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, @@ -69,6 +86,18 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { save_and_copy(&geometry, &save_path)?; } + // Both capture paths copy the PNG to the clipboard. `path` is null + // when the user asked for clipboard-only (no file on disk). + emit_captured( + mode, + true, + if clipboard_only { + None + } else { + Some(save_path.as_path()) + }, + ); + if !silent { let msg = if clipboard_only { "Copied to clipboard".to_string() @@ -289,6 +318,26 @@ 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"]); @@ -384,3 +433,34 @@ 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"); + } +} From 365072d65eb525fb2a75f37e93bcea158df699b4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:49:51 +0800 Subject: [PATCH 19/22] Honor bread.command.shot.region via breadshot listen Pin bread-utils to bread-ecosystem v0.7.2. breadshot listen subscribes to bread.command.shot.**, runs the same region capture as the CLI (clipboard-only), and emits bread.shot.region.done / .failed. --- Cargo.lock | 4 +- Cargo.toml | 2 +- EVENTS.md | 77 +++++++++++++++++++++------------- README.md | 6 +++ src/capture.rs | 15 +++---- src/listen.rs | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 65 +++++++++++++++++++++++------ 7 files changed, 227 insertions(+), 52 deletions(-) create mode 100644 src/listen.rs diff --git a/Cargo.lock b/Cargo.lock index 2f2acd5..5890128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,8 +95,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 5d7286c..fbba4d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ 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.1", features = ["bread-client"] } +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 index abc2eb8..5b7ed86 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -1,33 +1,37 @@ # breadshot — bread event integration -breadshot is a standalone, one-shot Wayland screenshot orchestrator: it -works exactly the same with or without `breadd` running. When breadd *is* -present, a successful capture publishes one event 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. +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`). breadshot is a short-lived CLI, not a daemon — -each `emit` is its own fire-and-forget connection (the same stance -`bread-emit` takes for occasional callers). There is no process to hold a -command subscription open. +(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). Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | +| `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`. Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | +| `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). | `mode` is the CLI mode name (same strings `breadshot ` accepts). `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). +disk). 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 @@ -35,32 +39,47 @@ screenshot. ## Commands honored (`bread.command.shot.*`) -None. breadshot is a one-shot CLI with no persistent process to subscribe -to `bread.command.shot.*`. A Lua workflow that wants a screenshot should -shell out: +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. | + +```lua +bread.spawn(function() + bread.emit("bread.command.shot.region") + bread.wait("bread.shot.region.done", { timeout = 30000 }) +end) +``` + +A workflow that wants a file on disk (not just the clipboard) should +still shell out: ```lua bread.exec("breadshot region") --- or -bread.exec("breadshot region --clipboard-only") bread.exec("breadshot active-output") ``` -The outcome of that exec is the same `bread.shot.captured` event the -keybind path already publishes — `bread.wait("bread.shot.captured")` -inside a spawned coroutine if the workflow needs to react to the file. +### Not implemented: extra verbs -There is no `pin`, `select`, `edit`, or other command verb. breadshot has -no editor, no history, and no concept those verbs could hang on. -`bread.exec("breadshot …")` is the whole command surface. If/when a -long-running piece exists, verbs should be added then, not stubbed as -no-ops ahead of it. +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, and breadshot has no editor, no +history, and no concept those other verbs could hang on. If/when that +changes, the corresponding `bread.command.shot.*` verb should be added +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) — breadshot's + (`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. -- There is no command subscription, so a breadd restart cannot drop one. - The next `breadshot` invocation emits (or silently doesn't) on its own - short-lived connection. +- 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 cf01f85..1fdefc1 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,14 @@ make install PREFIX=/usr ``` breadshot [options] +breadshot listen ``` +`breadshot listen` is the long-running process that honors +`bread.command.shot.region` on the bread event bus (clipboard-only +region capture). See [EVENTS.md](EVENTS.md). Without it, the CLI still +works; bus commands are a silent no-op. + ### Modes | Mode | Description | diff --git a/src/capture.rs b/src/capture.rs index dd35f2f..73729b4 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -1,5 +1,4 @@ use anyhow::{bail, Context, Result}; -use clap::ValueEnum; use serde_json::Value; use std::{ io::Write, @@ -12,9 +11,9 @@ use crate::config::Config; /// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as /// `bread.shot.*`. See `EVENTS.md`. -const APP_ID: &str = "shot"; +pub(crate) const APP_ID: &str = "shot"; -#[derive(Debug, Clone, ValueEnum)] +#[derive(Debug, Clone)] pub enum Mode { /// Select a region interactively Region, @@ -23,10 +22,8 @@ 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, } @@ -353,8 +350,12 @@ fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) { 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}")) + bread_utils::proc::run_json( + "hyprctl", + &["-j", subcmd], + std::time::Duration::from_secs(3), + ) + .with_context(|| format!("running/parsing hyprctl {subcmd}")) } fn slurp(args: &[&str]) -> Result { diff --git a/src/listen.rs b/src/listen.rs new file mode 100644 index 0000000..6b31c6e --- /dev/null +++ b/src/listen.rs @@ -0,0 +1,110 @@ +//! 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, 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` is honored today — +/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. +/// +/// Emits `bread.shot.region.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), + 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, + 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", region_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 region_failed_payload(error: &impl ToString) -> serde_json::Value { + serde_json::json!({ "error": error.to_string() }) +} + +#[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.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 region_failed_payload_includes_error() { + let v = region_failed_payload(&"selection cancelled"); + assert_eq!(v["error"], "selection cancelled"); + } +} diff --git a/src/main.rs b/src/main.rs index 8e79de6..9a474d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ mod capture; mod config; +mod listen; use anyhow::Result; -use clap::Parser; +use clap::{Args, Parser, Subcommand}; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -14,12 +15,19 @@ use config::Config; name = "breadshot", version, about = "Screenshot utility for the bread ecosystem", - disable_help_subcommand = true, + disable_help_subcommand = true )] struct Cli { - /// Capture mode - mode: Mode, + #[command(subcommand)] + command: Command, + /// 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, @@ -39,10 +47,37 @@ struct Cli { /// Override output filename (without path) #[arg(long, short = 'f', value_name = "NAME")] filename: Option, +} - /// Path to config file - #[arg(long, value_name = "FILE")] - config: 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), + /// Subscribe to bread.command.shot.** and honor region 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::Listen => None, + } + } } fn main() -> Result<()> { @@ -58,15 +93,19 @@ fn main() -> Result<()> { None => Config::load()?, }; + let Some((mode, opts)) = cli.command.into_capture() else { + return listen::run(&config); + }; + capture::run( - &cli.mode, + &mode, &config, Overrides { - clipboard_only: cli.clipboard_only, - silent: cli.silent, - freeze: cli.freeze, - output_dir: cli.output_dir, - filename: cli.filename, + clipboard_only: opts.clipboard_only, + silent: opts.silent, + freeze: opts.freeze, + output_dir: opts.output_dir, + filename: opts.filename, }, ) } From ed371964c226ac71bd7452fda203d326f2452d34 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:05:47 +0800 Subject: [PATCH 20/22] Bump version to v0.1.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5890128..6cf4d70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,7 +106,7 @@ dependencies = [ [[package]] name = "breadshot" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "bread-utils", diff --git a/Cargo.toml b/Cargo.toml index fbba4d4..e156f4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadshot" -version = "0.1.1" +version = "0.1.2" edition = "2021" license = "MIT" authors = ["Breadway"] From b89e349342016e46bb88ae243eda2ed3c12aca44 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:55:36 +0800 Subject: [PATCH 21/22] Add freeze-frame annotate via optional satty/swappy After grim+slurp, satty (preferred) or swappy freezes the captured frame for arrows/text/rect. New `breadshot annotate` / --annotate and bread.command.shot.annotate. Missing tools warn and fall back to the existing capture path. listen still honors region. --- EVENTS.md | 27 ++++-- README.md | 24 ++++- bakery.toml | 2 +- src/capture.rs | 255 +++++++++++++++++++++++++++++++++++++++++++------ src/config.rs | 11 ++- src/listen.rs | 80 ++++++++++++++-- src/main.rs | 16 +++- 7 files changed, 359 insertions(+), 56 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 5b7ed86..a74da00 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -22,16 +22,20 @@ received while `breadshot listen` is running — that process holds the | 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`. Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | +| `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 mode name (same strings `breadshot ` accepts). +`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). The listen-triggered region path is clipboard-only, so `path` is -always `null` on `bread.shot.region.done`. +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 @@ -46,12 +50,18 @@ 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 @@ -60,16 +70,17 @@ 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, and breadshot has no editor, no -history, and no concept those other verbs could hang on. If/when that -changes, the corresponding `bread.command.shot.*` verb should be added -at the same time, not stubbed as a no-op ahead of it. +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 diff --git a/README.md b/README.md index 1fdefc1..77a7575 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ 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 @@ -47,13 +49,15 @@ make install PREFIX=/usr ``` breadshot [options] +breadshot annotate [options] breadshot listen ``` `breadshot listen` is the long-running process that honors -`bread.command.shot.region` on the bread event bus (clipboard-only -region capture). See [EVENTS.md](EVENTS.md). Without it, the CLI still -works; bus commands are a silent no-op. +`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 @@ -64,6 +68,7 @@ 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 @@ -72,10 +77,15 @@ 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 @@ -87,6 +97,11 @@ 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 @@ -105,6 +120,9 @@ 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 616d788..abcd841 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"] +optional_system_deps = ["hyprland", "hyprpicker", "libnotify", "satty", "swappy"] bread_deps = [] [config] diff --git a/src/capture.rs b/src/capture.rs index 73729b4..1422359 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -44,11 +44,35 @@ pub struct Overrides { pub clipboard_only: bool, pub silent: bool, pub freeze: bool, + pub annotate: bool, pub output_dir: Option, pub filename: Option, } -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, +} + +#[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 { check_deps()?; let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir); @@ -63,39 +87,61 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { let silent = overrides.silent || config.silent; let freeze = overrides.freeze || config.freeze; let clipboard_only = overrides.clipboard_only; + let annotator = resolve_annotator(overrides.annotate || config.annotate); - let _freeze_guard = if freeze { - FreezeGuard::try_spawn() - .map_err(|e| tracing::warn!("freeze: {e}")) - .ok() - } else { - None + // Capture under the optional hyprpicker freeze, then drop it before + // the annotator window appears so it can take the frozen frame. + let captured_png = { + let _freeze_guard = if freeze { + FreezeGuard::try_spawn() + .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)?; - tracing::debug!("geometry: {geometry}"); - - if clipboard_only { - copy_only(&geometry)?; - } else { - std::fs::create_dir_all(save_dir) - .with_context(|| format!("creating {}", save_dir.display()))?; - save_and_copy(&geometry, &save_path)?; + if let (Some(tool), Some(png)) = (annotator, captured_png) { + if !clipboard_only { + std::fs::create_dir_all(save_dir) + .with_context(|| format!("creating {}", save_dir.display()))?; + } + run_annotator( + tool, + &png, + (!clipboard_only).then_some(save_path.as_path()), + silent, + )?; } // Both capture paths copy the PNG to the clipboard. `path` is null - // when the user asked for clipboard-only (no file on disk). - emit_captured( - mode, - true, - if clipboard_only { - None - } else { - Some(save_path.as_path()) - }, - ); + // 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()) + }; - if !silent { + emit_captured(mode, true, path); + + // The annotator owns its own copy/save notifications. + if !silent && annotator.is_none() { let msg = if clipboard_only { "Copied to clipboard".to_string() } else { @@ -104,7 +150,10 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { send_notification("Screenshot", &msg, config.notif_timeout, &save_path); } - Ok(()) + Ok(CaptureOutcome { + clipboard: true, + path: path.map(Path::to_path_buf), + }) } // --- geometry --- @@ -263,6 +312,103 @@ fn trim_geometry(geometry: &str) -> 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, "-"]) @@ -464,4 +610,57 @@ mod tests { 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 eb89f47..5cbb6cf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,8 @@ 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, } @@ -20,6 +22,7 @@ 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(), } @@ -36,10 +39,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()))?; + 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) } diff --git a/src/listen.rs b/src/listen.rs index 6b31c6e..d4ed7aa 100644 --- a/src/listen.rs +++ b/src/listen.rs @@ -6,7 +6,7 @@ use anyhow::Result; use bread_utils::bread_client::{BreadClient, BreadEvent}; -use crate::capture::{self, Mode, Overrides, APP_ID}; +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. @@ -30,17 +30,19 @@ pub fn run(config: &Config) -> Result<()> { } } -/// Reacts to `bread.command.shot.*` verbs. Only `region` is honored today — -/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. +/// 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.region.done` / `.failed` per the confirmation convention -/// in bread's Documentation.md. +/// 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}'"); } @@ -57,16 +59,43 @@ fn handle_region(config: &Config) { 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()), + 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", region_failed_payload(&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)); } } } @@ -79,10 +108,17 @@ fn region_done_payload() -> serde_json::Value { serde_json::json!({ "clipboard": true, "path": serde_json::Value::Null }) } -fn region_failed_payload(error: &impl ToString) -> serde_json::Value { +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::*; @@ -90,6 +126,10 @@ mod tests { #[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); @@ -103,8 +143,28 @@ mod tests { } #[test] - fn region_failed_payload_includes_error() { - let v = region_failed_payload(&"selection cancelled"); + 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 9a474d2..d80cb4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,10 @@ 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, @@ -63,7 +67,9 @@ enum Command { /// Capture the active monitor #[command(name = "active-output")] ActiveOutput(CaptureOpts), - /// Subscribe to bread.command.shot.** and honor region captures + /// Capture a region, freeze the frame, and annotate + Annotate(CaptureOpts), + /// Subscribe to bread.command.shot.** and honor region/annotate captures Listen, } @@ -75,6 +81,10 @@ impl Command { 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, } } @@ -104,8 +114,10 @@ fn main() -> Result<()> { clipboard_only: opts.clipboard_only, silent: opts.silent, freeze: opts.freeze, + annotate: opts.annotate, output_dir: opts.output_dir, filename: opts.filename, }, - ) + )?; + Ok(()) } From 9137b4d7c2089ba6775b2b501c7730ae54f2644f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:50:22 +0800 Subject: [PATCH 22/22] CI: refuse unsigned bakery index on stable tag releases --- .forgejo/workflows/release.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 9e72233..f8ef6b9 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && bash ci/build.sh cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -33,8 +42,14 @@ jobs: 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