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> {
320 let path = session_home
321 .join("config")
322 .join("wire")
323 .join("relay-state.json");
324 let bytes = match std::fs::read(&path) {
325 Ok(b) => b,
326 Err(_) => return Vec::new(),
327 };
328 let val: Value = match serde_json::from_slice(&bytes) {
329 Ok(v) => v,
330 Err(_) => return Vec::new(),
331 };
332 self_endpoints(&val)
333}
334
335#[derive(Debug, Clone, Serialize)]
342pub struct LocalEndpointView {
343 pub relay_url: String,
344 pub slot_id: String,
345}
346
347#[derive(Debug, Clone, Serialize)]
350pub struct LocalSessionView {
351 pub name: String,
352 pub handle: Option<String>,
353 pub did: Option<String>,
354 pub cwd: Option<String>,
355 pub home_dir: PathBuf,
356 pub daemon_running: bool,
357 pub local_endpoints: Vec<LocalEndpointView>,
361}
362
363#[derive(Debug, Clone, Serialize)]
366pub struct FederationOnlySessionView {
367 pub name: String,
368 pub handle: Option<String>,
369 pub cwd: Option<String>,
370}
371
372#[derive(Debug, Clone, Serialize)]
376pub struct LocalSessionListing {
377 pub local: HashMap<String, Vec<LocalSessionView>>,
378 pub federation_only: Vec<FederationOnlySessionView>,
379}
380
381pub fn list_local_sessions() -> Result<LocalSessionListing> {
384 let sessions = list_sessions()?;
385 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
386 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
387
388 for s in sessions {
389 let endpoints = read_session_endpoints(&s.home_dir);
390 let local_eps: Vec<Endpoint> = endpoints
391 .into_iter()
392 .filter(|e| matches!(e.scope, EndpointScope::Local))
393 .collect();
394 if local_eps.is_empty() {
395 federation_only.push(FederationOnlySessionView {
396 name: s.name.clone(),
397 handle: s.handle.clone(),
398 cwd: s.cwd.clone(),
399 });
400 continue;
401 }
402 let redacted: Vec<LocalEndpointView> = local_eps
404 .iter()
405 .map(|e| LocalEndpointView {
406 relay_url: e.relay_url.clone(),
407 slot_id: e.slot_id.clone(),
408 })
409 .collect();
410 for ep in &local_eps {
413 local
414 .entry(ep.relay_url.clone())
415 .or_default()
416 .push(LocalSessionView {
417 name: s.name.clone(),
418 handle: s.handle.clone(),
419 did: s.did.clone(),
420 cwd: s.cwd.clone(),
421 home_dir: s.home_dir.clone(),
422 daemon_running: s.daemon_running,
423 local_endpoints: redacted.clone(),
424 });
425 }
426 }
427 for group in local.values_mut() {
429 group.sort_by(|a, b| a.name.cmp(&b.name));
430 }
431 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
432 Ok(LocalSessionListing {
433 local,
434 federation_only,
435 })
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn sanitize_handles_unicode_and_long_names() {
444 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
445 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
446 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
448 assert_eq!(sanitize_name("---"), "wire-session");
449 let long: String = "a".repeat(100);
450 assert_eq!(sanitize_name(&long).len(), 32);
451 }
452
453 #[test]
454 fn derive_name_returns_basename_when_no_collision() {
455 let reg = SessionRegistry::default();
456 assert_eq!(
457 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
458 "wire"
459 );
460 assert_eq!(
461 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
462 "slancha-mesh"
463 );
464 }
465
466 #[test]
467 fn derive_name_returns_stored_name_when_cwd_already_registered() {
468 let mut reg = SessionRegistry::default();
469 reg.by_cwd.insert(
470 "/Users/paul/Source/wire".to_string(),
471 "wire-special".to_string(),
472 );
473 assert_eq!(
474 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
475 "wire-special"
476 );
477 }
478
479 #[test]
480 fn read_session_endpoints_handles_missing_relay_state() {
481 let tmp = tempfile::tempdir().unwrap();
482 let endpoints = read_session_endpoints(tmp.path());
484 assert!(endpoints.is_empty());
485 }
486
487 #[test]
488 fn read_session_endpoints_parses_dual_slot_form() {
489 let tmp = tempfile::tempdir().unwrap();
490 let cfg = tmp.path().join("config").join("wire");
491 std::fs::create_dir_all(&cfg).unwrap();
492 let body = serde_json::json!({
493 "self": {
494 "relay_url": "https://wireup.net",
495 "slot_id": "fed-slot",
496 "slot_token": "fed-tok",
497 "endpoints": [
498 {
499 "relay_url": "https://wireup.net",
500 "slot_id": "fed-slot",
501 "slot_token": "fed-tok",
502 "scope": "federation"
503 },
504 {
505 "relay_url": "http://127.0.0.1:8771",
506 "slot_id": "loop-slot",
507 "slot_token": "loop-tok",
508 "scope": "local"
509 }
510 ]
511 }
512 });
513 std::fs::write(cfg.join("relay-state.json"), serde_json::to_vec(&body).unwrap())
514 .unwrap();
515 let endpoints = read_session_endpoints(tmp.path());
516 assert_eq!(endpoints.len(), 2);
517 let local_count = endpoints
518 .iter()
519 .filter(|e| matches!(e.scope, EndpointScope::Local))
520 .count();
521 assert_eq!(local_count, 1);
522 let local = endpoints
523 .iter()
524 .find(|e| matches!(e.scope, EndpointScope::Local))
525 .unwrap();
526 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
527 assert_eq!(local.slot_id, "loop-slot");
528 }
529
530 #[test]
539 fn derive_name_appends_path_hash_when_basename_collides() {
540 let mut reg = SessionRegistry::default();
541 reg.by_cwd.insert(
542 "/Users/paul/Source/wire".to_string(),
543 "wire".to_string(),
544 );
545 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
547 assert!(name.starts_with("wire-"));
548 assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
550 }
551}