use anyhow::{Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use crate::pair_session::{
PairSessionState, pair_session_confirm_sas, pair_session_finalize, pair_session_open,
pair_session_try_sas,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingPair {
pub code: String,
pub code_hash: String,
pub role: String,
pub relay_url: String,
pub status: String,
#[serde(default)]
pub sas: Option<String>,
#[serde(default)]
pub peer_did: Option<String>,
pub created_at: String,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub pair_id: Option<String>,
#[serde(default)]
pub our_slot_id: Option<String>,
#[serde(default)]
pub our_slot_token: Option<String>,
#[serde(default)]
pub spake2_seed_b64: Option<String>,
}
pub fn pending_dir() -> Result<PathBuf> {
let d = crate::config::state_dir()?.join("pending-pair");
std::fs::create_dir_all(&d)?;
Ok(d)
}
fn pending_path(code: &str) -> Result<PathBuf> {
let safe: String = code
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
Ok(pending_dir()?.join(format!("{safe}.json")))
}
pub fn write_pending(p: &PendingPair) -> Result<()> {
let path = pending_path(&p.code)?;
let body = serde_json::to_string_pretty(p)?;
std::fs::write(&path, body)?;
Ok(())
}
pub fn read_pending(code: &str) -> Result<Option<PendingPair>> {
let path = pending_path(code)?;
if !path.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(&path)?;
Ok(Some(serde_json::from_str(&body)?))
}
pub fn delete_pending(code: &str) -> Result<()> {
let path = pending_path(code)?;
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn list_pending() -> Result<Vec<PendingPair>> {
let dir = pending_dir()?;
let mut out = Vec::new();
if !dir.exists() {
return Ok(out);
}
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if entry.file_type()?.is_file() {
let body = std::fs::read_to_string(entry.path())?;
if let Ok(p) = serde_json::from_str::<PendingPair>(&body) {
out.push(p);
}
}
}
out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(out)
}
static LIVE_SESSIONS: OnceLock<Mutex<HashMap<String, PairSessionState>>> = OnceLock::new();
fn live() -> &'static Mutex<HashMap<String, PairSessionState>> {
LIVE_SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn daemon_pid_file() -> Result<PathBuf> {
Ok(crate::config::state_dir()?.join("daemon.pid"))
}
fn process_alive(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(not(target_os = "linux"))]
{
use std::process::Command;
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
pub fn cleanup_on_startup() -> Result<()> {
let pid_file = daemon_pid_file()?;
let my_pid = std::process::id();
let prev_alive = if pid_file.exists() {
if let Ok(s) = std::fs::read_to_string(&pid_file) {
if let Ok(pid) = s.trim().parse::<u32>() {
if pid == my_pid {
return Ok(());
}
process_alive(pid)
} else {
false
}
} else {
false
}
} else {
false
};
if !prev_alive {
for mut p in list_pending()? {
let transient =
p.status == "polling" || p.status == "request_host" || p.status == "request_guest";
if !transient {
continue;
}
let can_restore = p.status == "polling"
&& p.pair_id.is_some()
&& p.our_slot_id.is_some()
&& p.our_slot_token.is_some()
&& p.spake2_seed_b64.is_some();
if can_restore {
let restore_result = (|| -> Result<()> {
let seed_bytes =
crate::signing::b64decode(p.spake2_seed_b64.as_ref().unwrap())?;
if seed_bytes.len() != 32 {
bail!(
"spake2_seed_b64 decoded to {} bytes, want 32",
seed_bytes.len()
);
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&seed_bytes);
let role = match p.role.as_str() {
"host" => "host",
"guest" => "guest",
_ => bail!("invalid role {:?}", p.role),
};
let s = crate::pair_session::restore_pair_session(
role,
&p.relay_url,
p.pair_id.as_ref().unwrap(),
&p.code,
&p.code_hash,
p.our_slot_id.as_ref().unwrap(),
p.our_slot_token.as_ref().unwrap(),
seed,
)?;
live().lock().unwrap().insert(p.code.clone(), s);
Ok(())
})();
match restore_result {
Ok(()) => {
continue;
}
Err(e) => {
p.last_error = Some(format!("restore_pair_session failed: {e}"));
}
}
}
let client = crate::relay_client::RelayClient::new(&p.relay_url);
let _ = client.pair_abandon(&p.code_hash);
p.status = "aborted_restart".to_string();
if p.last_error.is_none() {
p.last_error = Some(
"daemon restarted mid-handshake; SPAKE2 state could not be restored (likely pre-v0.3.12 pending file). Re-issue with a fresh code phrase.".to_string(),
);
}
write_pending(&p)?;
crate::os_notify::toast(
&format!("wire — pair aborted on restart ({})", p.code),
"Daemon restarted mid-handshake. Re-issue: wire pair-host --detach",
);
}
}
if let Some(parent) = pid_file.parent() {
std::fs::create_dir_all(parent).ok();
}
let bin_path = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let started_at = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let did = crate::config::read_agent_card()
.ok()
.and_then(|card| {
card.get("did")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
let relay_url = crate::config::read_relay_state()
.ok()
.and_then(|state| {
state
.get("self")
.and_then(|s| s.get("relay_url"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
let record = crate::ensure_up::DaemonPid {
schema: crate::ensure_up::DAEMON_PID_SCHEMA.to_string(),
pid: my_pid,
bin_path,
version: env!("CARGO_PKG_VERSION").to_string(),
started_at,
did,
relay_url,
};
if let Ok(body) = serde_json::to_vec_pretty(&record) {
let _ = std::fs::write(&pid_file, body);
}
Ok(())
}
const TERMINAL_TTL_SECS: i64 = 3600;
pub fn tick() -> Result<Value> {
let mut transitions: Vec<Value> = Vec::new();
let now = time::OffsetDateTime::now_utc();
for mut p in list_pending()? {
let prev_status = p.status.clone();
if (p.status == "aborted" || p.status == "aborted_restart")
&& let Ok(created) = time::OffsetDateTime::parse(
&p.created_at,
&time::format_description::well_known::Rfc3339,
)
&& (now - created).whole_seconds() > TERMINAL_TTL_SECS
{
let _ = delete_pending(&p.code);
continue;
}
if let Err(e) = process_one(&mut p) {
p.last_error = Some(format!("{e:#}"));
p.status = "aborted".to_string();
let client = crate::relay_client::RelayClient::new(&p.relay_url);
let _ = client.pair_abandon(&p.code_hash);
let _ = write_pending(&p);
live().lock().unwrap().remove(&p.code);
let title = format!("wire — pair aborted ({})", p.code);
let body = p
.last_error
.clone()
.unwrap_or_else(|| "(no detail)".to_string());
crate::os_notify::toast(&title, &body);
}
if p.status != prev_status {
transitions.push(json!({
"code": p.code,
"from": prev_status,
"to": p.status,
"sas": p.sas,
"peer_did": p.peer_did,
}));
}
}
Ok(json!({"transitions": transitions}))
}
fn process_one(p: &mut PendingPair) -> Result<()> {
match p.status.as_str() {
"request_host" => {
let s = pair_session_open("host", &p.relay_url, Some(&p.code))?;
p.pair_id = Some(s.pair_id.clone());
p.our_slot_id = Some(s.our_slot_id.clone());
p.our_slot_token = Some(s.our_slot_token.clone());
p.spake2_seed_b64 = Some(crate::signing::b64encode(&s.spake2_seed));
live().lock().unwrap().insert(p.code.clone(), s);
p.status = "polling".to_string();
write_pending(p)?;
}
"request_guest" => {
let s = pair_session_open("guest", &p.relay_url, Some(&p.code))?;
p.pair_id = Some(s.pair_id.clone());
p.our_slot_id = Some(s.our_slot_id.clone());
p.our_slot_token = Some(s.our_slot_token.clone());
p.spake2_seed_b64 = Some(crate::signing::b64encode(&s.spake2_seed));
live().lock().unwrap().insert(p.code.clone(), s);
p.status = "polling".to_string();
write_pending(p)?;
}
"polling" => {
let mut sessions = live().lock().unwrap();
let s = sessions
.get_mut(&p.code)
.ok_or_else(|| anyhow!("no live session for {} (daemon restart?)", p.code))?;
if pair_session_try_sas(s)?.is_some() {
p.status = "sas_ready".to_string();
p.sas = s.sas.clone();
write_pending(p)?;
let formatted = p
.sas
.as_ref()
.map(|d| format!("{}-{}", &d[..3], &d[3..]))
.unwrap_or_default();
let title = format!("wire — pair SAS ready ({})", p.code);
let body = format!(
"Digits: {formatted}\nCompare with peer, then:\nwire pair-confirm {} {}",
p.code,
p.sas.as_deref().unwrap_or("")
);
crate::os_notify::toast(&title, &body);
}
}
"confirmed" => {
let mut sessions = live().lock().unwrap();
let s = sessions.get_mut(&p.code).ok_or_else(|| {
anyhow!(
"no live session for {} (status=confirmed but session lost; daemon restart between sas_ready and confirmed)",
p.code
)
})?;
let digits = p
.sas
.clone()
.ok_or_else(|| anyhow!("status=confirmed but sas missing"))?;
pair_session_confirm_sas(s, &digits)?;
let outcome = pair_session_finalize(s, 30)?;
p.peer_did = outcome
.get("peer_did")
.and_then(Value::as_str)
.map(str::to_string);
sessions.remove(&p.code);
delete_pending(&p.code)?;
let title = format!("wire — paired ({})", p.code);
let body = format!(
"Peer: {}\n`wire peers` to confirm.",
p.peer_did.as_deref().unwrap_or("?")
);
crate::os_notify::toast(&title, &body);
}
_ => {}
}
Ok(())
}