Compare commits

...

3 commits

Author SHA1 Message Date
Breadway
1b02cec0a6 Emit bread.box.launched after a successful app launch
All checks were successful
check / check (push) Successful in 16s
dev release / build (push) Successful in 33s
Link bread-utils bread-client (tag v0.7.1) from the breadbox crate.
Fire-and-forget via BreadClient; silent if breadd is down. No command
verbs — breadbox is not a daemon. EVENTS.md is the contract (app id box).
2026-08-15 22:14:42 +08:00
Breadway
e5ce91a9c9 Track AGENTS.md
All checks were successful
check / check (push) Successful in 55s
2026-08-15 22:03:30 +08:00
Breadway
e5ba33d810 Pin ecosystem crates and switch to bread-utils::singleton
All checks were successful
check / check (push) Successful in 18s
Repoint bread-theme and bread-utils at git.breadway.dev tag v0.7.1
(no github.com). bread-screenshots is not on that tag yet, so rev-pin
it instead of tracking branch=main.

Replace the homegrown PID-file toggle with bread-utils::singleton so
the launcher still dismisses on a second keybind without the TOCTOU
window. Screenshot mode still skips the lock.

README now documents the fixed BOS dark fallback (not Catppuccin
Mocha). Drop the unused GitHub Actions release workflow; Forgejo is
canonical. CONTRIBUTING already documents single-trunk.
2026-08-15 21:40:03 +08:00
10 changed files with 146 additions and 124 deletions

1
.github/README.md vendored Normal file
View file

@ -0,0 +1 @@
Forgejo (`.forgejo/workflows`) is the canonical CI. This `.github` tree is unused.

View file

@ -1,67 +0,0 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
DL_DIR: /srv/breadway-dl
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- uses: actions/checkout@v4
- name: install build deps
run: sudo apt-get install -y libgtk-4-dev librsvg2-dev libdbus-1-dev pkg-config 2>/dev/null || true
- name: build
run: cargo build --release --locked
- name: prepare artifacts
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/breadbox/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadbox breadbox-sync; do
cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp packaging/breadbox-sync.service "${PKG_DIR}/"
cp config.example.toml "${PKG_DIR}/"
cp bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "${DL_DIR}/breadbox/latest"
- name: ensure bread-ecosystem
run: |
if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then
git -C "${ECOSYSTEM_DIR}" pull --ff-only
else
mkdir -p "$(dirname "${ECOSYSTEM_DIR}")"
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
fi
- 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}/breadbox/${VERSION}"
gh release create "${GITHUB_REF_NAME}" \
--title "breadbox v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" \
"${PKG_DIR}/breadbox-x86_64" \
"${PKG_DIR}/breadbox-sync-x86_64" \
"${PKG_DIR}/breadbox-x86_64.sha256" \
"${PKG_DIR}/breadbox-sync-x86_64.sha256" \
--clobber

1
.gitignore vendored
View file

@ -21,4 +21,3 @@ Thumbs.db
.claude/
# Local hygiene notes (not for commit)
CLAUDE.md

13
AGENTS.md Normal file
View file

@ -0,0 +1,13 @@
# AGENTS.md — Repo hygiene
Follow [`CONTRIBUTING.md`](CONTRIBUTING.md). Single-trunk: `main` plus short-lived `feature/` / `fix/` branches. `dev`/`beta` are bakery tracks (tags / main), not git branches.
## Remotes
- `origin` — Forgejo (`git.breadway.dev`) — authoritative.
- `github` — mirror. Day-to-day push `origin` only.
## Product
GTK4 app launcher + `breadbox-sync` icon cache. Theme via `bread-theme` (pin by tag on `git.breadway.dev`). Toggle uses `bread-utils::singleton`, not a homegrown PID file. `EVENTS.md` is the bread-event contract (app id `box`); emit `bread.box.launched` after a successful launch. No command verbs.
## Distribution
Bakery (`bakery.toml`). Forgejo `.forgejo/workflows/` is canonical; do not re-add a GitHub Actions release workflow.

35
Cargo.lock generated
View file

