Add bread-utils and bread-onnx: shared crates for ecosystem-wide duplication
bread-utils extracts genuinely duplicated logic found across breadbox, breadclip, breadmon, breadcrumbs, bos-settings, and breadhelp: - hypr: Hyprland socket1 request/response client (breadbox's get_active_workspace + breadclip's position.rs hyprctl_json were near-identical), socket2 path resolution (breadmon), and a version-tolerant `fullscreen` field parser (Hyprland has shipped both bool and int representations across versions). - singleton: correct flock-based single-instance toggle, replacing the TOCTOU-prone read-pid/check-proc/kill/write-pid pattern duplicated verbatim between breadbox and breadclip (breadclip's own comment says "matches breadbox pattern"). - proc: breadcrumbs' timeout-guarded subprocess runner, promoted verbatim as the one implementation in the ecosystem that already got this right. - atomic + xdg: atomic (temp-then-rename) file writes with an optional .bak-before-overwrite variant, and XDG path helpers that never fall back to a literal "~/..." string (the exact breadclip-core and breadpad-shared bug: PathBuf never expands `~`). - tomlcfg (feature "toml"): the load_doc/save_doc TOML-editing discipline bos-settings and breadhelp both implemented byte-for-byte identically in the same fix pass that introduced it. - gtk_popup (feature "gtk"): layer-shell overlay window setup, visible-row navigation, and click-outside-close, deduplicated from breadbox and breadclip (~150 duplicated lines, per both apps' own "same as breadbox" comments). bread-onnx extracts the embedding pipeline (tokenize -> tensor build -> mean-pool -> L2-normalize) duplicated near-verbatim between breadarr and breadsearch, a shared execution-provider session builder with loud EP- registration logging, and a model download+integrity helper. Defaults AMD iGPU acceleration to ort::ep::MIGraphX (not ROCm) per this machine's own breadsearch-gpu-backends lesson: ROCMExecutionProvider silently no-ops to CPU on distro ROCm onnxruntime builds compiled with --use_migraphx. Both crates build and pass their own test suites standalone. Consumer migrations follow in subsequent commits.
This commit is contained in:
parent
394a252f9e
commit
853ee33415
17 changed files with 2503 additions and 5 deletions
240
bread-utils/src/hypr.rs
Normal file
240
bread-utils/src/hypr.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! Hyprland IPC client: socket1 request/response (JSON) and socket2 path
|
||||
//! resolution.
|
||||
//!
|
||||
//! The socket-path resolution + raw request/response round trip was
|
||||
//! duplicated near-verbatim in `breadbox/src/main.rs` (`get_active_workspace`,
|
||||
//! lines 26-42) and `breadclip/src/position.rs` (`hyprctl_json`, lines
|
||||
//! 58-71) — same `HYPRLAND_INSTANCE_SIGNATURE`/`XDG_RUNTIME_DIR` env lookup,
|
||||
//! same `.socket.sock` path format, same connect/write/shutdown-write/
|
||||
//! read-to-string sequence. `breadmon/src/main.rs`'s `hyprland_socket2_path`
|
||||
//! duplicates just the path-resolution half for the event socket.
|
||||
//!
|
||||
//! `active_window`'s `fullscreen` field deserializes leniently as either a
|
||||
//! JSON bool or integer: Hyprland has changed this field's type across
|
||||
//! versions (older releases emit a bool, `0`/`1`; newer ones emit an
|
||||
//! integer fullscreen *mode* — `0` none, `1` maximized, `2` fullscreen), and
|
||||
//! a client hard-coded to one shape silently misreads the other instead of
|
||||
//! erroring. `breadclip`'s own version (`as_i64().unwrap_or(0) != 0`) only
|
||||
//! handles the integer shape; a bool `true` would `.as_i64()` to `None` and
|
||||
//! silently read as "not fullscreen".
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Which of Hyprland's two IPC sockets: `.socket.sock` (request/response) or
|
||||
/// `.socket2.sock` (event stream).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Socket {
|
||||
Request,
|
||||
Events,
|
||||
}
|
||||
|
||||
/// Resolve the path to one of Hyprland's IPC sockets from
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
|
||||
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
|
||||
/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
|
||||
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
||||
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||
let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
let file = match kind {
|
||||
Socket::Request => ".socket.sock",
|
||||
Socket::Events => ".socket2.sock",
|
||||
};
|
||||
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
|
||||
}
|
||||
|
||||
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
|
||||
/// IPC socket and return the raw response body. Blocking/synchronous — this
|
||||
/// matches every current consumer (breadbox, breadclip), which call it from
|
||||
/// non-async GTK app code.
|
||||
pub fn request(request: &str) -> Option<String> {
|
||||
let socket = socket_path(Socket::Request)?;
|
||||
let mut stream = UnixStream::connect(&socket).ok()?;
|
||||
stream.write_all(request.as_bytes()).ok()?;
|
||||
stream.shutdown(std::net::Shutdown::Write).ok()?;
|
||||
|
||||
let mut buf = String::new();
|
||||
stream.read_to_string(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// Like [`request`], parsed as JSON. `request` should already carry the `j/`
|
||||
/// prefix Hyprland expects for JSON responses (e.g. `"j/activewindow"`).
|
||||
pub fn request_json(request_str: &str) -> Option<serde_json::Value> {
|
||||
serde_json::from_str(&request(request_str)?).ok()
|
||||
}
|
||||
|
||||
/// Connect to the socket2 event stream. Callers read newline-delimited
|
||||
/// `EVENT>>DATA` lines from the returned stream themselves — event framing
|
||||
/// and reconnect/backoff policy are genuinely per-consumer (see
|
||||
/// `breadmon`'s hotplug listener), so this only replaces the duplicated
|
||||
/// path-resolution + connect boilerplate, not a full event-loop
|
||||
/// abstraction.
|
||||
pub fn connect_events() -> Option<UnixStream> {
|
||||
let socket = socket_path(Socket::Events)?;
|
||||
UnixStream::connect(&socket).ok()
|
||||
}
|
||||
|
||||
/// Hyprland's `fullscreen` field, tolerant of either representation it has
|
||||
/// shipped across versions: a plain bool, or an integer fullscreen mode
|
||||
/// (`0` = none, nonzero = some fullscreen mode).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FullscreenState(bool);
|
||||
|
||||
impl FullscreenState {
|
||||
pub fn is_fullscreen(self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FullscreenState {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Repr {
|
||||
Bool(bool),
|
||||
Int(i64),
|
||||
}
|
||||
Ok(match Repr::deserialize(deserializer)? {
|
||||
Repr::Bool(b) => FullscreenState(b),
|
||||
Repr::Int(i) => FullscreenState(i != 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ActiveWindow {
|
||||
#[serde(default)]
|
||||
pub class: String,
|
||||
#[serde(default)]
|
||||
pub fullscreen: FullscreenState,
|
||||
pub at: (i32, i32),
|
||||
pub size: (i32, i32),
|
||||
}
|
||||
|
||||
impl ActiveWindow {
|
||||
pub fn x(&self) -> i32 {
|
||||
self.at.0
|
||||
}
|
||||
pub fn y(&self) -> i32 {
|
||||
self.at.1
|
||||
}
|
||||
pub fn width(&self) -> i32 {
|
||||
self.size.0
|
||||
}
|
||||
pub fn height(&self) -> i32 {
|
||||
self.size.1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Monitor {
|
||||
pub name: String,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
#[serde(default)]
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
/// Query the currently active (focused) window. Returns `None` if the
|
||||
/// window is fullscreen or no window is focused — same "centre the popup
|
||||
/// instead" contract `breadclip`'s original `get_active_window` had.
|
||||
pub fn active_window() -> Option<ActiveWindow> {
|
||||
let win: ActiveWindow = serde_json::from_value(request_json("j/activewindow")?).ok()?;
|
||||
if win.fullscreen.is_fullscreen() || win.class.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(win)
|
||||
}
|
||||
|
||||
/// Query all monitors and return the focused one (or the first, if none
|
||||
/// report as focused).
|
||||
pub fn focused_monitor() -> Option<Monitor> {
|
||||
let monitors: Vec<Monitor> = serde_json::from_value(request_json("j/monitors")?).ok()?;
|
||||
monitors
|
||||
.iter()
|
||||
.find(|m| m.focused)
|
||||
.or_else(|| monitors.first())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The active workspace's name (e.g. `"1"`, `"special:scratch"`).
|
||||
pub fn active_workspace_name() -> Option<String> {
|
||||
request_json("j/activeworkspace")?
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fullscreen_state_deserializes_from_bool() {
|
||||
let s: FullscreenState = serde_json::from_str("true").unwrap();
|
||||
assert!(s.is_fullscreen());
|
||||
let s: FullscreenState = serde_json::from_str("false").unwrap();
|
||||
assert!(!s.is_fullscreen());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_state_deserializes_from_int() {
|
||||
let s: FullscreenState = serde_json::from_str("0").unwrap();
|
||||
assert!(!s.is_fullscreen());
|
||||
let s: FullscreenState = serde_json::from_str("2").unwrap();
|
||||
assert!(s.is_fullscreen());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_window_parses_bool_fullscreen_shape() {
|
||||
let json = r#"{"class":"kitty","fullscreen":true,"at":[10,20],"size":[300,400]}"#;
|
||||
let win: ActiveWindow = serde_json::from_str(json).unwrap();
|
||||
assert!(win.fullscreen.is_fullscreen());
|
||||
assert_eq!(win.x(), 10);
|
||||
assert_eq!(win.height(), 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_window_parses_int_fullscreen_shape() {
|
||||
let json = r#"{"class":"kitty","fullscreen":1,"at":[0,0],"size":[100,100]}"#;
|
||||
let win: ActiveWindow = serde_json::from_str(json).unwrap();
|
||||
assert!(win.fullscreen.is_fullscreen());
|
||||
}
|
||||
|
||||
// Both env-var-dependent cases share one test function: `set_var`/
|
||||
// `remove_var` are process-global, and cargo runs tests in parallel
|
||||
// threads by default, so two separate #[test] fns racing on the same
|
||||
// vars would be flaky.
|
||||
#[test]
|
||||
fn socket_path_env_var_behavior() {
|
||||
unsafe { env::remove_var("HYPRLAND_INSTANCE_SIGNATURE") };
|
||||
assert!(socket_path(Socket::Request).is_none());
|
||||
|
||||
unsafe {
|
||||
env::set_var("HYPRLAND_INSTANCE_SIGNATURE", "test-sig");
|
||||
env::set_var("XDG_RUNTIME_DIR", "/run/user/9999");
|
||||
}
|
||||
assert_eq!(
|
||||
socket_path(Socket::Request).unwrap(),
|
||||
PathBuf::from("/run/user/9999/hypr/test-sig/.socket.sock")
|
||||
);
|
||||
assert_eq!(
|
||||
socket_path(Socket::Events).unwrap(),
|
||||
PathBuf::from("/run/user/9999/hypr/test-sig/.socket2.sock")
|
||||
);
|
||||
unsafe {
|
||||
env::remove_var("HYPRLAND_INSTANCE_SIGNATURE");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue