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
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:
parent
c296d26408
commit
11c0e844e5
19 changed files with 2049 additions and 32 deletions
20
bread-app/Cargo.toml
Normal file
20
bread-app/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "bread-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["gtk4", "wayland", "hyprland"]
|
||||
|
||||
[dependencies]
|
||||
bread-utils = { path = "../bread-utils" }
|
||||
|
||||
[features]
|
||||
# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a
|
||||
# consumer that only wants app-id / singleton helpers does not pull GTK4.
|
||||
gtk = ["bread-utils/gtk"]
|
||||
# `BreadClient` listen loop on `bread.command.<app>.**`. Matches
|
||||
# `bread-utils/bread-client`.
|
||||
bread-client = ["bread-utils/bread-client"]
|
||||
121
bread-app/src/command.rs
Normal file
121
bread-app/src/command.rs
Normal 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
145
bread-app/src/id.rs
Normal 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
67
bread-app/src/lib.rs
Normal 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};
|
||||
Loading…
Add table
Add a link
Reference in a new issue