Skip to main content

agentmux_srv/backend/
browser_start_page.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Browser pane start page — a single URL, deliberately stored under
5//! `shared_dir` (`~/.agentmux/shared/browser-start-page.json`) for the same
6//! reason bookmarks are (`bookmarks_store.rs`): `settings.json` isolation is
7//! whole-file only (`docs/specs/SPEC_SETTINGS_ISOLATED_BY_CHANNEL_2026_08_19.md`
8//! §6), so a value that must stay global across channels can't live inside
9//! it.
10//!
11//! Unlike bookmarks, this value also rides inside `FullConfigType` — see
12//! `docs/specs/SPEC_BROWSER_PANE_START_PAGE_2026_09_16.md` §3.1 for why a
13//! second on-demand RPC (bookmarks' `.list` pattern) was rejected in favor
14//! of piggybacking on `GetFullConfig`, which the frontend already awaits
15//! before its very first render. That means this module does two things
16//! `bookmarks_store.rs` doesn't: it plugs into `ConfigState`
17//! ([`load_start_page_from_disk`]) and it watches its own file for changes
18//! ([`spawn_start_page_watcher`]), mirroring `config_watcher_fs.rs`'s
19//! settings watcher exactly so an edit from any window — or an external
20//! process — reaches every connected client via the same live broadcast
21//! `settings.json` changes already use, not just the window that made it.
22
23use 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
42/// Resolves the shared, cross-channel start-page file path. Returns `None`
43/// when `DataPaths` can't resolve (CI / unusual env) — same best-effort
44/// posture as `bookmarks_store::bookmarks_file_path()`.
45pub 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
49/// Read the configured start page. A missing file, or a present file with
50/// no `url` set yet, is `Ok(None)` — "nothing configured", not an error,
51/// same as bookmarks' missing-file case. A present-but-unparseable file IS
52/// an error: silently treating it as unset would look exactly like the
53/// user's setting vanished (same reasoning `bookmarks_store::read_bookmarks`
54/// already documents).
55pub 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
68/// Write the start page, replacing whatever was there before. Same
69/// temp-file-then-rename crash safety as `bookmarks_store::write_bookmarks`.
70/// Two windows writing at the same instant still race at the whole-file
71/// level — last write wins — the same accepted limitation every other
72/// single-JSON-file store in this app has.
73pub 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
87/// Load `browser-start-page.json` from disk into the `ConfigState`. Called
88/// once at startup, mirroring `config_watcher_fs::load_settings_from_disk`.
89/// An unresolvable `shared_dir` or a missing file both leave the config's
90/// `browser_start_page` at its default (`None`) rather than failing boot —
91/// a start page is a convenience, never a startup precondition.
92pub 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            // Same "keep previous config, don't fail boot" posture
104            // `config_watcher_fs::reload_and_broadcast` already has for a
105            // corrupt settings.json — the in-memory default (None) stands.
106            tracing::warn!(path = %path.display(), error = %e, "browser-start-page.json failed to load (keeping unset)");
107        }
108    }
109}
110
111/// Whether a raw fs_watch event refers to `browser-start-page.json`
112/// specifically — mirrors `config_watcher_fs::is_settings_file_event`
113/// exactly, including excluding `Removed` (an external delete must not
114/// reset a configured start page back to "unset" and broadcast that).
115fn 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
124/// Subscribe to `browser-start-page.json` via the shared `FsWatchPool` (the
125/// SAME pool instance the settings watcher uses — no new watcher subsystem)
126/// and broadcast the updated config to every connected client on change.
127/// Mirrors `config_watcher_fs::spawn_settings_watcher` exactly.
128///
129/// Fire-and-forget: watched for the process's entire lifetime, same as
130/// settings.json — nothing for the caller to hold onto or unsubscribe.
131pub 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    // Same ordering requirement `spawn_settings_watcher` documents: events()
151    // must be called before subscribe_file(), or a change landing in the gap
152    // between them is silently missed.
153    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, // some other watched path — not ours
164                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            // Same 300ms burst-collapse debounce as the settings watcher.
173            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        // Distinguishes "file exists but url is empty" from "file has a
246        // real value" — both must be treated as unset, not as a valid
247        // empty-string start page.
248        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        // An external delete must not reset a configured start page back to
271        // "unset" and broadcast that to every window.
272        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        // subscribe_file directly on the real path (spawn_start_page_watcher
290        // resolves via DataPaths::from_env, which isn't set up in a unit
291        // test) — exercises the same event-driven reload/broadcast path
292        // spawn_start_page_watcher's loop body runs, without needing the
293        // env-dependent path resolution.
294        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        // Drive the same reload the watcher's loop body would.
313        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}