1use std::collections::{BTreeMap, HashMap};
19use std::path::{Path, PathBuf};
20use std::sync::{Mutex, OnceLock};
21
22use serde::{Deserialize, Serialize};
23
24use super::helper_hosts::HostInfo;
25use super::ConnTarget;
26use crate::backend::mps::Broker;
27use crate::backend::rpc_types::{ConnStatus, RemoteHelper, RemotePlatform, RemoteRecord, RemoteStatus};
28use crate::backend::wconfig::ConnKeywords;
29
30const RECENT_FILE: &str = "remote-recent.json";
33pub const MAX_RECENT: usize = 20;
35const TOUCH_EVERY_MS: u64 = 60_000;
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39struct RecentFile {
40 #[serde(default)]
41 recent: Vec<RecentEntry>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct RecentEntry {
47 pub name: String,
49 pub first_used_ms: u64,
50 pub last_used_ms: u64,
51}
52
53static RECENT_LOCK: Mutex<()> = Mutex::new(());
54
55fn recent_path(config_home: &Path) -> PathBuf {
56 config_home.join(RECENT_FILE)
57}
58
59fn read_recent(path: &Path) -> RecentFile {
60 std::fs::read_to_string(path)
61 .ok()
62 .and_then(|s| serde_json::from_str(&s).ok())
63 .unwrap_or_default()
64}
65
66fn write_recent(config_home: &Path, file: &RecentFile) -> std::io::Result<()> {
67 let path = recent_path(config_home);
68 std::fs::create_dir_all(config_home)?;
69 let tmp = path.with_extension("json.tmp");
70 std::fs::write(&tmp, serde_json::to_vec_pretty(file).unwrap_or_default())?;
71 std::fs::rename(&tmp, &path)
72}
73
74fn ssh_name(connection: &str) -> Option<String> {
75 match ConnTarget::parse(connection) {
76 Ok(t @ ConnTarget::Ssh(_)) => Some(t.name()),
77 _ => None,
78 }
79}
80
81pub fn record_recent_in(config_home: &Path, connection: &str, now_ms: u64) -> std::io::Result<bool> {
85 let Some(name) = ssh_name(connection) else {
86 return Ok(false);
87 };
88 let _g = RECENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
89 let mut file = read_recent(&recent_path(config_home));
90 if let Some(entry) = file.recent.iter_mut().find(|e| e.name == name) {
91 if now_ms.saturating_sub(entry.last_used_ms) < TOUCH_EVERY_MS {
92 return Ok(false);
93 }
94 entry.last_used_ms = now_ms;
95 write_recent(config_home, &file)?;
96 return Ok(false);
97 }
98 file.recent.push(RecentEntry {
99 name,
100 first_used_ms: now_ms,
101 last_used_ms: now_ms,
102 });
103 if file.recent.len() > MAX_RECENT {
104 file.recent.sort_by(|a, b| b.last_used_ms.cmp(&a.last_used_ms));
105 file.recent.truncate(MAX_RECENT);
106 }
107 write_recent(config_home, &file)?;
108 Ok(true)
109}
110
111pub fn forget_recent_in(config_home: &Path, connection: &str) -> std::io::Result<bool> {
113 let Some(name) = ssh_name(connection) else {
114 return Ok(false);
115 };
116 let _g = RECENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
117 let mut file = read_recent(&recent_path(config_home));
118 let before = file.recent.len();
119 file.recent.retain(|e| e.name != name);
120 if file.recent.len() == before {
121 return Ok(false);
122 }
123 write_recent(config_home, &file)?;
124 Ok(true)
125}
126
127pub fn recent_in(config_home: &Path) -> Vec<RecentEntry> {
129 let _g = RECENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
130 let mut recent = read_recent(&recent_path(config_home)).recent;
131 recent.sort_by(|a, b| b.last_used_ms.cmp(&a.last_used_ms));
132 recent
133}
134
135#[cfg(not(test))]
136use agentmux_common::time::now_ms_u64 as now_ms;
137
138#[cfg(not(test))]
140pub fn record_recent(connection: &str) -> bool {
141 record_recent_in(&crate::backend::base::get_mux_config_dir(), connection, now_ms()).unwrap_or_else(|e| {
142 tracing::warn!(connection = %connection, error = %e, "could not record a recent connection");
143 false
144 })
145}
146
147pub fn forget_recent(connection: &str) -> std::io::Result<bool> {
149 forget_recent_in(&crate::backend::base::get_mux_config_dir(), connection)
150}
151
152fn session_counts() -> &'static Mutex<HashMap<String, u32>> {
155 static COUNTS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
156 COUNTS.get_or_init(|| Mutex::new(HashMap::new()))
157}
158
159pub fn note_sessions(connection: &str, count: usize) {
162 if let Some(name) = ssh_name(connection) {
163 session_counts()
164 .lock()
165 .unwrap_or_else(|e| e.into_inner())
166 .insert(name, count as u32);
167 }
168}
169
170pub fn apply_connection_values(
176 connections: &mut serde_json::Map<String, serde_json::Value>,
177 connection: &str,
178 values: &serde_json::Map<String, serde_json::Value>,
179) {
180 let name = connection.trim().to_string();
181 let mut entry = match connections.remove(&name) {
182 Some(serde_json::Value::Object(map)) => map,
183 _ => serde_json::Map::new(),
184 };
185 for (key, value) in values {
186 if value.is_null() {
187 entry.remove(key);
188 } else {
189 entry.insert(key.clone(), value.clone());
190 }
191 }
192 if !entry.is_empty() {
193 connections.insert(name, serde_json::Value::Object(entry));
194 }
195}
196
197pub fn check_connection_entry(
201 connections: &serde_json::Map<String, serde_json::Value>,
202 connection: &str,
203) -> Result<(), String> {
204 let name = connection.trim();
205 match connections.get(name) {
206 None => Ok(()),
207 Some(entry) => serde_json::from_value::<ConnKeywords>(entry.clone())
208 .map(|_| ())
209 .map_err(|e| format!("invalid setting for {name}: {e}")),
210 }
211}
212
213pub fn publish_change(broker: &Broker) {
215 broker.publish_event::<crate::backend::mps_events::RemotesChanged>(&(), vec![], 0);
216}
217
218#[derive(Debug, Clone, Default)]
222pub struct Inputs {
223 pub ssh_config_hosts: Vec<String>,
224 pub wsl_distros: Vec<String>,
225 pub settings: HashMap<String, ConnKeywords>,
226 pub recent: Vec<RecentEntry>,
227 pub statuses: Vec<ConnStatus>,
228 pub helper_hosts: Vec<String>,
229 pub host_info: BTreeMap<String, HostInfo>,
230 pub sessions: HashMap<String, u32>,
231 pub grants: Vec<(String, String)>,
232}
233
234pub fn platform_of(uname: &str) -> Option<RemotePlatform> {
236 let mut words = uname.split_whitespace();
237 let os = words.next()?;
238 let arch = words.next().unwrap_or_default();
239 let os = match os {
240 "Linux" => "linux".to_string(),
241 "Darwin" => "macos".to_string(),
242 other => other.to_ascii_lowercase(),
243 };
244 let arch = match arch {
245 "x86_64" | "amd64" => "x86_64".to_string(),
246 "aarch64" | "arm64" => "arm64".to_string(),
247 other => other.to_string(),
248 };
249 Some(RemotePlatform { os, arch })
250}
251
252fn identify(connection: &str) -> Option<(String, &'static str)> {
254 match ConnTarget::parse(connection).ok()? {
255 ConnTarget::Local => None,
256 t @ ConnTarget::Wsl(_) => Some((t.name(), "wsl")),
257 t @ ConnTarget::Ssh(_) => Some((t.name(), "ssh")),
258 }
259}
260
261pub fn build(inputs: &Inputs) -> Vec<RemoteRecord> {
263 let mut found: BTreeMap<String, (&'static str, Vec<&'static str>)> = BTreeMap::new();
265 let mut add = |connection: &str, source: &'static str| {
266 if let Some((name, kind)) = identify(connection) {
267 let entry = found.entry(name).or_insert((kind, Vec::new()));
268 if !entry.1.contains(&source) {
269 entry.1.push(source);
270 }
271 }
272 };
273 for host in &inputs.ssh_config_hosts {
274 add(host, "ssh_config");
275 }
276 for name in inputs.settings.keys() {
277 add(name, "settings");
278 }
279 for entry in &inputs.recent {
280 add(&entry.name, "recent");
281 }
282 for distro in &inputs.wsl_distros {
283 add(&format!("wsl://{distro}"), "wsl");
284 }
285
286 let same = |a: &str, b: &str| super::conn::same_connection(a, b);
287 found
288 .into_iter()
289 .map(|(name, (kind, sources))| {
290 let status = inputs
291 .statuses
292 .iter()
293 .find(|s| same(&s.connection, &name))
294 .map(|s| RemoteStatus {
295 state: s.status.clone(),
296 error: s.error.clone(),
297 })
298 .unwrap_or_else(|| RemoteStatus {
299 state: super::status::state::DISCONNECTED.to_string(),
300 error: String::new(),
301 });
302 let settings = inputs.settings.iter().find(|(k, _)| same(k, &name)).map(|(_, v)| v);
303 let info = inputs.host_info.get(&name);
304 let platform = info.and_then(|i| platform_of(&i.uname));
305 let helper = if kind == "wsl" {
306 RemoteHelper { state: "none".to_string(), version: String::new(), installed: false }
307 } else {
308 let version = info.map(|i| i.helper_version.clone()).unwrap_or_default();
309 let state = if settings.is_some_and(|s| s.conn_helper == "never") {
310 "never"
311 } else if inputs.helper_hosts.contains(&name) {
312 "installed"
313 } else if info.is_some_and(|i| {
314 !i.uname.is_empty() && super::helper_install::target_for(&i.uname).is_none()
315 }) {
316 "unsupported"
317 } else {
318 "absent"
319 };
320 let installed = inputs.helper_hosts.contains(&name);
321 let version = if installed { version } else { String::new() };
322 RemoteHelper { state: state.to_string(), version, installed }
323 };
324 let mut agents: Vec<String> = inputs
325 .grants
326 .iter()
327 .filter(|(_, conn)| same(conn, &name))
328 .map(|(agent, _)| agent.clone())
329 .collect();
330 agents.sort();
331 agents.dedup();
332 RemoteRecord {
333 sessions: inputs.sessions.get(&name).copied(),
334 last_used_ms: inputs.recent.iter().find(|e| e.name == name).map(|e| e.last_used_ms),
335 settings: settings
336 .and_then(|s| serde_json::to_value(s).ok())
337 .unwrap_or_else(|| serde_json::json!({})),
338 name,
339 kind: kind.to_string(),
340 sources: sources.into_iter().map(str::to_string).collect(),
341 status,
342 platform,
343 helper,
344 agents,
345 }
346 })
347 .collect()
348}
349
350pub async fn list(settings: HashMap<String, ConnKeywords>) -> Vec<RemoteRecord> {
352 let ssh_config_hosts = tokio::task::spawn_blocking(super::ssh_config::hosts)
353 .await
354 .unwrap_or_default();
355 let wsl_distros = super::wsl::list_cached().await;
356 let config_home = crate::backend::base::get_mux_config_dir();
357 let (helper_hosts, host_info) = super::helper_hosts::all_in(&config_home);
358 let inputs = Inputs {
359 ssh_config_hosts,
360 wsl_distros,
361 settings,
362 recent: recent_in(&config_home),
363 statuses: super::status::all(),
364 helper_hosts,
365 host_info,
366 sessions: session_counts().lock().unwrap_or_else(|e| e.into_inner()).clone(),
367 grants: super::agent_access::all_in(&config_home),
368 };
369 build(&inputs)
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn status(connection: &str, state: &str) -> ConnStatus {
377 ConnStatus {
378 status: state.to_string(),
379 connection: connection.to_string(),
380 connected: state == "connected",
381 hasconnected: true,
382 activeconnnum: 0,
383 error: String::new(),
384 }
385 }
386
387 fn named<'a>(records: &'a [RemoteRecord], name: &str) -> &'a RemoteRecord {
388 records.iter().find(|r| r.name == name).unwrap_or_else(|| panic!("no record for {name}"))
389 }
390
391 #[test]
392 fn one_record_per_remote_whatever_lists_it() {
393 let mut settings = HashMap::new();
394 settings.insert("db1".to_string(), ConnKeywords::default());
395 settings.insert("local".to_string(), ConnKeywords::default());
396 let inputs = Inputs {
397 ssh_config_hosts: vec!["db1".into(), "web".into()],
398 wsl_distros: vec!["Ubuntu".into()],
399 settings,
400 recent: vec![
401 RecentEntry { name: "db1".into(), first_used_ms: 1, last_used_ms: 5 },
402 RecentEntry { name: "me@10.0.0.7".into(), first_used_ms: 2, last_used_ms: 9 },
403 ],
404 ..Default::default()
405 };
406 let records = build(&inputs);
407 let names: Vec<&str> = records.iter().map(|r| r.name.as_str()).collect();
408 assert_eq!(names, ["db1", "me@10.0.0.7", "web", "wsl://Ubuntu"], "local is never a remote");
409 assert_eq!(named(&records, "db1").sources, ["ssh_config", "settings", "recent"]);
410 assert_eq!(named(&records, "me@10.0.0.7").sources, ["recent"]);
411 assert_eq!(named(&records, "me@10.0.0.7").last_used_ms, Some(9));
412 let wsl = named(&records, "wsl://Ubuntu");
413 assert_eq!((wsl.kind.as_str(), wsl.helper.state.as_str()), ("wsl", "none"));
414 assert_eq!(named(&records, "web").status.state, "disconnected", "no status yet");
415 }
416
417 #[test]
418 fn a_record_carries_status_platform_helper_sessions_and_agents() {
419 let mut host_info = BTreeMap::new();
420 host_info.insert(
421 "db1".to_string(),
422 HostInfo { uname: "Linux aarch64".into(), helper_version: "0.59.9".into(), seen_at_ms: 1 },
423 );
424 host_info.insert("router".to_string(), HostInfo { uname: "FreeBSD amd64".into(), ..Default::default() });
425 let mut sessions = HashMap::new();
426 sessions.insert("db1".to_string(), 2);
427 let inputs = Inputs {
428 ssh_config_hosts: vec!["db1".into(), "router".into(), "web".into()],
429 statuses: vec![status("db1", "connected")],
430 helper_hosts: vec!["db1".into()],
431 host_info,
432 sessions,
433 grants: vec![("korp".into(), "db1".into()), ("camper".into(), " db1".into()), ("korp".into(), "web".into())],
434 ..Default::default()
435 };
436 let records = build(&inputs);
437 let db1 = named(&records, "db1");
438 assert_eq!(db1.status.state, "connected");
439 assert_eq!(db1.platform, Some(RemotePlatform { os: "linux".into(), arch: "arm64".into() }));
440 assert_eq!((db1.helper.state.as_str(), db1.helper.version.as_str()), ("installed", "0.59.9"));
441 assert_eq!(db1.sessions, Some(2));
442 assert_eq!(db1.agents, ["camper", "korp"]);
443 assert_eq!(named(&records, "router").helper.state, "unsupported", "no build for FreeBSD");
444 let web = named(&records, "web");
445 assert_eq!((web.helper.state.as_str(), web.sessions, web.platform.clone()), ("absent", None, None));
446 }
447
448 #[test]
449 fn never_wins_over_an_installed_helper() {
450 let mut settings = HashMap::new();
451 settings.insert("db1".to_string(), ConnKeywords { conn_helper: "never".into(), ..Default::default() });
452 let inputs = Inputs {
453 ssh_config_hosts: vec!["db1".into()],
454 settings,
455 helper_hosts: vec!["db1".into()],
456 ..Default::default()
457 };
458 let db1 = &build(&inputs)[0];
459 assert_eq!(db1.helper.state, "never");
460 assert!(db1.helper.installed, "Never stops new installs; the one there can still be removed");
461 }
462
463 #[test]
464 fn a_records_settings_are_its_stored_keys_only() {
465 let mut settings = HashMap::new();
466 settings.insert(
467 "db1".to_string(),
468 ConnKeywords {
469 display_name: "prod-db".into(),
470 display_color: "#e5484d".into(),
471 display_pinned: Some(true),
472 ..Default::default()
473 },
474 );
475 let inputs = Inputs { settings, ..Default::default() };
476 let db1 = &build(&inputs)[0];
477 assert_eq!(
478 db1.settings,
479 serde_json::json!({ "display:name": "prod-db", "display:color": "#e5484d", "display:pinned": true })
480 );
481 }
482
483 #[test]
484 fn platforms_read_from_uname() {
485 assert_eq!(platform_of("Darwin arm64"), Some(RemotePlatform { os: "macos".into(), arch: "arm64".into() }));
486 assert_eq!(platform_of("Linux x86_64\n"), Some(RemotePlatform { os: "linux".into(), arch: "x86_64".into() }));
487 assert_eq!(platform_of(""), None);
488 }
489
490 #[test]
491 fn recent_keeps_ssh_connections_touched_at_most_once_a_minute() {
492 let dir = tempfile::tempdir().unwrap();
493 let home = dir.path();
494 assert!(record_recent_in(home, "me@box", 1_000).unwrap(), "new");
495 assert!(!record_recent_in(home, " me@box ", 2_000).unwrap(), "same host, same minute");
496 assert_eq!(recent_in(home)[0].last_used_ms, 1_000, "not re-written within a minute");
497 assert!(!record_recent_in(home, "me@box", 70_000).unwrap());
498 assert_eq!(recent_in(home)[0].last_used_ms, 70_000);
499 assert!(!record_recent_in(home, "wsl://Ubuntu", 1).unwrap(), "not SSH");
500 assert!(!record_recent_in(home, "local", 1).unwrap());
501 assert_eq!(recent_in(home).len(), 1);
502
503 assert!(forget_recent_in(home, "me@box").unwrap());
504 assert!(!forget_recent_in(home, "me@box").unwrap());
505 assert!(recent_in(home).is_empty());
506 }
507
508 #[test]
509 fn recent_drops_the_least_recently_used_past_the_limit() {
510 let dir = tempfile::tempdir().unwrap();
511 for i in 0..(MAX_RECENT as u64 + 3) {
512 record_recent_in(dir.path(), &format!("me@host{i}"), 1_000 * (i + 1)).unwrap();
513 }
514 let recent = recent_in(dir.path());
515 assert_eq!(recent.len(), MAX_RECENT);
516 assert_eq!(recent[0].name, format!("me@host{}", MAX_RECENT + 2), "newest first");
517 assert!(!recent.iter().any(|e| e.name == "me@host0"), "oldest dropped");
518 }
519
520 #[test]
521 fn a_settings_write_changes_only_the_keys_given() {
522 let mut connections = serde_json::json!({
523 "db1": { "display:name": "old", "ssh:user": "admin", "custom:key": 7 },
524 "web": { "term:theme": "red" }
525 })
526 .as_object()
527 .unwrap()
528 .clone();
529 let values = serde_json::json!({ "display:name": "prod-db", "display:color": "#e5484d", "term:theme": null })
530 .as_object()
531 .unwrap()
532 .clone();
533 apply_connection_values(&mut connections, " db1 ", &values);
534 assert_eq!(
535 connections["db1"],
536 serde_json::json!({ "display:name": "prod-db", "display:color": "#e5484d", "ssh:user": "admin", "custom:key": 7 }),
537 "keys AgentMux doesn't know are kept"
538 );
539 assert_eq!(connections["web"], serde_json::json!({ "term:theme": "red" }), "other connections untouched");
540
541 let clear = serde_json::json!({ "term:theme": null }).as_object().unwrap().clone();
543 apply_connection_values(&mut connections, "web", &clear);
544 assert!(!connections.contains_key("web"));
545
546 let pin = serde_json::json!({ "display:pinned": true }).as_object().unwrap().clone();
548 apply_connection_values(&mut connections, "me@box", &pin);
549 assert_eq!(connections["me@box"], serde_json::json!({ "display:pinned": true }));
550 }
551
552 #[test]
553 fn an_edit_that_would_not_parse_is_refused() {
554 let connections = serde_json::json!({
555 "ok": { "display:pinned": true, "custom:key": 7 },
556 "bad": { "display:pinned": "yes" }
557 })
558 .as_object()
559 .unwrap()
560 .clone();
561 assert!(check_connection_entry(&connections, "ok").is_ok(), "unknown keys are fine");
562 assert!(check_connection_entry(&connections, "gone").is_ok(), "a removed entry is fine");
563 let err = check_connection_entry(&connections, " bad ").unwrap_err();
564 assert!(err.contains("bad"), "{err}");
565 }
566}