1use std::path::PathBuf;
14use std::sync::Arc;
15use std::time::Duration;
16
17use tokio::sync::broadcast;
18
19use super::eventbus::EventBus;
20use super::fs_watch::{FsWatchEvent, FsWatchEventKind, FsWatchPool};
21use super::wconfig::{self, ConfigState, SettingsType};
22
23pub fn resolve_settings_dir() -> PathBuf {
45 if let Some(dir) = std::env::var_os("AGENTMUX_SETTINGS_DIR") {
46 if !dir.is_empty() {
47 return PathBuf::from(dir);
48 }
49 }
50 if let Some(dir) = std::env::var_os("AGENTMUX_CONFIG_HOME") {
51 if !dir.is_empty() {
52 let path = PathBuf::from(&dir);
53 if agentmux_common::isolated_settings_enabled() {
54 return path;
56 }
57 if let Some(root) = path.parent().and_then(|p| p.parent()) {
60 return root.to_path_buf();
61 }
62 }
63 }
64 crate::backend::base::get_mux_data_dir()
67}
68
69pub fn load_settings_from_disk(config_watcher: &ConfigState) {
72 let settings_dir = resolve_settings_dir();
73 let settings_path = settings_dir.join(wconfig::SETTINGS_FILE);
74
75 tracing::info!(
82 reason = agentmux_common::isolated_settings_reason().as_str(),
83 "settings.json isolation"
84 );
85
86 tracing::info!(
87 path = %settings_path.display(),
88 exists = settings_path.exists(),
89 "loading settings.json from disk"
90 );
91
92 let (settings, errors): (SettingsType, _) = wconfig::read_config_file(&settings_path);
93
94 if !errors.is_empty() {
95 for err in &errors {
96 tracing::warn!(file = %err.file, error = %err.err, "settings parse error at startup");
97 }
98 return;
99 }
100
101 config_watcher.update_settings(settings);
102 tracing::info!("settings.json loaded successfully");
103}
104
105fn is_settings_file_event(event: &FsWatchEvent) -> bool {
116 let is_settings_file = event
117 .path
118 .file_name()
119 .map(|n| n == wconfig::SETTINGS_FILE)
120 .unwrap_or(false);
121 is_settings_file
122 && matches!(event.kind, FsWatchEventKind::Created | FsWatchEventKind::Modified)
123}
124
125pub fn spawn_settings_watcher(
133 pool: Arc<FsWatchPool>,
134 config_watcher: Arc<ConfigState>,
135 event_bus: Arc<EventBus>,
136) {
137 let settings_dir = resolve_settings_dir();
138 let settings_path = settings_dir.join(wconfig::SETTINGS_FILE);
139
140 if !settings_dir.exists() {
141 tracing::warn!(
142 dir = %settings_dir.display(),
143 "settings directory does not exist, file watcher not started"
144 );
145 return;
146 }
147
148 let mut events = pool.events();
155
156 let _ = pool.subscribe_file(&settings_path);
162
163 tracing::info!(
164 path = %settings_path.display(),
165 dir = %settings_dir.display(),
166 "fs_watch subscription active for settings.json"
167 );
168
169 let watched_path = settings_path.clone();
170 tokio::spawn(async move {
171 loop {
172 match events.recv().await {
173 Ok(ev) if is_settings_file_event(&ev) => {}
174 Ok(_) => continue, Err(broadcast::error::RecvError::Closed) => {
176 tracing::info!("settings watcher event stream closed, stopping");
177 break;
178 }
179 Err(broadcast::error::RecvError::Lagged(skipped)) => {
180 tracing::warn!(skipped, "settings watcher lagged; reloading defensively");
186 }
187 }
188 tokio::time::sleep(Duration::from_millis(300)).await;
193 while events.try_recv().is_ok() {}
194
195 reload_and_broadcast(&watched_path, &config_watcher, &event_bus);
196 }
197 });
198}
199
200pub fn merge_settings_into_current(
203 config_watcher: &wconfig::ConfigState,
204 new_keys: serde_json::Map<String, serde_json::Value>,
205) -> wconfig::SettingsType {
206 let mut current = config_watcher.get_settings();
207 if let Ok(mut current_val) = serde_json::to_value(¤t) {
209 if let serde_json::Value::Object(ref mut map) = current_val {
210 for (key, value) in new_keys {
214 if value.is_null() {
215 map.remove(&key);
216 } else {
217 map.insert(key, value);
218 }
219 }
220 }
221 if let Ok(merged) = serde_json::from_value(current_val) {
222 current = merged;
223 }
224 }
225 current
226}
227
228pub fn merge_settings_to_disk(new_keys: serde_json::Map<String, serde_json::Value>) -> Result<(), String> {
232 if new_keys.is_empty() {
233 return Ok(());
234 }
235 let settings_dir = resolve_settings_dir();
236 let settings_path = settings_dir.join(wconfig::SETTINGS_FILE);
237
238 let mut current = wconfig::read_settings_raw_jsonc(&settings_path);
239 current.extend(new_keys);
240
241 current.retain(|_, v| !v.is_null());
243
244 let merged = wconfig::merge_into_template(wconfig::SETTINGS_TEMPLATE, ¤t);
245 std::fs::write(&settings_path, &merged)
246 .map_err(|e| format!("write settings.json: {e}"))?;
247
248 tracing::info!(path = %settings_path.display(), "settings.json updated via setconfig");
249 Ok(())
250}
251
252fn reload_and_broadcast(
253 settings_path: &PathBuf,
254 config_watcher: &Arc<ConfigState>,
255 event_bus: &Arc<EventBus>,
256) {
257 tracing::info!(path = %settings_path.display(), "settings.json changed, reloading");
258
259 let (settings, errors): (SettingsType, _) = wconfig::read_config_file(settings_path);
260
261 if !errors.is_empty() {
262 for err in &errors {
263 tracing::warn!(file = %err.file, error = %err.err, "settings reload parse error (keeping previous config)");
264 }
265 return;
266 }
267
268 config_watcher.update_settings(settings);
269 tracing::info!("settings.json reloaded, broadcasting to clients");
270 broadcast_full_config(config_watcher, event_bus);
271}
272
273pub fn set_connection_values(
281 config_watcher: &Arc<ConfigState>,
282 event_bus: &Arc<EventBus>,
283 connection: &str,
284 values: &serde_json::Map<String, serde_json::Value>,
285) -> Result<(), String> {
286 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
287 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
288 let settings_path = resolve_settings_dir().join(wconfig::SETTINGS_FILE);
289 let raw = wconfig::read_settings_raw_jsonc(&settings_path);
290 let mut connections = match raw.get("connections") {
291 Some(serde_json::Value::Object(map)) => map.clone(),
292 _ => serde_json::Map::new(),
293 };
294 crate::backend::remote::remotes::apply_connection_values(&mut connections, connection, values);
295 crate::backend::remote::remotes::check_connection_entry(&connections, connection)?;
296 let mut keys = serde_json::Map::new();
297 keys.insert("connections".to_string(), serde_json::Value::Object(connections));
298 merge_settings_to_disk(keys.clone())?;
299 let merged = merge_settings_into_current(config_watcher, keys);
300 config_watcher.update_settings(merged);
301 broadcast_full_config(config_watcher, event_bus);
302 Ok(())
303}
304
305pub fn broadcast_full_config(config_watcher: &Arc<ConfigState>, event_bus: &Arc<EventBus>) {
314 crate::muxbus::wan_presence::settings_changed();
317 let config = config_watcher.get_full_config();
318 let client_count = event_bus.connection_count();
319 if let Ok(mut config_val) = serde_json::to_value(config.as_ref()) {
320 crate::backend::wconfig::redact_full_config_for_renderer(&mut config_val);
321 event_bus.broadcast_typed::<crate::backend::mps_events::Config>(&crate::backend::mps_events::ConfigEvent { fullconfig: config_val });
322 tracing::info!(clients = client_count, "config event broadcast complete");
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[tokio::test]
336 async fn settings_change_via_pool_reloads_config_watcher() {
337 let _lock = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
343 let tmp = tempfile::tempdir().unwrap();
344 let prev = std::env::var_os("AGENTMUX_SETTINGS_DIR");
345 std::env::set_var("AGENTMUX_SETTINGS_DIR", tmp.path());
346
347 let settings_path = tmp.path().join(wconfig::SETTINGS_FILE);
348 std::fs::write(&settings_path, wconfig::SETTINGS_TEMPLATE).unwrap();
349
350 let pool = FsWatchPool::new();
351 let config_watcher = Arc::new(ConfigState::new());
352 let event_bus = Arc::new(EventBus::new());
353 spawn_settings_watcher(pool.clone(), config_watcher.clone(), event_bus.clone());
354
355 tokio::time::sleep(Duration::from_millis(200)).await;
359
360 let mut new_keys = serde_json::Map::new();
361 new_keys.insert("app:defaultnewblock".to_string(), serde_json::json!("fs-watch-test-marker"));
362 let merged = wconfig::merge_into_template(wconfig::SETTINGS_TEMPLATE, &new_keys);
363 std::fs::write(&settings_path, &merged).unwrap();
364
365 let saw_it = tokio::time::timeout(Duration::from_secs(5), async {
366 loop {
367 if config_watcher.get_settings().app_default_new_block == "fs-watch-test-marker" {
368 return true;
369 }
370 tokio::time::sleep(Duration::from_millis(50)).await;
371 }
372 })
373 .await
374 .unwrap_or(false);
375
376 match prev {
377 Some(v) => std::env::set_var("AGENTMUX_SETTINGS_DIR", v),
378 None => std::env::remove_var("AGENTMUX_SETTINGS_DIR"),
379 }
380
381 assert!(saw_it, "expected config_watcher to reload the updated setting after a settings.json change detected via FsWatchPool");
382 }
383
384 fn evt(path: &str, kind: FsWatchEventKind) -> FsWatchEvent {
385 FsWatchEvent { path: PathBuf::from(path), kind }
386 }
387
388 #[test]
389 fn is_settings_file_event_matches_only_the_settings_filename() {
390 assert!(is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Modified)));
391 assert!(is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Created)));
392 assert!(!is_settings_file_event(&evt("/some/dir/other.json", FsWatchEventKind::Modified)));
393 assert!(!is_settings_file_event(&evt("/some/dir", FsWatchEventKind::Modified)));
394 }
395
396 #[test]
397 fn is_settings_file_event_ignores_removed_events() {
398 assert!(!is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Removed)));
403 assert!(!is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Other)));
404 }
405
406 #[test]
409 fn merge_into_current_removes_a_key_set_to_null() {
410 let config = ConfigState::new();
411 let mut keys = serde_json::Map::new();
412 keys.insert("widget:pinned".into(), serde_json::json!(["agent", "terminal"]));
413 keys.insert("widget:hidden".into(), serde_json::json!(["defwidget@drone"]));
414 keys.insert("some:dynamic".into(), serde_json::json!(1));
415 config.update_settings(merge_settings_into_current(&config, keys));
416 assert_eq!(config.get_settings().widget_pinned, Some(vec!["agent".to_string(), "terminal".to_string()]));
417 assert!(config.get_settings().extra.contains_key("some:dynamic"));
418
419 let mut reset = serde_json::Map::new();
420 reset.insert("widget:pinned".into(), serde_json::Value::Null);
421 reset.insert("some:dynamic".into(), serde_json::Value::Null);
422 config.update_settings(merge_settings_into_current(&config, reset));
423 let settings = config.get_settings();
424 assert_eq!(settings.widget_pinned, None);
425 assert!(!settings.extra.contains_key("some:dynamic"));
426 assert_eq!(settings.widget_hidden, Some(vec!["defwidget@drone".to_string()]));
428 }
429
430 #[test]
432 fn a_malformed_widget_list_is_ignored_not_fatal() {
433 let settings: wconfig::SettingsType =
434 serde_json::from_str(r#"{ "widget:pinned": "agent", "widget:hidden": ["defwidget@drone", 3], "widget:icononly": true }"#)
435 .unwrap();
436 assert_eq!(settings.widget_pinned, None);
437 assert_eq!(settings.widget_hidden, Some(vec!["defwidget@drone".to_string()]));
438 assert_eq!(settings.widget_icon_only, Some(true));
439 }
440
441 use crate::test_support::ISOLATED_AUTH_ENV_LOCK as SETTINGS_DIR_ENV_LOCK;
456
457 fn lock_settings_dir_env() -> std::sync::MutexGuard<'static, ()> {
458 SETTINGS_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
459 }
460
461 fn clear_settings_dir_env() {
466 std::env::remove_var("AGENTMUX_SETTINGS_DIR");
467 std::env::remove_var("AGENTMUX_CONFIG_HOME");
468 std::env::remove_var("AGENTMUX_CHANNEL");
469 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
470 }
471
472 #[test]
473 fn resolve_settings_dir_stays_global_on_stable_channel() {
474 let _lock = lock_settings_dir_env();
475 clear_settings_dir_env();
476 std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/stable/config");
477 std::env::set_var("AGENTMUX_CHANNEL", "stable");
478
479 let dir = resolve_settings_dir();
480
481 assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels"));
482 clear_settings_dir_env();
483 }
484
485 #[test]
486 fn resolve_settings_dir_is_isolated_by_default_on_non_stable_channel() {
487 let _lock = lock_settings_dir_env();
491 clear_settings_dir_env();
492 let config_home = "/home/user/.agentmux/channels/local-main-abc123-1/config";
493 std::env::set_var("AGENTMUX_CONFIG_HOME", config_home);
494 std::env::set_var("AGENTMUX_CHANNEL", "local-main-abc123-1");
495
496 let dir = resolve_settings_dir();
497
498 assert_eq!(
499 dir,
500 PathBuf::from(config_home),
501 "isolated-by-default settings dir must be the channel-scoped config dir itself, not its shared parent"
502 );
503 clear_settings_dir_env();
504 }
505
506 #[test]
507 fn resolve_settings_dir_explicit_opt_out_restores_global_on_non_stable_channel() {
508 let _lock = lock_settings_dir_env();
509 clear_settings_dir_env();
510 std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/dev-some-branch/config");
511 std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
512 std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", "0");
513
514 let dir = resolve_settings_dir();
515
516 assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels"));
517 clear_settings_dir_env();
518 }
519
520 #[test]
521 fn resolve_settings_dir_explicit_opt_in_isolates_even_the_stable_channel() {
522 let _lock = lock_settings_dir_env();
523 clear_settings_dir_env();
524 std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/stable/config");
525 std::env::set_var("AGENTMUX_CHANNEL", "stable");
526 std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", "1");
527
528 let dir = resolve_settings_dir();
529
530 assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels/stable/config"));
531 clear_settings_dir_env();
532 }
533
534 #[test]
535 fn resolve_settings_dir_stays_global_when_channel_unset() {
536 let _lock = lock_settings_dir_env();
540 clear_settings_dir_env();
541 std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/dev-some-branch/config");
542 let dir = resolve_settings_dir();
545
546 assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels"));
547 clear_settings_dir_env();
548 }
549
550 #[test]
551 fn resolve_settings_dir_settings_dir_override_wins_regardless_of_isolation() {
552 let _lock = lock_settings_dir_env();
553 clear_settings_dir_env();
554 std::env::set_var("AGENTMUX_SETTINGS_DIR", "/explicit/override");
555 std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/local-main-abc123-1/config");
556 std::env::set_var("AGENTMUX_CHANNEL", "local-main-abc123-1");
557
558 let dir = resolve_settings_dir();
559
560 assert_eq!(dir, PathBuf::from("/explicit/override"));
561 clear_settings_dir_env();
562 }
563}