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_str) = std::env::var("WIRE_HOME") {
52 let home = PathBuf::from(&home_str);
53 let direct = home.join("sessions");
54 if direct.exists() {
55 return Ok(direct);
56 }
57 if let Some(parent) = home.parent()
71 && parent.file_name().and_then(|s| s.to_str()) == Some("sessions")
72 {
73 return Ok(parent.to_path_buf());
74 }
75 return Ok(direct);
76 }
77 let state = dirs::state_dir()
78 .or_else(dirs::data_local_dir)
79 .ok_or_else(|| {
80 anyhow!(
81 "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
82 set WIRE_HOME or run on a platform with `dirs` support"
83 )
84 })?;
85 Ok(state.join("wire").join("sessions"))
86}
87
88pub fn session_dir(name: &str) -> Result<PathBuf> {
92 Ok(sessions_root()?.join(sanitize_name(name)))
93}
94
95pub fn registry_path() -> Result<PathBuf> {
99 Ok(sessions_root()?.join("registry.json"))
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct SessionRegistry {
104 #[serde(default)]
107 pub by_cwd: HashMap<String, String>,
108}
109
110pub fn read_registry() -> Result<SessionRegistry> {
111 let path = registry_path()?;
112 if !path.exists() {
113 return Ok(SessionRegistry::default());
114 }
115 let bytes =
116 std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
117 serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
118}
119
120pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
121 let path = registry_path()?;
122 if let Some(parent) = path.parent() {
123 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
124 }
125 let body = serde_json::to_vec_pretty(reg)?;
126 std::fs::write(&path, body).with_context(|| format!("writing session registry {path:?}"))?;
127 Ok(())
128}
129
130pub fn sanitize_name(raw: &str) -> String {
134 let mut out = String::with_capacity(raw.len());
135 let mut prev_dash = false;
136 for c in raw.chars() {
137 let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
138 let ch = if ok { c.to_ascii_lowercase() } else { '-' };
139 if ch == '-' {
140 if !prev_dash && !out.is_empty() {
141 out.push('-');
142 }
143 prev_dash = true;
144 } else {
145 out.push(ch);
146 prev_dash = false;
147 }
148 }
149 let trimmed = out.trim_matches('-').to_string();
150 if trimmed.is_empty() {
151 return "wire-session".to_string();
152 }
153 if trimmed.len() > 32 {
154 return trimmed[..32].trim_end_matches('-').to_string();
155 }
156 trimmed
157}
158
159fn path_hash_suffix(cwd: &Path) -> String {
163 let bytes = cwd.as_os_str().to_string_lossy().into_owned();
164 let mut h = Sha256::new();
165 h.update(bytes.as_bytes());
166 let digest = h.finalize();
167 hex::encode(&digest[..2]) }
169
170pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
179 let cwd_key = cwd.to_string_lossy().into_owned();
180 if let Some(existing) = registry.by_cwd.get(&cwd_key) {
181 return existing.clone();
182 }
183 let base = cwd
184 .file_name()
185 .and_then(|s| s.to_str())
186 .map(sanitize_name)
187 .unwrap_or_else(|| "wire-session".to_string());
188 let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
189 if !occupied.contains(&base) {
190 return base;
191 }
192 let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
193 if !occupied.contains(&with_hash) {
194 return with_hash;
195 }
196 for n in 2..1000 {
199 let candidate = format!("{base}-{n}");
200 if !occupied.contains(&candidate) {
201 return candidate;
202 }
203 }
204 format!("{base}-{}-overflow", path_hash_suffix(cwd))
206}
207
208#[derive(Debug, Clone, Serialize)]
210pub struct SessionInfo {
211 pub name: String,
212 pub cwd: Option<String>,
216 pub home_dir: PathBuf,
217 pub did: Option<String>,
218 pub handle: Option<String>,
219 pub daemon_running: bool,
223}
224
225pub fn list_sessions() -> Result<Vec<SessionInfo>> {
228 let root = sessions_root()?;
229 if !root.exists() {
230 return Ok(Vec::new());
231 }
232 let registry = read_registry().unwrap_or_default();
233 let mut name_to_cwd: HashMap<String, String> = HashMap::new();
235 for (cwd, name) in ®istry.by_cwd {
236 name_to_cwd.insert(name.clone(), cwd.clone());
237 }
238
239 let mut out = Vec::new();
240 for entry in std::fs::read_dir(&root)?.flatten() {
241 let path = entry.path();
242 if !path.is_dir() {
243 continue;
244 }
245 let name = match path.file_name().and_then(|s| s.to_str()) {
246 Some(s) => s.to_string(),
247 None => continue,
248 };
249 if name == "registry.json" {
251 continue;
252 }
253 let card_path = path.join("config").join("wire").join("agent-card.json");
254 let (did, handle) = read_card_identity(&card_path);
255 let daemon_running = check_daemon_live(&path);
256 out.push(SessionInfo {
257 name: name.clone(),
258 cwd: name_to_cwd.get(&name).cloned(),
259 home_dir: path,
260 did,
261 handle,
262 daemon_running,
263 });
264 }
265 out.sort_by(|a, b| a.name.cmp(&b.name));
266 Ok(out)
267}
268
269fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
270 let bytes = match std::fs::read(card_path) {
271 Ok(b) => b,
272 Err(_) => return (None, None),
273 };
274 let v: serde_json::Value = match serde_json::from_slice(&bytes) {
275 Ok(v) => v,
276 Err(_) => return (None, None),
277 };
278 let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
279 let handle = v
280 .get("handle")
281 .and_then(|x| x.as_str())
282 .map(str::to_string)
283 .or_else(|| {
284 did.as_ref()
285 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
286 });
287 (did, handle)
288}
289
290fn check_daemon_live(session_home: &Path) -> bool {
291 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
296 let bytes = match std::fs::read(&pidfile) {
297 Ok(b) => b,
298 Err(_) => return false,
299 };
300 let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
302 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
303 } else {
304 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
306 };
307 let pid = match pid_opt {
308 Some(p) => p,
309 None => return false,
310 };
311 is_process_live(pid)
312}
313
314fn is_process_live(pid: u32) -> bool {
315 #[cfg(target_os = "linux")]
316 {
317 std::path::Path::new(&format!("/proc/{pid}")).exists()
318 }
319 #[cfg(not(target_os = "linux"))]
320 {
321 std::process::Command::new("kill")
322 .args(["-0", &pid.to_string()])
323 .output()
324 .map(|o| o.status.success())
325 .unwrap_or(false)
326 }
327}
328
329pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
344 let path = session_home.join("config").join("wire").join("relay.json");
345 let bytes = match std::fs::read(&path) {
346 Ok(b) => b,
347 Err(_) => return Vec::new(),
348 };
349 let val: Value = match serde_json::from_slice(&bytes) {
350 Ok(v) => v,
351 Err(_) => return Vec::new(),
352 };
353 self_endpoints(&val)
354}
355
356#[derive(Debug, Clone, Serialize)]
363pub struct LocalEndpointView {
364 pub relay_url: String,
365 pub slot_id: String,
366}
367
368#[derive(Debug, Clone, Serialize)]
371pub struct LocalSessionView {
372 pub name: String,
373 pub handle: Option<String>,
374 pub did: Option<String>,
375 pub cwd: Option<String>,
376 pub home_dir: PathBuf,
377 pub daemon_running: bool,
378 pub local_endpoints: Vec<LocalEndpointView>,
382}
383
384#[derive(Debug, Clone, Serialize)]
387pub struct FederationOnlySessionView {
388 pub name: String,
389 pub handle: Option<String>,
390 pub cwd: Option<String>,
391}
392
393#[derive(Debug, Clone, Serialize)]
397pub struct LocalSessionListing {
398 pub local: HashMap<String, Vec<LocalSessionView>>,
399 pub federation_only: Vec<FederationOnlySessionView>,
400}
401
402pub fn list_local_sessions() -> Result<LocalSessionListing> {
405 let sessions = list_sessions()?;
406 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
407 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
408
409 for s in sessions {
410 let endpoints = read_session_endpoints(&s.home_dir);
411 let local_eps: Vec<Endpoint> = endpoints
412 .into_iter()
413 .filter(|e| matches!(e.scope, EndpointScope::Local))
414 .collect();
415 if local_eps.is_empty() {
416 federation_only.push(FederationOnlySessionView {
417 name: s.name.clone(),
418 handle: s.handle.clone(),
419 cwd: s.cwd.clone(),
420 });
421 continue;
422 }
423 let redacted: Vec<LocalEndpointView> = local_eps
425 .iter()
426 .map(|e| LocalEndpointView {
427 relay_url: e.relay_url.clone(),
428 slot_id: e.slot_id.clone(),
429 })
430 .collect();
431 for ep in &local_eps {
434 local
435 .entry(ep.relay_url.clone())
436 .or_default()
437 .push(LocalSessionView {
438 name: s.name.clone(),
439 handle: s.handle.clone(),
440 did: s.did.clone(),
441 cwd: s.cwd.clone(),
442 home_dir: s.home_dir.clone(),
443 daemon_running: s.daemon_running,
444 local_endpoints: redacted.clone(),
445 });
446 }
447 }
448 for group in local.values_mut() {
450 group.sort_by(|a, b| a.name.cmp(&b.name));
451 }
452 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
453 Ok(LocalSessionListing {
454 local,
455 federation_only,
456 })
457}
458
459pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
473 let registry = read_registry().ok()?;
474 let cwd_str = cwd.to_string_lossy().into_owned();
475 let session_name = registry.by_cwd.get(&cwd_str)?;
476 let session_home = session_dir(session_name).ok()?;
477 if !session_home.exists() {
478 return None;
479 }
480 Some(session_home)
481}
482
483pub fn maybe_adopt_session_wire_home(label: &str) {
498 if std::env::var("WIRE_HOME").is_ok() {
499 return;
500 }
501 let cwd = match std::env::current_dir() {
502 Ok(c) => c,
503 Err(_) => return,
504 };
505 let home = match detect_session_wire_home(&cwd) {
506 Some(h) => h,
507 None => return,
508 };
509 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
510 eprintln!(
511 "wire {label}: auto-detected session for cwd `{}` → WIRE_HOME=`{}`",
512 cwd.display(),
513 home.display()
514 );
515 }
516 unsafe {
520 std::env::set_var("WIRE_HOME", &home);
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn sanitize_handles_unicode_and_long_names() {
530 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
531 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
532 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
534 assert_eq!(sanitize_name("---"), "wire-session");
535 let long: String = "a".repeat(100);
536 assert_eq!(sanitize_name(&long).len(), 32);
537 }
538
539 #[test]
540 fn derive_name_returns_basename_when_no_collision() {
541 let reg = SessionRegistry::default();
542 assert_eq!(
543 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
544 "wire"
545 );
546 assert_eq!(
547 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
548 "slancha-mesh"
549 );
550 }
551
552 #[test]
553 fn derive_name_returns_stored_name_when_cwd_already_registered() {
554 let mut reg = SessionRegistry::default();
555 reg.by_cwd.insert(
556 "/Users/paul/Source/wire".to_string(),
557 "wire-special".to_string(),
558 );
559 assert_eq!(
560 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
561 "wire-special"
562 );
563 }
564
565 #[test]
566 fn read_session_endpoints_handles_missing_relay_state() {
567 let tmp = tempfile::tempdir().unwrap();
568 let endpoints = read_session_endpoints(tmp.path());
570 assert!(endpoints.is_empty());
571 }
572
573 #[test]
574 fn read_session_endpoints_parses_dual_slot_form() {
575 let tmp = tempfile::tempdir().unwrap();
576 let cfg = tmp.path().join("config").join("wire");
577 std::fs::create_dir_all(&cfg).unwrap();
578 let body = serde_json::json!({
579 "self": {
580 "relay_url": "https://wireup.net",
581 "slot_id": "fed-slot",
582 "slot_token": "fed-tok",
583 "endpoints": [
584 {
585 "relay_url": "https://wireup.net",
586 "slot_id": "fed-slot",
587 "slot_token": "fed-tok",
588 "scope": "federation"
589 },
590 {
591 "relay_url": "http://127.0.0.1:8771",
592 "slot_id": "loop-slot",
593 "slot_token": "loop-tok",
594 "scope": "local"
595 }
596 ]
597 }
598 });
599 std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
600 let endpoints = read_session_endpoints(tmp.path());
601 assert_eq!(endpoints.len(), 2);
602 let local_count = endpoints
603 .iter()
604 .filter(|e| matches!(e.scope, EndpointScope::Local))
605 .count();
606 assert_eq!(local_count, 1);
607 let local = endpoints
608 .iter()
609 .find(|e| matches!(e.scope, EndpointScope::Local))
610 .unwrap();
611 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
612 assert_eq!(local.slot_id, "loop-slot");
613 }
614
615 #[test]
624 fn derive_name_appends_path_hash_when_basename_collides() {
625 let mut reg = SessionRegistry::default();
626 reg.by_cwd
627 .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
628 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
630 assert!(name.starts_with("wire-"));
631 assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
633 }
634}