1use anyhow::{Result, bail};
21use serde::{Deserialize, Serialize};
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25pub const MARKER_SCHEMA: &str = "wire-retired-v1";
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RetiredMarker {
29 pub schema: String,
30 pub retired_at_unix: u64,
31 #[serde(default)]
32 pub reason: String,
33}
34
35pub fn marker_path(home: &Path) -> PathBuf {
37 home.join("state").join("wire").join("retired.json")
38}
39
40pub fn is_retired(home: &Path) -> bool {
43 marker_path(home).exists()
44}
45
46pub fn read_marker(home: &Path) -> Option<RetiredMarker> {
49 let bytes = std::fs::read(marker_path(home)).ok()?;
50 serde_json::from_slice(&bytes).ok()
51}
52
53fn write_marker(home: &Path, reason: &str, now_unix: u64) -> Result<()> {
54 let dir = home.join("state").join("wire");
55 std::fs::create_dir_all(&dir)?;
56 let m = RetiredMarker {
57 schema: MARKER_SCHEMA.to_string(),
58 retired_at_unix: now_unix,
59 reason: reason.to_string(),
60 };
61 let tmp = dir.join("retired.json.tmp");
62 std::fs::write(&tmp, serde_json::to_vec_pretty(&m)?)?;
63 std::fs::rename(&tmp, marker_path(home))?; Ok(())
65}
66
67fn remove_marker(home: &Path) -> Result<()> {
68 let p = marker_path(home);
69 if p.exists() {
70 std::fs::remove_file(&p)?;
71 }
72 Ok(())
73}
74
75pub fn retire_session<F>(home: &Path, reason: &str, now_unix: u64, stop: F) -> Result<Option<u32>>
82where
83 F: Fn(u32) -> bool,
84{
85 write_marker(home, reason, now_unix)?;
86 let pid = crate::session::session_daemon_pid(home);
87 if let Some(p) = pid {
88 stop(p);
89 }
90 Ok(pid)
91}
92
93pub fn revive_session(home: &Path) -> Result<()> {
96 remove_marker(home)
97}
98
99pub fn stop_daemon_graceful_then_force(pid: u32) -> bool {
103 crate::platform::kill_process(pid, false);
104 for _ in 0..10 {
105 if !crate::platform::process_alive(pid) {
106 return true;
107 }
108 std::thread::sleep(Duration::from_millis(100));
109 }
110 crate::platform::kill_process(pid, true);
111 for _ in 0..6 {
112 if !crate::platform::process_alive(pid) {
113 return true;
114 }
115 std::thread::sleep(Duration::from_millis(100));
116 }
117 !crate::platform::process_alive(pid)
118}
119
120pub fn current_home() -> Option<PathBuf> {
125 let cfg = crate::config::config_dir().ok()?; let home = cfg.parent()?.parent()?; if !home
134 .join("config")
135 .join("wire")
136 .join("private.key")
137 .exists()
138 {
139 return None;
140 }
141 Some(std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()))
142}
143
144pub fn is_current(home: &Path) -> bool {
146 match current_home() {
147 Some(cur) => {
148 let h = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
149 h == cur
150 }
151 None => false,
152 }
153}
154
155pub fn has_pending_inbound(home: &Path) -> bool {
160 let dir = home
161 .join("state")
162 .join("wire")
163 .join("pending-inbound-pairs");
164 match std::fs::read_dir(&dir) {
165 Ok(mut entries) => entries.any(|e| {
166 e.ok()
167 .and_then(|e| e.path().extension().map(|x| x == "json"))
168 .unwrap_or(false)
169 }),
170 Err(_) => false,
171 }
172}
173
174pub fn identity_age_s(home: &Path) -> Option<u64> {
182 let p = home.join("config").join("wire").join("private.key");
183 let mtime = std::fs::metadata(&p).ok()?.modified().ok()?;
184 mtime.elapsed().ok().map(|d| d.as_secs())
185}
186
187pub fn resolve_target(arg: &str) -> Result<crate::session::SessionInfo> {
192 let a = arg.trim();
193 if a.is_empty() {
194 bail!("empty identity — pass a handle, fingerprint, or session key");
195 }
196 let sessions = crate::session::list_sessions()?;
197 let matched: Vec<crate::session::SessionInfo> = sessions
198 .into_iter()
199 .filter(|s| {
200 s.handle.as_deref() == Some(a)
201 || s.name == a
202 || s.did
203 .as_deref()
204 .and_then(crate::dash::fingerprint_from_did)
205 .as_deref()
206 == Some(a)
207 })
208 .collect();
209 match matched.len() {
210 0 => bail!("no wire identity matches '{a}' (try a handle, fingerprint, or `wire dash`)"),
211 1 => Ok(matched.into_iter().next().unwrap()),
212 n => {
213 let names: Vec<String> = matched
214 .iter()
215 .map(|s| s.handle.clone().unwrap_or_else(|| s.name.clone()))
216 .collect();
217 bail!(
218 "'{a}' is ambiguous — {n} identities match: {}",
219 names.join(", ")
220 )
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn marker_roundtrip_and_is_retired() {
231 let dir = tempfile::tempdir().unwrap();
232 let home = dir.path();
233 assert!(!is_retired(home));
234 write_marker(home, "test", 1_700_000_000).unwrap();
235 assert!(is_retired(home), "marker present ⇒ retired");
236 let m = read_marker(home).unwrap();
237 assert_eq!(m.schema, MARKER_SCHEMA);
238 assert_eq!(m.retired_at_unix, 1_700_000_000);
239 assert_eq!(m.reason, "test");
240 remove_marker(home).unwrap();
241 assert!(!is_retired(home), "marker removed ⇒ not retired");
242 }
243
244 #[test]
245 fn is_retired_is_pure_existence_not_content() {
246 let dir = tempfile::tempdir().unwrap();
248 let home = dir.path();
249 std::fs::create_dir_all(home.join("state").join("wire")).unwrap();
250 std::fs::write(marker_path(home), b"{ this is not json").unwrap();
251 assert!(
252 is_retired(home),
253 "corrupt marker must still read as retired"
254 );
255 assert!(
256 read_marker(home).is_none(),
257 "corrupt body → None, but still retired"
258 );
259 }
260
261 #[test]
262 fn retire_writes_marker_before_kill() {
263 let dir = tempfile::tempdir().unwrap();
265 let home = dir.path().to_path_buf();
266 let called = std::cell::Cell::new(false);
268 let pid = retire_session(&home, "r", 1, |_p| {
269 called.set(true);
270 true
271 })
272 .unwrap();
273 assert_eq!(pid, None, "no pid file ⇒ nothing to stop");
274 assert!(!called.get());
275 assert!(is_retired(&home), "marker written even with no daemon");
276 }
277
278 #[test]
279 fn revive_is_noop_when_not_retired() {
280 let dir = tempfile::tempdir().unwrap();
281 revive_session(dir.path()).unwrap();
282 assert!(!is_retired(dir.path()));
283 }
284}