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 if !is_valid_extension_id(extension_id) {
74 return Err(InstallerError::InvalidExtensionId(extension_id.to_string()));
75 }
76 let manifest = host_manifest(binary_path, extension_id);
77 let json = serde_json::to_string_pretty(&manifest).map_err(InstallerError::Json)?;
78
79 let primary_path = host_manifest_path()?;
80
81 for path in all_manifest_paths()? {
82 if let Some(parent) = path.parent() {
83 let _ = std::fs::create_dir_all(parent);
84 }
85 let _ = std::fs::write(&path, &json);
86 }
87
88 #[cfg(target_os = "windows")]
89 {
90 register_windows_host(&primary_path)?;
91 }
92
93 Ok(primary_path.to_string_lossy().to_string())
94}
95
96fn all_manifest_paths() -> Result<Vec<PathBuf>, InstallerError> {
97 let home = home_dir()?;
98 let mut paths = vec![];
99
100 #[cfg(target_os = "windows")]
101 {
102 paths.push(home.join(".victauri").join("native-host-manifest.json"));
103 }
104
105 #[cfg(target_os = "macos")]
106 {
107 let app_support = home.join("Library").join("Application Support");
108 let manifest_file = format!("{HOST_NAME}.json");
109 for browser_dir in [
110 "Google/Chrome",
111 "Microsoft Edge",
112 "BraveSoftware/Brave-Browser",
113 "Arc/User Data",
114 ] {
115 paths.push(
116 app_support
117 .join(browser_dir)
118 .join("NativeMessagingHosts")
119 .join(&manifest_file),
120 );
121 }
122 }
123
124 #[cfg(target_os = "linux")]
125 {
126 let manifest_file = format!("{HOST_NAME}.json");
127 let config = home.join(".config");
128 for browser_dir in [
129 "google-chrome",
130 "microsoft-edge",
131 "BraveSoftware/Brave-Browser",
132 "chromium",
133 ] {
134 paths.push(
135 config
136 .join(browser_dir)
137 .join("NativeMessagingHosts")
138 .join(&manifest_file),
139 );
140 }
141 }
142
143 Ok(paths)
144}
145
146pub fn uninstall() -> Result<(), InstallerError> {
152 let manifest_path = host_manifest_path()?;
153 if manifest_path.exists() {
154 std::fs::remove_file(&manifest_path).map_err(InstallerError::Io)?;
155 }
156
157 #[cfg(target_os = "windows")]
158 {
159 unregister_windows_host();
160 }
161
162 Ok(())
163}
164
165#[cfg(target_os = "windows")]
166const WINDOWS_REGISTRY_PATHS: &[&str] = &[
167 r"HKCU\Software\Google\Chrome\NativeMessagingHosts",
168 r"HKCU\Software\Microsoft\Edge\NativeMessagingHosts",
169 r"HKCU\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts",
170];
171
172#[cfg(target_os = "windows")]
173fn register_windows_host(manifest_path: &std::path::Path) -> Result<(), InstallerError> {
174 use std::process::Command;
175
176 let value = manifest_path.to_string_lossy();
177
178 for base_key in WINDOWS_REGISTRY_PATHS {
179 let key = format!(r"{base_key}\{HOST_NAME}");
180 let _ = Command::new("reg")
181 .args(["add", &key, "/ve", "/t", "REG_SZ", "/d", &value, "/f"])
182 .output();
183 }
184
185 Ok(())
186}
187
188#[cfg(target_os = "windows")]
189fn unregister_windows_host() {
190 use std::process::Command;
191
192 for base_key in WINDOWS_REGISTRY_PATHS {
193 let key = format!(r"{base_key}\{HOST_NAME}");
194 let _ = Command::new("reg").args(["delete", &key, "/f"]).output();
195 }
196}
197
198fn home_dir() -> Result<PathBuf, InstallerError> {
199 #[cfg(target_os = "windows")]
200 {
201 std::env::var("USERPROFILE")
202 .map(PathBuf::from)
203 .map_err(|_| InstallerError::NoHomeDir)
204 }
205
206 #[cfg(not(target_os = "windows"))]
207 {
208 std::env::var("HOME")
209 .map(PathBuf::from)
210 .map_err(|_| InstallerError::NoHomeDir)
211 }
212}
213
214#[derive(Debug, thiserror::Error)]
215pub enum InstallerError {
216 #[error("cannot determine home directory")]
217 NoHomeDir,
218
219 #[error("IO error: {0}")]
220 Io(#[from] std::io::Error),
221
222 #[error("JSON error: {0}")]
223 Json(#[from] serde_json::Error),
224
225 #[error("invalid Chrome extension id: must be 32 chars a-p, got {0:?}")]
226 InvalidExtensionId(String),
227}
228
229#[must_use]
234pub fn is_valid_extension_id(id: &str) -> bool {
235 id.len() == 32 && id.bytes().all(|b| (b'a'..=b'p').contains(&b))
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn manifest_has_correct_name() {
244 let manifest = host_manifest("/usr/local/bin/victauri-browser-host", "abcdef123456");
245 assert_eq!(manifest["name"], HOST_NAME);
246 assert_eq!(manifest["type"], "stdio");
247 }
248
249 #[test]
250 fn extension_id_validation() {
251 assert!(is_valid_extension_id("abcdefghijklmnopabcdefghijklmnop"));
253 assert!(!is_valid_extension_id("EXTENSION_ID"));
255 assert!(!is_valid_extension_id("abcdef123456"));
256 assert!(!is_valid_extension_id("abcdefghijklmnopabcdefghijklmno")); assert!(!is_valid_extension_id("abcdefghijklmnopabcdefghijklmnqz")); assert!(!is_valid_extension_id(""));
259 }
260
261 #[test]
262 fn install_rejects_invalid_extension_id() {
263 let err = install("/tmp/victauri-browser-host", "EXTENSION_ID").unwrap_err();
264 assert!(matches!(err, InstallerError::InvalidExtensionId(_)));
265 }
266
267 #[test]
268 fn manifest_has_allowed_origin() {
269 let manifest = host_manifest("/path/to/binary", "test_extension_id");
270 let origins = manifest["allowed_origins"].as_array().unwrap();
271 assert_eq!(origins.len(), 1);
272 assert!(origins[0].as_str().unwrap().contains("test_extension_id"));
273 }
274
275 #[test]
276 fn manifest_path_is_deterministic() {
277 let p1 = host_manifest_path();
278 let p2 = host_manifest_path();
279 assert!(p1.is_ok());
280 assert_eq!(p1.unwrap(), p2.unwrap());
281 }
282
283 #[test]
284 fn install_dir_is_in_home() {
285 let dir = install_dir().unwrap();
286 assert!(dir.to_string_lossy().contains(".victauri"));
287 assert!(dir.to_string_lossy().contains("bin"));
288 }
289
290 #[test]
291 fn manifest_binary_path_preserved() {
292 let path = "/some/deeply/nested/path/to/victauri-browser-host";
293 let manifest = host_manifest(path, "abc");
294 assert_eq!(manifest["path"], path);
295 }
296
297 #[test]
298 fn manifest_extension_id_in_origin() {
299 let id = "abcdefghijklmnopqrstuvwxyz012345";
300 let manifest = host_manifest("/bin/host", id);
301 let origin = manifest["allowed_origins"][0].as_str().unwrap();
302 assert_eq!(origin, format!("chrome-extension://{id}/"));
303 }
304
305 #[test]
306 fn manifest_type_is_stdio() {
307 let manifest = host_manifest("/bin/host", "ext");
308 assert_eq!(manifest["type"], "stdio");
309 }
310
311 #[test]
312 fn manifest_description_present() {
313 let manifest = host_manifest("/bin/host", "ext");
314 assert!(manifest["description"].as_str().unwrap().len() > 5);
315 }
316
317 #[test]
318 fn manifest_path_components_are_valid() {
319 let path = host_manifest_path().unwrap();
320 let path_str = path.to_string_lossy();
321 assert!(path_str.contains("victauri") || path_str.contains(HOST_NAME));
322 assert!(path_str.ends_with(".json"));
323 }
324
325 #[test]
326 fn all_manifest_paths_non_empty() {
327 let paths = all_manifest_paths().unwrap();
328 assert!(!paths.is_empty());
329 for p in &paths {
330 assert!(p.to_string_lossy().ends_with(".json"));
331 }
332 }
333
334 #[test]
337 fn manifest_is_valid_json_object() {
338 let manifest = host_manifest("/bin/host", "ext");
339 assert!(manifest.is_object());
340 let obj = manifest.as_object().unwrap();
341 assert!(obj.contains_key("name"));
343 assert!(obj.contains_key("description"));
344 assert!(obj.contains_key("path"));
345 assert!(obj.contains_key("type"));
346 assert!(obj.contains_key("allowed_origins"));
347 }
348
349 #[test]
350 fn manifest_name_follows_chrome_spec() {
351 let manifest = host_manifest("/bin/host", "ext");
353 let name = manifest["name"].as_str().unwrap();
354 assert!(name.chars().next().unwrap().is_ascii_lowercase());
355 assert!(
356 name.chars()
357 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_')
358 );
359 assert!(name.len() <= 255);
360 }
361
362 #[test]
363 fn manifest_origin_format_correct() {
364 let manifest = host_manifest("/bin/host", "abcdefghijklmnopqrstuvwxyz012345");
366 let origin = manifest["allowed_origins"][0].as_str().unwrap();
367 assert!(origin.starts_with("chrome-extension://"));
368 assert!(origin.ends_with('/'));
369 }
370
371 #[test]
372 fn manifest_handles_windows_path() {
373 let manifest = host_manifest(
374 r"C:\Program Files\Victauri\victauri-browser-host.exe",
375 "ext",
376 );
377 let path = manifest["path"].as_str().unwrap();
378 assert!(path.contains("victauri-browser-host"));
379 assert!(path.contains(r"C:\Program Files"));
380 }
381
382 #[test]
383 fn manifest_handles_path_with_spaces() {
384 let manifest = host_manifest("/Users/My User/apps/victauri", "ext");
385 assert_eq!(manifest["path"], "/Users/My User/apps/victauri");
386 }
387
388 #[test]
389 fn manifest_handles_unicode_path() {
390 let manifest = host_manifest("/Users/用户/victauri", "ext");
391 assert_eq!(manifest["path"], "/Users/用户/victauri");
392 }
393
394 #[test]
395 fn manifest_serializes_to_valid_json() {
396 let manifest = host_manifest("/bin/host", "ext123");
397 let json_str = serde_json::to_string_pretty(&manifest).unwrap();
398 let reparsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
400 assert_eq!(reparsed, manifest);
401 }
402
403 #[cfg(target_os = "windows")]
404 #[test]
405 fn all_manifest_paths_in_victauri_dir() {
406 let paths = all_manifest_paths().unwrap();
407 for p in &paths {
408 assert!(p.to_string_lossy().contains(".victauri"));
409 }
410 }
411
412 #[cfg(target_os = "macos")]
413 #[test]
414 fn all_manifest_paths_cover_browsers() {
415 let paths = all_manifest_paths().unwrap();
416 let path_strs: Vec<String> = paths
417 .iter()
418 .map(|p| p.to_string_lossy().to_string())
419 .collect();
420 assert!(path_strs.iter().any(|p| p.contains("Chrome")));
421 assert!(path_strs.iter().any(|p| p.contains("Edge")));
422 assert!(path_strs.iter().any(|p| p.contains("Brave")));
423 assert!(path_strs.iter().any(|p| p.contains("Arc")));
424 }
425
426 #[cfg(target_os = "linux")]
427 #[test]
428 fn all_manifest_paths_cover_browsers() {
429 let paths = all_manifest_paths().unwrap();
430 let path_strs: Vec<String> = paths
431 .iter()
432 .map(|p| p.to_string_lossy().to_string())
433 .collect();
434 assert!(path_strs.iter().any(|p| p.contains("google-chrome")));
435 assert!(path_strs.iter().any(|p| p.contains("microsoft-edge")));
436 assert!(path_strs.iter().any(|p| p.contains("Brave")));
437 assert!(path_strs.iter().any(|p| p.contains("chromium")));
438 }
439}