agentmux_srv/backend/
browser_start_page.rs1use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::time::Duration;
26
27use tokio::sync::broadcast;
28
29use super::config_watcher_fs::broadcast_full_config;
30use super::eventbus::EventBus;
31use super::fs_watch::{FsWatchEvent, FsWatchEventKind, FsWatchPool};
32use super::wconfig::ConfigState;
33
34const START_PAGE_FILE_NAME: &str = "browser-start-page.json";
35
36#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
37struct StartPageFile {
38 #[serde(default)]
39 url: Option<String>,
40}
41
42pub fn start_page_file_path() -> Option<PathBuf> {
46 agentmux_common::DataPaths::from_env().map(|p| p.shared_dir.join(START_PAGE_FILE_NAME))
47}
48
49pub fn read_start_page(path: &Path) -> Result<Option<String>, String> {
56 if !path.exists() {
57 return Ok(None);
58 }
59 let content = std::fs::read_to_string(path).map_err(|e| format!("read start page: {e}"))?;
60 if content.trim().is_empty() {
61 return Ok(None);
62 }
63 let parsed: StartPageFile =
64 serde_json::from_str(&content).map_err(|e| format!("parse start page: {e}"))?;
65 Ok(parsed.url.filter(|u| !u.is_empty()))
66}
67
68pub fn write_start_page(path: &Path, url: &str) -> Result<(), String> {
74 if let Some(parent) = path.parent() {
75 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
76 }
77 let file = StartPageFile { url: Some(url.to_string()) };
78 let json = serde_json::to_string_pretty(&file).map_err(|e| format!("serialize start page: {e}"))?;
79 let tmp = path.with_file_name(format!(".{START_PAGE_FILE_NAME}.{}.tmp", uuid::Uuid::new_v4()));
80 std::fs::write(&tmp, &json).map_err(|e| format!("write start page: {e}"))?;
81 std::fs::rename(&tmp, path).map_err(|e| {
82 let _ = std::fs::remove_file(&tmp);
83 format!("rename start page: {e}")
84 })
85}
86
87pub fn load_start_page_from_disk(config_watcher: &ConfigState) {
93 let Some(path) = start_page_file_path() else {
94 tracing::info!("browser-start-page.json: shared dir unresolvable, starting with no start page");
95 return;
96 };
97 match read_start_page(&path) {
98 Ok(url) => {
99 tracing::info!(path = %path.display(), set = url.is_some(), "browser-start-page.json loaded");
100 config_watcher.update_browser_start_page(url);
101 }
102 Err(e) => {
103 tracing::warn!(path = %path.display(), error = %e, "browser-start-page.json failed to load (keeping unset)");
107 }
108 }
109}
110
111fn is_start_page_file_event(event: &FsWatchEvent) -> bool {
116 let is_start_page_file = event
117 .path
118 .file_name()
119 .map(|n| n == START_PAGE_FILE_NAME)
120 .unwrap_or(false);
121 is_start_page_file && matches!(event.kind, FsWatchEventKind::Created | FsWatchEventKind::Modified)
122}
123
124pub fn spawn_start_page_watcher(
132 pool: Arc<FsWatchPool>,
133 config_watcher: Arc<ConfigState>,
134 event_bus: Arc<EventBus>,
135) {
136 let Some(path) = start_page_file_path() else {
137 tracing::warn!("browser-start-page.json: shared dir unresolvable, file watcher not started");
138 return;
139 };
140 let Some(dir) = path.parent() else {
141 return;
142 };
143 if !dir.exists() {
144 if let Err(e) = std::fs::create_dir_all(dir) {
145 tracing::warn!(dir = %dir.display(), error = %e, "could not create shared dir, browser-start-page.json watcher not started");
146 return;
147 }
148 }
149
150 let mut events = pool.events();
154 let _ = pool.subscribe_file(&path);
155
156 tracing::info!(path = %path.display(), "fs_watch subscription active for browser-start-page.json");
157
158 let watched_path = path.clone();
159 tokio::spawn(async move {
160 loop {
161 match events.recv().await {
162 Ok(ev) if is_start_page_file_event(&ev) => {}
163 Ok(_) => continue, Err(broadcast::error::RecvError::Closed) => {
165 tracing::info!("browser-start-page watcher event stream closed, stopping");
166 break;
167 }
168 Err(broadcast::error::RecvError::Lagged(skipped)) => {
169 tracing::warn!(skipped, "browser-start-page watcher lagged; reloading defensively");
170 }
171 }
172 tokio::time::sleep(Duration::from_millis(300)).await;
174 while events.try_recv().is_ok() {}
175
176 match read_start_page(&watched_path) {
177 Ok(url) => {
178 tracing::info!(path = %watched_path.display(), "browser-start-page.json changed, reloading");
179 config_watcher.update_browser_start_page(url);
180 broadcast_full_config(&config_watcher, &event_bus);
181 }
182 Err(e) => {
183 tracing::warn!(path = %watched_path.display(), error = %e, "browser-start-page reload parse error (keeping previous value)");
184 }
185 }
186 }
187 });
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn missing_file_reads_as_none() {
196 let dir = tempfile::tempdir().unwrap();
197 let path = dir.path().join(START_PAGE_FILE_NAME);
198 assert_eq!(read_start_page(&path).unwrap(), None);
199 }
200
201 #[test]
202 fn empty_file_reads_as_none() {
203 let dir = tempfile::tempdir().unwrap();
204 let path = dir.path().join(START_PAGE_FILE_NAME);
205 std::fs::write(&path, "").unwrap();
206 assert_eq!(read_start_page(&path).unwrap(), None);
207 }
208
209 #[test]
210 fn write_then_read_round_trips() {
211 let dir = tempfile::tempdir().unwrap();
212 let path = dir.path().join(START_PAGE_FILE_NAME);
213 write_start_page(&path, "https://example.com").unwrap();
214 assert_eq!(read_start_page(&path).unwrap(), Some("https://example.com".to_string()));
215 }
216
217 #[test]
218 fn write_creates_missing_parent_directories() {
219 let dir = tempfile::tempdir().unwrap();
220 let path = dir.path().join("nested").join("dir").join(START_PAGE_FILE_NAME);
221 write_start_page(&path, "https://example.com").unwrap();
222 assert!(path.exists());
223 }
224
225 #[test]
226 fn write_overwrites_a_previous_value_wholesale() {
227 let dir = tempfile::tempdir().unwrap();
228 let path = dir.path().join(START_PAGE_FILE_NAME);
229 write_start_page(&path, "https://example.com").unwrap();
230 write_start_page(&path, "https://agentmux.ai").unwrap();
231 assert_eq!(read_start_page(&path).unwrap(), Some("https://agentmux.ai".to_string()));
232 }
233
234 #[test]
235 fn corrupt_json_fails_loud_instead_of_silently_resetting_to_unset() {
236 let dir = tempfile::tempdir().unwrap();
237 let path = dir.path().join(START_PAGE_FILE_NAME);
238 std::fs::write(&path, "{not valid json").unwrap();
239 let err = read_start_page(&path).unwrap_err();
240 assert!(err.contains("parse start page"));
241 }
242
243 #[test]
244 fn a_file_with_an_empty_url_reads_as_none() {
245 let dir = tempfile::tempdir().unwrap();
249 let path = dir.path().join(START_PAGE_FILE_NAME);
250 std::fs::write(&path, r#"{"url":""}"#).unwrap();
251 assert_eq!(read_start_page(&path).unwrap(), None);
252 }
253
254 #[test]
255 fn is_start_page_file_event_matches_only_the_start_page_filename() {
256 let evt = |p: &str, k| FsWatchEvent { path: PathBuf::from(p), kind: k };
257 assert!(is_start_page_file_event(&evt(
258 "/some/dir/browser-start-page.json",
259 FsWatchEventKind::Modified
260 )));
261 assert!(is_start_page_file_event(&evt(
262 "/some/dir/browser-start-page.json",
263 FsWatchEventKind::Created
264 )));
265 assert!(!is_start_page_file_event(&evt("/some/dir/other.json", FsWatchEventKind::Modified)));
266 }
267
268 #[test]
269 fn is_start_page_file_event_ignores_removed_events() {
270 let evt = |p: &str, k| FsWatchEvent { path: PathBuf::from(p), kind: k };
273 assert!(!is_start_page_file_event(&evt(
274 "/some/dir/browser-start-page.json",
275 FsWatchEventKind::Removed
276 )));
277 }
278
279 #[tokio::test]
280 async fn a_disk_change_via_the_pool_reloads_config_watcher_and_broadcasts() {
281 let dir = tempfile::tempdir().unwrap();
282 let path = dir.path().join(START_PAGE_FILE_NAME);
283 write_start_page(&path, "https://initial.example").unwrap();
284
285 let pool = FsWatchPool::new();
286 let config_watcher = Arc::new(ConfigState::new());
287 let event_bus = Arc::new(EventBus::new());
288
289 let mut events = pool.events();
295 let _ = pool.subscribe_file(&path);
296
297 write_start_page(&path, "https://changed.example").unwrap();
298
299 let saw_it = tokio::time::timeout(Duration::from_secs(5), async {
300 loop {
301 if let Ok(ev) = events.recv().await {
302 if is_start_page_file_event(&ev) {
303 return true;
304 }
305 }
306 }
307 })
308 .await
309 .unwrap_or(false);
310 assert!(saw_it, "expected a Created/Modified event for the changed start-page file");
311
312 let url = read_start_page(&path).unwrap();
314 config_watcher.update_browser_start_page(url.clone());
315 broadcast_full_config(&config_watcher, &event_bus);
316
317 assert_eq!(url, Some("https://changed.example".to_string()));
318 assert_eq!(config_watcher.get_full_config().browser_start_page, url);
319 }
320}