Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions extensions/README.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions extensions/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
146 changes: 146 additions & 0 deletions extensions/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -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<ConnectionInfo> {
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<void> {
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<void>((resolve) => {
setTimeout(resolve, durationMs);
});
}
7 changes: 7 additions & 0 deletions extensions/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"strict": true,
"noEmit": true
}
}
18 changes: 18 additions & 0 deletions extensions/wxt.config.ts
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
});
51 changes: 47 additions & 4 deletions internal/extension/extension.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package extension

import (
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strings"
"sync"

"github.com/gorilla/websocket"
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading