workspace: add bread-app crate and first-cut bread-polkit agent
Some checks failed
dev bread-theme / build (push) Successful in 17s
dev bakery / build (push) Successful in 43s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
Build and publish package / package (push) Successful in 1m46s
release bakery / build (push) Failing after 52s
release bread-theme / build (push) Failing after 14s

bread-app is the GTK bootstrap new tools should use instead of another
copied main.rs: com.breadway.* app id, singleton lock, optional
gtk_popup re-export, optional bread.command.<app>.** listen loop.
Tests cover app-id helpers and command-verb parse. Existing apps are
not migrated.

bread-polkit is an own PolicyKit1 session authentication agent with a
bread-theme GTK4 password prompt (not a polkit-gnome wrapper).
Autostart via contrib/bread-polkit.desktop or exec-once. Not a bakery
product; not added to the BOS ISO lockfile.
This commit is contained in:
Breadway 2026-08-16 00:34:12 +08:00
parent c296d26408
commit 11c0e844e5
19 changed files with 2049 additions and 32 deletions

121
bread-app/src/command.rs Normal file
View file

@ -0,0 +1,121 @@
//! Command-bus helpers for `bread.command.<app>.**`.
//!
//! The `command_id` here is the breadd sibling-app id (`clip`, `box`,
//! `shot`) — often shorter than the GTK / singleton name (`breadclip`).
use crate::id::{parse_app_name, InvalidAppId};
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
/// Same charset as [`parse_app_name`]: a single command-bus segment.
pub fn parse_command_id(command_id: &str) -> Result<&str, InvalidAppId> {
parse_app_name(command_id)
}
/// Subscribe glob: `bread.command.<app>.**`.
pub fn command_pattern(command_id: &str) -> Result<String, InvalidAppId> {
let id = parse_command_id(command_id)?;
Ok(format!("bread.command.{id}.**"))
}
/// The verb segment of `bread.command.<app>.<verb>` (and extra trailing
/// segments, if any). `None` when the event is not addressed to
/// `command_id` or the verb is missing.
///
/// Extra dotted remainder (`bread.command.clip.stack.clear`) yields the
/// first remaining segment (`stack`) — a verb is one segment, matching
/// [`BreadClient::command`].
pub fn command_verb<'a>(event: &'a str, command_id: &str) -> Option<&'a str> {
if command_id.is_empty() {
return None;
}
let prefix = format!("bread.command.{command_id}.");
let rest = event.strip_prefix(&prefix)?;
let verb = rest.split('.').next()?;
if verb.is_empty() {
None
} else {
Some(verb)
}
}
/// Subscribe to `bread.command.<command_id>.**` and invoke `on_verb` with
/// the parsed verb plus the raw event.
///
/// Fail-silent: constructing the client and holding the subscription never
/// requires breadd to be running. Drop the returned [`Subscription`] (or
/// call [`Subscription::stop`]) to end the loop.
pub fn listen_commands<F>(command_id: &str, on_verb: F) -> Result<Subscription, InvalidAppId>
where
F: Fn(&str, BreadEvent) + Send + 'static,
{
let id = parse_command_id(command_id)?.to_string();
let client = BreadClient::connect(id.clone());
let pattern = format!("bread.command.{id}.**");
Ok(client.subscribe(pattern, move |event| {
let Some(verb) = command_verb(&event.event, &id).map(str::to_owned) else {
return;
};
on_verb(&verb, event);
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_pattern_uses_double_star() {
assert_eq!(command_pattern("clip").unwrap(), "bread.command.clip.**");
assert_eq!(command_pattern("shot").unwrap(), "bread.command.shot.**");
}
#[test]
fn command_pattern_rejects_invalid_id() {
assert!(command_pattern("").is_err());
assert!(command_pattern("clip.clear").is_err());
}
#[test]
fn command_verb_strips_app_prefix() {
assert_eq!(
command_verb("bread.command.clip.clear", "clip"),
Some("clear")
);
assert_eq!(
command_verb("bread.command.shot.region", "shot"),
Some("region")
);
assert_eq!(
command_verb("bread.command.shot.annotate", "shot"),
Some("annotate")
);
}
#[test]
fn command_verb_takes_first_segment_only() {
assert_eq!(
command_verb("bread.command.clip.stack.clear", "clip"),
Some("stack")
);
}
#[test]
fn command_verb_rejects_other_apps_and_missing_verb() {
assert_eq!(command_verb("bread.command.clip.clear", "shot"), None);
assert_eq!(command_verb("bread.command.clip", "clip"), None);
assert_eq!(command_verb("bread.command.clip.", "clip"), None);
assert_eq!(command_verb("bread.clip.copied", "clip"), None);
assert_eq!(command_verb("bread.command.clip.clear", ""), None);
}
#[test]
fn listen_commands_rejects_invalid_id() {
assert!(listen_commands("", |_, _| {}).is_err());
}
#[test]
fn listen_commands_stop_joins_without_a_daemon() {
let sub = listen_commands("clip", |_, _| {}).unwrap();
sub.stop();
}
}

145
bread-app/src/id.rs Normal file
View file

@ -0,0 +1,145 @@
//! App-id helpers shared by GTK tools and the singleton lock.
//!
//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the
//! last segment of the GApplication id (`com.breadway.breadbox`). That is
//! *not* always the breadd command-bus id (`box`, `clip`) — see
//! [`crate::command_verb`] under feature `bread-client`.
use std::io;
use crate::singleton::{self, Acquire, Toggle};
/// Why [`parse_app_name`] / [`application_id`] rejected a string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidAppId {
/// The rejected input, owned so the error is `'static`.
pub name: String,
/// Short reason suitable for an `io::Error` / clap message.
pub reason: &'static str,
}
impl std::fmt::Display for InvalidAppId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid app id '{}': {}", self.name, self.reason)
}
}
impl std::error::Error for InvalidAppId {}
/// Accept a process / GTK application name (`breadbox`, `bread-polkit`).
///
/// Rules match a GApplication id *element*: non-empty, ASCII letter first,
/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can
/// sit in `com.breadway.<name>` without creating extra segments.
pub fn parse_app_name(name: &str) -> Result<&str, InvalidAppId> {
if name.is_empty() {
return Err(InvalidAppId {
name: name.to_string(),
reason: "must not be empty",
});
}
let mut chars = name.chars();
let first = chars.next().expect("non-empty");
if !first.is_ascii_alphabetic() {
return Err(InvalidAppId {
name: name.to_string(),
reason: "must start with an ASCII letter",
});
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(InvalidAppId {
name: name.to_string(),
reason: "only ASCII letters, digits, '-' and '_' are allowed",
});
}
Ok(name)
}
/// Reverse-DNS GApplication id: `com.breadway.<name>`.
pub fn application_id(app_name: &str) -> Result<String, InvalidAppId> {
let name = parse_app_name(app_name)?;
Ok(format!("com.breadway.{name}"))
}
/// [`singleton::try_acquire`] after [`parse_app_name`].
///
/// Invalid names become [`io::ErrorKind::InvalidInput`] and never touch
/// the pid file.
pub fn try_acquire(app_name: &str) -> io::Result<Acquire> {
let name =
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
singleton::try_acquire(name)
}
/// [`singleton::toggle_or_kill`] after [`parse_app_name`].
pub fn toggle_or_kill(app_name: &str) -> io::Result<Toggle> {
let name =
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
singleton::toggle_or_kill(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_app_name_accepts_existing_tool_names() {
for name in ["breadbox", "breadclip", "bread-polkit", "breadcast"] {
assert_eq!(parse_app_name(name), Ok(name));
}
}
#[test]
fn parse_app_name_rejects_empty_dot_and_leading_digit() {
assert!(parse_app_name("").is_err());
assert!(parse_app_name("bread.box").is_err());
assert!(parse_app_name("1box").is_err());
assert!(parse_app_name("-box").is_err());
assert!(parse_app_name("bread box").is_err());
}
#[test]
fn application_id_uses_com_breadway_prefix() {
assert_eq!(application_id("breadbox").unwrap(), "com.breadway.breadbox");
assert_eq!(
application_id("bread-polkit").unwrap(),
"com.breadway.bread-polkit"
);
}
#[test]
fn application_id_rejects_invalid_name() {
assert!(application_id("").is_err());
assert!(application_id("bread.box").is_err());
}
#[test]
fn try_acquire_rejects_invalid_name_before_lock() {
match try_acquire("") {
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
Ok(_) => panic!("empty name must not acquire a lock"),
}
match try_acquire("bread.box") {
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
Ok(_) => panic!("dotted name must not acquire a lock"),
}
}
#[test]
fn try_acquire_accepts_valid_name() {
let name = format!("bread-app-id-test-{}", std::process::id());
match try_acquire(&name).unwrap() {
Acquire::Acquired(_guard) => {}
Acquire::HeldByOther(_) => panic!("expected first acquire to succeed"),
}
}
#[test]
fn toggle_or_kill_starts_when_nothing_else_is_running() {
let name = format!("bread-app-toggle-test-{}", std::process::id());
match toggle_or_kill(&name).unwrap() {
Toggle::Started(_guard) => {}
Toggle::KilledExisting => panic!("expected to start as the first instance"),
}
}
}

67
bread-app/src/lib.rs Normal file
View file

@ -0,0 +1,67 @@
//! GTK application bootstrap for bread desktop tools.
//!
//! New GTK tools should depend on this crate instead of copying a sixth
//! `main.rs` that wires a `com.breadway.*` application id, a
//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a
//! `bread.command.<app>.**` listen loop.
//!
//! # What this is
//!
//! The pieces every bread GTK binary already copies:
//!
//! - [`application_id`] / [`parse_app_name`] — reverse-DNS id
//! (`com.breadway.breadbox`) and the same name used for the singleton
//! pid file.
//! - [`try_acquire`] / [`toggle_or_kill`] — [`bread_utils::singleton`]
//! wrappers that reject an invalid name before touching the lock.
//! - feature `gtk` — re-exports [`gtk_popup`] (`bread_utils::gtk_popup`)
//! for the full-screen overlay breadbox / breadclip / breadcast start
//! from.
//! - feature `bread-client` — [`listen_commands`] plus [`command_verb`] /
//! [`command_pattern`] so a tool can honor `bread.command.<app>.**`
//! without re-deriving the prefix strip.
//!
//! This crate does **not** migrate existing apps. Callers still own their
//! widgets, CSS, and clap. Screenshot / `--screenshot` helpers stay in
//! [`bread_utils::screenshot_cli`].
//!
//! # Example
//!
//! ```ignore
//! let _guard = match bread_app::try_acquire("breadbox")? {
//! bread_app::singleton::Acquire::Acquired(g) => g,
//! bread_app::singleton::Acquire::HeldByOther(_) => return Ok(()),
//! };
//! let app = gtk4::Application::builder()
//! .application_id(&bread_app::application_id("breadbox")?)
//! .build();
//!
//! #[cfg(feature = "gtk")]
//! app.connect_activate(|app| {
//! let window = bread_app::gtk_popup::new_overlay_window(app, "breadbox");
//! window.present();
//! });
//!
//! #[cfg(feature = "bread-client")]
//! let _commands = bread_app::listen_commands("box", |verb, event| {
//! // verb is the single segment after `bread.command.box.`
//! let _ = (verb, event);
//! })?;
//! ```
pub use bread_utils::singleton;
#[cfg(feature = "gtk")]
pub use bread_utils::gtk_popup;
mod id;
pub use id::{application_id, parse_app_name, toggle_or_kill, try_acquire, InvalidAppId};
#[cfg(feature = "bread-client")]
mod command;
#[cfg(feature = "bread-client")]
pub use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
#[cfg(feature = "bread-client")]
pub use command::{command_pattern, command_verb, listen_commands, parse_command_id};