victauri_browser/
installer.rs1use std::path::PathBuf;
2
3const HOST_NAME: &str = "com.victauri.browser";
4
5pub fn host_manifest_path() -> Result<PathBuf, InstallerError> {
11 let home = home_dir()?;
12
13 #[cfg(target_os = "windows")]
14 {
15 Ok(home.join(".victauri").join("native-host-manifest.json"))
16 }
17
18 #[cfg(target_os = "macos")]
19 {
20 Ok(home
21 .join("Library")
22 .join("Application Support")
23 .join("Google")
24 .join("Chrome")
25 .join("NativeMessagingHosts")
26 .join(format!("{HOST_NAME}.json")))
27 }
28
29 #[cfg(target_os = "linux")]
30 {
31 Ok(home
32 .join(".config")
33 .join("google-chrome")
34 .join("NativeMessagingHosts")
35 .join(format!("{HOST_NAME}.json")))
36 }
37}
38
39#[must_use]
41pub fn host_manifest(binary_path: &str, extension_id: &str) -> serde_json::Value {
42 serde_json::json!({
43 "name": HOST_NAME,
44 "description": "Victauri Browser — MCP inspection for web pages",
45 "path": binary_path,
46 "type": "stdio",
47 "allowed_origins": [
48 format!("chrome-extension://{extension_id}/")
49 ]
50 })
51}
52
53#[allow(dead_code)]
59pub fn install_dir() -> Result<PathBuf, InstallerError> {
60 let home = home_dir()?;
61 Ok(home.join(".victauri").join("bin"))
62}
63
64pub fn install(binary_path: &str, extension_id: &str) -> Result<String, InstallerError> {
73 let manifest = host_manifest(binary_path, extension_id);
74 let json = serde_json::to_string_pretty(&manifest).map_err(InstallerError::Json)?;
75
76 let primary_path = host_manifest_path()?;
77
78 for path in all_manifest_paths()? {
79 if let Some(parent) = path.parent() {
80 let _ = std::fs::create_dir_all(parent);
81 }
82 let _ = std::fs::write(&path, &json);
83 }
84
85 #[cfg(target_os = "windows")]
86 {
87 register_windows_host(&primary_path)?;
88 }
89
90 Ok(primary_path.to_string_lossy().to_string())
91}
92
93fn all_manifest_paths() -> Result<Vec<PathBuf>, InstallerError> {
94 let home = home_dir()?;
95 let mut paths = vec![];
96
97 #[cfg(target_os = "windows")]
98 {
99 paths.push(home.join(".victauri").join("native-host-manifest.json"));
100 }
101
102 #[cfg(target_os = "macos")]
103 {
104 let app_support = home.join("Library").join("Application Support");
105 let manifest_file = format!("{HOST_NAME}.json");
106 for browser_dir in [
107 "Google/Chrome",
108 "Microsoft Edge",
109 "BraveSoftware/Brave-Browser",
110 "Arc/User Data",
111 ] {
112 paths.push(
113 app_support
114 .join(browser_dir)
115 .join("NativeMessagingHosts")
116 .join(&manifest_file),
117 );
118 }
119 }
120
121 #[cfg(target_os = "linux")]
122 {
123 let manifest_file = format!("{HOST_NAME}.json");
124 let config = home.join(".config");
125 for browser_dir in [
126 "google-chrome",
127 "microsoft-edge",
128 "BraveSoftware/Brave-Browser",
129 "chromium",
130 ] {
131 paths.push(
132 config
133 .join(browser_dir)
134 .join("NativeMessagingHosts")
135 .join(&manifest_file),
136 );
137 }
138 }
139
140 Ok(paths)
141}
142
143pub fn uninstall() -> Result<(), InstallerError> {
149 let manifest_path = host_manifest_path()?;
150 if manifest_path.exists() {
151 std::fs::remove_file(&manifest_path).map_err(InstallerError::Io)?;
152 }
153
154 #[cfg(target_os = "windows")]
155 {
156 unregister_windows_host();
157 }
158
159 Ok(())
160}
161
162#[cfg(target_os = "windows")]
163const WINDOWS_REGISTRY_PATHS: &[&str] = &[
164 r"HKCU\Software\Google\Chrome\NativeMessagingHosts",
165 r"HKCU\Software\Microsoft\Edge\NativeMessagingHosts",
166 r"HKCU\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts",
167];
168
169#[cfg(target_os = "windows")]
170fn register_windows_host(manifest_path: &std::path::Path) -> Result<(), InstallerError> {
171 use std::process::Command;
172
173 let value = manifest_path.to_string_lossy();
174
175 for base_key in WINDOWS_REGISTRY_PATHS {
176 let key = format!(r"{base_key}\{HOST_NAME}");
177 let _ = Command::new("reg")
178 .args(["add", &key, "/ve", "/t", "REG_SZ", "/d", &value, "/f"])
179 .output();
180 }
181
182 Ok(())
183}
184
185#[cfg(target_os = "windows")]
186fn unregister_windows_host() {
187 use std::process::Command;
188
189 for base_key in WINDOWS_REGISTRY_PATHS {
190 let key = format!(r"{base_key}\{HOST_NAME}");
191 let _ = Command::new("reg").args(["delete", &key, "/f"]).output();
192 }
193}
194
195fn home_dir() -> Result<PathBuf, InstallerError> {
196 #[cfg(target_os = "windows")]
197 {
198 std::env::var("USERPROFILE")
199 .map(PathBuf::from)
200 .map_err(|_| InstallerError::NoHomeDir)
201 }
202
203 #[cfg(not(target_os = "windows"))]
204 {
205 std::env::var("HOME")
206 .map(PathBuf::from)
207 .map_err(|_| InstallerError::NoHomeDir)
208 }
209}
210
211#[derive(Debug, thiserror::Error)]
212pub enum InstallerError {
213 #[error("cannot determine home directory")]
214 NoHomeDir,
215
216 #[error("IO error: {0}")]
217 Io(#[from] std::io::Error),
218
219 #[error("JSON error: {0}")]
220 Json(#[from] serde_json::Error),
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn manifest_has_correct_name() {
229 let manifest = host_manifest("/usr/local/bin/victauri-browser-host", "abcdef123456");
230 assert_eq!(manifest["name"], HOST_NAME);
231 assert_eq!(manifest["type"], "stdio");
232 }
233
234 #[test]
235 fn manifest_has_allowed_origin() {
236 let manifest = host_manifest("/path/to/binary", "test_extension_id");
237 let origins = manifest["allowed_origins"].as_array().unwrap();
238 assert_eq!(origins.len(), 1);
239 assert!(origins[0].as_str().unwrap().contains("test_extension_id"));
240 }
241
242 #[test]
243 fn manifest_path_is_deterministic() {
244 let p1 = host_manifest_path();
245 let p2 = host_manifest_path();
246 assert!(p1.is_ok());
247 assert_eq!(p1.unwrap(), p2.unwrap());
248 }
249
250 #[test]
251 fn install_dir_is_in_home() {
252 let dir = install_dir().unwrap();
253 assert!(dir.to_string_lossy().contains(".victauri"));
254 assert!(dir.to_string_lossy().contains("bin"));
255 }
256
257 #[test]
258 fn manifest_binary_path_preserved() {
259 let path = "/some/deeply/nested/path/to/victauri-browser-host";
260 let manifest = host_manifest(path, "abc");
261 assert_eq!(manifest["path"], path);
262 }
263
264 #[test]
265 fn manifest_extension_id_in_origin() {
266 let id = "abcdefghijklmnopqrstuvwxyz012345";
267 let manifest = host_manifest("/bin/host", id);
268 let origin = manifest["allowed_origins"][0].as_str().unwrap();
269 assert_eq!(origin, format!("chrome-extension://{id}/"));
270 }
271
272 #[test]
273 fn manifest_type_is_stdio() {
274 let manifest = host_manifest("/bin/host", "ext");
275 assert_eq!(manifest["type"], "stdio");
276 }
277
278 #[test]
279 fn manifest_description_present() {
280 let manifest = host_manifest("/bin/host", "ext");
281 assert!(manifest["description"].as_str().unwrap().len() > 5);
282 }
283
284 #[test]
285 fn manifest_path_components_are_valid() {
286 let path = host_manifest_path().unwrap();
287 let path_str = path.to_string_lossy();
288 assert!(path_str.contains("victauri") || path_str.contains(HOST_NAME));
289 assert!(path_str.ends_with(".json"));
290 }
291
292 #[test]
293 fn all_manifest_paths_non_empty() {
294 let paths = all_manifest_paths().unwrap();
295 assert!(!paths.is_empty());
296 for p in &paths {
297 assert!(p.to_string_lossy().ends_with(".json"));
298 }
299 }
300
301 #[test]
304 fn manifest_is_valid_json_object() {
305 let manifest = host_manifest("/bin/host", "ext");
306 assert!(manifest.is_object());
307 let obj = manifest.as_object().unwrap();
308 assert!(obj.contains_key("name"));
310 assert!(obj.contains_key("description"));
311 assert!(obj.contains_key("path"));
312 assert!(obj.contains_key("type"));
313 assert!(obj.contains_key("allowed_origins"));
314 }
315
316 #[test]
317 fn manifest_name_follows_chrome_spec() {
318 let manifest = host_manifest("/bin/host", "ext");
320 let name = manifest["name"].as_str().unwrap();
321 assert!(name.chars().next().unwrap().is_ascii_lowercase());
322 assert!(
323 name.chars()
324 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_')
325 );
326 assert!(name.len() <= 255);
327 }
328
329 #[test]
330 fn manifest_origin_format_correct() {
331 let manifest = host_manifest("/bin/host", "abcdefghijklmnopqrstuvwxyz012345");
333 let origin = manifest["allowed_origins"][0].as_str().unwrap();
334 assert!(origin.starts_with("chrome-extension://"));
335 assert!(origin.ends_with('/'));
336 }
337
338 #[test]
339 fn manifest_handles_windows_path() {
340 let manifest = host_manifest(
341 r"C:\Program Files\Victauri\victauri-browser-host.exe",
342 "ext",
343 );
344 let path = manifest["path"].as_str().unwrap();
345 assert!(path.contains("victauri-browser-host"));
346 assert!(path.contains(r"C:\Program Files"));
347 }
348
349 #[test]
350 fn manifest_handles_path_with_spaces() {
351 let manifest = host_manifest("/Users/My User/apps/victauri", "ext");
352 assert_eq!(manifest["path"], "/Users/My User/apps/victauri");
353 }
354
355 #[test]
356 fn manifest_handles_unicode_path() {
357 let manifest = host_manifest("/Users/用户/victauri", "ext");
358 assert_eq!(manifest["path"], "/Users/用户/victauri");
359 }
360
361 #[test]
362 fn manifest_serializes_to_valid_json() {
363 let manifest = host_manifest("/bin/host", "ext123");
364 let json_str = serde_json::to_string_pretty(&manifest).unwrap();
365 let reparsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
367 assert_eq!(reparsed, manifest);
368 }
369
370 #[cfg(target_os = "windows")]
371 #[test]
372 fn all_manifest_paths_in_victauri_dir() {
373 let paths = all_manifest_paths().unwrap();
374 for p in &paths {
375 assert!(p.to_string_lossy().contains(".victauri"));
376 }
377 }
378
379 #[cfg(target_os = "macos")]
380 #[test]
381 fn all_manifest_paths_cover_browsers() {
382 let paths = all_manifest_paths().unwrap();
383 let path_strs: Vec<String> = paths
384 .iter()
385 .map(|p| p.to_string_lossy().to_string())
386 .collect();
387 assert!(path_strs.iter().any(|p| p.contains("Chrome")));
388 assert!(path_strs.iter().any(|p| p.contains("Edge")));
389 assert!(path_strs.iter().any(|p| p.contains("Brave")));
390 assert!(path_strs.iter().any(|p| p.contains("Arc")));
391 }
392
393 #[cfg(target_os = "linux")]
394 #[test]
395 fn all_manifest_paths_cover_browsers() {
396 let paths = all_manifest_paths().unwrap();
397 let path_strs: Vec<String> = paths
398 .iter()
399 .map(|p| p.to_string_lossy().to_string())
400 .collect();
401 assert!(path_strs.iter().any(|p| p.contains("google-chrome")));
402 assert!(path_strs.iter().any(|p| p.contains("microsoft-edge")));
403 assert!(path_strs.iter().any(|p| p.contains("Brave")));
404 assert!(path_strs.iter().any(|p| p.contains("chromium")));
405 }
406}