Compare commits

..

No commits in common. "710cb768ea921202f9c06479aa6cf4598dbf738b" and "b7aed8a37cb943e604d1b0831997bb627eefcf3e" have entirely different histories.

14 changed files with 452 additions and 610 deletions

761
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.5.0"
version = "0.4.1"
edition = "2021"
license = "MIT"
authors = ["Breadway"]
@ -26,11 +26,7 @@ tokio = { version = "1", features = ["full"] }
zbus = { version = "4", default-features = false, features = ["tokio"] }
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] }
ndarray = "0.16"
# Bumped 0.21 -> 0.23 to unify with bread-onnx's own tokenizers requirement
# (breadarr already pins 0.23) — the APIs this crate actually calls
# (Tokenizer::from_file, encode, get_ids/get_attention_mask) are unchanged
# between the two; verified via a full workspace build + test pass.
tokenizers = { version = "0.23", default-features = false, features = ["http", "fancy-regex"] }
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"
hyprland = "0.4.0-beta.3"

View file

@ -146,7 +146,7 @@ breadpad model-info # shows active EP and model path
## Installation
```bash
git clone https://git.breadway.dev/Breadway/breadpad
git clone https://github.com/breadway/breadpad
cd breadpad
cargo build --release
cp target/release/breadpad ~/.local/bin/

View file

@ -26,8 +26,6 @@ regex.workspace = true
ureq.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
ical = "0.11"
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
[dev-dependencies]
tempfile = "3"

View file

@ -61,14 +61,8 @@ impl OllamaClient {
"stream": false
});
// ureq 2's default agent has no overall request timeout, so a hung
// local Ollama endpoint would otherwise stall this call forever —
// and since classification now runs from an idle callback after the
// capture window has already closed (see `main.rs`), a hang here is
// invisible to the user, not just slow.
let response = ureq::post(&url)
.set("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(10))
.send_json(payload)
.map_err(|e| anyhow::anyhow!("Ollama HTTP error: {}", e))?;

View file

@ -18,13 +18,7 @@ impl CalDavClient {
pub fn new(config: CalendarConfig) -> Self {
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
// initialised; fall back to `Client::new()` semantics rather than panicking.
//
// A request-wide timeout is set here (rather than on `Client::new()`'s
// untimed defaults) so a hung/unreachable CalDAV server can't hang
// whatever's making the request indefinitely — `reqwest::Client::new()`
// has no timeout of its own.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_else(|e| {
tracing::warn!("falling back to default HTTP client: {}", e);

View file

@ -16,7 +16,7 @@ pub enum ExecutionProvider {
impl ExecutionProvider {
pub fn as_str(&self) -> &str {
match self {
ExecutionProvider::Gpu => "MIGraphX (iGPU)",
ExecutionProvider::Gpu => "ROCm (iGPU)",
ExecutionProvider::Cpu => "CPU",
}
}
@ -32,13 +32,10 @@ pub struct Classifier {
}
fn model_dir() -> PathBuf {
// Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))`
// — the same literal-tilde-fallback bug flagged (but not fixed) in
// breadclip-core tonight: PathBuf/std::fs never expand `~`, so on the
// rare box where `dirs` can't resolve a home directory this silently
// resolved to a directory literally named `~` under the current working
// directory instead of the user's actual home.
bread_utils::xdg::data_dir("breadpad").join("model")
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("breadpad")
.join("model")
}
impl Classifier {
@ -249,46 +246,21 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
fn try_load_session(
path: &std::path::Path,
) -> (Option<ort::session::Session>, ExecutionProvider) {
// AMD iGPU via MIGraphX, falling back to CPU. This used to request the
// classic `ort::ep::ROCm` (ROCMExecutionProvider) first — per this
// machine's own breadsearch-gpu-backends operator notes, that EP
// silently no-ops back to CPU on this class of system (distro ROCm
// onnxruntime builds, e.g. Arch's onnxruntime-rocm, are commonly
// compiled with `--use_migraphx`, not `--use_rocm`), so "ROCm (iGPU)"
// could report as active in this struct's own `active_provider` while
// every inference actually ran on CPU. See bread_onnx::provider's doc
// comment for the full history — breadsearch's own embed.rs already
// got this right.
//
// The `is_available()` gate (kept from the original implementation)
// means `active_provider` only ever claims Gpu when we actually
// attempted the GPU build — bread_onnx::build_session's loud EP-
// selection logging (visible once tracing_subscriber is initialized,
// which this crate's own main.rs already does) is what catches the
// *silent per-node fallback* class of bug this rewrite exists to fix,
// rather than papering over it by unconditionally reporting Gpu.
let migraphx_available = {
// Try ROCm (iGPU) first, fall back to CPU.
let rocm_available = {
use ort::execution_providers::ExecutionProvider as _;
ort::ep::MIGraphX::default().is_available().unwrap_or(false)
ort::ep::ROCm::default().is_available().unwrap_or(false)
};
if migraphx_available {
match bread_onnx::build_session(
path,
ort::session::builder::GraphOptimizationLevel::Level3,
&[bread_onnx::Provider::MiGraphX { device_id: 0 }],
) {
if rocm_available {
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
Ok(s) => {
tracing::info!("ONNX session loaded (MIGraphX iGPU)");
tracing::info!("ONNX session loaded (ROCm iGPU)");
return (Some(s), ExecutionProvider::Gpu);
}
Err(e) => tracing::debug!("MIGraphX EP unavailable: {}; trying CPU", e),
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
}
}
match bread_onnx::build_session(
path,
ort::session::builder::GraphOptimizationLevel::Level3,
&[bread_onnx::Provider::Cpu],
) {
match build_onnx_session(path, ort::ep::CPU::default().build()) {
Ok(s) => {
tracing::info!("ONNX session loaded (CPU)");
(Some(s), ExecutionProvider::Cpu)
@ -299,3 +271,14 @@ fn try_load_session(
}
}
}
fn build_onnx_session(
path: &std::path::Path,
ep: ort::ep::ExecutionProviderDispatch,
) -> anyhow::Result<ort::session::Session> {
let mut builder = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
.with_execution_providers([ep])
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
}

View file

@ -193,32 +193,20 @@ impl Config {
}
let text = toml::to_string_pretty(self)?;
fs::write(&path, text)?;
// This file can hold the CalDAV password in plaintext (see
// `CalendarConfig`'s own doc comment) — `fs::write` creates it with
// the process's default umask, which on most setups means
// world-readable. Lock it down to owner-only rather than just
// telling the user to do it themselves.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) {
tracing::warn!("failed to restrict permissions on {}: {}", path.display(), e);
}
}
Ok(())
}
}
pub fn config_path() -> PathBuf {
// Was `dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))`
// — same literal-tilde-fallback bug as `classifier.rs::model_dir` (see
// its doc comment) and breadclip-core's `data_dir`; PathBuf/std::fs
// never expand `~`.
bread_utils::xdg::config_dir("breadpad").join("breadpad.toml")
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("breadpad.toml")
}
pub fn style_css_path() -> PathBuf {
bread_utils::xdg::config_dir("breadpad").join("style.css")
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("style.css")
}

View file

@ -206,42 +206,6 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
return Some(local_naive_to_utc(target_date));
}
"MONTHLY" => {
use chrono::Datelike;
// BYMONTHDAY isn't guaranteed to be present — breadman's note
// editor lets a user type an arbitrary RRULE by hand, and
// "FREQ=MONTHLY" alone is a perfectly valid (if under-specified)
// one. Fall back to today's day-of-month, mirroring how WEEKLY
// above defaults BYDAY to "MO" when absent.
let day: u32 = parts
.get("BYMONTHDAY")
.and_then(|v| v.parse().ok())
.filter(|d: &u32| (1..=31).contains(d))
.unwrap_or_else(|| now.day());
let mut year = now.year();
let mut month = now.month();
// Walk forward month by month for the next calendar date that
// (a) actually has this day-of-month (a 31st skips e.g. April)
// and (b) is still in the future. Bounded to 24 months as a
// defensive cap — every valid day (1-31) recurs well within a
// year, so this should never come close to firing.
for _ in 0..24 {
if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
let candidate = date.and_time(fire_time);
if now.naive_local() < candidate {
return Some(local_naive_to_utc(candidate));
}
}
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
None
}
_ => None,
}
}
@ -440,40 +404,7 @@ mod tests {
#[test]
fn unknown_freq_returns_none() {
assert!(parse_next_from_rrule("RRULE:FREQ=YEARLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
}
#[test]
fn monthly_reschedules_instead_of_returning_none() {
// This used to be the exact bug: MONTHLY fell into the `_ => None`
// arm, so a monthly reminder fired once and never rescheduled.
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=9;BYMINUTE=0", "08:00");
assert!(t.is_some(), "MONTHLY must produce a next occurrence, not None");
let local: chrono::DateTime<Local> = t.unwrap().into();
assert_eq!(local.day(), 15);
assert_eq!(local.hour(), 9);
assert_eq!(local.minute(), 0);
assert!(local > Local::now());
}
#[test]
fn monthly_without_bymonthday_uses_todays_day_of_month() {
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=23;BYMINUTE=59", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.day(), Local::now().day());
}
#[test]
fn monthly_on_the_31st_skips_shorter_months() {
// Every candidate month/day combination this walks must actually
// exist (from_ymd_opt returning None for e.g. April 31 is skipped
// internally) — this mostly guards against a panic/infinite loop
// regression rather than a specific date, since "next Feb 31" et al
// must fall through to a month that really has a 31st.
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0", "08:00");
assert!(t.is_some());
let local: chrono::DateTime<Local> = t.unwrap().into();
assert_eq!(local.day(), 31);
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
}
#[test]

View file

@ -44,43 +44,11 @@ impl Store {
}
}
/// Path to the sidecar lock file guarding the whole note store — a
/// single lock covers both `notes.jsonl` and `archive.jsonl` since
/// `rotate_archive` moves notes between them in one logical operation.
fn lock_path(&self) -> PathBuf {
self.notes_path.with_extension("lock")
}
/// Blocks until an exclusive advisory lock (`flock`) is held on the
/// sidecar lock file, and holds it for as long as the returned `File`
/// stays alive (the lock is released automatically when it's dropped,
/// same as it would be on process exit/crash).
///
/// breadpad (capture/fire/snooze), breadman (edit), and multiple
/// concurrent reminder-fire processes all read and rewrite the same
/// JSONL file with no coordination otherwise: two concurrent
/// load-modify-rewrite cycles racing `write_all`'s rename can silently
/// lose one side's change. This is intentionally one lock for the
/// entire store rather than per-note or per-file locking — contention
/// is expected to be rare (a handful of short-lived CLI-ish processes,
/// not a server), so simplicity wins over fine-grained locking here.
fn acquire_lock(&self) -> Result<fs::File> {
let file = OpenOptions::new()
.create(true)
.write(true)
.open(self.lock_path())
.context("failed to open notes store lock file")?;
file.lock().context("failed to acquire notes store lock")?;
Ok(file)
}
pub fn load_all(&self) -> Result<Vec<Note>> {
let _lock = self.acquire_lock()?;
self.load_from(&self.notes_path)
}
pub fn load_archive(&self) -> Result<Vec<Note>> {
let _lock = self.acquire_lock()?;
self.load_from(&self.archive_path)
}
@ -106,15 +74,12 @@ impl Store {
}
pub fn save_note(&self, note: &Note) -> Result<()> {
{
let _lock = self.acquire_lock()?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.notes_path)?;
let line = serde_json::to_string(note)?;
writeln!(file, "{}", line)?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.notes_path)?;
let line = serde_json::to_string(note)?;
writeln!(file, "{}", line)?;
if let Some(cal_cfg) = &self.calendar {
if cal_cfg.enabled && (note.time.is_some() || note.rrule.is_some()) {
@ -138,18 +103,13 @@ impl Store {
}
pub fn delete_note(&self, id: &str) -> Result<()> {
let to_delete_note = {
let _lock = self.acquire_lock()?;
let all = self.load_from(&self.notes_path)?;
let (to_delete, keep): (Vec<Note>, Vec<Note>) =
all.into_iter().partition(|n| n.id == id);
self.write_all(&self.notes_path, &keep)?;
to_delete.into_iter().next()
};
let all = self.load_all()?;
let (to_delete, keep): (Vec<Note>, Vec<Note>) = all.into_iter().partition(|n| n.id == id);
self.write_all(&self.notes_path, &keep)?;
if let Some(cal_cfg) = &self.calendar {
if cal_cfg.enabled {
if let Some(note) = to_delete_note {
if let Some(note) = to_delete.into_iter().next() {
spawn_caldav_delete(caldav_uid(&note), cal_cfg.clone());
}
}
@ -158,17 +118,11 @@ impl Store {
Ok(())
}
/// Holds the store lock across the whole load-modify-write span (not
/// just the write) — `load_from` is used directly here rather than the
/// public, self-locking `load_all`, since re-acquiring the same
/// process-wide advisory lock while already holding it would block
/// forever (`flock` isn't re-entrant across separate file descriptors).
fn rewrite_notes<F>(&self, mut f: F) -> Result<()>
where
F: FnMut(Note) -> Note,
{
let _lock = self.acquire_lock()?;
let notes: Vec<Note> = self.load_from(&self.notes_path)?.into_iter().map(|n| f(n)).collect();
let notes: Vec<Note> = self.load_all()?.into_iter().map(|n| f(n)).collect();
self.write_all(&self.notes_path, &notes)
}
@ -187,9 +141,8 @@ impl Store {
}
pub fn rotate_archive(&self, archive_after_days: i64) -> Result<usize> {
let _lock = self.acquire_lock()?;
let cutoff = Utc::now() - Duration::days(archive_after_days);
let notes = self.load_from(&self.notes_path)?;
let notes = self.load_all()?;
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
.into_iter()
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff));

View file

@ -284,14 +284,8 @@ mod tests {
#[test]
fn css_defines_bg_color() {
// #1e1e2e (Catppuccin Mocha) was the default before bread-theme
// v0.2.10, which deliberately fixed background/surface/overlay to
// BOS's own dark theme (#0c0c0c etc.) instead of tracking pywal —
// see bread-theme's `palette.rs` `FIXED_BACKGROUND` doc comment.
// This assertion was never updated for that bump, so it started
// failing the moment breadpad's Cargo.toml pin moved to v0.2.10.
let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color bg #0c0c0c"), "css missing bg: {}", &css[..300]);
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
}
#[test]

View file

@ -23,4 +23,3 @@ gtk4-layer-shell.workspace = true
hyprland.workspace = true
dirs.workspace = true
tokio.workspace = true
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }

View file

@ -536,14 +536,13 @@ fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Res
}
fn get_active_workspace() -> Option<String> {
// Was a `Command::new("hyprctl").output()` call with no timeout (the
// `hyprland` crate's async API would require a runtime here, which this
// call site doesn't have) — bread_utils::hypr's socket1 client is
// synchronous and needs neither a subprocess nor a runtime.
bread_utils::hypr::request_json("j/activeworkspace")?
.get("id")
.and_then(|v| v.as_i64())
.map(|id| id.to_string())
// Use hyprctl via CLI since the async API would require a runtime here
let out = std::process::Command::new("hyprctl")
.args(["activeworkspace", "-j"])
.output()
.ok()?;
let val: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
val.get("id").and_then(|v| v.as_i64()).map(|id| id.to_string())
}
fn build_window(
@ -695,7 +694,6 @@ fn build_window(
let selected_type_clone = selected_type.clone();
let cfg_clone = cfg.clone();
let workspace_clone = workspace.clone();
let app_clone = app.clone();
let save_and_close = {
let win = win_clone.clone();
@ -703,7 +701,6 @@ fn build_window(
let selected_type = selected_type_clone.clone();
let cfg = cfg_clone.clone();
let workspace = workspace_clone.clone();
let app = app_clone.clone();
move || {
let text = entry.text().to_string();
@ -714,20 +711,10 @@ fn build_window(
let note_type = selected_type.borrow().clone();
let cfg_c = cfg.clone();
let ws_c = workspace.clone();
// Close first so the popup disappears immediately, then save —
// but `hold()` the application across the gap first. Without
// this, closing the only open window can let the whole process
// (and with it, the `idle_add_local_once` callback below that
// actually writes the note) exit before that callback ever
// runs, silently losing the note the user just typed. `hold()`
// returns an RAII guard that keeps the app alive with zero
// windows open until it's dropped, right after the save
// finishes below.
let hold_guard = app.hold();
// Close first so the popup disappears immediately, then save.
win.close();
glib::idle_add_local_once(move || {
save_note_classified(&text, note_type, no_classify, cfg_c, ws_c);
drop(hold_guard);
});
}
};

View file

@ -1,11 +1,11 @@
# Maintainer: Breadway <plasticbread849@gmail.com>
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=breadpad
pkgver=0.3.1
pkgrel=1
pkgdesc="Quick-capture scratchpad and note viewer with AI classification"
arch=('x86_64')
url="https://git.breadway.dev/Breadway/breadpad"
url="https://github.com/Breadway/breadpad"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,