Skip to main content

victauri_browser/
installer.rs

1use std::path::PathBuf;
2
3const HOST_NAME: &str = "com.victauri.browser";
4
5/// Platform-specific native messaging host manifest location.
6///
7/// # Errors
8///
9/// Returns an error if the home directory cannot be determined.
10pub 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/// Generate the native messaging host manifest JSON.
40#[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/// Directory where the native host binary should be installed.
54///
55/// # Errors
56///
57/// Returns an error if the home directory cannot be determined.
58#[allow(dead_code)]
59pub fn install_dir() -> Result<PathBuf, InstallerError> {
60    let home = home_dir()?;
61    Ok(home.join(".victauri").join("bin"))
62}
63
64/// Install the native messaging host manifest for all supported Chromium browsers.
65///
66/// Writes the manifest JSON to Chrome, Edge, and Brave locations.
67/// On Windows, also creates registry keys for all browsers.
68///
69/// # Errors
70///
71/// Returns an error if file I/O or registry operations fail.
72pub 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
143/// Uninstall the native messaging host manifest.
144///
145/// # Errors
146///
147/// Returns an error if file I/O or registry operations fail.
148pub 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    // --- Deep challenger: Chrome manifest spec compliance ---
302
303    #[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        // Chrome requires exactly these fields
309        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        // Chrome: name must match regex [a-z][a-z0-9._]*
319        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        // Chrome requires "chrome-extension://<id>/" format with trailing slash
332        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        // Should be parseable back
366        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}