211 lines
5.7 KiB
Go
211 lines
5.7 KiB
Go
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
|
|
}
|