@ -85,17 +85,28 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bread-screenshots"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d"
dependencies = [
"anyhow",
"bread-utils",
"bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d)",
"tracing",
]
[[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 0.8.23",
]
[[package]]
name = "bread-theme"
version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
dependencies = [
"dirs",
"gtk4",
@ -106,7 +117,18 @@ dependencies = [
[[package]]
name = "bread-utils"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
dependencies = [
"bread-shared",
"dirs",
"serde",
"serde_json",
]
[[package]]
name = "bread-utils"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d"
dependencies = [
"dirs",
"serde",
@ -120,6 +142,7 @@ dependencies = [
"anyhow",
"bread-screenshots",
"bread-theme",
"bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1)",
"breadbox-shared",
"clap",
"gtk4",
@ -480,7 +503,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]

41
EVENTS.md Normal file
View file

@ -0,0 +1,41 @@
# breadbox — bread event integration
breadbox is a standalone app launcher: it works exactly the same with or
without `breadd` running. When breadd *is* present, the GTK launcher
publishes a single event into the shared bread automation fabric after a
successful launch. See the parent `bread` repo's `Documentation.md`
specifically its "Namespaces" and "Integrating a bread\* app" sections —
for the general convention this follows.
App id: **`box`**. Transport: `bread-utils`'s `bread_client` module
(feature `bread-client`) — `breadbox` links it directly. breadbox is a
short-lived process (it exits when the launcher closes), so each `emit`
is its own short-lived connection. There is no long-running daemon and
therefore no command subscription.
## Events published (`bread.box.*`)
| Event | Data | When |
|-------|------|------|
| `bread.box.launched` | `{ "id": "<desktop id or exec>", "name": "<display name>" }` | The user launched an app (Enter / keypad Enter on the selected row, or activating a row) **and** the spawn succeeded. Not emitted if `Command::spawn` fails (missing terminal, `exec` that cannot start). `id` is the desktop-file id (the `.desktop` filename, e.g. `firefox.desktop`), falling back to the stripped `Exec=` line when that id is empty. `name` is the desktop-entry display name. |
Launch history is local to breadbox (`~/.cache/breadbox/history.json`);
the event bus is a notification that a launch happened, not a channel
for the exec line's arguments or the resulting process.
## Commands honored (`bread.command.box.*`)
None. breadbox is not a daemon — it is not running (and not subscribed)
except while the launcher overlay is open. There is no existing
"launch this desktop id from the bus" product surface, and inventing
`bread.command.box.launch` (or similar) without that surface would be a
stub. If/when breadbox grows a long-running piece that can honor a
verb, the corresponding `bread.command.box.*` command should be added
at the same time, not stubbed out 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) — launching,
history, theming, and the singleton toggle are entirely unaffected.
- Closing the launcher without launching anything emits nothing.

View file

@ -14,7 +14,7 @@ breadbox GTK4 layer-shell launcher
- Reads the active Hyprland workspace and sorts apps by context priority
- Fuzzy filtering as you type; Enter launches, Escape closes
- App icons loaded from the resolved icon cache (see `breadbox-sync`)
- pywal palette auto-detected from `~/.cache/wal/colors.json`, falls back to Catppuccin Mocha
- pywal accents from `~/.cache/wal/colors.json`; background/surface/overlay/foreground stay fixed BOS dark
- User CSS override at `~/.config/breadbox/style.css`
- Toggle/dismiss: running a second instance kills the first

View file

@ -54,6 +54,9 @@ pub fn app_dirs() -> Vec<PathBuf> {
#[derive(Debug, Clone)]
pub struct DesktopEntry {
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
/// Empty only if the path had no file name; callers fall back to `exec`.
pub id: String,
pub name: String,
pub exec: String,
pub icon_name: String,
@ -155,7 +158,14 @@ pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
.map(|s| s.to_string())
.collect();
let id = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.unwrap_or_default();
Some(DesktopEntry {
id,
name,
exec,
icon_name,

View file

@ -9,9 +9,12 @@ name = "breadbox"
path = "src/main.rs"
[dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" }
# Not on tag v0.7.1 (crate landed after that tag); rev-pin so this is not
# `branch = "main"`.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" }
breadbox-shared = { path = "../breadbox-shared" }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"

View file

@ -1,4 +1,5 @@
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
use bread_utils::bread_client::BreadClient;
use std::{
cell::RefCell,
collections::HashMap,
@ -11,6 +12,10 @@ use std::{
rc::Rc,
};
/// This app's id in bread's sibling-app namespace registry
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.box.*`.
const APP_ID: &str = "box";
use breadbox_shared::{
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory,
};
@ -206,24 +211,42 @@ fn pick_terminal() -> String {
fn do_launch(entry: &DesktopEntry) {
let cmd = entry.exec.trim();
if entry.terminal {
let spawned = if entry.terminal {
let term = pick_terminal();
let _ = Command::new(&term)
Command::new(&term)
.args(["-e", "bash", "-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
.spawn()
} else {
let _ = Command::new("bash")
Command::new("bash")
.args(["-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
.spawn()
};
if spawned.is_ok() {
emit_launched(entry);
}
}
/// Publishes `bread.box.launched` after a successful spawn. Fire-and-forget
/// and non-fatal (`BreadClient::emit` never blocks or errors this caller) —
/// breadd being absent must never affect launching itself.
fn emit_launched(entry: &DesktopEntry) {
let id = if entry.id.is_empty() {
entry.exec.as_str()
} else {
entry.id.as_str()
};
BreadClient::connect(APP_ID).emit(
"bread.box.launched",
serde_json::json!({ "id": id, "name": entry.name }),
);
}
// ---- Fuzzy matching ---------------------------------------------------------
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
@ -254,40 +277,6 @@ fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
4 // subsequence match
}
// ---- PID file toggle --------------------------------------------------------
fn pid_file() -> PathBuf {
env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("breadbox.pid")
}
fn is_breadbox_pid(pid: u32) -> bool {
fs::read_to_string(format!("/proc/{}/comm", pid))
.map(|s| s.trim() == "breadbox")
.unwrap_or(false)
}
// Returns false if an existing instance was killed (caller should exit).
fn toggle_or_continue() -> bool {
let pf = pid_file();
if let Ok(content) = fs::read_to_string(&pf) {
if let Ok(pid) = content.trim().parse::<u32>() {
if is_breadbox_pid(pid) {
let _ = Command::new("kill").arg(pid.to_string()).status();
return false;
}
}
}
let _ = fs::write(&pf, std::process::id().to_string());
true
}
fn cleanup_pid() {
let _ = fs::remove_file(pid_file());
}
// ---- UI ---------------------------------------------------------------------
fn get_row_entry(row: &gtk4::ListBoxRow) -> Option<DesktopEntry> {
@ -344,7 +333,6 @@ fn run_ui(
let close_all: Rc<dyn Fn()> = Rc::new({
let w = window.clone();
move || {
cleanup_pid();
w.close();
}
});
@ -551,8 +539,6 @@ fn run_ui(
});
window.add_controller(outside_click);
window.connect_destroy(|_| cleanup_pid());
if let Some(req) = screenshot_req.clone() {
screenshot::dispatch(&window, req);
}
@ -578,14 +564,27 @@ fn main() {
let cli = screenshot::Cli::parse();
let screenshot_req = cli.screenshot_request();
// The PID-file toggle kills whatever's holding the file — a real,
// already-running breadbox instance included. A screenshot run must
// `toggle_or_kill` kills whatever's holding the single-instance lock —
// a real, already-running breadbox included. A screenshot run must
// never touch it: it's a separate, disposable instance by design (same
// reasoning as breadbar's `allow_multiple_instances`), not a toggle of
// the operator's real launcher.
if screenshot_req.is_none() && !toggle_or_continue() {
return;
}
//
// Kept alive for the rest of `main` — dropping it releases the
// single-instance lock and removes the pid file, which happens
// naturally once `run_ui` returns (after the window closes).
let _singleton_guard = if screenshot_req.is_some() {
None
} else {
match bread_utils::singleton::toggle_or_kill("breadbox") {
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
Err(e) => {
eprintln!("breadbox: single-instance lock unavailable ({e}); continuing without it");
None
}
}
};
let config = Config::load();
let workspace = get_active_workspace().unwrap_or_default();