Persist notification history to XDG state
All checks were successful
dev release / build (push) Successful in 1m32s
All checks were successful
dev release / build (push) Successful in 1m32s
This commit is contained in:
parent
9691485bd6
commit
4fddad510f
3 changed files with 191 additions and 5 deletions
|
|
@ -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.)
|
- 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)
|
- Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s)
|
||||||
- Supports `CloseNotification` and `replaces_id`
|
- 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**:
|
**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/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/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/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/osd.rs` | Volume/brightness on-screen display |
|
||||||
| `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) |
|
| `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) |
|
||||||
| `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection |
|
| `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection |
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::Urgency;
|
use super::Urgency;
|
||||||
|
|
||||||
|
|
@ -32,6 +35,30 @@ pub fn new_store() -> Store {
|
||||||
Arc::new(Mutex::new(VecDeque::new()))
|
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`].
|
/// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`].
|
||||||
pub fn record(store: &Store, entry: Entry) {
|
pub fn record(store: &Store, entry: Entry) {
|
||||||
let mut hist = store.lock().unwrap();
|
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<PathBuf> {
|
||||||
|
Some(state_dir()?.join("history.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_dir() -> Option<PathBuf> {
|
||||||
|
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<PersistedEntry> = 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::<Vec<PersistedEntry>>(&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 {
|
pub fn build_window(store: Store) -> Ui {
|
||||||
let window = gtk4::Window::new();
|
let window = gtk4::Window::new();
|
||||||
window.add_css_class("breadbar-history");
|
window.add_css_class("breadbar-history");
|
||||||
|
|
@ -271,4 +398,55 @@ mod tests {
|
||||||
assert_eq!(truncate("hello", 10), "hello");
|
assert_eq!(truncate("hello", 10), "hello");
|
||||||
assert_eq!(truncate("hello world", 5), "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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,8 @@ struct NotifServer {
|
||||||
/// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`.
|
/// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`.
|
||||||
sync_tags: Mutex<HashMap<(String, String), u32>>,
|
sync_tags: Mutex<HashMap<(String, String), u32>>,
|
||||||
history: history::Store,
|
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
|
/// Private breadbar control surface on the same connection as
|
||||||
|
|
@ -181,6 +183,9 @@ impl NotifServer {
|
||||||
received: SystemTime::now(),
|
received: SystemTime::now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if self.persist_history {
|
||||||
|
history::persist(&self.history);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.tx
|
.tx
|
||||||
|
|
@ -264,15 +269,17 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
|
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());
|
let history_ui = history::build_window(store.clone());
|
||||||
|
|
||||||
relm4::spawn(async move {
|
relm4::spawn(async move {
|
||||||
let server = NotifServer {
|
let server = NotifServer {
|
||||||
tx: tx.clone(),
|
tx: tx.clone(),
|
||||||
next_id: AtomicU32::new(1),
|
next_id: AtomicU32::new(next_id),
|
||||||
sync_tags: Mutex::new(HashMap::new()),
|
sync_tags: Mutex::new(HashMap::new()),
|
||||||
history: store,
|
history: store,
|
||||||
|
persist_history: true,
|
||||||
};
|
};
|
||||||
let bar = BarService { tx };
|
let bar = BarService { tx };
|
||||||
// Builder failures here would only occur with invalid static strings — safe to unwrap.
|
// Builder failures here would only occur with invalid static strings — safe to unwrap.
|
||||||
|
|
@ -353,6 +360,7 @@ mod tests {
|
||||||
next_id: AtomicU32::new(1),
|
next_id: AtomicU32::new(1),
|
||||||
sync_tags: Mutex::new(HashMap::new()),
|
sync_tags: Mutex::new(HashMap::new()),
|
||||||
history: history::new_store(),
|
history: history::new_store(),
|
||||||
|
persist_history: false,
|
||||||
},
|
},
|
||||||
rx,
|
rx,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue