1use anyhow::{Context, Result, anyhow};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sha2::{Digest, Sha256};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
38
39pub fn sessions_root() -> Result<PathBuf> {
51 if let Ok(home) = std::env::var("WIRE_HOME") {
52 return Ok(PathBuf::from(home).join("sessions"));
53 }
54 let state = dirs::state_dir()
55 .or_else(dirs::data_local_dir)
56 .ok_or_else(|| {
57 anyhow!(
58 "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
59 set WIRE_HOME or run on a platform with `dirs` support"
60 )
61 })?;
62 Ok(state.join("wire").join("sessions"))
63}
64
65pub fn session_dir(name: &str) -> Result<PathBuf> {
69 Ok(sessions_root()?.join(sanitize_name(name)))
70}
71
72pub fn registry_path() -> Result<PathBuf> {
76 Ok(sessions_root()?.join("registry.json"))
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct SessionRegistry {
81 #[serde(default)]
84 pub by_cwd: HashMap<String, String>,
85}
86
87pub fn read_registry() -> Result<SessionRegistry> {
88 let path = registry_path()?;
89 if !path.exists() {
90 return Ok(SessionRegistry::default());
91 }
92 let bytes = std::fs::read(&path)
93 .with_context(|| format!("reading session registry {path:?}"))?;
94 serde_json::from_slice(&bytes)
95 .with_context(|| format!("parsing session registry {path:?}"))
96}
97
98pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
99 let path = registry_path()?;
100 if let Some(parent) = path.parent() {
101 std::fs::create_dir_all(parent)
102 .with_context(|| format!("creating {parent:?}"))?;
103 }
104 let body = serde_json::to_vec_pretty(reg)?;
105 std::fs::write(&path, body)
106 .with_context(|| format!("writing session registry {path:?}"))?;
107 Ok(())
108}
109
110pub fn sanitize_name(raw: &str) -> String {
114 let mut out = String::with_capacity(raw.len());
115 let mut prev_dash = false;
116 for c in raw.chars() {
117 let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
118 let ch = if ok { c.to_ascii_lowercase() } else { '-' };
119 if ch == '-' {
120 if !prev_dash && !out.is_empty() {
121 out.push('-');
122 }
123 prev_dash = true;
124 } else {
125 out.push(ch);
126 prev_dash = false;
127 }
128 }
129 let trimmed = out.trim_matches('-').to_string();
130 if trimmed.is_empty() {
131 return "wire-session".to_string();
132 }
133 if trimmed.len() > 32 {
134 return trimmed[..32].trim_end_matches('-').to_string();
135 }
136 trimmed
137}
138
139fn path_hash_suffix(cwd: &Path) -> String {
143 let bytes = cwd.as_os_str().to_string_lossy().into_owned();
144 let mut h = Sha256::new();
145 h.update(bytes.as_bytes());
146 let digest = h.finalize();
147 hex::encode(&digest[..2]) }
149
150pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
159 let cwd_key = cwd.to_string_lossy().into_owned();
160 if let Some(existing) = registry.by_cwd.get(&cwd_key) {
161 return existing.clone();
162 }
163 let base = cwd
164 .file_name()
165 .and_then(|s| s.to_str())
166 .map(sanitize_name)
167 .unwrap_or_else(|| "wire-session".to_string());
168 let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
169 if !occupied.contains(&base) {
170 return base;
171 }
172 let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
173 if !occupied.contains(&with_hash) {
174 return with_hash;
175 }
176 for n in 2..1000 {
179 let candidate = format!("{base}-{n}");
180 if !occupied.contains(&candidate) {
181 return candidate;
182 }
183 }
184 format!("{base}-{}-overflow", path_hash_suffix(cwd))
186}
187
188#[derive(Debug, Clone, Serialize)]
190pub struct SessionInfo {
191 pub name: String,
192 pub cwd: Option<String>,
196 pub home_dir: PathBuf,
197 pub did: Option<String>,
198 pub handle: Option<String>,
199 pub daemon_running: bool,
203}
204
205pub fn list_sessions() -> Result<Vec<SessionInfo>> {
208 let root = sessions_root()?;
209 if !root.exists() {
210 return Ok(Vec::new());
211 }
212 let registry = read_registry().unwrap_or_default();
213 let mut name_to_cwd: HashMap<String, String> = HashMap::new();
215 for (cwd, name) in ®istry.by_cwd {
216 name_to_cwd.insert(name.clone(), cwd.clone());
217 }
218
219 let mut out = Vec::new();
220 for entry in std::fs::read_dir(&root)?.flatten() {
221 let path = entry.path();
222 if !path.is_dir() {
223 continue;
224 }
225 let name = match path.file_name().and_then(|s| s.to_str()) {
226 Some(s) => s.to_string(),
227 None => continue,
228 };
229 if name == "registry.json" {
231 continue;
232 }
233 let card_path = path.join("config").join("wire").join("agent-card.json");
234 let (did, handle) = read_card_identity(&card_path);
235 let daemon_running = check_daemon_live(&path);
236 out.push(SessionInfo {
237 name: name.clone(),
238 cwd: name_to_cwd.get(&name).cloned(),
239 home_dir: path,
240 did,
241 handle,
242 daemon_running,
243 });
244 }
245 out.sort_by(|a, b| a.name.cmp(&b.name));
246 Ok(out)
247}
248
249fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
250 let bytes = match std::fs::read(card_path) {
251 Ok(b) => b,
252 Err(_) => return (None, None),
253 };
254 let v: serde_json::Value = match serde_json::from_slice(&bytes) {
255 Ok(v) => v,
256 Err(_) => return (None, None),
257 };
258 let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
259 let handle = v
260 .get("handle")
261 .and_then(|x| x.as_str())
262 .map(str::to_string)
263 .or_else(|| {
264 did.as_ref().map(|d| {
265 crate::agent_card::display_handle_from_did(d).to_string()
266 })
267 });
268 (did, handle)
269}
270
271fn check_daemon_live(session_home: &Path) -> bool {
272 let pidfile = session_home
277 .join("state")
278 .join("wire")
279 .join("daemon.pid");
280 let bytes = match std::fs::read(&pidfile) {
281 Ok(b) => b,
282 Err(_) => return false,
283 };
284 let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
286 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
287 } else {
288 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
290 };
291 let pid = match pid_opt {
292 Some(p) => p,
293 None => return false,
294 };
295 is_process_live(pid)
296}
297
298fn is_process_live(pid: u32) -> bool {
299 #[cfg(target_os = "linux")]
300 {
301 std::path::Path::new(&format!("/proc/{pid}")).exists()
302 }
303 #[cfg(not(target_os = "linux"))]
304 {
305 std::process::Command::new("kill")
306 .args(["-0", &pid.to_string()])
307 .output()
308 .map(|o| o.status.success())
309 .unwrap_or(false)
310 }
311}
312
313pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
328 let path = session_home
329 .join("config")
330 .join("wire")
331 .join("relay.json");
332 let bytes = match std::fs::read(&path) {
333 Ok(b) => b,
334 Err(_) => return Vec::new(),
335 };
336 let val: Value = match serde_json::from_slice(&bytes) {
337 Ok(v) => v,
338 Err(_) => return Vec::new(),
339 };
340 self_endpoints(&val)
341}
342
343#[derive(Debug, Clone, Serialize)]
350pub struct LocalEndpointView {
351 pub relay_url: String,
352 pub slot_id: String,
353}
354
355#[derive(Debug, Clone, Serialize)]
358pub struct LocalSessionView {
359 pub name: String,
360 pub handle: Option<String>,
361 pub did: Option<String>,
362 pub cwd: Option<String>,
363 pub home_dir: PathBuf,
364 pub daemon_running: bool,
365 pub local_endpoints: Vec<LocalEndpointView>,
369}
370
371#[derive(Debug, Clone, Serialize)]
374pub struct FederationOnlySessionView {
375 pub name: String,
376 pub handle: Option<String>,
377 pub cwd: Option<String>,
378}
379
380#[derive(Debug, Clone, Serialize)]
384pub struct LocalSessionListing {
385 pub local: HashMap<String, Vec<LocalSessionView>>,
386 pub federation_only: Vec<FederationOnlySessionView>,
387}
388
389pub fn list_local_sessions() -> Result<LocalSessionListing> {
392 let sessions = list_sessions()?;
393 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
394 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
395
396 for s in sessions {
397 let endpoints = read_session_endpoints(&s.home_dir);
398 let local_eps: Vec<Endpoint> = endpoints
399 .into_iter()
400 .filter(|e| matches!(e.scope, EndpointScope::Local))
401 .collect();
402 if local_eps.is_empty() {
403 federation_only.push(FederationOnlySessionView {
404 name: s.name.clone(),
405 handle: s.handle.clone(),
406 cwd: s.cwd.clone(),
407 });
408 continue;
409 }
410 let redacted: Vec<LocalEndpointView> = local_eps
412 .iter()
413 .map(|e| LocalEndpointView {
414 relay_url: e.relay_url.clone(),
415 slot_id: e.slot_id.clone(),
416 })
417 .collect();
418 for ep in &local_eps {
421 local
422 .entry(ep.relay_url.clone())
423 .or_default()
424 .push(LocalSessionView {
425 name: s.name.clone(),
426 handle: s.handle.clone(),
427 did: s.did.clone(),
428 cwd: s.cwd.clone(),
429 home_dir: s.home_dir.clone(),
430 daemon_running: s.daemon_running,
431 local_endpoints: redacted.clone(),
432 });
433 }
434 }
435 for group in local.values_mut() {
437 group.sort_by(|a, b| a.name.cmp(&b.name));
438 }
439 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
440 Ok(LocalSessionListing {
441 local,
442 federation_only,
443 })
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn sanitize_handles_unicode_and_long_names() {
452 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
453 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
454 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
456 assert_eq!(sanitize_name("---"), "wire-session");
457 let long: String = "a".repeat(100);
458 assert_eq!(sanitize_name(&long).len(), 32);
459 }
460
461 #[test]
462 fn derive_name_returns_basename_when_no_collision() {
463 let reg = SessionRegistry::default();
464 assert_eq!(
465 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
466 "wire"
467 );
468 assert_eq!(
469 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
470 "slancha-mesh"
471 );
472 }
473
474 #[test]
475 fn derive_name_returns_stored_name_when_cwd_already_registered() {
476 let mut reg = SessionRegistry::default();
477 reg.by_cwd.insert(
478 "/Users/paul/Source/wire".to_string(),
479 "wire-special".to_string(),
480 );
481 assert_eq!(
482 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
483 "wire-special"
484 );
485 }
486
487 #[test]
488 fn read_session_endpoints_handles_missing_relay_state() {
489 let tmp = tempfile::tempdir().unwrap();
490 let endpoints = read_session_endpoints(tmp.path());
492 assert!(endpoints.is_empty());
493 }
494
495 #[test]
496 fn read_session_endpoints_parses_dual_slot_form() {
497 let tmp = tempfile::tempdir().unwrap();
498 let cfg = tmp.path().join("config").join("wire");
499 std::fs::create_dir_all(&cfg).unwrap();
500 let body = serde_json::json!({
501 "self": {
502 "relay_url": "https://wireup.net",
503 "slot_id": "fed-slot",
504 "slot_token": "fed-tok",
505 "endpoints": [
506 {
507 "relay_url": "https://wireup.net",
508 "slot_id": "fed-slot",
509 "slot_token": "fed-tok",
510 "scope": "federation"
511 },
512 {
513 "relay_url": "http://127.0.0.1:8771",
514 "slot_id": "loop-slot",
515 "slot_token": "loop-tok",
516 "scope": "local"
517 }
518 ]
519 }
520 });
521 std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap())
522 .unwrap();
523 let endpoints = read_session_endpoints(tmp.path());
524 assert_eq!(endpoints.len(), 2);
525 let local_count = endpoints
526 .iter()
527 .filter(|e| matches!(e.scope, EndpointScope::Local))
528 .count();
529 assert_eq!(local_count, 1);
530 let local = endpoints
531 .iter()
532 .find(|e| matches!(e.scope, EndpointScope::Local))
533 .unwrap();
534 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
535 assert_eq!(local.slot_id, "loop-slot");
536 }
537
538 #[test]
547 fn derive_name_appends_path_hash_when_basename_collides() {
548 let mut reg = SessionRegistry::default();
549 reg.by_cwd.insert(
550 "/Users/paul/Source/wire".to_string(),
551 "wire".to_string(),
552 );
553 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
555 assert!(name.starts_with("wire-"));
556 assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
558 }
559}