Skip to main content

agentmux_srv/backend/
browser_profiles_store.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Named browser profiles: the list of them, in `shared_dir`
5//! (`~/.agentmux/shared/browser-profiles.json`), like the bookmarks
6//! (docs/specs/SPEC_BROWSER_PANE_PROFILES_MENU_2026_10_09.md §3, §6). A
7//! profile's cookie jar is a disk-backed CEF profile the host keeps at
8//! `<cef-cache>/profile-<id>`; this file only names and colours them.
9//! "Personal" (the shared jar every tab had before profiles) isn't listed:
10//! it always exists and can't be deleted.
11
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
17#[ts(export, export_to = "../../../frontend/types/rpc/")]
18pub struct BrowserProfile {
19    /// Stable id, `[a-z0-9-]`, used in `browser:identity` (`profile:<id>`)
20    /// and the host's profile folder. Never reused.
21    pub id: String,
22    pub name: String,
23    /// `#rrggbb`: the profile's colour on its button and tabs.
24    pub color: String,
25    #[serde(default)]
26    #[ts(type = "number")]
27    pub created_at: i64,
28    /// "Agents may use it" (Settings → Browser): an agent's `OpenBrowser` may browse as
29    /// it. Off until the user switches it on.
30    #[serde(default)]
31    pub agents_allowed: bool,
32}
33
34const PROFILES_FILE_NAME: &str = "browser-profiles.json";
35pub const MAX_PROFILES: usize = 20;
36pub const MAX_NAME_CHARS: usize = 40;
37
38/// Colours a new profile is given in turn, unless one is chosen.
39pub const PALETTE: [&str; 8] = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#64748b"];
40
41pub fn profiles_file_path() -> Option<PathBuf> {
42    agentmux_common::DataPaths::from_env().map(|p| p.shared_dir.join(PROFILES_FILE_NAME))
43}
44
45/// The list. A missing or empty file is "no profiles yet"; a present but
46/// unparseable one is an error, not an empty list that would look like the
47/// profiles vanished.
48pub fn read_profiles(path: &Path) -> Result<Vec<BrowserProfile>, String> {
49    if !path.exists() {
50        return Ok(Vec::new());
51    }
52    let content = std::fs::read_to_string(path).map_err(|e| format!("read browser profiles: {e}"))?;
53    if content.trim().is_empty() {
54        return Ok(Vec::new());
55    }
56    serde_json::from_str(&content).map_err(|e| format!("parse browser profiles: {e}"))
57}
58
59/// Replace the list, through a temp file and a rename, so a crash never
60/// leaves a half-written file.
61pub fn write_profiles(path: &Path, profiles: &[BrowserProfile]) -> Result<(), String> {
62    if let Some(parent) = path.parent() {
63        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
64    }
65    let json = serde_json::to_string_pretty(profiles).map_err(|e| format!("serialize browser profiles: {e}"))?;
66    let tmp = path.with_file_name(format!(".{PROFILES_FILE_NAME}.{}.tmp", uuid::Uuid::new_v4()));
67    std::fs::write(&tmp, &json).map_err(|e| format!("write browser profiles: {e}"))?;
68    std::fs::rename(&tmp, path).map_err(|e| {
69        let _ = std::fs::remove_file(&tmp);
70        format!("rename browser profiles: {e}")
71    })
72}
73
74/// Is `id` a well-formed profile id?
75pub fn is_profile_id(id: &str) -> bool {
76    (1..=64).contains(&id.len()) && id.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
77}
78
79fn check_name(profiles: &[BrowserProfile], name: &str, except: Option<&str>) -> Result<String, String> {
80    let name = name.trim();
81    if name.is_empty() {
82        return Err("a profile needs a name".to_string());
83    }
84    if name.chars().count() > MAX_NAME_CHARS {
85        return Err(format!("a profile's name is at most {MAX_NAME_CHARS} characters"));
86    }
87    if ["personal", "incognito"].contains(&name.to_lowercase().as_str()) {
88        return Err(format!("{name:?} is taken: pick another name"));
89    }
90    if profiles.iter().any(|p| Some(p.id.as_str()) != except && p.name.eq_ignore_ascii_case(name)) {
91        return Err(format!("there is already a profile called {name:?}"));
92    }
93    Ok(name.to_string())
94}
95
96fn check_color(color: &str) -> Result<String, String> {
97    let c = color.trim().to_lowercase();
98    let hex = c.strip_prefix('#').unwrap_or("");
99    if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
100        Ok(c)
101    } else {
102        Err(format!("{color:?} isn't a colour (#rrggbb)"))
103    }
104}
105
106/// A new profile added to `profiles`: a fresh id, a checked name, and the
107/// given colour or the next one from the palette.
108pub fn add(profiles: &mut Vec<BrowserProfile>, name: &str, color: Option<&str>, now_ms: i64) -> Result<BrowserProfile, String> {
109    if profiles.len() >= MAX_PROFILES {
110        return Err(format!("at most {MAX_PROFILES} profiles"));
111    }
112    let name = check_name(profiles, name, None)?;
113    let color = match color {
114        Some(c) => check_color(c)?,
115        None => PALETTE[profiles.len() % PALETTE.len()].to_string(),
116    };
117    let id = format!("p-{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
118    let profile = BrowserProfile { id, name, color, created_at: now_ms, agents_allowed: false };
119    profiles.push(profile.clone());
120    Ok(profile)
121}
122
123/// Rename or recolour profile `id`.
124pub fn update(
125    profiles: &mut [BrowserProfile],
126    id: &str,
127    name: Option<&str>,
128    color: Option<&str>,
129    agents_allowed: Option<bool>,
130) -> Result<(), String> {
131    let name = name.map(|n| check_name(profiles, n, Some(id))).transpose()?;
132    let color = color.map(check_color).transpose()?;
133    let p = profiles.iter_mut().find(|p| p.id == id).ok_or_else(|| format!("no profile {id:?}"))?;
134    if let Some(n) = name {
135        p.name = n;
136    }
137    if let Some(c) = color {
138        p.color = c;
139    }
140    if let Some(a) = agents_allowed {
141        p.agents_allowed = a;
142    }
143    Ok(())
144}
145
146/// Remove profile `id`. Its tabs and its jar are the caller's to close.
147pub fn remove(profiles: &mut Vec<BrowserProfile>, id: &str) -> Result<(), String> {
148    let before = profiles.len();
149    profiles.retain(|p| p.id != id);
150    if profiles.len() == before {
151        return Err(format!("no profile {id:?}"));
152    }
153    Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn profiles_are_added_named_and_coloured() {
162        let mut ps = Vec::new();
163        let work = add(&mut ps, "  Work ", None, 1).unwrap();
164        assert_eq!(work.name, "Work");
165        assert_eq!(work.color, PALETTE[0]);
166        assert!(is_profile_id(&work.id));
167        let client = add(&mut ps, "Client X", Some("#ABCDEF"), 2).unwrap();
168        assert_eq!(client.color, "#abcdef");
169        assert_ne!(work.id, client.id);
170    }
171
172    #[test]
173    fn bad_names_and_colours_are_refused() {
174        let mut ps = Vec::new();
175        add(&mut ps, "Work", None, 1).unwrap();
176        for bad in ["", "   ", "work", "Personal", "incognito", &"x".repeat(MAX_NAME_CHARS + 1)] {
177            assert!(add(&mut ps, bad, None, 2).is_err(), "{bad:?}");
178        }
179        assert!(add(&mut ps, "Home", Some("blue"), 2).is_err());
180        assert!(add(&mut ps, "Home", Some("#12345"), 2).is_err());
181    }
182
183    #[test]
184    fn renaming_keeps_its_own_name_free_and_others_taken() {
185        let mut ps = Vec::new();
186        let a = add(&mut ps, "A", None, 1).unwrap();
187        add(&mut ps, "B", None, 1).unwrap();
188        update(&mut ps, &a.id, Some("a"), None, None).unwrap();
189        assert!(update(&mut ps, &a.id, Some("B"), None, None).is_err());
190        update(&mut ps, &a.id, None, Some("#000000"), None).unwrap();
191        assert_eq!(ps[0].color, "#000000");
192        assert!(update(&mut ps, "p-none", Some("C"), None, None).is_err());
193    }
194
195    #[test]
196    fn removing_and_the_cap() {
197        let mut ps = Vec::new();
198        for i in 0..MAX_PROFILES {
199            add(&mut ps, &format!("P{i}"), None, 1).unwrap();
200        }
201        assert!(add(&mut ps, "One more", None, 1).is_err());
202        let id = ps[0].id.clone();
203        remove(&mut ps, &id).unwrap();
204        assert!(remove(&mut ps, &id).is_err());
205        assert_eq!(ps.len(), MAX_PROFILES - 1);
206    }
207
208    #[test]
209    fn the_file_round_trips_and_a_missing_one_is_empty() {
210        let dir = tempfile::tempdir().unwrap();
211        let path = dir.path().join(PROFILES_FILE_NAME);
212        assert!(read_profiles(&path).unwrap().is_empty());
213        let mut ps = Vec::new();
214        add(&mut ps, "Work", None, 5).unwrap();
215        write_profiles(&path, &ps).unwrap();
216        assert_eq!(read_profiles(&path).unwrap(), ps);
217        std::fs::write(&path, "{not json").unwrap();
218        assert!(read_profiles(&path).is_err());
219    }
220}