From b2685fb4da615ec674707c8b7c64e13f99694133 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Tue, 24 Mar 2026 14:05:41 +0000 Subject: [PATCH] feat(extension): add native messaging bridge foundation Introduce a single-source browser extension scaffold and wire native messaging to issue websocket connection credentials with per-session API key checks. --- extensions/README.md | 28 +++++ extensions/package.json | 18 +++ extensions/src/entrypoints/background.ts | 146 +++++++++++++++++++++++ extensions/tsconfig.json | 7 ++ extensions/wxt.config.ts | 18 +++ internal/extension/extension.go | 51 +++++++- internal/nativemessaging/host.go | 146 +++++++++++++++++++++++ internal/nativemessaging/install.go | 31 ++++- main.go | 51 +++++++- 9 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 extensions/README.md create mode 100644 extensions/package.json create mode 100644 extensions/src/entrypoints/background.ts create mode 100644 extensions/tsconfig.json create mode 100644 extensions/wxt.config.ts create mode 100644 internal/nativemessaging/host.go diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000..97f0bcb --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,28 @@ +# Focusd Browser Extensions + +This package uses WXT to build one extension source for Chromium and Firefox. + +## Development + +```bash +cd extensions +npm install +npm run dev +``` + +For Firefox: + +```bash +npm run dev:firefox +``` + +## Build + +```bash +npm run build +npm run build:firefox +``` + +The background worker opens a native messaging port to `app.focusd.so`, requests +connection info, and then connects to Focusd websocket endpoint at +`ws://127.0.0.1:50533/extension/ws` with the returned API key. diff --git a/extensions/package.json b/extensions/package.json new file mode 100644 index 0000000..c995aba --- /dev/null +++ b/extensions/package.json @@ -0,0 +1,18 @@ +{ + "name": "focusd-browser-extension", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "wxt", + "dev:firefox": "wxt -b firefox", + "build": "wxt build", + "build:firefox": "wxt build -b firefox", + "zip": "wxt zip", + "zip:firefox": "wxt zip -b firefox" + }, + "devDependencies": { + "typescript": "^5.9.3", + "wxt": "^0.20.6" + } +} diff --git a/extensions/src/entrypoints/background.ts b/extensions/src/entrypoints/background.ts new file mode 100644 index 0000000..dae37f3 --- /dev/null +++ b/extensions/src/entrypoints/background.ts @@ -0,0 +1,146 @@ +const NATIVE_HOST_NAME = "app.focusd.so"; +const RECONNECT_BASE_DELAY_MS = 750; +const RECONNECT_MAX_DELAY_MS = 12_000; +const CONNECTION_TIMEOUT_MS = 5_000; + +type NativeHostRequest = { + type: "get_connection_info"; + application_name: string; +}; + +type NativeHostResponse = { + type: "connection_info" | "error"; + ws_url?: string; + api_key?: string; + application_name?: string; + version?: string; + error?: string; +}; + +type ConnectionInfo = { + wsUrl: string; + apiKey: string; + applicationName: string; +}; + +let reconnectDelay = RECONNECT_BASE_DELAY_MS; + +void startBridge(); + +async function startBridge() { + while (true) { + try { + const appName = detectApplicationName(); + const connectionInfo = await requestConnectionInfo(appName); + await connectWebSocket(connectionInfo); + reconnectDelay = RECONNECT_BASE_DELAY_MS; + } catch (error) { + console.warn("focusd bridge reconnect", error); + } + + await sleep(reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS); + } +} + +function requestConnectionInfo(applicationName: string): Promise { + return new Promise((resolve, reject) => { + const port = browser.runtime.connectNative(NATIVE_HOST_NAME); + + const cleanup = () => { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + clearTimeout(timeout); + try { + port.disconnect(); + } catch { + // Ignore disconnect errors. + } + }; + + const onMessage = (message: NativeHostResponse) => { + cleanup(); + + if (!message || message.type !== "connection_info") { + reject(new Error(message?.error || "native host returned an invalid response")); + return; + } + + if (!message.ws_url || !message.api_key) { + reject(new Error("native host response missing ws_url or api_key")); + return; + } + + resolve({ + wsUrl: message.ws_url, + apiKey: message.api_key, + applicationName: message.application_name || applicationName + }); + }; + + const onDisconnect = () => { + const disconnectError = browser.runtime.lastError?.message; + cleanup(); + reject(new Error(disconnectError || "native host disconnected before sending a response")); + }; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("native host connection timed out")); + }, CONNECTION_TIMEOUT_MS); + + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + + const request: NativeHostRequest = { + type: "get_connection_info", + application_name: applicationName + }; + port.postMessage(request); + }); +} + +function connectWebSocket(info: ConnectionInfo): Promise { + return new Promise((resolve) => { + const wsURL = new URL(info.wsUrl); + wsURL.searchParams.set("api_key", info.apiKey); + wsURL.searchParams.set("application_name", info.applicationName); + + const ws = new WebSocket(wsURL.toString()); + + ws.onopen = () => { + reconnectDelay = RECONNECT_BASE_DELAY_MS; + }; + + ws.onclose = () => { + resolve(); + }; + + ws.onerror = () => { + ws.close(); + }; + }); +} + +function detectApplicationName() { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes("firefox")) { + return "Firefox"; + } + + if (userAgent.includes("edg/")) { + return "Microsoft Edge"; + } + + if (userAgent.includes("brave")) { + return "Brave"; + } + + return "Chrome"; +} + +function sleep(durationMs: number) { + return new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); +} diff --git a/extensions/tsconfig.json b/extensions/tsconfig.json new file mode 100644 index 0000000..2a7ad8c --- /dev/null +++ b/extensions/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "strict": true, + "noEmit": true + } +} diff --git a/extensions/wxt.config.ts b/extensions/wxt.config.ts new file mode 100644 index 0000000..52a3767 --- /dev/null +++ b/extensions/wxt.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "wxt"; + +export default defineConfig({ + srcDir: "src", + extensionApi: "webextension-polyfill", + manifest: { + name: "Focusd Bridge", + description: "Connects browser activity to the local Focusd app", + version: "0.0.1", + permissions: ["nativeMessaging"], + host_permissions: ["http://127.0.0.1:50533/*", "ws://127.0.0.1:50533/*"], + browser_specific_settings: { + gecko: { + id: "focusd@focusd.so" + } + } + } +}); diff --git a/internal/extension/extension.go b/internal/extension/extension.go index 820d543..6599213 100644 --- a/internal/extension/extension.go +++ b/internal/extension/extension.go @@ -1,9 +1,11 @@ package extension import ( + "crypto/subtle" "encoding/json" "errors" "net/http" + "strings" "sync" "github.com/gorilla/websocket" @@ -13,6 +15,12 @@ type ConnectRequest struct { ApplicationName string `json:"application_name"` } +type BootstrapResponse struct { + WSURL string `json:"ws_url"` + APIKey string `json:"api_key"` + Version string `json:"version"` +} + // Hub tracks active websocket clients. type extensionHub struct { mu sync.RWMutex @@ -31,16 +39,51 @@ var hub = &extensionHub{ }, } +func RequireAPIKey(tokenProvider func() string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := tokenProvider() + actual := strings.TrimSpace(r.URL.Query().Get("api_key")) + + if expected == "" || actual == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + if subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func BootstrapHandler(wsURL string, tokenProvider func() string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + response := BootstrapResponse{ + WSURL: wsURL, + APIKey: tokenProvider(), + Version: "1", + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + } +} + // Connect upgrades the request to websocket and tracks the client. // When the connection is lost, the client is removed from the hub. func Connect(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { - var request ConnectRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - return nil, err + request := ConnectRequest{ + ApplicationName: strings.TrimSpace(r.URL.Query().Get("application_name")), } if request.ApplicationName == "" { - return nil, errors.New("application name is required") + err := errors.New("application name is required") + http.Error(w, err.Error(), http.StatusBadRequest) + return nil, err } conn, err := hub.upgrader.Upgrade(w, r, nil) diff --git a/internal/nativemessaging/host.go b/internal/nativemessaging/host.go new file mode 100644 index 0000000..09e0d1e --- /dev/null +++ b/internal/nativemessaging/host.go @@ -0,0 +1,146 @@ +package nativemessaging + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const bootstrapURL = "http://127.0.0.1:50533/extension/bootstrap" + +type hostRequest struct { + Type string `json:"type"` + ApplicationName string `json:"application_name,omitempty"` +} + +type hostResponse struct { + Type string `json:"type"` + WSURL string `json:"ws_url,omitempty"` + APIKey string `json:"api_key,omitempty"` + ApplicationName string `json:"application_name,omitempty"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` +} + +type bootstrapResponse struct { + WSURL string `json:"ws_url"` + APIKey string `json:"api_key"` + Version string `json:"version"` +} + +func ServeHost() error { + for { + raw, err := readMessage(os.Stdin) + if err != nil { + if err == io.EOF { + return nil + } + return err + } + + resp := handleMessage(raw) + if err := writeMessage(os.Stdout, resp); err != nil { + return err + } + } +} + +func handleMessage(raw []byte) hostResponse { + var req hostRequest + if err := json.Unmarshal(raw, &req); err != nil { + return hostResponse{Type: "error", Error: "invalid request payload"} + } + + requestType := strings.TrimSpace(req.Type) + if requestType == "" { + requestType = "get_connection_info" + } + + switch requestType { + case "get_connection_info": + bootstrap, err := fetchBootstrap() + if err != nil { + return hostResponse{Type: "error", Error: err.Error()} + } + + return hostResponse{ + Type: "connection_info", + WSURL: bootstrap.WSURL, + APIKey: bootstrap.APIKey, + ApplicationName: req.ApplicationName, + Version: bootstrap.Version, + } + default: + return hostResponse{Type: "error", Error: "unsupported request type"} + } +} + +func fetchBootstrap() (*bootstrapResponse, error) { + client := &http.Client{Timeout: 3 * time.Second} + req, err := http.NewRequest(http.MethodGet, bootstrapURL, nil) + if err != nil { + return nil, fmt.Errorf("create bootstrap request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("query focusd bootstrap endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bootstrap endpoint returned status %d", resp.StatusCode) + } + + var parsed bootstrapResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("decode bootstrap response: %w", err) + } + + if parsed.WSURL == "" || parsed.APIKey == "" { + return nil, fmt.Errorf("bootstrap response missing required fields") + } + + return &parsed, nil +} + +func readMessage(r io.Reader) ([]byte, error) { + header := make([]byte, 4) + if _, err := io.ReadFull(r, header); err != nil { + return nil, err + } + + size := binary.LittleEndian.Uint32(header) + if size == 0 { + return nil, fmt.Errorf("empty native message") + } + + payload := make([]byte, size) + if _, err := io.ReadFull(r, payload); err != nil { + return nil, err + } + + return payload, nil +} + +func writeMessage(w io.Writer, msg hostResponse) error { + payload, err := json.Marshal(msg) + if err != nil { + return err + } + + buf := bytes.NewBuffer(make([]byte, 0, 4+len(payload))) + header := make([]byte, 4) + binary.LittleEndian.PutUint32(header, uint32(len(payload))) + buf.Write(header) + buf.Write(payload) + + _, err = w.Write(buf.Bytes()) + return err +} diff --git a/internal/nativemessaging/install.go b/internal/nativemessaging/install.go index 89fb5cf..84d17c0 100644 --- a/internal/nativemessaging/install.go +++ b/internal/nativemessaging/install.go @@ -55,20 +55,25 @@ func EnsureHostManifests() error { return fmt.Errorf("resolve user home directory: %w", err) } + launcherPath, err := ensureNativeMessagingLauncher(execPath, home) + if err != nil { + return fmt.Errorf("prepare native messaging launcher: %w", err) + } + chromeIDs := readListEnv("FOCUSD_CHROME_EXTENSION_IDS", defaultChromeExtensionIDs) firefoxIDs := readListEnv("FOCUSD_FIREFOX_EXTENSION_IDS", defaultFirefoxExtensionIDs) chromeManifest := hostManifest{ Name: hostName, Description: hostDescription, - Path: execPath, + Path: launcherPath, Type: "stdio", AllowedOrigins: toChromeOrigins(chromeIDs), } firefoxManifest := hostManifest{ Name: hostName, Description: hostDescription, - Path: execPath, + Path: launcherPath, Type: "stdio", AllowedExtensions: firefoxIDs, } @@ -163,3 +168,25 @@ func writeManifest(path string, manifest hostManifest) error { return nil } + +func ensureNativeMessagingLauncher(execPath, home string) (string, error) { + launcherPath := filepath.Join(home, ".focusd", "bin", "focusd-native-messaging-host.sh") + launcherDir := filepath.Dir(launcherPath) + + if err := os.MkdirAll(launcherDir, 0700); err != nil { + return "", fmt.Errorf("create launcher directory: %w", err) + } + + escapedExecPath := strings.ReplaceAll(execPath, `"`, `\"`) + content := strings.Join([]string{ + "#!/bin/sh", + "exec \"" + escapedExecPath + "\" --native-messaging-host", + "", + }, "\n") + + if err := os.WriteFile(launcherPath, []byte(content), 0700); err != nil { + return "", fmt.Errorf("write launcher script: %w", err) + } + + return launcherPath, nil +} diff --git a/main.go b/main.go index 2e45e6b..1a845be 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,11 @@ package main import ( "context" + "crypto/rand" "database/sql" "embed" _ "embed" + "encoding/base64" "fmt" "io" "log" @@ -63,6 +65,13 @@ func init() { // and starts a goroutine that emits a time-based event every second. It subsequently runs the application and // logs any error that might occur. func main() { + if isNativeMessagingHostMode() { + if err := nativemessaging.ServeHost(); err != nil { + log.Fatalf("failed to serve native messaging host: %v", err) + } + return + } + userdir, err := os.UserHomeDir() if err != nil { log.Fatalf("failed to get user home directory: %v", err) @@ -84,6 +93,11 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + extensionSessionAPIKey, err := generateSessionAPIKey() + if err != nil { + log.Fatalf("failed to generate extension session API key: %v", err) + } + if err := nativemessaging.EnsureHostManifests(); err != nil { slog.Error("failed to ensure native messaging manifests", "error", err) } @@ -92,7 +106,7 @@ func main() { db = setupDB() ) - mux, _, err := setUpWebServer(ctx) + mux, _, err := setUpWebServer(ctx, extensionSessionAPIKey) if err != nil { log.Fatal("failed to setup web server: %w", err) } @@ -429,12 +443,26 @@ func setupDB() *gorm.DB { return gormDB } -func setUpWebServer(ctx context.Context) (*chi.Mux, int, error) { +func setUpWebServer(ctx context.Context, extensionSessionAPIKey string) (*chi.Mux, int, error) { const port = 50533 r := chi.NewRouter() r.Use(middleware.Logger) + extensionWSURL := fmt.Sprintf("ws://127.0.0.1:%d/extension/ws", port) + tokenProvider := func() string { + return extensionSessionAPIKey + } + + r.Route("/extension", func(r chi.Router) { + r.Get("/bootstrap", extension.BootstrapHandler(extensionWSURL, tokenProvider)) + r.With(extension.RequireAPIKey(tokenProvider)).Get("/ws", func(w http.ResponseWriter, req *http.Request) { + if _, err := extension.Connect(w, req); err != nil { + slog.Warn("extension websocket connection failed", "error", err) + } + }) + }) + slog.Info("web server running on port", "port", port) server := &http.Server{ @@ -459,3 +487,22 @@ func setUpWebServer(ctx context.Context) (*chi.Mux, int, error) { return r, port, nil } + +func isNativeMessagingHostMode() bool { + for _, arg := range os.Args[1:] { + if arg == "--native-messaging-host" { + return true + } + } + + return false +} + +func generateSessionAPIKey() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +}