use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
pub fn sessions_root() -> Result<PathBuf> {
if let Ok(home) = std::env::var("WIRE_HOME") {
return Ok(PathBuf::from(home).join("sessions"));
}
let state = dirs::state_dir()
.ok_or_else(|| anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?;
Ok(state.join("wire").join("sessions"))
}
pub fn session_dir(name: &str) -> Result<PathBuf> {
Ok(sessions_root()?.join(sanitize_name(name)))
}
pub fn registry_path() -> Result<PathBuf> {
Ok(sessions_root()?.join("registry.json"))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionRegistry {
#[serde(default)]
pub by_cwd: HashMap<String, String>,
}
pub fn read_registry() -> Result<SessionRegistry> {
let path = registry_path()?;
if !path.exists() {
return Ok(SessionRegistry::default());
}
let bytes = std::fs::read(&path)
.with_context(|| format!("reading session registry {path:?}"))?;
serde_json::from_slice(&bytes)
.with_context(|| format!("parsing session registry {path:?}"))
}
pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
let path = registry_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {parent:?}"))?;
}
let body = serde_json::to_vec_pretty(reg)?;
std::fs::write(&path, body)
.with_context(|| format!("writing session registry {path:?}"))?;
Ok(())
}
pub fn sanitize_name(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut prev_dash = false;
for c in raw.chars() {
let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
let ch = if ok { c.to_ascii_lowercase() } else { '-' };
if ch == '-' {
if !prev_dash && !out.is_empty() {
out.push('-');
}
prev_dash = true;
} else {
out.push(ch);
prev_dash = false;
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
return "wire-session".to_string();
}
if trimmed.len() > 32 {
return trimmed[..32].trim_end_matches('-').to_string();
}
trimmed
}
fn path_hash_suffix(cwd: &Path) -> String {
let bytes = cwd.as_os_str().to_string_lossy().into_owned();
let mut h = Sha256::new();
h.update(bytes.as_bytes());
let digest = h.finalize();
hex::encode(&digest[..2]) }
pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
let cwd_key = cwd.to_string_lossy().into_owned();
if let Some(existing) = registry.by_cwd.get(&cwd_key) {
return existing.clone();
}
let base = cwd
.file_name()
.and_then(|s| s.to_str())
.map(sanitize_name)
.unwrap_or_else(|| "wire-session".to_string());
let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
if !occupied.contains(&base) {
return base;
}
let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
if !occupied.contains(&with_hash) {
return with_hash;
}
for n in 2..1000 {
let candidate = format!("{base}-{n}");
if !occupied.contains(&candidate) {
return candidate;
}
}
format!("{base}-{}-overflow", path_hash_suffix(cwd))
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub name: String,
pub cwd: Option<String>,
pub home_dir: PathBuf,
pub did: Option<String>,
pub handle: Option<String>,
pub daemon_running: bool,
}
pub fn list_sessions() -> Result<Vec<SessionInfo>> {
let root = sessions_root()?;
if !root.exists() {
return Ok(Vec::new());
}
let registry = read_registry().unwrap_or_default();
let mut name_to_cwd: HashMap<String, String> = HashMap::new();
for (cwd, name) in ®istry.by_cwd {
name_to_cwd.insert(name.clone(), cwd.clone());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&root)?.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if name == "registry.json" {
continue;
}
let card_path = path.join("config").join("wire").join("agent-card.json");
let (did, handle) = read_card_identity(&card_path);
let daemon_running = check_daemon_live(&path);
out.push(SessionInfo {
name: name.clone(),
cwd: name_to_cwd.get(&name).cloned(),
home_dir: path,
did,
handle,
daemon_running,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
let bytes = match std::fs::read(card_path) {
Ok(b) => b,
Err(_) => return (None, None),
};
let v: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return (None, None),
};
let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
let handle = v
.get("handle")
.and_then(|x| x.as_str())
.map(str::to_string)
.or_else(|| {
did.as_ref().map(|d| {
crate::agent_card::display_handle_from_did(d).to_string()
})
});
(did, handle)
}
fn check_daemon_live(session_home: &Path) -> bool {
let pidfile = session_home
.join("state")
.join("wire")
.join("daemon.pid");
let bytes = match std::fs::read(&pidfile) {
Ok(b) => b,
Err(_) => return false,
};
let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
} else {
String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
};
let pid = match pid_opt {
Some(p) => p,
None => return false,
};
is_process_live(pid)
}
fn is_process_live(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(not(target_os = "linux"))]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
let path = session_home
.join("config")
.join("wire")
.join("relay-state.json");
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let val: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
self_endpoints(&val)
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalEndpointView {
pub relay_url: String,
pub slot_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalSessionView {
pub name: String,
pub handle: Option<String>,
pub did: Option<String>,
pub cwd: Option<String>,
pub home_dir: PathBuf,
pub daemon_running: bool,
pub local_endpoints: Vec<LocalEndpointView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FederationOnlySessionView {
pub name: String,
pub handle: Option<String>,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalSessionListing {
pub local: HashMap<String, Vec<LocalSessionView>>,
pub federation_only: Vec<FederationOnlySessionView>,
}
pub fn list_local_sessions() -> Result<LocalSessionListing> {
let sessions = list_sessions()?;
let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
for s in sessions {
let endpoints = read_session_endpoints(&s.home_dir);
let local_eps: Vec<Endpoint> = endpoints
.into_iter()
.filter(|e| matches!(e.scope, EndpointScope::Local))
.collect();
if local_eps.is_empty() {
federation_only.push(FederationOnlySessionView {
name: s.name.clone(),
handle: s.handle.clone(),
cwd: s.cwd.clone(),
});
continue;
}
let redacted: Vec<LocalEndpointView> = local_eps
.iter()
.map(|e| LocalEndpointView {
relay_url: e.relay_url.clone(),
slot_id: e.slot_id.clone(),
})
.collect();
for ep in &local_eps {
local
.entry(ep.relay_url.clone())
.or_default()
.push(LocalSessionView {
name: s.name.clone(),
handle: s.handle.clone(),
did: s.did.clone(),
cwd: s.cwd.clone(),
home_dir: s.home_dir.clone(),
daemon_running: s.daemon_running,
local_endpoints: redacted.clone(),
});
}
}
for group in local.values_mut() {
group.sort_by(|a, b| a.name.cmp(&b.name));
}
federation_only.sort_by(|a, b| a.name.cmp(&b.name));
Ok(LocalSessionListing {
local,
federation_only,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_handles_unicode_and_long_names() {
assert_eq!(sanitize_name("paul-mac"), "paul-mac");
assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
assert_eq!(sanitize_name("---"), "wire-session");
let long: String = "a".repeat(100);
assert_eq!(sanitize_name(&long).len(), 32);
}
#[test]
fn derive_name_returns_basename_when_no_collision() {
let reg = SessionRegistry::default();
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
"wire"
);
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
"slancha-mesh"
);
}
#[test]
fn derive_name_returns_stored_name_when_cwd_already_registered() {
let mut reg = SessionRegistry::default();
reg.by_cwd.insert(
"/Users/paul/Source/wire".to_string(),
"wire-special".to_string(),
);
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
"wire-special"
);
}
#[test]
fn read_session_endpoints_handles_missing_relay_state() {
let tmp = tempfile::tempdir().unwrap();
let endpoints = read_session_endpoints(tmp.path());
assert!(endpoints.is_empty());
}
#[test]
fn read_session_endpoints_parses_dual_slot_form() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
let body = serde_json::json!({
"self": {
"relay_url": "https://wireup.net",
"slot_id": "fed-slot",
"slot_token": "fed-tok",
"endpoints": [
{
"relay_url": "https://wireup.net",
"slot_id": "fed-slot",
"slot_token": "fed-tok",
"scope": "federation"
},
{
"relay_url": "http://127.0.0.1:8771",
"slot_id": "loop-slot",
"slot_token": "loop-tok",
"scope": "local"
}
]
}
});
std::fs::write(cfg.join("relay-state.json"), serde_json::to_vec(&body).unwrap())
.unwrap();
let endpoints = read_session_endpoints(tmp.path());
assert_eq!(endpoints.len(), 2);
let local_count = endpoints
.iter()
.filter(|e| matches!(e.scope, EndpointScope::Local))
.count();
assert_eq!(local_count, 1);
let local = endpoints
.iter()
.find(|e| matches!(e.scope, EndpointScope::Local))
.unwrap();
assert_eq!(local.relay_url, "http://127.0.0.1:8771");
assert_eq!(local.slot_id, "loop-slot");
}
#[test]
fn derive_name_appends_path_hash_when_basename_collides() {
let mut reg = SessionRegistry::default();
reg.by_cwd.insert(
"/Users/paul/Source/wire".to_string(),
"wire".to_string(),
);
let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
assert!(name.starts_with("wire-"));
assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
}
}