Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers
This commit is contained in:
parent
89c5849539
commit
1208c5d1b7
29 changed files with 4098 additions and 339 deletions
113
bread-shared/src/apps.rs
Normal file
113
bread-shared/src/apps.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//! The known-apps registry for sibling `bread*` application integration.
|
||||
//!
|
||||
//! Adding a new sibling app is a one-line edit to [`KNOWN_APPS`] — no new
|
||||
//! `AdapterSource` variant, no new normalizer arm, no recompile-driven
|
||||
//! exhaustiveness churn. This is what makes the sibling-app integration
|
||||
//! model extensible: see `Documentation.md`'s "Namespaces" section and
|
||||
//! "Integrating a bread* app" recipe.
|
||||
|
||||
/// Registered sibling `bread*` app ids. Each id is also the app's reserved
|
||||
/// segment in the `bread.<id>.*` (events) and `bread.command.<id>.*`
|
||||
/// (commands) namespaces.
|
||||
pub const KNOWN_APPS: &[&str] = &[
|
||||
"clip", "pad", "bar", "box", "lock", "mon", "paper", "search", "shot", "arr", "crumbs", "help",
|
||||
"bakery",
|
||||
];
|
||||
|
||||
/// Daemon-internal domains that are reserved and can never be claimed as an
|
||||
/// app id, even if a future `bread*` app would otherwise want that name —
|
||||
/// these are the top-level segments the normalizer and built-in event
|
||||
/// families already use.
|
||||
const RESERVED_DOMAINS: &[&str] = &[
|
||||
"terminal",
|
||||
"git",
|
||||
"hyprland",
|
||||
"device",
|
||||
"power",
|
||||
"network",
|
||||
"service",
|
||||
"container",
|
||||
"project",
|
||||
"remote",
|
||||
"system",
|
||||
"profile",
|
||||
"notify",
|
||||
"command",
|
||||
"workflow",
|
||||
];
|
||||
|
||||
/// Whether `id` is a registered sibling-app id.
|
||||
pub fn is_known_app(id: &str) -> bool {
|
||||
KNOWN_APPS.contains(&id)
|
||||
}
|
||||
|
||||
/// Whether `id` is reserved for daemon-internal use and can never be
|
||||
/// registered as a sibling-app id.
|
||||
pub fn is_reserved_domain(id: &str) -> bool {
|
||||
RESERVED_DOMAINS.contains(&id)
|
||||
}
|
||||
|
||||
/// Whether `event` is a well-formed event name for `app` — i.e. it starts
|
||||
/// with `bread.<app>.`. An app may only publish within its own namespace
|
||||
/// segment; this is what the IPC boundary checks before constructing a
|
||||
/// `RawEvent` tagged `AdapterSource::App(app)`.
|
||||
pub fn validate_app_namespace(app: &str, event: &str) -> bool {
|
||||
event.starts_with(&format!("bread.{app}."))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_apps_are_recognized() {
|
||||
assert!(is_known_app("clip"));
|
||||
assert!(is_known_app("bakery"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_app_is_not_recognized() {
|
||||
assert!(!is_known_app("notanapp"));
|
||||
assert!(!is_known_app(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_domains_are_never_known_apps() {
|
||||
for domain in RESERVED_DOMAINS {
|
||||
assert!(
|
||||
!is_known_app(domain),
|
||||
"reserved domain '{domain}' must not double as a known app id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_domains_are_recognized() {
|
||||
assert!(is_reserved_domain("power"));
|
||||
assert!(is_reserved_domain("hyprland"));
|
||||
assert!(!is_reserved_domain("clip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_app_namespace_accepts_own_namespace() {
|
||||
assert!(validate_app_namespace("clip", "bread.clip.copied"));
|
||||
assert!(validate_app_namespace(
|
||||
"clip",
|
||||
"bread.clip.stack_trace.captured"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_app_namespace_rejects_other_namespaces() {
|
||||
assert!(!validate_app_namespace("clip", "bread.pad.reminder.due"));
|
||||
assert!(!validate_app_namespace("clip", "bread.power.ac.connected"));
|
||||
assert!(!validate_app_namespace("clip", "bread.clipboard.copied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_app_namespace_rejects_bare_prefix_without_trailing_dot() {
|
||||
// "bread.clipx..." must not satisfy the "clip" namespace just
|
||||
// because it shares a string prefix.
|
||||
assert!(!validate_app_namespace("clip", "bread.clipx.copied"));
|
||||
}
|
||||
}
|
||||
|
|
@ -155,12 +155,18 @@ mod tests {
|
|||
#[test]
|
||||
fn dot_double_star_does_not_match_sibling_prefix() {
|
||||
assert!(!matches_pattern("bread.device.**", "bread.devicex"));
|
||||
assert!(!matches_pattern("bread.device.**", "bread.network.connected"));
|
||||
assert!(!matches_pattern(
|
||||
"bread.device.**",
|
||||
"bread.network.connected"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_pattern_star_does_not_cross_dots() {
|
||||
assert!(matches_pattern("bread.*.connected", "bread.alpha.connected"));
|
||||
assert!(matches_pattern(
|
||||
"bread.*.connected",
|
||||
"bread.alpha.connected"
|
||||
));
|
||||
assert!(!matches_pattern(
|
||||
"bread.*.connected",
|
||||
"bread.alpha.beta.connected"
|
||||
|
|
|
|||
|
|
@ -8,13 +8,19 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod apps;
|
||||
pub mod glob;
|
||||
|
||||
/// Identifies which adapter produced an event.
|
||||
///
|
||||
/// The state engine uses this to choose a normalization strategy and the
|
||||
/// IPC layer surfaces it so subscribers can filter by origin.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
///
|
||||
/// Not `Copy`: the [`App`](AdapterSource::App) variant carries an owned
|
||||
/// `String` (a sibling `bread*` app id), so callers that used to copy a
|
||||
/// `AdapterSource` by value now `.clone()` it — see `breadd/src/core/normalizer.rs`
|
||||
/// for the (small, compiler-driven) set of call sites this touches.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdapterSource {
|
||||
/// The Hyprland compositor IPC socket.
|
||||
|
|
@ -30,6 +36,23 @@ pub enum AdapterSource {
|
|||
System,
|
||||
/// BlueZ Bluetooth stack via D-Bus.
|
||||
Bluetooth,
|
||||
/// Shell precmd/preexec hooks (terminal command lifecycle, cwd changes).
|
||||
Terminal,
|
||||
/// Git hooks (commit/branch) and the in-daemon dirty-state poller.
|
||||
Git,
|
||||
/// Project-root file watches (via `notify`/inotify).
|
||||
Filesystem,
|
||||
/// systemd --user unit state, via the session D-Bus.
|
||||
Systemd,
|
||||
/// Podman container lifecycle, via `podman events`.
|
||||
Podman,
|
||||
/// SSH/remote session detection, via the shell hook.
|
||||
Remote,
|
||||
/// A sibling `bread*` application (breadclip, breadpad, ...), identified
|
||||
/// by its registered app id (see [`apps::KNOWN_APPS`]). Confined to the
|
||||
/// `bread.<app>.*` event namespace — enforced at the IPC boundary via
|
||||
/// [`apps::validate_app_namespace`], not by this type itself.
|
||||
App(String),
|
||||
}
|
||||
|
||||
/// An unnormalized event as emitted by an adapter.
|
||||
|
|
@ -91,6 +114,44 @@ pub fn now_unix_ms() -> u64 {
|
|||
.as_millis() as u64
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct DaemonSection {
|
||||
#[serde(default)]
|
||||
socket_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct SocketPathConfig {
|
||||
#[serde(default)]
|
||||
daemon: DaemonSection,
|
||||
}
|
||||
|
||||
/// Resolve breadd's Unix socket path exactly as `breadd::core::config::Config::socket_path`
|
||||
/// resolves its own: an explicit `daemon.socket_path` in `~/.config/bread/breadd.toml` wins,
|
||||
/// otherwise `$XDG_RUNTIME_DIR/bread/breadd.sock`, falling back to `/tmp/bread/breadd.sock`.
|
||||
///
|
||||
/// Shared by every socket client that lives outside the daemon itself (`bread-emit`, and
|
||||
/// `bread-client` in `bread-ecosystem/bread-utils`) so they can't drift from how the daemon
|
||||
/// actually resolves its own socket — before this existed, `bread-emit` carried its own
|
||||
/// hand-rolled copy of this exact logic.
|
||||
pub fn resolve_socket_path() -> std::path::PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let config_path = home.join(".config/bread/breadd.toml");
|
||||
if let Ok(contents) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(cfg) = toml::from_str::<SocketPathConfig>(&contents) {
|
||||
if !cfg.daemon.socket_path.is_empty() {
|
||||
return expand_path(&cfg.daemon.socket_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
std::path::PathBuf::from(runtime_dir)
|
||||
.join("bread")
|
||||
.join("breadd.sock")
|
||||
}
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
///
|
||||
/// Falls back to returning the path unchanged if `$HOME` is unset, which keeps
|
||||
|
|
@ -164,6 +225,38 @@ mod tests {
|
|||
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
|
||||
"\"bluetooth\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Terminal).unwrap(),
|
||||
"\"terminal\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Git).unwrap(),
|
||||
"\"git\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Filesystem).unwrap(),
|
||||
"\"filesystem\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Systemd).unwrap(),
|
||||
"\"systemd\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Podman).unwrap(),
|
||||
"\"podman\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Remote).unwrap(),
|
||||
"\"remote\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_source_app_serializes_as_externally_tagged_object() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::App("clip".to_string())).unwrap(),
|
||||
"{\"app\":\"clip\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -175,6 +268,13 @@ mod tests {
|
|||
AdapterSource::Network,
|
||||
AdapterSource::System,
|
||||
AdapterSource::Bluetooth,
|
||||
AdapterSource::Terminal,
|
||||
AdapterSource::Git,
|
||||
AdapterSource::Filesystem,
|
||||
AdapterSource::Systemd,
|
||||
AdapterSource::Podman,
|
||||
AdapterSource::Remote,
|
||||
AdapterSource::App("clip".to_string()),
|
||||
] {
|
||||
let s = serde_json::to_string(&source).unwrap();
|
||||
let back: AdapterSource = serde_json::from_str(&s).unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue