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
775
Cargo.lock
generated
775
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["bakery", "bread-theme"]
|
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
@ -20,6 +20,7 @@ hex = "0.4"
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
minisign-verify = "0.2"
|
minisign-verify = "0.2"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
|
||||||
34
bread-onnx/Cargo.toml
Normal file
34
bread-onnx/Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[package]
|
||||||
|
name = "bread-onnx"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Shared ONNX Runtime plumbing for the bread ecosystem: session building, execution-provider fallback with loud diagnostics, embedding-pipeline tensor math, and verified model downloads"
|
||||||
|
repository = "https://github.com/Breadway/bread-ecosystem"
|
||||||
|
keywords = ["onnx", "onnxruntime", "ml", "embeddings"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bread-utils = { path = "../bread-utils" }
|
||||||
|
# Left at default-features = false, with no api-XX/download-binaries/
|
||||||
|
# load-dynamic/tls-native features of our own: those choices (how each app
|
||||||
|
# obtains/links its onnxruntime .so, and which ONNX Runtime C API version to
|
||||||
|
# bind) are consumer-build-environment decisions that stay in each app's own
|
||||||
|
# Cargo.toml (breadarr, breadmill, and breadpad already each pin different
|
||||||
|
# ones). Cargo's feature unification means this crate's minimal declaration
|
||||||
|
# just rides along with whatever the consuming app already selected.
|
||||||
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing"] }
|
||||||
|
# Default features left on (unlike `ort` above) — breadarr and breadmill
|
||||||
|
# both already build against plain default-featured tokenizers; only
|
||||||
|
# breadpad customizes this (http, fancy-regex), and Cargo's feature
|
||||||
|
# unification only ever adds features on top of this minimal baseline, so
|
||||||
|
# breadpad's own selection still applies in its own build.
|
||||||
|
tokenizers = "0.23"
|
||||||
|
tracing = { workspace = true }
|
||||||
|
ureq = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
112
bread-onnx/src/download.rs
Normal file
112
bread-onnx/src/download.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
//! Model download + integrity checking.
|
||||||
|
//!
|
||||||
|
//! `breadarrd/src/matcher/mod.rs::download` (async, `reqwest`) and
|
||||||
|
//! `breadmill/src/main.rs::download_if_missing` (sync, `ureq`) independently
|
||||||
|
//! implement "download to a temp file, then rename over the destination"
|
||||||
|
//! for fetching an ONNX model/tokenizer if it isn't already present —
|
||||||
|
//! genuinely duplicated intent, different HTTP clients. Neither verifies
|
||||||
|
//! the download's integrity beyond "the response wasn't empty". This module
|
||||||
|
//! is a fresh, shared implementation (sync, `ureq` — matching this
|
||||||
|
//! workspace's existing `bakery` convention for downloads) that adds an
|
||||||
|
//! optional SHA-256 check, built on [`bread_utils::atomic::write_atomic_bytes`]
|
||||||
|
//! for the same crash-safety property both originals already had.
|
||||||
|
//!
|
||||||
|
//! `breadarrd`'s async caller should wrap a call to [`ensure_file`] in
|
||||||
|
//! `tokio::task::spawn_blocking` rather than block its async runtime
|
||||||
|
//! directly — see that crate's migration for the concrete pattern.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Download `url` to `dest` if `dest` doesn't already exist. If
|
||||||
|
/// `expected_sha256` is given, verifies the downloaded bytes against it
|
||||||
|
/// (case-insensitive hex) before the atomic rename and returns an error on
|
||||||
|
/// mismatch — the temp file is discarded, `dest` is left untouched. An
|
||||||
|
/// already-present `dest` is trusted as-is and not re-verified (matches
|
||||||
|
/// both original implementations' "if it exists, skip" behavior; re-hashing
|
||||||
|
/// a ~90MB+ model file on every startup would be wasted work for the common
|
||||||
|
/// case of a stable, previously-verified file).
|
||||||
|
pub fn ensure_file(url: &str, dest: &Path, expected_sha256: Option<&str>) -> anyhow::Result<PathBuf> {
|
||||||
|
if dest.exists() {
|
||||||
|
return Ok(dest.to_path_buf());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("bread-onnx: downloading {url} -> {}", dest.display());
|
||||||
|
let agent = ureq::AgentBuilder::new()
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.build();
|
||||||
|
let response = agent
|
||||||
|
.get(url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to download {url}: {e}"))?;
|
||||||
|
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
response
|
||||||
|
.into_reader()
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read response body from {url}: {e}"))?;
|
||||||
|
|
||||||
|
if bytes.is_empty() {
|
||||||
|
anyhow::bail!("empty download from {url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expected) = expected_sha256 {
|
||||||
|
let actual = sha256_hex(&bytes);
|
||||||
|
if !actual.eq_ignore_ascii_case(expected) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"checksum mismatch for {url}: expected {expected}, got {actual} — refusing to install"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!("bread-onnx: verified sha256 for {}", dest.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
bread_utils::atomic::write_atomic_bytes(dest, &bytes, None)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", dest.display()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"bread-onnx: saved {} ({:.1} MB)",
|
||||||
|
dest.display(),
|
||||||
|
bytes.len() as f64 / 1_048_576.0
|
||||||
|
);
|
||||||
|
Ok(dest.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256_hex_matches_known_vector() {
|
||||||
|
// sha256("") — well-known empty-input digest.
|
||||||
|
assert_eq!(
|
||||||
|
sha256_hex(b""),
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_file_skips_download_when_already_present() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bread-onnx-download-test-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let dest = dir.join("model.onnx");
|
||||||
|
std::fs::write(&dest, b"already here").unwrap();
|
||||||
|
|
||||||
|
// A bogus URL would fail if actually requested — success here proves
|
||||||
|
// the existing-file short-circuit fired instead of dialing out.
|
||||||
|
let result = ensure_file("http://127.0.0.1:1/unreachable", &dest, None);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(std::fs::read(&dest).unwrap(), b"already here");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
bread-onnx/src/embedding.rs
Normal file
220
bread-onnx/src/embedding.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
//! Shared BERT-family embedding pipeline: tokenize → build `input_ids`/
|
||||||
|
//! `attention_mask`/`token_type_ids` tensors → run → mean-pool the
|
||||||
|
//! non-padded positions of `last_hidden_state` → L2-normalize → clamp/pad to
|
||||||
|
//! a configured output dimension.
|
||||||
|
//!
|
||||||
|
//! This is extracted from two independently-written but essentially
|
||||||
|
//! byte-identical implementations:
|
||||||
|
//! - `breadarrd/src/matcher/embed.rs::OrtEmbedder::embed` (lines 45-111) and
|
||||||
|
//! its `l2_normalize` (lines 114-121)
|
||||||
|
//! - `breadmill/src/embed.rs::OrtEmbedder::embed_with_prefix` (lines 65-153)
|
||||||
|
//! and its `l2_normalize` (lines 156-163)
|
||||||
|
//!
|
||||||
|
//! Both truncate to a max sequence length, build the same three `i64`
|
||||||
|
//! tensors, run the same `input_ids`/`attention_mask`/`token_type_ids` →
|
||||||
|
//! `last_hidden_state` shape contract, mean-pool over `actual_seq.min(mask.len())`
|
||||||
|
//! positions (both already independently arrived at the same `.min()` guard
|
||||||
|
//! for execution providers that pad the output sequence dimension), and
|
||||||
|
//! L2-normalize with the same `1e-10` epsilon. `breadmill`'s only real
|
||||||
|
//! difference is prepending a document/query prefix string before
|
||||||
|
//! tokenizing, which stays the caller's responsibility here — pass the
|
||||||
|
//! already-prefixed text to [`EmbeddingSession::embed`].
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use ort::session::builder::GraphOptimizationLevel;
|
||||||
|
use ort::session::Session;
|
||||||
|
use ort::value::Tensor;
|
||||||
|
use tokenizers::Tokenizer;
|
||||||
|
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::session::build_session;
|
||||||
|
|
||||||
|
pub struct EmbeddingSession {
|
||||||
|
session: Session,
|
||||||
|
tokenizer: Tokenizer,
|
||||||
|
dim: usize,
|
||||||
|
max_seq_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddingSession {
|
||||||
|
/// Load a BERT-family embedding model + tokenizer, selecting execution
|
||||||
|
/// providers via [`build_session`]. `dim` is the output embedding
|
||||||
|
/// dimension (results are truncated/zero-padded to it — matches how
|
||||||
|
/// both original implementations handled a model whose `dim` config
|
||||||
|
/// might not exactly match `last_hidden_state`'s actual width). `max_seq_len`
|
||||||
|
/// caps tokenized input length before inference (truncating, not
|
||||||
|
/// erroring) to bound attention memory on pathological inputs.
|
||||||
|
pub fn load(
|
||||||
|
model_path: &Path,
|
||||||
|
tokenizer_path: &Path,
|
||||||
|
dim: usize,
|
||||||
|
max_seq_len: usize,
|
||||||
|
providers: &[Provider],
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let session = build_session(model_path, GraphOptimizationLevel::Level3, providers)?;
|
||||||
|
let tokenizer = Tokenizer::from_file(tokenizer_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to load tokenizer: {e}"))?;
|
||||||
|
Ok(Self { session, tokenizer, dim, max_seq_len })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed `text` (already prefixed by the caller, if the model expects a
|
||||||
|
/// document/query prefix). Returns an L2-normalized vector of length
|
||||||
|
/// `dim`.
|
||||||
|
pub fn embed(&mut self, text: &str) -> anyhow::Result<Vec<f32>> {
|
||||||
|
let encoding = self
|
||||||
|
.tokenizer
|
||||||
|
.encode(text, true)
|
||||||
|
.map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?;
|
||||||
|
|
||||||
|
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
||||||
|
let mut mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
|
||||||
|
let mut type_ids: Vec<i64> = encoding.get_type_ids().iter().map(|&x| x as i64).collect();
|
||||||
|
|
||||||
|
ids.truncate(self.max_seq_len);
|
||||||
|
mask.truncate(self.max_seq_len);
|
||||||
|
type_ids.truncate(self.max_seq_len);
|
||||||
|
|
||||||
|
let seq_len = ids.len() as i64;
|
||||||
|
let id_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], ids))
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?;
|
||||||
|
let mask_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone()))
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?;
|
||||||
|
let type_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], type_ids))
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to build token_type_ids tensor: {e}"))?;
|
||||||
|
|
||||||
|
let outputs = self
|
||||||
|
.session
|
||||||
|
.run(ort::inputs! {
|
||||||
|
"input_ids" => id_tensor,
|
||||||
|
"attention_mask" => mask_tensor,
|
||||||
|
"token_type_ids" => type_tensor,
|
||||||
|
})
|
||||||
|
.map_err(|e| anyhow::anyhow!("ort inference failed: {e}"))?;
|
||||||
|
|
||||||
|
let (shape, data) = outputs["last_hidden_state"]
|
||||||
|
.try_extract_tensor::<f32>()
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to extract last_hidden_state: {e}"))?;
|
||||||
|
|
||||||
|
let actual_seq = shape[1] as usize;
|
||||||
|
let actual_dim = shape[2] as usize;
|
||||||
|
|
||||||
|
Ok(mean_pool_normalize(data, &mask, actual_seq, actual_dim, self.dim))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean-pool `data` (flattened `[1, actual_seq, actual_dim]`) over the
|
||||||
|
/// positions `mask` marks as non-padding, L2-normalize the result, then
|
||||||
|
/// clamp/zero-pad to `target_dim`. `actual_seq.min(mask.len())` guards
|
||||||
|
/// against execution providers (MIGraphX observed doing this) that pad the
|
||||||
|
/// output sequence dimension for kernel efficiency, making `actual_seq`
|
||||||
|
/// exceed the caller's own `mask` length.
|
||||||
|
fn mean_pool_normalize(data: &[f32], mask: &[i64], actual_seq: usize, actual_dim: usize, target_dim: usize) -> Vec<f32> {
|
||||||
|
let mut result = vec![0.0f32; actual_dim];
|
||||||
|
let mut count = 0usize;
|
||||||
|
for t in 0..actual_seq.min(mask.len()) {
|
||||||
|
if mask[t] > 0 {
|
||||||
|
for d in 0..actual_dim {
|
||||||
|
result[d] += data[t * actual_dim + d];
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
for x in &mut result {
|
||||||
|
*x /= count as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l2_normalize(&mut result);
|
||||||
|
result.truncate(target_dim);
|
||||||
|
while result.len() < target_dim {
|
||||||
|
result.push(0.0);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn l2_normalize(v: &mut [f32]) {
|
||||||
|
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if norm > 1e-10 {
|
||||||
|
for x in v.iter_mut() {
|
||||||
|
*x /= norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||||
|
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn l2_normalize_produces_unit_vector() {
|
||||||
|
let mut v = vec![3.0, 4.0];
|
||||||
|
l2_normalize(&mut v);
|
||||||
|
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
assert!((norm - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn l2_normalize_leaves_zero_vector_untouched() {
|
||||||
|
let mut v = vec![0.0, 0.0, 0.0];
|
||||||
|
l2_normalize(&mut v);
|
||||||
|
assert_eq!(v, vec![0.0, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cosine_similarity_of_identical_unit_vectors_is_one() {
|
||||||
|
let mut v = vec![1.0, 2.0, 3.0];
|
||||||
|
l2_normalize(&mut v);
|
||||||
|
let sim = cosine_similarity(&v, &v);
|
||||||
|
assert!((sim - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cosine_similarity_of_orthogonal_vectors_is_zero() {
|
||||||
|
let a = vec![1.0, 0.0];
|
||||||
|
let b = vec![0.0, 1.0];
|
||||||
|
assert!(cosine_similarity(&a, &b).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mean_pool_ignores_padded_positions() {
|
||||||
|
// actual_dim = 2, 3 positions: two real tokens + one padded (mask=0)
|
||||||
|
let data = vec![
|
||||||
|
1.0, 1.0, // t0: real
|
||||||
|
9.0, 9.0, // t1: padded, should be ignored
|
||||||
|
3.0, 3.0, // t2: real
|
||||||
|
];
|
||||||
|
let mask = vec![1, 0, 1];
|
||||||
|
let pooled = mean_pool_normalize(&data, &mask, 3, 2, 2);
|
||||||
|
// Mean of (1,1) and (3,3) is (2,2), normalized to unit length.
|
||||||
|
let expected_norm = (2.0f32 * 2.0 + 2.0 * 2.0).sqrt();
|
||||||
|
assert!((pooled[0] - 2.0 / expected_norm).abs() < 1e-5);
|
||||||
|
assert!((pooled[1] - 2.0 / expected_norm).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mean_pool_clamps_actual_seq_to_mask_len_for_padded_ep_output() {
|
||||||
|
// Regression guard for the MIGraphX-padded-output-sequence case both
|
||||||
|
// original implementations independently guarded against: actual_seq
|
||||||
|
// (4) exceeds mask.len() (2) — must not index out of the mask.
|
||||||
|
let data = vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
|
||||||
|
let mask = vec![1, 1];
|
||||||
|
let pooled = mean_pool_normalize(&data, &mask, 4, 2, 2);
|
||||||
|
assert!(pooled.iter().all(|x| x.is_finite()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mean_pool_pads_short_result_to_target_dim() {
|
||||||
|
let data = vec![1.0, 1.0];
|
||||||
|
let mask = vec![1];
|
||||||
|
let pooled = mean_pool_normalize(&data, &mask, 1, 1, 4);
|
||||||
|
assert_eq!(pooled.len(), 4);
|
||||||
|
assert_eq!(pooled[2], 0.0);
|
||||||
|
assert_eq!(pooled[3], 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
bread-onnx/src/lib.rs
Normal file
32
bread-onnx/src/lib.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
//! Shared ONNX Runtime plumbing for the bread ecosystem.
|
||||||
|
//!
|
||||||
|
//! Extracted from breadarr, breadsearch, and breadpad during the
|
||||||
|
//! 2026-07-16 ecosystem-wide utility audit — see each module's doc comment
|
||||||
|
//! for the original file:line duplication it replaces.
|
||||||
|
//!
|
||||||
|
//! **Important**: [`session::build_session`] logs execution-provider
|
||||||
|
//! selection via the `tracing` crate, but does *not* initialize a
|
||||||
|
//! subscriber itself. Without one, ONNX Runtime's own "successfully
|
||||||
|
//! registered `XExecutionProvider`" log line (and this crate's own
|
||||||
|
//! selection logging) go nowhere — which is exactly how a GPU execution
|
||||||
|
//! provider can silently no-op back to CPU with zero visible error (see
|
||||||
|
//! [`provider`]'s doc comment for the concrete history behind this). All
|
||||||
|
//! three current consumers already call `tracing_subscriber::fmt().init()`
|
||||||
|
//! (or an `EnvFilter`-configured equivalent) at startup; any new consumer
|
||||||
|
//! must do the same before calling [`session::build_session`].
|
||||||
|
//!
|
||||||
|
//! - [`provider`] — the [`provider::Provider`] enum and the
|
||||||
|
//! MIGraphX-not-ROCm default rationale.
|
||||||
|
//! - [`session`] — session construction with EP fallback + loud logging.
|
||||||
|
//! - [`embedding`] — the shared tokenize → tensor → mean-pool → normalize
|
||||||
|
//! pipeline for BERT-family embedding models.
|
||||||
|
//! - [`download`] — model download with atomic write + optional SHA-256
|
||||||
|
//! integrity check.
|
||||||
|
|
||||||
|
pub mod download;
|
||||||
|
pub mod embedding;
|
||||||
|
pub mod provider;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use provider::Provider;
|
||||||
|
pub use session::build_session;
|
||||||
113
bread-onnx/src/provider.rs
Normal file
113
bread-onnx/src/provider.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
//! Execution-provider selection.
|
||||||
|
//!
|
||||||
|
//! This crate defaults AMD iGPU acceleration to
|
||||||
|
//! [`ort::ep::MIGraphX`](ort::ep::MIGraphX), *not*
|
||||||
|
//! [`ort::ep::ROCm`](ort::ep::ROCm), on purpose. `breadpad-shared/src/
|
||||||
|
//! classifier.rs::try_load_session` used the classic `ROCMExecutionProvider`
|
||||||
|
//! and — per the hard-won lesson recorded in this machine's own operator
|
||||||
|
//! notes (`breadsearch-gpu-backends`, from `breadsearch`'s own history) —
|
||||||
|
//! that EP silently no-ops on this class of system and falls back to CPU
|
||||||
|
//! with zero visible error: distro ROCm ONNX Runtime builds (e.g. Arch's
|
||||||
|
//! `onnxruntime-rocm`) are commonly compiled with `--use_migraphx`, not
|
||||||
|
//! `--use_rocm`, so `ROCMExecutionProvider` never actually registers, and
|
||||||
|
//! nothing surfaces that fact unless a `tracing` subscriber is initialized
|
||||||
|
//! to catch ONNX Runtime's own EP-registration log line. `breadmill/src/
|
||||||
|
//! embed.rs::rocm_session` already got this right; this module promotes
|
||||||
|
//! that provider choice (and the loud logging around it) to the shared
|
||||||
|
//! crate so it can't silently regress in any consumer again.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// A requested execution provider, in the shared vocabulary consumers use.
|
||||||
|
/// Convert to an `ort` dispatch entry with [`Provider::to_dispatch`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Provider {
|
||||||
|
Cpu,
|
||||||
|
/// AMD iGPU/dGPU via MIGraphX (ROCm-backed onnxruntime builds). See this
|
||||||
|
/// module's doc comment for why this — not `ROCm` — is the correct
|
||||||
|
/// choice on this class of system.
|
||||||
|
MiGraphX { device_id: i32 },
|
||||||
|
/// NVIDIA GPU via CUDA.
|
||||||
|
Cuda { device_id: i32 },
|
||||||
|
/// Intel iGPU/dGPU (Arc) via OpenVINO. `cache_dir` stores OpenVINO's
|
||||||
|
/// compiled-model blobs between runs.
|
||||||
|
OpenVino { device_type: String, cache_dir: PathBuf },
|
||||||
|
/// AMD XDNA NPU via the VitisAI execution provider (Ryzen AI SDK).
|
||||||
|
/// `cache_dir` stores the compiled NPU model between runs.
|
||||||
|
Vitis {
|
||||||
|
config_file: PathBuf,
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
cache_key: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Provider::Cpu => "CPU",
|
||||||
|
Provider::MiGraphX { .. } => "MIGraphX (AMD iGPU/dGPU)",
|
||||||
|
Provider::Cuda { .. } => "CUDA (NVIDIA GPU)",
|
||||||
|
Provider::OpenVino { .. } => "OpenVINO (Intel iGPU/dGPU)",
|
||||||
|
Provider::Vitis { .. } => "VitisAI (AMD XDNA NPU)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The literal execution-provider name ONNX Runtime's own log line
|
||||||
|
/// reports on successful registration (e.g. `"Successfully registered
|
||||||
|
/// \`MIGraphXExecutionProvider\`"`) — used to build the loud log hint in
|
||||||
|
/// [`crate::session::build_session`].
|
||||||
|
fn ort_registration_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Provider::Cpu => "CPUExecutionProvider",
|
||||||
|
Provider::MiGraphX { .. } => "MIGraphXExecutionProvider",
|
||||||
|
Provider::Cuda { .. } => "CUDAExecutionProvider",
|
||||||
|
Provider::OpenVino { .. } => "OpenVINOExecutionProvider",
|
||||||
|
Provider::Vitis { .. } => "VitisAIExecutionProvider",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_dispatch(&self) -> anyhow::Result<ort::ep::ExecutionProviderDispatch> {
|
||||||
|
Ok(match self {
|
||||||
|
Provider::Cpu => ort::ep::CPU::default().build(),
|
||||||
|
Provider::MiGraphX { device_id } => {
|
||||||
|
ort::ep::MIGraphX::default().with_device_id(*device_id).build()
|
||||||
|
}
|
||||||
|
Provider::Cuda { device_id } => {
|
||||||
|
ort::ep::CUDA::default().with_device_id(*device_id).build()
|
||||||
|
}
|
||||||
|
Provider::OpenVino { device_type, cache_dir } => {
|
||||||
|
std::fs::create_dir_all(cache_dir)?;
|
||||||
|
ort::ep::OpenVINO::default()
|
||||||
|
.with_device_type(device_type.clone())
|
||||||
|
.with_cache_dir(cache_dir.to_string_lossy())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
Provider::Vitis { config_file, cache_dir, cache_key } => {
|
||||||
|
std::fs::create_dir_all(cache_dir)?;
|
||||||
|
ort::ep::Vitis::default()
|
||||||
|
.with_config_file(config_file.to_string_lossy())
|
||||||
|
.with_cache_dir(cache_dir.to_string_lossy())
|
||||||
|
.with_cache_key(cache_key.clone())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a loud, consistent "using X" line plus (for non-CPU providers) a
|
||||||
|
/// reminder of exactly what to grep ONNX Runtime's own log output for —
|
||||||
|
/// this is the "at minimum log EP registration success/failure loudly
|
||||||
|
/// by default" half of the fix, independent of whether the caller has
|
||||||
|
/// wired up `tracing_subscriber` (see [`crate::init_tracing`]).
|
||||||
|
pub(crate) fn log_selection(&self) {
|
||||||
|
tracing::info!("bread-onnx: requesting {} execution provider", self.name());
|
||||||
|
if !matches!(self, Provider::Cpu) {
|
||||||
|
tracing::info!(
|
||||||
|
"bread-onnx: check ONNX Runtime's own log output for \"Successfully registered \
|
||||||
|
`{}`\" — if it's missing, the ONNX Runtime build in use wasn't compiled/shipped \
|
||||||
|
with this provider and inference silently fell back to CPU. This line only \
|
||||||
|
appears if a `tracing` subscriber is initialized (see `bread_onnx::init_tracing`).",
|
||||||
|
self.ort_registration_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
bread-onnx/src/session.rs
Normal file
49
bread-onnx/src/session.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
//! Session construction with execution-provider fallback.
|
||||||
|
//!
|
||||||
|
//! Builds one `ort::session::Session` whose execution-provider dispatch
|
||||||
|
//! list is exactly `providers` (in order) with an implicit `CPU` appended
|
||||||
|
//! if the caller didn't already include one — ONNX Runtime tries each
|
||||||
|
//! listed EP per-node and falls through the list on failure, so this
|
||||||
|
//! mirrors (and replaces) the identical `.with_execution_providers([primary,
|
||||||
|
//! CPU])` pattern already proven out in `breadmill/src/embed.rs::rocm_session`
|
||||||
|
//! /`cuda_session`/`openvino_session`/`npu_session`.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use ort::session::builder::GraphOptimizationLevel;
|
||||||
|
use ort::session::Session;
|
||||||
|
|
||||||
|
use crate::provider::Provider;
|
||||||
|
|
||||||
|
/// Build a session, trying each of `providers` in order (ONNX Runtime falls
|
||||||
|
/// through per-node on registration failure) with a trailing `CPU` fallback
|
||||||
|
/// implicitly appended if not already present. Always logs which provider
|
||||||
|
/// was requested — see [`Provider::log_selection`] — regardless of whether
|
||||||
|
/// `tracing_subscriber` is initialized, so at minimum the *attempt* is
|
||||||
|
/// visible even without wired-up logging; the actual per-EP success/failure
|
||||||
|
/// detail only surfaces once a subscriber is listening.
|
||||||
|
pub fn build_session(
|
||||||
|
model_path: &Path,
|
||||||
|
opt_level: GraphOptimizationLevel,
|
||||||
|
providers: &[Provider],
|
||||||
|
) -> anyhow::Result<Session> {
|
||||||
|
let mut dispatch = Vec::with_capacity(providers.len() + 1);
|
||||||
|
for p in providers {
|
||||||
|
p.log_selection();
|
||||||
|
dispatch.push(p.to_dispatch()?);
|
||||||
|
}
|
||||||
|
if !providers.iter().any(|p| matches!(p, Provider::Cpu)) {
|
||||||
|
dispatch.push(Provider::Cpu.to_dispatch()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = Session::builder()
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to create ort session builder: {e}"))?
|
||||||
|
.with_optimization_level(opt_level)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to set optimization level: {e}"))?
|
||||||
|
.with_execution_providers(dispatch)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to configure execution providers: {e}"))?;
|
||||||
|
|
||||||
|
builder
|
||||||
|
.commit_from_file(model_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to load model from {}: {e}", model_path.display()))
|
||||||
|
}
|
||||||
30
bread-utils/Cargo.toml
Normal file
30
bread-utils/Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "bread-utils"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Shared plumbing for the bread ecosystem: Hyprland IPC, single-instance toggling, timeout-guarded subprocess execution, atomic file writes, XDG paths, and a GTK4 layer-shell popup scaffold"
|
||||||
|
repository = "https://github.com/Breadway/bread-ecosystem"
|
||||||
|
keywords = ["hyprland", "wayland", "xdg", "gtk4"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||||
|
gtk4-layer-shell = { version = "0.8", optional = true }
|
||||||
|
toml_edit = { version = "0.22", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional
|
||||||
|
# so headless/daemon consumers (breadmon, breadhelp's CLI half, breadcrumbs)
|
||||||
|
# don't have to pull in GTK4 + layer-shell just for `hypr`/`proc`/`xdg`.
|
||||||
|
gtk = ["dep:gtk4", "dep:gtk4-layer-shell"]
|
||||||
|
# Enable the non-destructive TOML doc load/save discipline (bos-settings,
|
||||||
|
# breadhelp). Optional so consumers that don't edit TOML configs (breadbox,
|
||||||
|
# breadclip, breadmon, ...) don't pull in toml_edit.
|
||||||
|
toml = ["dep:toml_edit"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
185
bread-utils/src/atomic.rs
Normal file
185
bread-utils/src/atomic.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
//! Atomic file writes: write to a sibling temp file, then `rename` over the
|
||||||
|
//! target so a crash, power loss, or disk-full error mid-write never leaves
|
||||||
|
//! a truncated/corrupt file behind (a same-filesystem rename is atomic).
|
||||||
|
//!
|
||||||
|
//! Two flavors, both extracted from real (and identical) duplication:
|
||||||
|
//!
|
||||||
|
//! - [`write_atomic`] — temp-then-rename, with an optional Unix `mode` set
|
||||||
|
//! up front (so secrets never exist world-readable even briefly). This is
|
||||||
|
//! `breadcrumbs/src/util.rs::write_atomic`, promoted verbatim.
|
||||||
|
//! - [`write_atomic_backed_up`] — temp-then-rename *plus* a best-effort
|
||||||
|
//! `<path>.bak` copy of whatever was there before, so a successful-but-wrong
|
||||||
|
//! write is always recoverable. This is `bos-settings/src/config/mod.rs`'s
|
||||||
|
//! `atomic_write`, which `breadhelp/src/config.rs` re-implemented
|
||||||
|
//! byte-for-byte in the same fix pass that introduced it (its own doc
|
||||||
|
//! comment says "same discipline as bos-settings/src/config/mod.rs") —
|
||||||
|
//! exactly the kind of fresh duplication this crate exists to remove.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Write `contents` to `path` atomically. `mode` (Unix only) is applied to
|
||||||
|
/// the temp file *before* any data is written, so a file that must stay
|
||||||
|
/// private (secrets, tokens) is never briefly world-readable.
|
||||||
|
pub fn write_atomic(path: &Path, contents: &str, mode: Option<u32>) -> io::Result<()> {
|
||||||
|
write_atomic_bytes(path, contents.as_bytes(), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-oriented sibling of [`write_atomic`], for binary payloads (e.g. a
|
||||||
|
/// downloaded ONNX model file — see `bread-onnx`'s downloader).
|
||||||
|
pub fn write_atomic_bytes(path: &Path, contents: &[u8], mode: Option<u32>) -> io::Result<()> {
|
||||||
|
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
let tmp = tmp_path(path, dir);
|
||||||
|
|
||||||
|
let mut open = fs::OpenOptions::new();
|
||||||
|
open.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(mode) = mode {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
open.mode(mode);
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = mode;
|
||||||
|
|
||||||
|
let res = (|| {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = open.open(&tmp)?;
|
||||||
|
f.write_all(contents)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
fs::rename(&tmp, path)
|
||||||
|
})();
|
||||||
|
if res.is_err() {
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`write_atomic`] (no `mode`), but first best-effort copies whatever
|
||||||
|
/// is currently at `path` to `<path>.bak`. The backup is best-effort — a
|
||||||
|
/// failure to back up (e.g. read-only source, first-ever write) does not
|
||||||
|
/// block the write itself.
|
||||||
|
pub fn write_atomic_backed_up(path: &Path, contents: &str) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
if path.exists() {
|
||||||
|
let backup = backup_path(path);
|
||||||
|
let _ = fs::copy(path, &backup);
|
||||||
|
}
|
||||||
|
write_atomic(path, contents, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp_path(path: &Path, dir: &Path) -> PathBuf {
|
||||||
|
let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or("bread");
|
||||||
|
dir.join(format!(".{stem}.tmp.{}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backup_path(path: &Path) -> PathBuf {
|
||||||
|
backup_path_for(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<path>.bak` — shared with [`crate::tomlcfg`] so its own backup-before-
|
||||||
|
/// falling-back-to-defaults logging points at the same file this module
|
||||||
|
/// would have backed up to on a write.
|
||||||
|
pub(crate) fn backup_path_for(path: &Path) -> PathBuf {
|
||||||
|
PathBuf::from(format!("{}.bak", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
fn tmp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bread-utils-atomic-test-{name}-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_creates_file_with_contents() {
|
||||||
|
let dir = tmp_dir("basic");
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
write_atomic(&path, "hello", None).unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_leaves_no_tmp_file_behind() {
|
||||||
|
let dir = tmp_dir("no-leftover");
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
write_atomic(&path, "hello", None).unwrap();
|
||||||
|
let leftover: Vec<_> = fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| n.contains(".tmp."))
|
||||||
|
.collect();
|
||||||
|
assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}");
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_applies_mode_before_any_data_hits_disk() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tmp_dir("mode");
|
||||||
|
let path = dir.join("secret");
|
||||||
|
write_atomic(&path, "token", Some(0o600)).unwrap();
|
||||||
|
let perms = fs::metadata(&path).unwrap().permissions();
|
||||||
|
assert_eq!(perms.mode() & 0o777, 0o600);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_backed_up_backs_up_previous_contents() {
|
||||||
|
let dir = tmp_dir("backup");
|
||||||
|
let path = dir.join("state.toml");
|
||||||
|
let backup = dir.join("state.toml.bak");
|
||||||
|
|
||||||
|
write_atomic_backed_up(&path, "first").unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "first");
|
||||||
|
assert!(!backup.exists(), "no backup should exist before the first overwrite");
|
||||||
|
|
||||||
|
write_atomic_backed_up(&path, "second").unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "second");
|
||||||
|
assert_eq!(fs::read_to_string(&backup).unwrap(), "first");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_backed_up_leaves_no_tmp_file_behind() {
|
||||||
|
let dir = tmp_dir("backup-no-leftover");
|
||||||
|
let path = dir.join("state.toml");
|
||||||
|
write_atomic_backed_up(&path, "first").unwrap();
|
||||||
|
write_atomic_backed_up(&path, "second").unwrap();
|
||||||
|
let leftover: Vec<_> = fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| n.contains(".tmp."))
|
||||||
|
.collect();
|
||||||
|
assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}");
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_overwrite_never_leaves_partial_contents_visible() {
|
||||||
|
// Not a true crash-injection test (hard to do portably), but pins
|
||||||
|
// down the observable contract: after a successful call, the file
|
||||||
|
// is either fully old or fully new, never truncated.
|
||||||
|
let dir = tmp_dir("no-partial");
|
||||||
|
let path = dir.join("f");
|
||||||
|
write_atomic(&path, "aaaaaaaaaa", None).unwrap();
|
||||||
|
write_atomic(&path, "b", None).unwrap();
|
||||||
|
let mut s = String::new();
|
||||||
|
fs::File::open(&path).unwrap().read_to_string(&mut s).unwrap();
|
||||||
|
assert_eq!(s, "b");
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
bread-utils/src/gtk_popup.rs
Normal file
111
bread-utils/src/gtk_popup.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//! Shared GTK4 layer-shell popup scaffold: the full-screen transparent
|
||||||
|
//! overlay window setup, `ListBox` up/down visible-row navigation, and
|
||||||
|
//! click-outside-to-close gesture were duplicated near-verbatim between
|
||||||
|
//! `breadbox/src/main.rs` and `breadclip/src/main.rs`:
|
||||||
|
//!
|
||||||
|
//! - Layer-shell window setup: `breadbox/src/main.rs:357-365` /
|
||||||
|
//! `breadclip/src/main.rs:231-239` — identical `init_layer_shell` +
|
||||||
|
//! namespace + `Layer::Overlay` + `KeyboardMode::Exclusive` + anchor all
|
||||||
|
//! four edges + zero exclusive zone.
|
||||||
|
//! - Up/Down navigation loop: `breadbox/src/main.rs:515-546` /
|
||||||
|
//! `breadclip/src/main.rs:423-454` — byte-for-byte identical "find the
|
||||||
|
//! next/previous *visible* row" loop (breadclip's own comment even reads
|
||||||
|
//! `// ---- Keyboard handler (capture phase, same as breadbox) ----`).
|
||||||
|
//! - Click-outside-close: `breadbox/src/main.rs:566-581` /
|
||||||
|
//! `breadclip/src/main.rs:474-...` — identical bounds-check against a
|
||||||
|
//! content widget (breadclip: `// ---- Click outside panel → close (same
|
||||||
|
//! pattern as breadbox) ----`).
|
||||||
|
//!
|
||||||
|
//! Deliberately *not* extracted: the rest of each app's `EventControllerKey`
|
||||||
|
//! handling (Enter/Delete semantics, filter chips, search) — those differ
|
||||||
|
//! per app (`do_launch` vs `do_copy`+`Delete`-to-remove) and forcing them
|
||||||
|
//! into one callback-owning "scaffold" struct would be a leakier
|
||||||
|
//! abstraction than the ~5 free functions below.
|
||||||
|
//!
|
||||||
|
//! Requires the `gtk` feature.
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{ApplicationWindow, GestureClick};
|
||||||
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
|
|
||||||
|
/// Build the full-screen transparent overlay window every layer-shell popup
|
||||||
|
/// in this ecosystem starts from: layered above normal windows, keyboard-
|
||||||
|
/// exclusive (so Escape/Enter/arrow keys reach the popup instead of the
|
||||||
|
/// focused client behind it), anchored to all four edges with zero
|
||||||
|
/// exclusive zone (so it doesn't reserve screen space or push other layer
|
||||||
|
/// clients around).
|
||||||
|
pub fn new_overlay_window(app: >k4::Application, namespace: &str) -> ApplicationWindow {
|
||||||
|
let window = ApplicationWindow::builder().application(app).build();
|
||||||
|
window.init_layer_shell();
|
||||||
|
window.set_namespace(Some(namespace));
|
||||||
|
window.set_layer(Layer::Overlay);
|
||||||
|
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
||||||
|
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
|
||||||
|
window.set_anchor(edge, true);
|
||||||
|
}
|
||||||
|
window.set_exclusive_zone(0);
|
||||||
|
window
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the next *visible* row after the current selection (rows can be
|
||||||
|
/// hidden by a live search filter — a plain "select index + 1" would land
|
||||||
|
/// on a filtered-out row). No-op if there is no next visible row.
|
||||||
|
pub fn select_next_visible(list: >k4::ListBox) {
|
||||||
|
let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1);
|
||||||
|
let mut i = cur + 1;
|
||||||
|
loop {
|
||||||
|
match list.row_at_index(i) {
|
||||||
|
Some(r) if r.is_visible() => {
|
||||||
|
list.select_row(Some(&r));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(_) => i += 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the previous *visible* row before the current selection. No-op if
|
||||||
|
/// there is no previous visible row.
|
||||||
|
pub fn select_prev_visible(list: >k4::ListBox) {
|
||||||
|
let cur = list.selected_row().map(|r| r.index()).unwrap_or(0);
|
||||||
|
let mut i = cur - 1;
|
||||||
|
loop {
|
||||||
|
if i < 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match list.row_at_index(i) {
|
||||||
|
Some(r) if r.is_visible() => {
|
||||||
|
list.select_row(Some(&r));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(_) => i -= 1,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a click gesture to `window` that calls `on_outside` whenever a
|
||||||
|
/// click lands outside `content`'s bounds (e.g. clicking the transparent
|
||||||
|
/// full-screen backdrop around a centered launcher/panel widget).
|
||||||
|
pub fn close_on_outside_click(
|
||||||
|
window: &ApplicationWindow,
|
||||||
|
content: &impl IsA<gtk4::Widget>,
|
||||||
|
on_outside: impl Fn() + 'static,
|
||||||
|
) {
|
||||||
|
let content = content.clone().upcast::<gtk4::Widget>();
|
||||||
|
let win_ref = window.clone();
|
||||||
|
let gesture = GestureClick::new();
|
||||||
|
gesture.connect_pressed(move |_, _, x, y| {
|
||||||
|
if let Some(b) = content.compute_bounds(&win_ref) {
|
||||||
|
if x < b.x() as f64
|
||||||
|
|| x > (b.x() + b.width()) as f64
|
||||||
|
|| y < b.y() as f64
|
||||||
|
|| y > (b.y() + b.height()) as f64
|
||||||
|
{
|
||||||
|
on_outside();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.add_controller(gesture);
|
||||||
|
}
|
||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
bread-utils/src/lib.rs
Normal file
32
bread-utils/src/lib.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
//! Shared plumbing for the bread desktop-automation ecosystem.
|
||||||
|
//!
|
||||||
|
//! Extracted from genuine, verified duplication across breadbox, breadclip,
|
||||||
|
//! breadmon, breadcrumbs, bos-settings, and breadhelp during the 2026-07-16
|
||||||
|
//! ecosystem-wide utility audit. Each module's doc comment cites the
|
||||||
|
//! original file:line locations the code was extracted from.
|
||||||
|
//!
|
||||||
|
//! - [`hypr`] — Hyprland IPC: socket path resolution, socket1
|
||||||
|
//! request/response, typed `activewindow`/`monitors` queries with
|
||||||
|
//! version-tolerant `fullscreen` field parsing.
|
||||||
|
//! - [`singleton`] — correct, TOCTOU-free single-instance/PID-toggle.
|
||||||
|
//! - [`proc`] — timeout-guarded subprocess execution.
|
||||||
|
//! - [`atomic`] — atomic (temp-then-rename) file writes, with an optional
|
||||||
|
//! `.bak`-before-overwrite variant.
|
||||||
|
//! - [`xdg`] — XDG base directory helpers with a real (never literal-tilde)
|
||||||
|
//! `$HOME` fallback.
|
||||||
|
//! - [`tomlcfg`] (feature `toml`) — non-destructive TOML document
|
||||||
|
//! load/save discipline built on [`atomic`].
|
||||||
|
//! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup,
|
||||||
|
//! list navigation, and click-outside-to-close.
|
||||||
|
|
||||||
|
pub mod atomic;
|
||||||
|
pub mod hypr;
|
||||||
|
pub mod proc;
|
||||||
|
pub mod singleton;
|
||||||
|
pub mod xdg;
|
||||||
|
|
||||||
|
#[cfg(feature = "toml")]
|
||||||
|
pub mod tomlcfg;
|
||||||
|
|
||||||
|
#[cfg(feature = "gtk")]
|
||||||
|
pub mod gtk_popup;
|
||||||
174
bread-utils/src/proc.rs
Normal file
174
bread-utils/src/proc.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
//! Timeout-guarded subprocess execution.
|
||||||
|
//!
|
||||||
|
//! Promoted verbatim from `breadcrumbs/src/util.rs` (the one implementation
|
||||||
|
//! in the ecosystem that already got this right — see the audit note in
|
||||||
|
//! `bread-utils`'s crate root). Several other repos shell out to
|
||||||
|
//! Wayland/Hyprland tools (`hyprctl`, `grim`, `wl-paste`, ...) via bare
|
||||||
|
//! `std::process::Command` with no timeout at all, so a hung child can wedge
|
||||||
|
//! the whole caller indefinitely. `run`/`run_with_stdin` below kill the
|
||||||
|
//! child and return a failed [`Output`] once `timeout` elapses instead.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Output {
|
||||||
|
pub success: bool,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Output {
|
||||||
|
pub fn failed() -> Output {
|
||||||
|
Output {
|
||||||
|
success: false,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a command with a hard timeout. The child is killed if it overruns so
|
||||||
|
/// a hung subprocess can never wedge the caller.
|
||||||
|
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
||||||
|
run_with_stdin(prog, args, None, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`run`], but feeds `stdin` to the child's standard input. Useful for
|
||||||
|
/// handing secrets (e.g. Wi-Fi PSKs, API tokens) to a CLI without exposing
|
||||||
|
/// them in argv, where any local user could read them via `ps`.
|
||||||
|
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||||
|
let stdin_cfg = if stdin.is_some() {
|
||||||
|
Stdio::piped()
|
||||||
|
} else {
|
||||||
|
Stdio::null()
|
||||||
|
};
|
||||||
|
let mut child = match Command::new(prog)
|
||||||
|
.args(args)
|
||||||
|
// Pin the C locale so message text callers parse (hyprctl JSON keys,
|
||||||
|
// status output, ...) is stable regardless of the user's LANG.
|
||||||
|
.env("LC_ALL", "C")
|
||||||
|
.env("LANG", "C")
|
||||||
|
.stdin(stdin_cfg)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Output::failed(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stdout_pipe = child.stdout.take();
|
||||||
|
let mut stderr_pipe = child.stderr.take();
|
||||||
|
|
||||||
|
let out_handle = thread::spawn(move || {
|
||||||
|
let mut buf = String::new();
|
||||||
|
if let Some(ref mut p) = stdout_pipe {
|
||||||
|
let _ = p.read_to_string(&mut buf);
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
let err_handle = thread::spawn(move || {
|
||||||
|
let mut buf = String::new();
|
||||||
|
if let Some(ref mut p) = stderr_pipe {
|
||||||
|
let _ = p.read_to_string(&mut buf);
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feed stdin only after the reader threads are draining stdout/stderr, so
|
||||||
|
// a child that writes more than a pipe buffer before consuming stdin
|
||||||
|
// can't deadlock against our blocking write.
|
||||||
|
if let Some(data) = stdin {
|
||||||
|
if let Some(mut sink) = child.stdin.take() {
|
||||||
|
let _ = sink.write_all(data.as_bytes());
|
||||||
|
// Drop closes the pipe so the child's read sees EOF.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let status = loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(s)) => break Some(s),
|
||||||
|
Ok(None) => {
|
||||||
|
if start.elapsed() >= timeout {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(_) => break None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = out_handle.join().unwrap_or_default();
|
||||||
|
let stderr = err_handle.join().unwrap_or_default();
|
||||||
|
|
||||||
|
Output {
|
||||||
|
success: status.map(|s| s.success()).unwrap_or(false),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
|
||||||
|
run(prog, args, timeout).success
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a command and parse its stdout as JSON on success. Convenience for the
|
||||||
|
/// very common `hyprctl -j <subcommand>` / `<tool> --json` pattern.
|
||||||
|
pub fn run_json(prog: &str, args: &[&str], timeout: Duration) -> Option<serde_json::Value> {
|
||||||
|
let out = run(prog, args, timeout);
|
||||||
|
if !out.success {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
serde_json::from_str(&out.stdout).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_captures_stdout() {
|
||||||
|
let out = run("printf", &["hello"], Duration::from_secs(2));
|
||||||
|
assert!(out.success);
|
||||||
|
assert_eq!(out.stdout, "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_reports_failure_for_nonzero_exit() {
|
||||||
|
let out = run("sh", &["-c", "exit 3"], Duration::from_secs(2));
|
||||||
|
assert!(!out.success);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_kills_hung_child_after_timeout() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let out = run("sleep", &["30"], Duration::from_millis(200));
|
||||||
|
assert!(!out.success);
|
||||||
|
assert!(start.elapsed() < Duration::from_secs(5), "child was not killed promptly");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_with_stdin_feeds_child_input() {
|
||||||
|
let out = run_with_stdin("cat", &[], Some("secret-data"), Duration::from_secs(2));
|
||||||
|
assert!(out.success);
|
||||||
|
assert_eq!(out.stdout, "secret-data");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_json_parses_stdout() {
|
||||||
|
let out = run_json("printf", &["{\"a\":1}"], Duration::from_secs(2));
|
||||||
|
assert_eq!(out.unwrap()["a"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_json_returns_none_on_failure() {
|
||||||
|
let out = run_json("sh", &["-c", "exit 1"], Duration::from_secs(2));
|
||||||
|
assert!(out.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
203
bread-utils/src/singleton.rs
Normal file
203
bread-utils/src/singleton.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
//! Correct single-instance / PID-toggle, replacing the TOCTOU-prone pattern
|
||||||
|
//! duplicated in `breadbox/src/main.rs` (`toggle_or_continue`/`pid_file`,
|
||||||
|
//! ~30 lines) and `breadclip/src/main.rs` (same function names, whose own
|
||||||
|
//! comment reads `// ---- PID file toggle (single-instance, matches breadbox
|
||||||
|
//! pattern) ----`).
|
||||||
|
//!
|
||||||
|
//! The old pattern: read the PID file, `/proc/<pid>/comm`-check whether it's
|
||||||
|
//! still this app, `kill` it if so, otherwise `fs::write` our own PID over
|
||||||
|
//! it. That's three separate, non-atomic steps — two instances launched at
|
||||||
|
//! once can both read "no valid PID" and both proceed as the "first"
|
||||||
|
//! instance; a stale PID file left by a crash can also collide with an
|
||||||
|
//! unrelated process that was later assigned the same PID by the kernel,
|
||||||
|
//! sending it a `kill` it never asked for.
|
||||||
|
//!
|
||||||
|
//! This module instead holds an exclusive, kernel-atomic advisory lock
|
||||||
|
//! (`std::fs::File::try_lock`, i.e. `flock(2)`) on the PID file for the
|
||||||
|
//! entire lifetime of the process that acquires it. Lock ownership itself
|
||||||
|
//! *is* the liveness check — there is no window where two processes can
|
||||||
|
//! both believe they're the sole instance, and a crashed process's lock is
|
||||||
|
//! released by the kernel the instant it dies, so there's no stale-lock
|
||||||
|
//! case to reason about at all.
|
||||||
|
//!
|
||||||
|
//! [`try_acquire`] is the side-effect-free primitive (no signals sent);
|
||||||
|
//! [`toggle_or_kill`] layers breadbox/breadclip's actual desired behavior
|
||||||
|
//! (kill whoever's running, then exit) on top of it.
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Held for the lifetime of the running instance. Dropping it releases the
|
||||||
|
/// flock and removes the PID file. Keep this alive (e.g. in a `let _guard =
|
||||||
|
/// ...` bound in `main`) for as long as the app should be considered "the"
|
||||||
|
/// running instance.
|
||||||
|
pub struct Guard {
|
||||||
|
_file: File,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Guard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Acquire {
|
||||||
|
/// No other instance was running; we now hold the lock.
|
||||||
|
Acquired(Guard),
|
||||||
|
/// Another instance already holds the lock and is therefore alive right
|
||||||
|
/// now. Carries whatever PID it last recorded, if the file contents
|
||||||
|
/// parsed as one.
|
||||||
|
HeldByOther(Option<u32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Toggle {
|
||||||
|
/// No other instance was running; we now hold the lock. Keep the guard
|
||||||
|
/// alive for the process's lifetime.
|
||||||
|
Started(Guard),
|
||||||
|
/// Another instance was already running and (if a PID could be read
|
||||||
|
/// from the file) has been sent `SIGTERM`. The caller should exit
|
||||||
|
/// immediately without starting.
|
||||||
|
KilledExisting,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$XDG_RUNTIME_DIR/<app>.pid` (falling back to `/tmp`, matching every
|
||||||
|
/// existing consumer's own fallback) — same location `breadbox`/`breadclip`
|
||||||
|
/// already used.
|
||||||
|
pub fn pid_file_path(app: &str) -> PathBuf {
|
||||||
|
crate::xdg::runtime_dir().join(format!("{app}.pid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to become the single instance of `app`, with no side effects beyond
|
||||||
|
/// the lock/file itself — in particular, unlike [`toggle_or_kill`], this
|
||||||
|
/// never signals another process. Prefer this if your app wants different
|
||||||
|
/// behavior than "kill the existing instance" (e.g. just refuse to start a
|
||||||
|
/// second copy).
|
||||||
|
pub fn try_acquire(app: &str) -> std::io::Result<Acquire> {
|
||||||
|
let path = pid_file_path(app);
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.open(&path)?;
|
||||||
|
|
||||||
|
match file.try_lock() {
|
||||||
|
Ok(()) => {
|
||||||
|
file.set_len(0)?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
write!(file, "{}", std::process::id())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
Ok(Acquire::Acquired(Guard { _file: file, path }))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let mut contents = String::new();
|
||||||
|
let _ = file.read_to_string(&mut contents);
|
||||||
|
Ok(Acquire::HeldByOther(contents.trim().parse::<u32>().ok()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle behavior: acquire the single-instance lock for `app`. If already
|
||||||
|
/// held by another live process, signal it to quit (`SIGTERM` via `kill`)
|
||||||
|
/// and return [`Toggle::KilledExisting`] — the caller should exit. Otherwise
|
||||||
|
/// take the lock and return [`Toggle::Started`] — the caller should proceed
|
||||||
|
/// and keep the guard alive.
|
||||||
|
pub fn toggle_or_kill(app: &str) -> std::io::Result<Toggle> {
|
||||||
|
Ok(match try_acquire(app)? {
|
||||||
|
Acquire::Acquired(guard) => Toggle::Started(guard),
|
||||||
|
Acquire::HeldByOther(Some(pid)) => {
|
||||||
|
kill(pid);
|
||||||
|
Toggle::KilledExisting
|
||||||
|
}
|
||||||
|
Acquire::HeldByOther(None) => Toggle::KilledExisting,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn kill(pid: u32) {
|
||||||
|
// Shells out rather than binding libc directly, matching how every
|
||||||
|
// existing consumer already did this (`Command::new("kill")`) — no new
|
||||||
|
// dependency for a one-shot signal.
|
||||||
|
let _ = std::process::Command::new("kill")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn kill(_pid: u32) {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn unique_app(name: &str) -> String {
|
||||||
|
format!("bread-utils-singleton-test-{name}-{}", std::process::id())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_acquire_succeeds_and_releases_on_drop() {
|
||||||
|
let app = unique_app("first");
|
||||||
|
match try_acquire(&app).unwrap() {
|
||||||
|
Acquire::Acquired(_guard) => {}
|
||||||
|
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
|
||||||
|
}
|
||||||
|
// Guard dropped at end of scope; pid file should be gone.
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
assert!(!pid_file_path(&app).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_acquire_while_first_is_held_reports_held_by_other_with_our_pid() {
|
||||||
|
let app = unique_app("second");
|
||||||
|
let guard = match try_acquire(&app).unwrap() {
|
||||||
|
Acquire::Acquired(g) => g,
|
||||||
|
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// A second attempt while the first guard is still held must not be
|
||||||
|
// able to acquire the lock too — that's the whole point. No signal
|
||||||
|
// is sent by `try_acquire` itself (that's `toggle_or_kill`'s job),
|
||||||
|
// so this is safe to assert without affecting the test process.
|
||||||
|
match try_acquire(&app).unwrap() {
|
||||||
|
Acquire::HeldByOther(pid) => assert_eq!(pid, Some(std::process::id())),
|
||||||
|
Acquire::Acquired(_) => panic!("second acquire succeeded while the first still holds the lock"),
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_is_released_after_guard_drop_so_a_later_instance_can_acquire() {
|
||||||
|
let app = unique_app("release");
|
||||||
|
let guard = match try_acquire(&app).unwrap() {
|
||||||
|
Acquire::Acquired(g) => g,
|
||||||
|
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
|
||||||
|
};
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
match try_acquire(&app).unwrap() {
|
||||||
|
Acquire::Acquired(_g) => {}
|
||||||
|
Acquire::HeldByOther(_) => panic!("lock should have been released when the guard was dropped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_or_kill_starts_when_nothing_else_is_running() {
|
||||||
|
let app = unique_app("toggle-start");
|
||||||
|
match toggle_or_kill(&app).unwrap() {
|
||||||
|
Toggle::Started(_guard) => {}
|
||||||
|
Toggle::KilledExisting => panic!("expected to start as the first instance"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately not unit-tested: `toggle_or_kill`'s kill-the-existing-
|
||||||
|
// instance branch. Exercising it for real means sending a real SIGTERM
|
||||||
|
// to a real process; the only PID a test process can safely target is
|
||||||
|
// its own (as a stand-in "other instance" via a shared PID file), and
|
||||||
|
// doing that would SIGTERM the test binary itself. The branch is a
|
||||||
|
// two-line, directly-inspectable call to `kill()` gated on
|
||||||
|
// `HeldByOther(Some(pid))`, which the `second_acquire_...` test above
|
||||||
|
// already exercises up to (and excluding) the signal send.
|
||||||
|
}
|
||||||
100
bread-utils/src/tomlcfg.rs
Normal file
100
bread-utils/src/tomlcfg.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
//! Non-destructive TOML config editing discipline.
|
||||||
|
//!
|
||||||
|
//! Extracted from `bos-settings/src/config/mod.rs` (`load_doc`/`save_doc`)
|
||||||
|
//! and `breadhelp/src/config.rs`, which re-implemented the exact same
|
||||||
|
//! function bodies in the same fix pass that introduced `bos-settings`'s
|
||||||
|
//! version — right down to the eprintln wording template. Both parse into a
|
||||||
|
//! `toml_edit::DocumentMut` (preserving keys/comments/formatting this app
|
||||||
|
//! doesn't model) and back up a file that exists but fails to parse, once,
|
||||||
|
//! before falling back to an empty document — so a bad edit is always
|
||||||
|
//! recoverable from `<path>.bak` instead of silently destroying whatever the
|
||||||
|
//! file used to hold.
|
||||||
|
//!
|
||||||
|
//! Requires the `toml` feature.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use toml_edit::DocumentMut;
|
||||||
|
|
||||||
|
/// Load a TOML file into an editable document. A missing file yields an
|
||||||
|
/// empty document (normal for a fresh install). A file that *exists* but
|
||||||
|
/// fails to parse is backed up to `<path>.bak` once before falling back to
|
||||||
|
/// an empty document, so the next [`save_doc`] doesn't silently overwrite an
|
||||||
|
/// unparseable-but-recoverable file with only the caller's modelled keys.
|
||||||
|
///
|
||||||
|
/// `app` is used only to prefix the parse-failure log line (e.g.
|
||||||
|
/// `"breadhelp"`, `"bos-settings"`).
|
||||||
|
pub fn load_doc(app: &str, path: &Path) -> DocumentMut {
|
||||||
|
let Ok(text) = std::fs::read_to_string(path) else {
|
||||||
|
return DocumentMut::default();
|
||||||
|
};
|
||||||
|
match text.parse::<DocumentMut>() {
|
||||||
|
Ok(doc) => doc,
|
||||||
|
Err(e) => {
|
||||||
|
let backup = super::atomic::backup_path_for(path);
|
||||||
|
eprintln!(
|
||||||
|
"{app}: {} failed to parse ({e}); backed up to {} before falling back to defaults",
|
||||||
|
path.display(),
|
||||||
|
backup.display()
|
||||||
|
);
|
||||||
|
let _ = std::fs::write(&backup, &text);
|
||||||
|
DocumentMut::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the document back to disk atomically (temp-then-rename), backing up
|
||||||
|
/// whatever was there before overwriting it — see
|
||||||
|
/// [`crate::atomic::write_atomic_backed_up`].
|
||||||
|
pub fn save_doc(path: &Path, doc: &DocumentMut) -> std::io::Result<()> {
|
||||||
|
super::atomic::write_atomic_backed_up(path, &doc.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use toml_edit::value;
|
||||||
|
|
||||||
|
fn tmp_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bread-utils-tomlcfg-test-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_yields_empty_document() {
|
||||||
|
let dir = tmp_dir("missing");
|
||||||
|
let doc = load_doc("test", &dir.join("nope.toml"));
|
||||||
|
assert!(doc.is_empty());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_then_load_round_trips() {
|
||||||
|
let dir = tmp_dir("roundtrip");
|
||||||
|
let path = dir.join("state.toml");
|
||||||
|
let mut doc = DocumentMut::default();
|
||||||
|
doc["general"]["mode"] = value("dad");
|
||||||
|
save_doc(&path, &doc).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_doc("test", &path);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()),
|
||||||
|
Some("dad")
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unparseable_existing_file_is_backed_up_before_falling_back() {
|
||||||
|
let dir = tmp_dir("bad-parse");
|
||||||
|
let path = dir.join("state.toml");
|
||||||
|
std::fs::write(&path, "this is not [ valid toml").unwrap();
|
||||||
|
|
||||||
|
let doc = load_doc("test", &path);
|
||||||
|
assert!(doc.is_empty());
|
||||||
|
let backup = dir.join("state.toml.bak");
|
||||||
|
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "this is not [ valid toml");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
bread-utils/src/xdg.rs
Normal file
95
bread-utils/src/xdg.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//! XDG base directory helpers.
|
||||||
|
//!
|
||||||
|
//! Several repos independently rolled `dirs::data_local_dir().unwrap_or_else(||
|
||||||
|
//! PathBuf::from("~/.local/share"))`-shaped fallbacks. The literal-tilde
|
||||||
|
//! string is the bug: `PathBuf`/`std::fs` never expand `~`, so on the rare
|
||||||
|
//! box where `dirs` can't resolve a home directory (no `HOME` env var, e.g.
|
||||||
|
//! some container/systemd-service contexts) the fallback silently resolves
|
||||||
|
//! to a directory literally named `~` in the process's current working
|
||||||
|
//! directory instead of the user's actual home. Confirmed present in:
|
||||||
|
//! - `breadclip-core/src/lib.rs:171-175` (`data_dir`)
|
||||||
|
//! - `breadpad-shared/src/classifier.rs:34-39` (`model_dir`)
|
||||||
|
//! - `breadpad-shared/src/config.rs:214-219` and `:221-226`
|
||||||
|
//! (`config_path`, `style_css_path`)
|
||||||
|
//!
|
||||||
|
//! The helpers here resolve a real `$HOME` (via `dirs::home_dir()`, which
|
||||||
|
//! itself falls back to reading `HOME` directly) before ever falling back,
|
||||||
|
//! so the fallback path is always an absolute, expanded path.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn home_or_root() -> PathBuf {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$XDG_CONFIG_HOME` (only if it's set to an absolute path) or `~/.config`,
|
||||||
|
/// joined with `app`.
|
||||||
|
pub fn config_dir(app: &str) -> PathBuf {
|
||||||
|
base_config_dir().join(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$XDG_DATA_HOME` (only if absolute) or `~/.local/share`, joined with `app`.
|
||||||
|
pub fn data_dir(app: &str) -> PathBuf {
|
||||||
|
dirs::data_local_dir()
|
||||||
|
.unwrap_or_else(|| home_or_root().join(".local/share"))
|
||||||
|
.join(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$XDG_CACHE_HOME` (only if absolute) or `~/.cache`, joined with `app`.
|
||||||
|
pub fn cache_dir(app: &str) -> PathBuf {
|
||||||
|
dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| home_or_root().join(".cache"))
|
||||||
|
.join(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$XDG_RUNTIME_DIR`, falling back to `/tmp` — matches the fallback every
|
||||||
|
/// consumer (breadbox, breadclip, breadmon) already used for PID/socket
|
||||||
|
/// scratch files, which don't need to survive a reboot.
|
||||||
|
pub fn runtime_dir() -> PathBuf {
|
||||||
|
std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_config_dir() -> PathBuf {
|
||||||
|
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
let p = PathBuf::from(xdg);
|
||||||
|
if p.is_absolute() {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs::config_dir().unwrap_or_else(|| home_or_root().join(".config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_dir_joins_app_name() {
|
||||||
|
let d = config_dir("breadpad");
|
||||||
|
assert!(d.ends_with("breadpad"));
|
||||||
|
assert!(d.is_absolute());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_dir_never_contains_literal_tilde() {
|
||||||
|
// Regression guard for the exact bug this module replaces: the
|
||||||
|
// fallback must never be a literal "~/..." path component.
|
||||||
|
let d = data_dir("breadclip");
|
||||||
|
assert!(!d.components().any(|c| c.as_os_str() == "~"));
|
||||||
|
assert!(d.is_absolute());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_dir_is_absolute() {
|
||||||
|
assert!(cache_dir("breadsearch").is_absolute());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_dir_falls_back_to_tmp() {
|
||||||
|
// We don't unset XDG_RUNTIME_DIR here (test isolation), just confirm
|
||||||
|
// the function returns *something* absolute either way.
|
||||||
|
assert!(runtime_dir().is_absolute());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue