gardesk/garnotify / 695a6eb

Browse files

feat(history): add persistence across daemon restarts

- Add Serialize/Deserialize to Notification and Hints types
- Save history to ~/.local/share/garnotify/history.json on shutdown
- Load history from file on daemon startup
- Enable persistence by default (config: history.persist)
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
695a6ebc0a6a95e684827cd1930ad8b211d9a4a4
Parents
b105cb6
Tree
70a08e8

4 changed files

StatusFile+-
M garnotify/src/config.rs 1 1
M garnotify/src/daemon.rs 35 0
M garnotify/src/notification/history.rs 64 1
M garnotify/src/notification/types.rs 6 4
garnotify/src/config.rsmodified
@@ -245,7 +245,7 @@ impl Default for HistoryConfig {
245245
     fn default() -> Self {
246246
         Self {
247247
             max_length: 100,
248
-            persist: false,
248
+            persist: true,
249249
         }
250250
     }
251251
 }
garnotify/src/daemon.rsmodified
@@ -191,6 +191,34 @@ impl Daemon {
191191
         Ok(())
192192
     }
193193
 
194
+    /// Initialize history (load from file if persistence is enabled)
195
+    pub async fn init_history(&mut self) -> Result<()> {
196
+        if self.config.history.persist {
197
+            let mut history = self.history.lock().await;
198
+            match history.load_from_file() {
199
+                Ok(count) => {
200
+                    if count > 0 {
201
+                        info!("Loaded {} notifications from history", count);
202
+                    }
203
+                }
204
+                Err(e) => {
205
+                    warn!("Failed to load history: {}", e);
206
+                }
207
+            }
208
+        }
209
+        Ok(())
210
+    }
211
+
212
+    /// Save history to file (if persistence is enabled)
213
+    async fn save_history(&self) {
214
+        if self.config.history.persist {
215
+            let history = self.history.lock().await;
216
+            if let Err(e) = history.save_to_file() {
217
+                error!("Failed to save history: {}", e);
218
+            }
219
+        }
220
+    }
221
+
194222
     /// Initialize D-Bus service
195223
     pub async fn init_dbus(&mut self) -> Result<()> {
196224
         let service = NotificationsService::new(
@@ -284,6 +312,9 @@ impl Daemon {
284312
             }
285313
         }
286314
 
315
+        // Save history before shutdown
316
+        self.save_history().await;
317
+
287318
         // Clean up UI thread
288319
         if let Some(tx) = self.ui_cmd_tx.take() {
289320
             drop(tx); // Close the channel to signal UI thread to exit
@@ -634,6 +665,10 @@ pub async fn run(config_path: Option<String>, _foreground: bool) -> Result<()> {
634665
         .await
635666
         .context("Failed to initialize D-Bus")?;
636667
     daemon.init_ui().context("Failed to initialize UI")?;
668
+    daemon
669
+        .init_history()
670
+        .await
671
+        .context("Failed to initialize history")?;
637672
     daemon.run().await
638673
 }
639674
 
garnotify/src/notification/history.rsmodified
@@ -1,10 +1,21 @@
11
 //! Notification history management
22
 
33
 use std::collections::VecDeque;
4
-use tracing::debug;
4
+use std::fs;
5
+use std::io::{BufReader, BufWriter};
6
+use std::path::PathBuf;
7
+use tracing::{debug, info, warn};
58
 
69
 use super::types::Notification;
710
 
11
+/// Get the history file path
12
+fn history_file_path() -> PathBuf {
13
+    dirs::data_dir()
14
+        .unwrap_or_else(|| PathBuf::from("~/.local/share"))
15
+        .join("garnotify")
16
+        .join("history.json")
17
+}
18
+
819
 /// Notification history with circular buffer
920
 pub struct History {
1021
     /// Stored notifications (most recent at back)
@@ -88,6 +99,58 @@ impl History {
8899
     pub fn list_recent_first(&self) -> Vec<&Notification> {
89100
         self.items.iter().rev().collect()
90101
     }
102
+
103
+    /// Load history from file
104
+    pub fn load_from_file(&mut self) -> Result<usize, std::io::Error> {
105
+        let path = history_file_path();
106
+
107
+        if !path.exists() {
108
+            debug!("No history file found at {}", path.display());
109
+            return Ok(0);
110
+        }
111
+
112
+        let file = fs::File::open(&path)?;
113
+        let reader = BufReader::new(file);
114
+
115
+        let items: Vec<Notification> = serde_json::from_reader(reader)
116
+            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
117
+
118
+        let count = items.len();
119
+
120
+        // Only keep up to max_length items
121
+        self.items = items
122
+            .into_iter()
123
+            .rev() // Reverse because we want most recent at back
124
+            .take(self.max_length)
125
+            .collect::<Vec<_>>()
126
+            .into_iter()
127
+            .rev() // Reverse back to original order
128
+            .collect();
129
+
130
+        info!("Loaded {} notifications from history file", self.items.len());
131
+        Ok(count)
132
+    }
133
+
134
+    /// Save history to file
135
+    pub fn save_to_file(&self) -> Result<(), std::io::Error> {
136
+        let path = history_file_path();
137
+
138
+        // Create parent directory if needed
139
+        if let Some(parent) = path.parent() {
140
+            fs::create_dir_all(parent)?;
141
+        }
142
+
143
+        let file = fs::File::create(&path)?;
144
+        let writer = BufWriter::new(file);
145
+
146
+        // Save as array (oldest first, matching internal order)
147
+        let items: Vec<&Notification> = self.items.iter().collect();
148
+        serde_json::to_writer_pretty(writer, &items)
149
+            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
150
+
151
+        info!("Saved {} notifications to history file", self.items.len());
152
+        Ok(())
153
+    }
91154
 }
92155
 
93156
 impl Default for History {
garnotify/src/notification/types.rsmodified
@@ -81,7 +81,7 @@ impl Action {
8181
 }
8282
 
8383
 /// Parsed notification hints
84
-#[derive(Debug, Clone, Default)]
84
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8585
 pub struct Hints {
8686
     /// Urgency level (0=low, 1=normal, 2=critical)
8787
     pub urgency: Urgency,
@@ -91,7 +91,8 @@ pub struct Hints {
9191
     pub desktop_entry: Option<String>,
9292
     /// Path to image file
9393
     pub image_path: Option<String>,
94
-    /// Raw image data
94
+    /// Raw image data (not persisted - too large)
95
+    #[serde(skip)]
9596
     pub image_data: Option<ImageData>,
9697
     /// Path to sound file to play
9798
     pub sound_file: Option<String>,
@@ -208,7 +209,7 @@ impl Hints {
208209
 }
209210
 
210211
 /// A desktop notification
211
-#[derive(Debug, Clone)]
212
+#[derive(Debug, Clone, Serialize, Deserialize)]
212213
 pub struct Notification {
213214
     /// Unique notification ID
214215
     pub id: u32,
@@ -228,7 +229,8 @@ pub struct Notification {
228229
     pub hints: Hints,
229230
     /// Expiration timeout in milliseconds (-1 = default, 0 = never)
230231
     pub expire_timeout: i32,
231
-    /// When the notification was created
232
+    /// When the notification was created (not persisted)
233
+    #[serde(skip, default = "Instant::now")]
232234
     pub created_at: Instant,
233235
 }
234236