1use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24use anyhow::{Context, Result};
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28const BODY_PREVIEW_CHARS: usize = 120;
31
32fn self_seed() -> Option<&'static [u8; 32]> {
35 static SEED: std::sync::OnceLock<Option<[u8; 32]>> = std::sync::OnceLock::new();
36 SEED.get_or_init(|| {
37 let v = crate::config::read_private_key().ok()?;
38 let s = v.get(..32)?;
39 let mut a = [0u8; 32];
40 a.copy_from_slice(s);
41 Some(a)
42 })
43 .as_ref()
44}
45
46fn decrypt_body_for_display(signed: &Value) -> Value {
51 self_seed()
52 .and_then(|seed| {
53 let trust = crate::config::read_trust().ok()?;
54 crate::enc::wire_x25519::open_event_body(signed, &trust, seed)
55 .ok()
56 .flatten()
57 })
58 .unwrap_or_else(|| Value::String("<encrypted: cannot read>".to_string()))
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct InboxEvent {
64 pub peer: String,
65 pub event_id: String,
66 pub kind: String,
67 pub body_preview: String,
68 pub verified: bool,
69 pub timestamp: String,
70 pub raw: Value,
72}
73
74impl InboxEvent {
75 pub(crate) fn from_signed(peer: &str, signed: Value, verified: bool) -> Self {
76 let event_id = signed
77 .get("event_id")
78 .and_then(Value::as_str)
79 .unwrap_or("")
80 .to_string();
81 let kind = signed
82 .get("type")
83 .and_then(Value::as_str)
84 .map(str::to_string)
85 .unwrap_or_else(|| {
86 signed
87 .get("kind")
88 .map(|k| k.to_string())
89 .unwrap_or_default()
90 });
91 let timestamp = signed
92 .get("timestamp")
93 .and_then(Value::as_str)
94 .unwrap_or("")
95 .to_string();
96 let body_raw = if signed.get("enc").and_then(Value::as_str)
102 == Some(crate::enc::wire_x25519::ENC_DISCRIMINATOR)
103 {
104 decrypt_body_for_display(&signed)
105 } else {
106 signed.get("body").cloned().unwrap_or(Value::Null)
107 };
108 let body_str = match &body_raw {
109 Value::String(s) => s.clone(),
110 other => serde_json::to_string(other).unwrap_or_default(),
111 };
112 let body_preview: String = body_str.chars().take(BODY_PREVIEW_CHARS).collect();
113 InboxEvent {
114 peer: peer.to_string(),
115 event_id,
116 kind,
117 body_preview,
118 verified,
119 timestamp,
120 raw: signed,
121 }
122 }
123}
124
125pub struct InboxWatcher {
131 cursors: HashMap<String, u64>,
132 inbox_dir: PathBuf,
133}
134
135impl InboxWatcher {
136 pub fn from_dir_and_cursor(inbox_dir: PathBuf, cursor_path: &Path) -> Result<Self> {
141 let cursors = if cursor_path.exists() {
142 let bytes = std::fs::read(cursor_path)
143 .with_context(|| format!("reading cursor file {cursor_path:?}"))?;
144 serde_json::from_slice(&bytes).unwrap_or_else(|e| {
149 eprintln!(
150 "wire: cursor file {cursor_path:?} is corrupt ({e}) — resetting cursors; \
151 inbox history may re-notify once. Delete the file to silence this."
152 );
153 HashMap::new()
154 })
155 } else {
156 HashMap::new()
157 };
158 Ok(Self { cursors, inbox_dir })
159 }
160
161 pub fn from_dir_head(inbox_dir: PathBuf) -> Result<Self> {
166 let mut cursors = HashMap::new();
167 if inbox_dir.exists() {
168 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
169 let path = entry.path();
170 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
171 continue;
172 }
173 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
174 let len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
175 cursors.insert(stem.to_string(), len);
176 }
177 }
178 }
179 Ok(Self { cursors, inbox_dir })
180 }
181
182 pub fn from_cursor_file(cursor_path: &Path) -> Result<Self> {
185 Self::from_dir_and_cursor(crate::config::inbox_dir()?, cursor_path)
186 }
187
188 pub fn from_head() -> Result<Self> {
190 Self::from_dir_head(crate::config::inbox_dir()?)
191 }
192
193 pub fn save_cursors(&self, cursor_path: &Path) -> Result<()> {
196 if let Some(parent) = cursor_path.parent() {
197 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
198 }
199 let bytes = serde_json::to_vec(&self.cursors)?;
200 std::fs::write(cursor_path, bytes)
201 .with_context(|| format!("writing cursor file {cursor_path:?}"))?;
202 Ok(())
203 }
204
205 pub fn poll(&mut self) -> Result<Vec<InboxEvent>> {
210 let mut out = Vec::new();
211 if !self.inbox_dir.exists() {
212 return Ok(out);
213 }
214
215 let trust = crate::config::read_trust().unwrap_or(Value::Null);
216
217 for entry in std::fs::read_dir(&self.inbox_dir)?.flatten() {
218 let path = entry.path();
219 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
220 continue;
221 }
222 let peer = match path.file_stem().and_then(|s| s.to_str()) {
223 Some(s) => s.to_string(),
224 None => continue,
225 };
226 let meta = match std::fs::metadata(&path) {
227 Ok(m) => m,
228 Err(_) => continue,
229 };
230 let cur_len = meta.len();
231 let start_at = *self.cursors.get(&peer).unwrap_or(&0);
232
233 if cur_len <= start_at {
234 self.cursors.insert(peer.clone(), start_at);
235 continue;
236 }
237
238 const READ_CAP: u64 = 8 * 1024 * 1024;
243 let bytes = if cur_len <= READ_CAP {
244 std::fs::read(&path)?
245 } else {
246 let mut f = std::fs::File::open(&path)?;
248 use std::io::{Read, Seek, SeekFrom};
249 f.seek(SeekFrom::Start(start_at))?;
250 let mut tail = Vec::new();
251 f.take(READ_CAP).read_to_end(&mut tail)?;
252 self.cursors
253 .insert(peer.clone(), start_at + tail.len() as u64);
254 tail
255 };
256
257 let slice: &[u8] = if cur_len <= READ_CAP {
259 &bytes[start_at as usize..]
260 } else {
261 &bytes[..]
262 };
263
264 let mut consumed: u64 = start_at;
267 let mut cursor_in_slice: usize = 0;
268 while let Some(nl) = slice[cursor_in_slice..].iter().position(|&b| b == b'\n') {
269 let line = &slice[cursor_in_slice..cursor_in_slice + nl];
270 cursor_in_slice += nl + 1;
271 consumed += (nl + 1) as u64;
272 if line.is_empty() {
273 continue;
274 }
275 let event: Value = match serde_json::from_slice(line) {
276 Ok(v) => v,
277 Err(_) => continue,
278 };
279 let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
280 out.push(InboxEvent::from_signed(&peer, event, verified));
281 }
282 self.cursors.insert(peer, consumed);
283 }
284 Ok(out)
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::io::Write;
292
293 fn fresh_home() -> PathBuf {
294 let pid = std::process::id();
295 let n = std::time::SystemTime::now()
296 .duration_since(std::time::UNIX_EPOCH)
297 .unwrap()
298 .subsec_nanos();
299 let path = std::env::temp_dir().join(format!("wire-watch-{pid}-{n}"));
300 let _ = std::fs::remove_dir_all(&path);
301 std::fs::create_dir_all(&path).unwrap();
302 path
303 }
304
305 fn write_event(inbox_dir: &Path, peer: &str, kind: &str, body: &str) {
308 std::fs::create_dir_all(inbox_dir).unwrap();
309 let path = inbox_dir.join(format!("{peer}.jsonl"));
310 let mut f = std::fs::OpenOptions::new()
311 .create(true)
312 .append(true)
313 .open(&path)
314 .unwrap();
315 let event = serde_json::json!({
316 "event_id": format!("test-{}-{}", peer, body.len()),
317 "from": format!("did:wire:{peer}"),
318 "to": "did:wire:self",
319 "type": kind,
320 "kind": 1,
321 "timestamp": "2026-05-10T00:00:00Z",
322 "body": body,
323 "sig": "fake",
324 });
325 writeln!(f, "{}", serde_json::to_string(&event).unwrap()).unwrap();
326 }
327
328 #[test]
329 fn from_head_starts_at_eof_skips_history() {
330 let home = fresh_home();
331 let inbox = home.join("inbox");
332 write_event(&inbox, "paul", "decision", "old event");
333 let mut w = InboxWatcher::from_dir_head(inbox.clone()).unwrap();
334 assert!(w.poll().unwrap().is_empty(), "from_head must skip history");
335 write_event(&inbox, "paul", "decision", "new event");
336 let evs = w.poll().unwrap();
337 assert_eq!(evs.len(), 1);
338 assert_eq!(evs[0].peer, "paul");
339 assert_eq!(evs[0].kind, "decision");
340 assert!(evs[0].body_preview.contains("new event"));
341 }
342
343 #[test]
344 fn cursor_file_resumes_across_restarts() {
345 let home = fresh_home();
346 let inbox = home.join("inbox");
347 let cursor = home.join("notify.cursor");
348
349 write_event(&inbox, "paul", "decision", "first");
350 let mut w1 = InboxWatcher::from_dir_and_cursor(inbox.clone(), &cursor).unwrap();
351 let evs1 = w1.poll().unwrap();
352 assert_eq!(evs1.len(), 1);
353 w1.save_cursors(&cursor).unwrap();
354 drop(w1);
355
356 write_event(&inbox, "paul", "decision", "second");
357 let mut w2 = InboxWatcher::from_dir_and_cursor(inbox, &cursor).unwrap();
358 let evs2 = w2.poll().unwrap();
359 assert_eq!(evs2.len(), 1, "should see only the new event");
360 assert!(evs2[0].body_preview.contains("second"));
361 }
362
363 #[test]
364 fn body_preview_truncated_at_limit() {
365 let home = fresh_home();
366 let inbox = home.join("inbox");
367 let body = "x".repeat(500);
368 write_event(&inbox, "paul", "decision", &body);
369 let mut w = InboxWatcher::from_dir_and_cursor(inbox, &home.join("notify.cursor")).unwrap();
370 let evs = w.poll().unwrap();
371 assert_eq!(evs[0].body_preview.chars().count(), BODY_PREVIEW_CHARS);
372 }
373
374 #[test]
375 fn multi_peer_files_handled_independently() {
376 let home = fresh_home();
377 let inbox = home.join("inbox");
378 write_event(&inbox, "paul", "decision", "p1");
379 write_event(&inbox, "willard", "decision", "w1");
380 let mut w =
381 InboxWatcher::from_dir_and_cursor(inbox.clone(), &home.join("notify.cursor")).unwrap();
382 let evs = w.poll().unwrap();
383 assert_eq!(evs.len(), 2);
384 let peers: std::collections::HashSet<_> = evs.iter().map(|e| e.peer.clone()).collect();
385 assert!(peers.contains("paul"));
386 assert!(peers.contains("willard"));
387
388 write_event(&inbox, "paul", "decision", "p2");
390 let evs2 = w.poll().unwrap();
391 assert_eq!(evs2.len(), 1);
392 assert_eq!(evs2[0].peer, "paul");
393 assert!(evs2[0].body_preview.contains("p2"));
394 }
395}