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> {
45 if let Ok(home) = std::env::var("WIRE_HOME") {
46 return Ok(PathBuf::from(home).join("sessions"));
47 }
48 let state = dirs::state_dir()
49 .ok_or_else(|| anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?;
50 Ok(state.join("wire").join("sessions"))
51}
52
53pub fn session_dir(name: &str) -> Result<PathBuf> {
57 Ok(sessions_root()?.join(sanitize_name(name)))
58}
59
60pub fn registry_path() -> Result<PathBuf> {
64 Ok(sessions_root()?.join("registry.json"))
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct SessionRegistry {
69 #[serde(default)]
72 pub by_cwd: HashMap<String, String>,
73}
74
75pub fn read_registry() -> Result<SessionRegistry> {
76 let path = registry_path()?;
77 if !path.exists() {
78 return Ok(SessionRegistry::default());
79 }
80 let bytes = std::fs::read(&path)
81 .with_context(|| format!("reading session registry {path:?}"))?;
82 serde_json::from_slice(&bytes)
83 .with_context(|| format!("parsing session registry {path:?}"))
84}
85
86pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
87 let path = registry_path()?;
88 if let Some(parent) = path.parent() {
89 std::fs::create_dir_all(parent)
90 .with_context(|| format!("creating {parent:?}"))?;
91 }
92 let body = serde_json::to_vec_pretty(reg)?;
93 std::fs::write(&path, body)
94 .with_context(|| format!("writing session registry {path:?}"))?;
95 Ok(())
96}
97
98pub fn sanitize_name(raw: &str) -> String {
102 let mut out = String::with_capacity(raw.len());
103 let mut prev_dash = false;
104 for c in raw.chars() {
105 let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
106 let ch = if ok { c.to_ascii_lowercase() } else { '-' };
107 if ch == '-' {
108 if !prev_dash && !out.is_empty() {
109 out.push('-');
110 }
111 prev_dash = true;
112 } else {
113 out.push(ch);
114 prev_dash = false;
115 }
116 }
117 let trimmed = out.trim_matches('-').to_string();
118 if trimmed.is_empty() {
119 return "wire-session".to_string();
120 }
121 if trimmed.len() > 32 {
122 return trimmed[..32].trim_end_matches('-').to_string();
123 }
124 trimmed
125}
126
127fn path_hash_suffix(cwd: &Path) -> String {
131 let bytes = cwd.as_os_str().to_string_lossy().into_owned();
132 let mut h = Sha256::new();
133 h.update(bytes.as_bytes());
134 let digest = h.finalize();
135 hex::encode(&digest[..2]) }
137
138pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
147 let cwd_key = cwd.to_string_lossy().into_owned();
148 if let Some(existing) = registry.by_cwd.get(&cwd_key) {
149 return existing.clone();
150 }
151 let base = cwd
152 .file_name()
153 .and_then(|s| s.to_str())
154 .map(sanitize_name)
155 .unwrap_or_else(|| "wire-session".to_string());
156 let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
157 if !occupied.contains(&base) {
158 return base;
159 }
160 let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
161 if !occupied.contains(&with_hash) {
162 return with_hash;
163 }
164 for n in 2..1000 {
167 let candidate = format!("{base}-{n}");
168 if !occupied.contains(&candidate) {
169 return candidate;
170 }
171 }
172 format!("{base}-{}-overflow", path_hash_suffix(cwd))
174}
175
176#[derive(Debug, Clone, Serialize)]
178pub struct SessionInfo {
179 pub name: String,
180 pub cwd: Option<String>,
184 pub home_dir: PathBuf,
185 pub did: Option<String>,
186 pub handle: Option<String>,
187 pub daemon_running: bool,
191}
192
193pub fn list_sessions() -> Result<Vec<SessionInfo>> {
196 let root = sessions_root()?;
197 if !root.exists() {
198 return Ok(Vec::new());
199 }
200 let registry = read_registry().unwrap_or_default();
201 let mut name_to_cwd: HashMap<String, String> = HashMap::new();
203 for (cwd, name) in ®istry.by_cwd {
204 name_to_cwd.insert(name.clone(), cwd.clone());
205 }
206
207 let mut out = Vec::new();
208 for entry in std::fs::read_dir(&root)?.flatten() {
209 let path = entry.path();
210 if !path.is_dir() {
211 continue;
212 }
213 let name = match path.file_name().and_then(|s| s.to_str()) {
214 Some(s) => s.to_string(),
215 None => continue,
216 };
217 if name == "registry.json" {
219 continue;
220 }
221 let card_path = path.join("config").join("wire").join("agent-card.json");
222 let (did, handle) = read_card_identity(&card_path);
223 let daemon_running = check_daemon_live(&path);
224 out.push(SessionInfo {
225 name: name.clone(),
226 cwd: name_to_cwd.get(&name).cloned(),
227 home_dir: path,
228 did,
229 handle,
230 daemon_running,
231 });
232 }
233 out.sort_by(|a, b| a.name.cmp(&b.name));
234 Ok(out)
235}
236
237fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
238 let bytes = match std::fs::read(card_path) {
239 Ok(b) => b,
240 Err(_) => return (None, None),
241 };
242 let v: serde_json::Value = match serde_json::from_slice(&bytes) {
243 Ok(v) => v,
244 Err(_) => return (None, None),
245 };
246 let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
247 let handle = v
248 .get("handle")
249 .and_then(|x| x.as_str())
250 .map(str::to_string)
251 .or_else(|| {
252 did.as_ref().map(|d| {
253 crate::agent_card::display_handle_from_did(d).to_string()
254 })
255 });
256 (did, handle)
257}
258
259fn check_daemon_live(session_home: &Path) -> bool {
260 let pidfile = session_home
265 .join("state")
266 .join("wire")
267 .join("daemon.pid");
268 let bytes = match std::fs::read(&pidfile) {
269 Ok(b) => b,
270 Err(_) => return false,
271 };
272 let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
274 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
275 } else {
276 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
278 };
279 let pid = match pid_opt {
280 Some(p) => p,
281 None => return false,
282 };
283 is_process_live(pid)
284}
285
286fn is_process_live(pid: u32) -> bool {
287 #[cfg(target_os = "linux")]
288 {
289 std::path::Path::new(&format!("/proc/{pid}")).exists()
290 }
291 #[cfg(not(target_os = "linux"))]
292 {
293 std::process::Command::new("kill")
294 .args(["-0", &pid.to_string()])
295 .output()
296 .map(|o| o.status.success())
297 .unwrap_or(false)
298 }
299}
300
301pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
308 let path = session_home
309 .join("config")
310 .join("wire")
311 .join("relay-state.json");
312 let bytes = match std::fs::read(&path) {
313 Ok(b) => b,
314 Err(_) => return Vec::new(),
315 };
316 let val: Value = match serde_json::from_slice(&bytes) {
317 Ok(v) => v,
318 Err(_) => return Vec::new(),
319 };
320 self_endpoints(&val)
321}
322
323#[derive(Debug, Clone, Serialize)]
330pub struct LocalEndpointView {
331 pub relay_url: String,
332 pub slot_id: String,
333}
334
335#[derive(Debug, Clone, Serialize)]
338pub struct LocalSessionView {
339 pub name: String,
340 pub handle: Option<String>,
341 pub did: Option<String>,
342 pub cwd: Option<String>,
343 pub home_dir: PathBuf,
344 pub daemon_running: bool,
345 pub local_endpoints: Vec<LocalEndpointView>,
349}
350
351#[derive(Debug, Clone, Serialize)]
354pub struct FederationOnlySessionView {
355 pub name: String,
356 pub handle: Option<String>,
357 pub cwd: Option<String>,
358}
359
360#[derive(Debug, Clone, Serialize)]
364pub struct LocalSessionListing {
365 pub local: HashMap<String, Vec<LocalSessionView>>,
366 pub federation_only: Vec<FederationOnlySessionView>,
367}
368
369pub fn list_local_sessions() -> Result<LocalSessionListing> {
372 let sessions = list_sessions()?;
373 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
374 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
375
376 for s in sessions {
377 let endpoints = read_session_endpoints(&s.home_dir);
378 let local_eps: Vec<Endpoint> = endpoints
379 .into_iter()
380 .filter(|e| matches!(e.scope, EndpointScope::Local))
381 .collect();
382 if local_eps.is_empty() {
383 federation_only.push(FederationOnlySessionView {
384 name: s.name.clone(),
385 handle: s.handle.clone(),
386 cwd: s.cwd.clone(),
387 });
388 continue;
389 }
390 let redacted: Vec<LocalEndpointView> = local_eps
392 .iter()
393 .map(|e| LocalEndpointView {
394 relay_url: e.relay_url.clone(),
395 slot_id: e.slot_id.clone(),
396 })
397 .collect();
398 for ep in &local_eps {
401 local
402 .entry(ep.relay_url.clone())
403 .or_default()
404 .push(LocalSessionView {
405 name: s.name.clone(),
406 handle: s.handle.clone(),
407 did: s.did.clone(),
408 cwd: s.cwd.clone(),
409 home_dir: s.home_dir.clone(),
410 daemon_running: s.daemon_running,
411 local_endpoints: redacted.clone(),
412 });
413 }
414 }
415 for group in local.values_mut() {
417 group.sort_by(|a, b| a.name.cmp(&b.name));
418 }
419 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
420 Ok(LocalSessionListing {
421 local,
422 federation_only,
423 })
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn sanitize_handles_unicode_and_long_names() {
432 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
433 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
434 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
436 assert_eq!(sanitize_name("---"), "wire-session");
437 let long: String = "a".repeat(100);
438 assert_eq!(sanitize_name(&long).len(), 32);
439 }
440
441 #[test]
442 fn derive_name_returns_basename_when_no_collision() {
443 let reg = SessionRegistry::default();
444 assert_eq!(
445 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
446 "wire"
447 );
448 assert_eq!(
449 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
450 "slancha-mesh"
451 );
452 }
453
454 #[test]
455 fn derive_name_returns_stored_name_when_cwd_already_registered() {
456 let mut reg = SessionRegistry::default();
457 reg.by_cwd.insert(
458 "/Users/paul/Source/wire".to_string(),
459 "wire-special".to_string(),
460 );
461 assert_eq!(
462 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
463 "wire-special"
464 );
465 }
466
467 #[test]
468 fn read_session_endpoints_handles_missing_relay_state() {
469 let tmp = tempfile::tempdir().unwrap();
470 let endpoints = read_session_endpoints(tmp.path());
472 assert!(endpoints.is_empty());
473 }
474
475 #[test]
476 fn read_session_endpoints_parses_dual_slot_form() {
477 let tmp = tempfile::tempdir().unwrap();
478 let cfg = tmp.path().join("config").join("wire");
479 std::fs::create_dir_all(&cfg).unwrap();
480 let body = serde_json::json!({
481 "self": {
482 "relay_url": "https://wireup.net",
483 "slot_id": "fed-slot",
484 "slot_token": "fed-tok",
485 "endpoints": [
486 {
487 "relay_url": "https://wireup.net",
488 "slot_id": "fed-slot",
489 "slot_token": "fed-tok",
490 "scope": "federation"
491 },
492 {
493 "relay_url": "http://127.0.0.1:8771",
494 "slot_id": "loop-slot",
495 "slot_token": "loop-tok",
496 "scope": "local"
497 }
498 ]
499 }
500 });
501 std::fs::write(cfg.join("relay-state.json"), serde_json::to_vec(&body).unwrap())
502 .unwrap();
503 let endpoints = read_session_endpoints(tmp.path());
504 assert_eq!(endpoints.len(), 2);
505 let local_count = endpoints
506 .iter()
507 .filter(|e| matches!(e.scope, EndpointScope::Local))
508 .count();
509 assert_eq!(local_count, 1);
510 let local = endpoints
511 .iter()
512 .find(|e| matches!(e.scope, EndpointScope::Local))
513 .unwrap();
514 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
515 assert_eq!(local.slot_id, "loop-slot");
516 }
517
518 #[test]
527 fn derive_name_appends_path_hash_when_basename_collides() {
528 let mut reg = SessionRegistry::default();
529 reg.by_cwd.insert(
530 "/Users/paul/Source/wire".to_string(),
531 "wire".to_string(),
532 );
533 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
535 assert!(name.starts_with("wire-"));
536 assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
538 }
539}