1use std::path::{Path, PathBuf};
7use tracing::info;
8
9pub struct ProfileManager {
16 profiles_dir: PathBuf,
17}
18
19impl ProfileManager {
20 pub fn new() -> Self {
21 let profiles_dir = std::env::var_os("GLASS_CONFIG_HOME")
22 .map(PathBuf::from)
23 .or_else(dirs::config_dir)
24 .unwrap_or_else(|| PathBuf::from("."))
25 .join("glass")
26 .join("profiles");
27 Self { profiles_dir }
28 }
29
30 pub fn validate_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
31 if name.is_empty()
32 || name == "."
33 || name == ".."
34 || name.len() > 64
35 || !name
36 .chars()
37 .all(|character| character.is_ascii_alphanumeric() || "-_".contains(character))
38 {
39 return Err(
40 "profile names must be 1-64 characters of A-Z, a-z, 0-9, '-' or '_'".into(),
41 );
42 }
43 Ok(())
44 }
45
46 pub fn profile_dir(&self, name: &str) -> PathBuf {
48 self.profile_data_root().join(name)
49 }
50
51 pub fn create_profile(&self, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
57 Self::validate_name(name)?;
58 std::fs::create_dir_all(self.profile_data_root())?;
59
60 let profile_dir = self.profile_dir(name);
61 let legacy_chrome_dir = self.legacy_chrome_data_dir(name);
62 if !profile_dir.exists() && legacy_chrome_dir.is_dir() {
63 std::fs::rename(&legacy_chrome_dir, &profile_dir)?;
64 info!(%name, "migrated legacy Chrome profile directory");
65 }
66 std::fs::create_dir_all(&profile_dir)?;
67 info!(%name, path = %profile_dir.display(), "ensured Chrome profile directory");
68 Ok(profile_dir)
69 }
70
71 pub fn ensure_profile_dir(&self, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
73 self.create_profile(name)
74 }
75
76 pub fn list_profiles(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
77 let mut profiles = Vec::new();
78 let profile_data_root = self.profile_data_root();
79 if profile_data_root.exists() {
80 for entry in std::fs::read_dir(profile_data_root)? {
81 let entry = entry?;
82 if entry.path().is_dir()
83 && let Some(name) = entry.file_name().to_str()
84 && Self::validate_name(name).is_ok()
85 {
86 profiles.push(name.to_string());
87 }
88 }
89 }
90
91 if self.profiles_dir.exists() {
94 for entry in std::fs::read_dir(&self.profiles_dir)? {
95 let entry = entry?;
96 let path = entry.path();
97 let file_name = entry.file_name();
98 let Some(file_name) = file_name.to_str() else {
99 continue;
100 };
101
102 let name = if path.is_dir() {
103 file_name.strip_suffix("_chrome").map(str::to_string)
104 } else if path.extension().and_then(|extension| extension.to_str()) == Some("json")
105 {
106 Some(file_name.trim_end_matches(".json").to_string())
107 } else {
108 None
109 };
110
111 if let Some(name) = name
112 && Self::validate_name(&name).is_ok()
113 {
114 profiles.push(name);
115 }
116 }
117 }
118 profiles.sort();
119 profiles.dedup();
120 Ok(profiles)
121 }
122
123 pub fn delete_profile(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
128 Self::validate_name(name)?;
129
130 remove_dir_if_exists(&self.profile_dir(name))?;
131 remove_dir_if_exists(&self.legacy_chrome_data_dir(name))?;
132
133 let legacy_metadata = self.legacy_metadata_path(name);
134 if legacy_metadata.exists() {
135 std::fs::remove_file(legacy_metadata)?;
136 }
137 info!(%name, "deleted persisted Chrome profile data");
138 Ok(())
139 }
140
141 fn legacy_chrome_data_dir(&self, name: &str) -> PathBuf {
142 self.profiles_dir.join(format!("{name}_chrome"))
143 }
144
145 fn profile_data_root(&self) -> PathBuf {
146 self.profiles_dir.join("data")
147 }
148
149 fn legacy_metadata_path(&self, name: &str) -> PathBuf {
150 self.profiles_dir.join(format!("{name}.json"))
151 }
152
153 #[cfg(test)]
154 fn with_profiles_dir(profiles_dir: PathBuf) -> Self {
155 Self { profiles_dir }
156 }
157}
158
159fn remove_dir_if_exists(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
160 if path.exists() {
161 std::fs::remove_dir_all(path)?;
162 }
163 Ok(())
164}
165
166impl Default for ProfileManager {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::sync::atomic::{AtomicU64, Ordering};
176
177 static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
178
179 fn test_directory() -> PathBuf {
180 let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
181 std::env::temp_dir().join(format!("glass-profile-test-{}-{id}", std::process::id()))
182 }
183
184 #[test]
185 fn validates_profile_names() {
186 assert!(ProfileManager::validate_name("work").is_ok());
187 assert!(ProfileManager::validate_name("personal_2026").is_ok());
188 assert!(ProfileManager::validate_name("../escape").is_err());
189 assert!(ProfileManager::validate_name("with space").is_err());
190 }
191
192 #[test]
193 fn profiles_are_chrome_data_directories_and_delete_removes_legacy_data() {
194 let profiles_dir = test_directory();
195 let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
196 let profile_dir = manager.create_profile("work").unwrap();
197 std::fs::write(profile_dir.join("Cookies"), "persisted-by-chrome").unwrap();
198 std::fs::create_dir_all(manager.legacy_chrome_data_dir("work")).unwrap();
199 std::fs::write(
200 manager.legacy_chrome_data_dir("work").join("History"),
201 "legacy",
202 )
203 .unwrap();
204 std::fs::write(manager.legacy_metadata_path("work"), "{}\n").unwrap();
205
206 assert_eq!(manager.list_profiles().unwrap(), vec!["work"]);
207 manager.delete_profile("work").unwrap();
208
209 assert!(!profile_dir.exists());
210 assert!(!manager.legacy_chrome_data_dir("work").exists());
211 assert!(!manager.legacy_metadata_path("work").exists());
212 assert!(manager.list_profiles().unwrap().is_empty());
213 let _ = std::fs::remove_dir_all(profiles_dir);
214 }
215
216 #[test]
217 fn create_profile_migrates_legacy_chrome_data() {
218 let profiles_dir = test_directory();
219 let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
220 std::fs::create_dir_all(manager.legacy_chrome_data_dir("work")).unwrap();
221 std::fs::write(
222 manager.legacy_chrome_data_dir("work").join("Cookies"),
223 "legacy",
224 )
225 .unwrap();
226
227 let profile_dir = manager.create_profile("work").unwrap();
228
229 assert!(profile_dir.join("Cookies").exists());
230 assert!(!manager.legacy_chrome_data_dir("work").exists());
231 let _ = std::fs::remove_dir_all(profiles_dir);
232 }
233
234 #[test]
235 fn profile_names_ending_in_chrome_are_not_confused_with_legacy_directories() {
236 let profiles_dir = test_directory();
237 let manager = ProfileManager::with_profiles_dir(profiles_dir.clone());
238
239 manager.create_profile("work_chrome").unwrap();
240
241 assert_eq!(manager.list_profiles().unwrap(), vec!["work_chrome"]);
242 let _ = std::fs::remove_dir_all(profiles_dir);
243 }
244}