diff --git a/README.md b/README.md index 0047613..3addfa1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Supports `CloseNotification` and `replaces_id` -- In-memory history of the last 50 notifications (app, summary, truncated body, time). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. Not persisted. +- History of the last 50 notifications (app, summary, truncated body, time). Loaded from and saved to `$XDG_STATE_HOME/breadbar/history.json` (typically `~/.local/state/breadbar/history.json`). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. **Volume/brightness OSD**: @@ -141,7 +141,7 @@ Example — change the font size: | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | -| `src/notifications/history.rs` | Bounded in-memory history and layer-shell history window | +| `src/notifications/history.rs` | Bounded history (last 50, persisted under XDG state) and layer-shell history window | | `src/osd.rs` | Volume/brightness on-screen display | | `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 711f229..0e158cc 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -1,9 +1,12 @@ use std::collections::VecDeque; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use serde::{Deserialize, Serialize}; use super::Urgency; @@ -32,6 +35,30 @@ pub fn new_store() -> Store { Arc::new(Mutex::new(VecDeque::new())) } +/// Load the last [`LIMIT`] entries from `$XDG_STATE_HOME/breadbar/history.json` +/// (or `~/.local/state/breadbar/history.json`). Missing or corrupt files +/// yield an empty store — never fail startup. +pub fn load_store() -> Store { + let store = new_store(); + if let Some(path) = history_path() { + load_into(&store, &path); + } + store +} + +/// Next D-Bus notification id so persisted rows are not replaced on restart. +pub fn next_id(store: &Store) -> u32 { + store + .lock() + .unwrap() + .iter() + .map(|e| e.id) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1) +} + /// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`]. pub fn record(store: &Store, entry: Entry) { let mut hist = store.lock().unwrap(); @@ -44,6 +71,106 @@ pub fn record(store: &Store, entry: Entry) { } } +/// Best-effort write of the in-memory store (already bounded) to the +/// XDG state file. Failures are silent — history stays in memory. +pub fn persist(store: &Store) { + if let Some(path) = history_path() { + let _ = persist_to(store, &path); + } +} + +fn history_path() -> Option { + Some(state_dir()?.join("history.json")) +} + +fn state_dir() -> Option { + if let Ok(xdg) = std::env::var("XDG_STATE_HOME") { + if !xdg.is_empty() { + return Some(PathBuf::from(xdg).join("breadbar")); + } + } + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".local/state/breadbar")) +} + +#[derive(Serialize, Deserialize)] +struct PersistedEntry { + id: u32, + app_name: String, + summary: String, + body: String, + urgency: String, + received_unix: u64, +} + +fn urgency_name(u: Urgency) -> &'static str { + match u { + Urgency::Low => "low", + Urgency::Normal => "normal", + Urgency::Critical => "critical", + } +} + +fn urgency_from_name(s: &str) -> Urgency { + match s { + "low" => Urgency::Low, + "critical" => Urgency::Critical, + _ => Urgency::Normal, + } +} + +fn to_persisted(entry: &Entry) -> PersistedEntry { + let received_unix = entry + .received + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + PersistedEntry { + id: entry.id, + app_name: entry.app_name.clone(), + summary: entry.summary.clone(), + body: entry.body.clone(), + urgency: urgency_name(entry.urgency).into(), + received_unix, + } +} + +fn from_persisted(entry: PersistedEntry) -> Entry { + Entry { + id: entry.id, + app_name: entry.app_name, + summary: entry.summary, + body: entry.body, + urgency: urgency_from_name(&entry.urgency), + received: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.received_unix), + } +} + +fn persist_to(store: &Store, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let payload: Vec = store.lock().unwrap().iter().map(to_persisted).collect(); + let bytes = serde_json::to_vec(&payload).map_err(std::io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, bytes)?; + fs::rename(&tmp, path) +} + +fn load_into(store: &Store, path: &Path) { + let Ok(bytes) = fs::read(path) else { + return; + }; + let Ok(parsed) = serde_json::from_slice::>(&bytes) else { + return; + }; + let mut hist = store.lock().unwrap(); + hist.clear(); + for entry in parsed.into_iter().take(LIMIT) { + hist.push_back(from_persisted(entry)); + } +} + pub fn build_window(store: Store) -> Ui { let window = gtk4::Window::new(); window.add_css_class("breadbar-history"); @@ -271,4 +398,55 @@ mod tests { assert_eq!(truncate("hello", 10), "hello"); assert_eq!(truncate("hello world", 5), "hello…"); } + + #[test] + fn persist_roundtrip_keeps_newest_first_and_bound() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + let store = new_store(); + for i in 0..(LIMIT as u32 + 3) { + record(&store, entry(i, &format!("n{i}"))); + } + persist_to(&store, &path).unwrap(); + + let loaded = new_store(); + load_into(&loaded, &path); + assert_eq!(next_id(&loaded), LIMIT as u32 + 3); + let hist = loaded.lock().unwrap(); + assert_eq!(hist.len(), LIMIT); + assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 2); + assert_eq!( + hist.front().unwrap().summary, + format!("n{}", LIMIT as u32 + 2) + ); + drop(hist); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn load_into_ignores_corrupt_file() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-bad-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + fs::write(&path, "not-json").unwrap(); + let store = new_store(); + load_into(&store, &path); + assert!(store.lock().unwrap().is_empty()); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index ccc8125..eccc2e7 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -96,6 +96,8 @@ struct NotifServer { /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. sync_tags: Mutex>, history: history::Store, + /// Unit tests leave this off so `Notify` does not write `$XDG_STATE_HOME`. + persist_history: bool, } /// Private breadbar control surface on the same connection as @@ -181,6 +183,9 @@ impl NotifServer { received: SystemTime::now(), }, ); + if self.persist_history { + history::persist(&self.history); + } let _ = self .tx @@ -264,15 +269,17 @@ pub fn spawn(sample: Option) -> gtk4::Window { } None => { let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); - let store = history::new_store(); + let store = history::load_store(); + let next_id = history::next_id(&store); let history_ui = history::build_window(store.clone()); relm4::spawn(async move { let server = NotifServer { tx: tx.clone(), - next_id: AtomicU32::new(1), + next_id: AtomicU32::new(next_id), sync_tags: Mutex::new(HashMap::new()), history: store, + persist_history: true, }; let bar = BarService { tx }; // Builder failures here would only occur with invalid static strings — safe to unwrap. @@ -353,6 +360,7 @@ mod tests { next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), history: history::new_store(), + persist_history: false, }, rx, )