Honor bread.command.box.open via breadbox listen
All checks were successful
dev release / build (push) Successful in 40s
All checks were successful
dev release / build (push) Successful in 40s
This commit is contained in:
parent
fbd73c1984
commit
4d0c0288b7
4 changed files with 127 additions and 13 deletions
|
|
@ -7,7 +7,7 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md). Single-trunk: `main` plus short-liv
|
||||||
- `github` — mirror. Day-to-day push `origin` only.
|
- `github` — mirror. Day-to-day push `origin` only.
|
||||||
|
|
||||||
## Product
|
## 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.
|
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. `breadbox listen` honors `bread.command.box.open`.
|
||||||
|
|
||||||
## Distribution
|
## Distribution
|
||||||
Bakery (`bakery.toml`). Forgejo `.forgejo/workflows/` is canonical; do not re-add a GitHub Actions release workflow.
|
Bakery (`bakery.toml`). Forgejo `.forgejo/workflows/` is canonical; do not re-add a GitHub Actions release workflow.
|
||||||
|
|
|
||||||
48
EVENTS.md
48
EVENTS.md
|
|
@ -8,16 +8,19 @@ specifically its "Namespaces" and "Integrating a bread\* app" sections —
|
||||||
for the general convention this follows.
|
for the general convention this follows.
|
||||||
|
|
||||||
App id: **`box`**. Transport: `bread-utils`'s `bread_client` module
|
App id: **`box`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
(feature `bread-client`) — `breadbox` links it directly. breadbox is a
|
(feature `bread-client`) — `breadbox` links it directly. One-shot
|
||||||
short-lived process (it exits when the launcher closes), so each `emit`
|
launcher invocations each `emit` on their own fire-and-forget
|
||||||
is its own short-lived connection. There is no long-running daemon and
|
connection. Command verbs are only received while `breadbox listen` is
|
||||||
therefore no command subscription.
|
running — that process holds the `bread.command.box.**` subscription
|
||||||
|
open.
|
||||||
|
|
||||||
## Events published (`bread.box.*`)
|
## Events published (`bread.box.*`)
|
||||||
|
|
||||||
| Event | Data | When |
|
| 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. |
|
| `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. |
|
||||||
|
| `bread.box.open.done` | `{}` | `bread.command.box.open` was received and `breadbox` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same toggle as a keybind. |
|
||||||
|
| `bread.box.open.failed` | `{ "error": "<message>" }` | `bread.command.box.open` was received but this binary could not be started. |
|
||||||
|
|
||||||
Launch history is local to breadbox (`~/.cache/breadbox/history.json`);
|
Launch history is local to breadbox (`~/.cache/breadbox/history.json`);
|
||||||
the event bus is a notification that a launch happened, not a channel
|
the event bus is a notification that a launch happened, not a channel
|
||||||
|
|
@ -25,17 +28,38 @@ for the exec line's arguments or the resulting process.
|
||||||
|
|
||||||
## Commands honored (`bread.command.box.*`)
|
## Commands honored (`bread.command.box.*`)
|
||||||
|
|
||||||
None. breadbox is not a daemon — it is not running (and not subscribed)
|
These are only received while `breadbox listen` is running. Publishing a
|
||||||
except while the launcher overlay is open. There is no existing
|
command with no subscriber is a silent no-op — that is the documented
|
||||||
"launch this desktop id from the bus" product surface, and inventing
|
bread convention, not a breadbox bug.
|
||||||
`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 | Data | Effect |
|
||||||
verb, the corresponding `bread.command.box.*` command should be added
|
|------|------|--------|
|
||||||
at the same time, not stubbed out ahead of it.
|
| `open` | none | Same as running `breadbox` (toggle the launcher overlay via the existing singleton). Emits `bread.box.open.done` / `.failed`. |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.spawn(function()
|
||||||
|
bread.emit("bread.command.box.open")
|
||||||
|
bread.wait("bread.box.open.done", { timeout = 5000 })
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
|
There is no `launch` / `close` / `query` command verb. Picking a desktop
|
||||||
|
id from the bus would be a new product surface. If/when that exists, add
|
||||||
|
the corresponding `bread.command.box.*` verb at the same time, not
|
||||||
|
stubbed as a no-op ahead of it.
|
||||||
|
|
||||||
## Fail-safe behavior
|
## Fail-safe behavior
|
||||||
|
|
||||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
(`BreadClient::emit` never blocks or errors the caller) — launching,
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
command subscription simply never receives anything — launching,
|
||||||
history, theming, and the singleton toggle are entirely unaffected.
|
history, theming, and the singleton toggle are entirely unaffected.
|
||||||
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
|
(`BreadClient::subscribe`'s background thread has its own backoff
|
||||||
|
loop); no restart of `breadbox listen` is needed.
|
||||||
|
- If `breadbox 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.box.launched` on their own short-lived connection.
|
||||||
- Closing the launcher without launching anything emits nothing.
|
- Closing the launcher without launching anything emits nothing.
|
||||||
|
|
|
||||||
84
breadbox/src/listen.rs
Normal file
84
breadbox/src/listen.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
//! Long-running command subscription for `bread.command.box.*`.
|
||||||
|
//!
|
||||||
|
//! `breadbox` is still a one-shot toggle overlay by default. `breadbox listen`
|
||||||
|
//! is the optional persistent process that can honor bus commands. See
|
||||||
|
//! `EVENTS.md`.
|
||||||
|
|
||||||
|
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||||
|
|
||||||
|
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`.
|
||||||
|
const APP_ID: &str = "box";
|
||||||
|
|
||||||
|
/// Subscribe to `bread.command.box.**` 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() {
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
if client.health().is_none() {
|
||||||
|
eprintln!("breadbox: breadd unreachable; command subscription will connect when it comes back");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _commands = client.subscribe("bread.command.box.**", |event| {
|
||||||
|
handle_command(&event);
|
||||||
|
});
|
||||||
|
|
||||||
|
eprintln!("breadbox: listening for bread.command.box.**");
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to `bread.command.box.*` verbs. Only `open` is honored today —
|
||||||
|
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||||
|
fn handle_command(event: &BreadEvent) {
|
||||||
|
let Some(verb) = command_verb(&event.event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match verb {
|
||||||
|
"open" => handle_open(),
|
||||||
|
other => {
|
||||||
|
eprintln!("breadbox: ignoring unrecognized bread.command.box.{other}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_open() {
|
||||||
|
// Same as running `breadbox` from a keybind: toggle the overlay via the
|
||||||
|
// existing singleton. Spawn success is the command confirmation — we do
|
||||||
|
// not wait for the GTK window to map.
|
||||||
|
let result = spawn_self();
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
match result {
|
||||||
|
Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("breadbox: bread.command.box.open failed: {e}");
|
||||||
|
client.emit(
|
||||||
|
"bread.box.open.failed",
|
||||||
|
serde_json::json!({ "error": e.to_string() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_self() -> std::io::Result<std::process::Child> {
|
||||||
|
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadbox"));
|
||||||
|
std::process::Command::new(exe).spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_verb(event_name: &str) -> Option<&str> {
|
||||||
|
event_name.strip_prefix("bread.command.box.")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_verb_strips_box_prefix() {
|
||||||
|
assert_eq!(command_verb("bread.command.box.open"), Some("open"));
|
||||||
|
assert_eq!(command_verb("bread.command.box.launch"), Some("launch"));
|
||||||
|
assert_eq!(command_verb("bread.command.clip.clear"), None);
|
||||||
|
assert_eq!(command_verb("bread.box.launched"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ use gtk4::{
|
||||||
};
|
};
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
|
|
||||||
|
mod listen;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
|
|
||||||
// ---- Hyprland IPC -----------------------------------------------------------
|
// ---- Hyprland IPC -----------------------------------------------------------
|
||||||
|
|
@ -560,6 +561,11 @@ fn run_ui(
|
||||||
// ---- Main -------------------------------------------------------------------
|
// ---- Main -------------------------------------------------------------------
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("listen") {
|
||||||
|
listen::run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
let cli = screenshot::Cli::parse();
|
let cli = screenshot::Cli::parse();
|
||||||
let screenshot_req = cli.screenshot_request();
|
let screenshot_req = cli.screenshot_request();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue