package browserbridge import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "slices" "strings" ) const browserExtensionName = "KeePassGO Browser" type extensionManifestMetadata struct { Name string `json:"name"` } func ResolveBridgeBinaryPath(appBinaryPath string) (string, error) { path := strings.TrimSpace(appBinaryPath) if path == "" { var err error path, err = os.Executable() if err != nil { return "", fmt.Errorf("resolve app executable: %w", err) } } if strings.TrimSpace(path) == "" { return "", fmt.Errorf("app executable path is required") } if filepath.Base(path) == "keepassgo-browser-bridge" { return path, nil } candidate := filepath.Join(filepath.Dir(path), "keepassgo-browser-bridge") if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate, nil } resolved, err := exec.LookPath("keepassgo-browser-bridge") if err == nil { return resolved, nil } return "", fmt.Errorf("locate keepassgo-browser-bridge next to %q or in PATH: %w", path, err) } func EnsureNativeHostManifests(appBinaryPath string) error { bridgePath, err := ResolveBridgeBinaryPath(appBinaryPath) if err != nil { return err } var errs []error if _, err := InstallManifest(BrowserFirefox, bridgePath, "", ""); err != nil { errs = append(errs, fmt.Errorf("install firefox native host: %w", err)) } for _, browser := range []Browser{BrowserChrome, BrowserChromium} { ids, err := DiscoverInstalledExtensionIDs(browser) if err != nil { errs = append(errs, fmt.Errorf("discover %s extension ids: %w", browser, err)) continue } if len(ids) == 0 { continue } if _, err := InstallManifestSet(browser, bridgePath, ids, ""); err != nil { errs = append(errs, fmt.Errorf("install %s native host: %w", browser, err)) } } return errors.Join(errs...) } func DiscoverInstalledExtensionIDs(browser Browser) ([]string, error) { root, err := defaultBrowserProfileRoot(browser) if err != nil { return nil, err } return DiscoverInstalledExtensionIDsInRoot(root) } func DiscoverInstalledExtensionIDsInRoot(root string) ([]string, error) { base := strings.TrimSpace(root) if base == "" { return nil, fmt.Errorf("browser profile root is required") } pattern := filepath.Join(base, "*", "Extensions", "*", "*", "manifest.json") paths, err := filepath.Glob(pattern) if err != nil { return nil, fmt.Errorf("glob browser extensions: %w", err) } ids := make(map[string]struct{}, len(paths)) for _, path := range paths { ok, err := isKeePassGOExtensionManifest(path) if err != nil { return nil, err } if !ok { continue } id := filepath.Base(filepath.Dir(filepath.Dir(path))) if strings.TrimSpace(id) == "" { continue } ids[id] = struct{}{} } out := make([]string, 0, len(ids)) for id := range ids { out = append(out, id) } slices.Sort(out) return out, nil } func InstallManifestSet(browser Browser, binaryPath string, extensionIDs []string, outputPath string) (string, error) { manifest, err := ManifestSet(browser, binaryPath, extensionIDs) if err != nil { return "", err } path := strings.TrimSpace(outputPath) if path == "" { path, err = DefaultManifestPath(browser) if err != nil { return "", err } } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return "", fmt.Errorf("create native host manifest dir: %w", err) } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return "", fmt.Errorf("encode native host manifest: %w", err) } data = append(data, '\n') if err := os.WriteFile(path, data, 0o644); err != nil { return "", fmt.Errorf("write native host manifest: %w", err) } return path, nil } func ManifestSet(browser Browser, binaryPath string, extensionIDs []string) (NativeHostManifest, error) { path := strings.TrimSpace(binaryPath) if path == "" { return NativeHostManifest{}, fmt.Errorf("native host binary path is required") } switch browser { case BrowserFirefox: return Manifest(browser, path, "") case BrowserChrome, BrowserChromium: ids := normalizedExtensionIDs(extensionIDs) if len(ids) == 0 { return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser) } origins := make([]string, 0, len(ids)) for _, id := range ids { origins = append(origins, "chrome-extension://"+id+"/") } return NativeHostManifest{ Name: NativeHostName, Description: "KeePassGO browser bridge", Path: path, Type: "stdio", AllowedOrigins: origins, }, nil default: return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser) } } func defaultBrowserProfileRoot(browser Browser) (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } switch browser { case BrowserChrome: return filepath.Join(home, ".config", "google-chrome"), nil case BrowserChromium: return filepath.Join(home, ".config", "chromium"), nil default: return "", fmt.Errorf("installed extension discovery is unsupported for %q", browser) } } func isKeePassGOExtensionManifest(path string) (bool, error) { data, err := os.ReadFile(path) if err != nil { return false, fmt.Errorf("read extension manifest %q: %w", path, err) } var metadata extensionManifestMetadata if err := json.Unmarshal(data, &metadata); err != nil { return false, fmt.Errorf("decode extension manifest %q: %w", path, err) } return strings.TrimSpace(metadata.Name) == browserExtensionName, nil } func normalizedExtensionIDs(ids []string) []string { seen := make(map[string]struct{}, len(ids)) out := make([]string, 0, len(ids)) for _, raw := range ids { id := strings.TrimSpace(raw) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } slices.Sort(out) return out }