1use serde::Serialize;
21use std::collections::BTreeSet;
22use std::path::Path;
23use std::time::Duration;
24
25#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28#[serde(tag = "state", rename_all = "snake_case")]
29pub enum DaemonState {
30 Running { pid: u32 },
32 StalePid { pid: u32 },
34 None,
36}
37
38impl DaemonState {
39 pub fn is_running(&self) -> bool {
40 matches!(self, DaemonState::Running { .. })
41 }
42}
43
44#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
46pub struct PeerRow {
47 pub handle: String,
48 pub did: String,
49 pub tier: String,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct SessionSnapshot {
55 pub key: String,
57 pub handle: Option<String>,
58 pub did: Option<String>,
59 pub fingerprint: Option<String>,
61 pub nickname: Option<String>,
62 pub emoji: Option<String>,
63 pub primary_hex: Option<String>,
65 pub ansi256_primary: Option<u8>,
67 pub daemon: DaemonState,
68 pub daemon_version: Option<String>,
69 pub relay_url: Option<String>,
70 pub slot_id: Option<String>,
71 pub last_sync_age_s: Option<u64>,
74 pub peers: Vec<PeerRow>,
75 pub cwd: Option<String>,
76 pub likely_idle: bool,
82 pub retired: bool,
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct RelayHealth {
90 pub url: String,
91 pub ok: bool,
92 pub status: Option<u16>,
93 pub unprobed: bool,
95}
96
97#[derive(Debug, Clone, Serialize)]
100pub struct DashReport {
101 pub schema: &'static str,
102 pub sessions: Vec<SessionSnapshot>,
103 pub relays: Vec<RelayHealth>,
104}
105
106#[derive(Debug, Clone, Default)]
107pub struct CollectOpts {
108 pub probe_relays: bool,
111}
112
113pub const SCHEMA: &str = "wire-dash-v1";
114
115const MAX_STATE_FILE: u64 = 256 * 1024;
119
120fn read_capped(path: &Path) -> Option<Vec<u8>> {
122 use std::io::Read;
123 let f = std::fs::File::open(path).ok()?;
124 let mut buf = Vec::new();
125 f.take(MAX_STATE_FILE).read_to_end(&mut buf).ok()?;
126 Some(buf)
127}
128
129pub fn fingerprint_from_did(did: &str) -> Option<String> {
133 let tail = did.rsplit(':').next()?; let fp = tail.rsplit('-').next().unwrap_or("");
135 if fp.is_empty() {
136 return None;
137 }
138 Some(fp.chars().take(16).collect())
139}
140
141pub fn read_peers(home: &Path, own_did: Option<&str>, own_handle: Option<&str>) -> Vec<PeerRow> {
146 let path = home.join("config").join("wire").join("trust.json");
147 let Some(bytes) = read_capped(&path) else {
148 return Vec::new();
149 };
150 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
151 return Vec::new();
152 };
153 let Some(agents) = v.get("agents").and_then(|a| a.as_object()) else {
154 return Vec::new();
155 };
156 let mut out = Vec::new();
157 for (handle, rec) in agents {
158 let did = rec.get("did").and_then(|d| d.as_str()).unwrap_or("");
159 if own_did == Some(did) || own_handle == Some(handle.as_str()) {
163 continue;
164 }
165 out.push(PeerRow {
166 handle: handle.clone(),
167 did: did.to_string(),
168 tier: rec
169 .get("tier")
170 .and_then(|t| t.as_str())
171 .unwrap_or("UNTRUSTED")
172 .to_string(),
173 });
174 }
175 out.sort_by(|a, b| a.handle.cmp(&b.handle));
176 out
177}
178
179pub fn read_relay_binding(home: &Path) -> (Option<String>, Option<String>) {
181 let path = home.join("config").join("wire").join("relay.json");
182 let Some(bytes) = read_capped(&path) else {
183 return (None, None);
184 };
185 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
186 return (None, None);
187 };
188 let sf = v.get("self");
189 let url = sf
190 .and_then(|s| s.get("relay_url"))
191 .and_then(|u| u.as_str())
192 .map(|s| s.to_string());
193 let slot = sf
194 .and_then(|s| s.get("slot_id"))
195 .and_then(|u| u.as_str())
196 .map(|s| s.to_string());
197 (url, slot)
198}
199
200pub fn last_sync_age_s(home: &Path) -> Option<u64> {
202 let path = home.join("state").join("wire").join("last_sync.json");
203 let mtime = std::fs::metadata(&path).ok()?.modified().ok()?;
204 mtime.elapsed().ok().map(|d| d.as_secs())
205}
206
207fn read_daemon_version(home: &Path) -> Option<String> {
209 let path = home.join("state").join("wire").join("daemon.pid");
210 let bytes = read_capped(&path)?;
211 let v = serde_json::from_slice::<serde_json::Value>(&bytes).ok()?;
212 v.get("version")
213 .and_then(|s| s.as_str())
214 .map(|s| s.to_string())
215}
216
217fn snapshot_one(si: &crate::session::SessionInfo) -> SessionSnapshot {
220 let home = &si.home_dir;
221 let pid = crate::session::session_daemon_pid(home);
222 let daemon = match (pid, si.daemon_running) {
223 (Some(pid), true) => DaemonState::Running { pid },
224 (Some(pid), false) => DaemonState::StalePid { pid },
225 (None, _) => DaemonState::None,
226 };
227 let peers = read_peers(home, si.did.as_deref(), si.handle.as_deref());
228 let (relay_url, slot_id) = read_relay_binding(home);
229 let (nickname, emoji, primary_hex, ansi256_primary) = match &si.character {
230 Some(c) => (
231 Some(c.nickname.clone()),
232 Some(c.emoji.clone()),
233 Some(c.palette.primary_hex.clone()),
234 Some(c.palette.ansi256_primary),
235 ),
236 None => (None, None, None, None),
237 };
238 let retired = crate::retire::is_retired(home);
239 let likely_idle = daemon.is_running() && peers.is_empty() && !retired;
240 SessionSnapshot {
241 key: si.name.clone(),
242 handle: si.handle.clone(),
243 fingerprint: si.did.as_deref().and_then(fingerprint_from_did),
244 did: si.did.clone(),
245 nickname,
246 emoji,
247 primary_hex,
248 ansi256_primary,
249 daemon,
250 daemon_version: read_daemon_version(home),
251 relay_url,
252 slot_id,
253 last_sync_age_s: last_sync_age_s(home),
254 peers,
255 cwd: si.cwd.clone(),
256 likely_idle,
257 retired,
258 }
259}
260
261fn probe_relay(url: &str) -> RelayHealth {
262 let base = url.trim_end_matches('/');
263 if !(base.starts_with("http://") || base.starts_with("https://")) {
267 return RelayHealth {
268 url: url.to_string(),
269 ok: false,
270 status: None,
271 unprobed: false,
272 };
273 }
274 let build = reqwest::blocking::Client::builder()
275 .timeout(Duration::from_secs(2))
276 .redirect(reqwest::redirect::Policy::none())
277 .build();
278 let Ok(client) = build else {
279 return RelayHealth {
280 url: url.to_string(),
281 ok: false,
282 status: None,
283 unprobed: false,
284 };
285 };
286 match client.get(format!("{base}/healthz")).send() {
287 Ok(r) => RelayHealth {
288 url: url.to_string(),
289 ok: r.status().is_success(),
290 status: Some(r.status().as_u16()),
291 unprobed: false,
292 },
293 Err(_) => RelayHealth {
294 url: url.to_string(),
295 ok: false,
296 status: None,
297 unprobed: false,
298 },
299 }
300}
301
302pub fn collect(opts: &CollectOpts) -> anyhow::Result<DashReport> {
305 let sessions = crate::session::list_sessions()?;
306 let mut snaps = Vec::with_capacity(sessions.len());
307 let mut relay_urls: BTreeSet<String> = BTreeSet::new();
308 for si in &sessions {
309 let snap = snapshot_one(si);
310 if let Some(u) = &snap.relay_url {
311 relay_urls.insert(u.clone());
312 }
313 snaps.push(snap);
314 }
315 snaps.sort_by(|a, b| {
319 b.peers
320 .len()
321 .cmp(&a.peers.len())
322 .then(b.daemon.is_running().cmp(&a.daemon.is_running()))
323 .then(a.key.cmp(&b.key))
324 });
325 let relays = relay_urls
326 .into_iter()
327 .map(|u| {
328 if opts.probe_relays {
329 probe_relay(&u)
330 } else {
331 RelayHealth {
332 url: u,
333 ok: false,
334 status: None,
335 unprobed: true,
336 }
337 }
338 })
339 .collect();
340 Ok(DashReport {
341 schema: SCHEMA,
342 sessions: snaps,
343 relays,
344 })
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use std::fs;
351
352 #[test]
353 fn fingerprint_extracts_trailing_hex() {
354 assert_eq!(
355 fingerprint_from_did("did:wire:terra-plain-e6511a52").as_deref(),
356 Some("e6511a52")
357 );
358 assert_eq!(
360 fingerprint_from_did("did:wire:a-b-c-deadbeef").as_deref(),
361 Some("deadbeef")
362 );
363 assert_eq!(fingerprint_from_did("garbage").as_deref(), Some("garbage"));
364 }
365
366 #[test]
367 fn read_peers_excludes_self_and_reads_tier() {
368 let dir = tempfile::tempdir().unwrap();
369 let cw = dir.path().join("config").join("wire");
370 fs::create_dir_all(&cw).unwrap();
371 fs::write(
372 cw.join("trust.json"),
373 r#"{"agents":{
374 "terra-plain":{"did":"did:wire:terra-plain-e6511a52","tier":"ATTESTED"},
375 "raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"}
376 },"version":1}"#,
377 )
378 .unwrap();
379 let peers = read_peers(
380 dir.path(),
381 Some("did:wire:terra-plain-e6511a52"),
382 Some("terra-plain"),
383 );
384 assert_eq!(peers.len(), 1, "self must be excluded");
385 assert_eq!(peers[0].handle, "raven-kettle");
386 assert_eq!(peers[0].tier, "VERIFIED");
387 }
388
389 #[test]
390 fn read_peers_excludes_self_by_handle_when_did_missing() {
391 let dir = tempfile::tempdir().unwrap();
393 let cw = dir.path().join("config").join("wire");
394 fs::create_dir_all(&cw).unwrap();
395 fs::write(
396 cw.join("trust.json"),
397 r#"{"agents":{
398 "terra-plain":{"tier":"ATTESTED"},
399 "raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"}
400 },"version":1}"#,
401 )
402 .unwrap();
403 let peers = read_peers(
404 dir.path(),
405 Some("did:wire:terra-plain-e6511a52"),
406 Some("terra-plain"),
407 );
408 assert_eq!(peers.len(), 1, "did-less self entry excluded by handle");
409 assert_eq!(peers[0].handle, "raven-kettle");
410 }
411
412 #[test]
413 fn read_peers_missing_file_is_empty() {
414 let dir = tempfile::tempdir().unwrap();
415 assert!(read_peers(dir.path(), None, None).is_empty());
416 }
417
418 #[test]
419 fn fingerprint_none_for_empty_tail() {
420 assert_eq!(fingerprint_from_did("did:wire:"), None);
421 assert_eq!(fingerprint_from_did(""), None);
422 }
423
424 #[test]
425 fn dash_report_json_shape_is_stable() {
426 let report = DashReport {
430 schema: SCHEMA,
431 sessions: vec![SessionSnapshot {
432 key: "k".into(),
433 handle: Some("h".into()),
434 did: Some("did:wire:h-deadbeef".into()),
435 fingerprint: Some("deadbeef".into()),
436 nickname: Some("h".into()),
437 emoji: Some("🦊".into()),
438 primary_hex: Some("#da60a3".into()),
439 ansi256_primary: Some(175),
440 daemon: DaemonState::StalePid { pid: 9 },
441 daemon_version: Some("0.16.0".into()),
442 relay_url: Some("https://wireup.net".into()),
443 slot_id: Some("s".into()),
444 last_sync_age_s: Some(5),
445 peers: vec![],
446 cwd: Some("/tmp/x".into()),
447 likely_idle: false,
448 retired: false,
449 }],
450 relays: vec![],
451 };
452 let v = serde_json::to_value(&report).unwrap();
453 assert_eq!(v["schema"], "wire-dash-v1");
454 let s = &v["sessions"][0];
455 for key in [
456 "key",
457 "handle",
458 "did",
459 "fingerprint",
460 "nickname",
461 "emoji",
462 "primary_hex",
463 "ansi256_primary",
464 "daemon",
465 "daemon_version",
466 "relay_url",
467 "slot_id",
468 "last_sync_age_s",
469 "peers",
470 "cwd",
471 "likely_idle",
472 "retired",
473 ] {
474 assert!(s.get(key).is_some(), "missing golden field: {key}");
475 }
476 assert_eq!(s["daemon"]["state"], "stale_pid");
478 assert_eq!(s["daemon"]["pid"], 9);
479 }
480
481 #[test]
482 fn read_relay_binding_parses_self() {
483 let dir = tempfile::tempdir().unwrap();
484 let cw = dir.path().join("config").join("wire");
485 fs::create_dir_all(&cw).unwrap();
486 fs::write(
487 cw.join("relay.json"),
488 r#"{"self":{"relay_url":"https://wireup.net","slot_id":"abc123"},"peers":{}}"#,
489 )
490 .unwrap();
491 let (url, slot) = read_relay_binding(dir.path());
492 assert_eq!(url.as_deref(), Some("https://wireup.net"));
493 assert_eq!(slot.as_deref(), Some("abc123"));
494 }
495
496 #[test]
497 fn daemon_state_serializes_with_tag() {
498 let j = serde_json::to_value(DaemonState::Running { pid: 42 }).unwrap();
499 assert_eq!(j["state"], "running");
500 assert_eq!(j["pid"], 42);
501 let n = serde_json::to_value(DaemonState::None).unwrap();
502 assert_eq!(n["state"], "none");
503 }
504}