From 39b1a5262c99e8cac5cffbc404d10294ac5b54eb Mon Sep 17 00:00:00 2001 From: Marandi Date: Sat, 31 Jan 2026 12:00:09 +0800 Subject: [PATCH] Add configurable relay server URL in extension Allow users to configure a custom relay server URL in the extension popup, enabling remote development scenarios where the relay server runs on a different machine (e.g., via SSH tunnel). Changes: - Add shared constants file with DEFAULT_RELAY_URL and wsUrlToHttpUrl helper - Add relay URL storage in StateManager - Accept relay URL as parameter in ConnectionManager - Support both ws:// and wss:// URL schemes - Add URL input field in popup UI - Handle URL changes and reconnection in background script --- extension/entrypoints/background.ts | 43 +++++++++++++- extension/entrypoints/popup/index.html | 9 +++ extension/entrypoints/popup/main.ts | 65 ++++++++++++++++++++- extension/entrypoints/popup/style.css | 70 ++++++++++++++++++++++- extension/services/ConnectionManager.ts | 39 +++++++++++-- extension/services/StateManager.ts | 19 ++++++ extension/utils/constants.ts | 48 ++++++++++++++++ extension/utils/types.ts | 16 +++++- skills/dev-browser/scripts/start-relay.ts | 13 ++++- 9 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 extension/utils/constants.ts diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts index e785ab3..7d9d2ce 100644 --- a/extension/entrypoints/background.ts +++ b/extension/entrypoints/background.ts @@ -10,7 +10,7 @@ import { TabManager } from "../services/TabManager"; import { ConnectionManager } from "../services/ConnectionManager"; import { CDPRouter } from "../services/CDPRouter"; import { StateManager } from "../services/StateManager"; -import type { PopupMessage, StateResponse } from "../utils/types"; +import type { PopupMessage, StateResponse, RelayUrlResponse } from "../utils/types"; export default defineBackground(() => { // Create connection manager first (needed for sendMessage) @@ -34,11 +34,12 @@ export default defineBackground(() => { tabManager, }); - // Create connection manager + // Create connection manager with relay URL from state connectionManager = new ConnectionManager({ logger, onMessage: (msg) => cdpRouter.handleCommand(msg), onDisconnect: () => tabManager.detachAll(), + getRelayUrl: () => stateManager.getRelayUrl(), }); // Keep-alive alarm name for Chrome Alarms API @@ -63,6 +64,16 @@ export default defineBackground(() => { updateBadge(isActive); } + // Handle relay URL changes + async function handleRelayUrlChange(relayUrl: string): Promise { + await stateManager.setRelayUrl(relayUrl); + // Reconnect with new URL if active + const state = await stateManager.getState(); + if (state.isActive) { + await connectionManager.reconnect(); + } + } + // Handle debugger events function onDebuggerEvent( source: chrome.debugger.DebuggerSession, @@ -88,7 +99,7 @@ export default defineBackground(() => { ( message: PopupMessage, _sender: chrome.runtime.MessageSender, - sendResponse: (response: StateResponse) => void + sendResponse: (response: StateResponse | RelayUrlResponse) => void ) => { if (message.type === "getState") { (async () => { @@ -115,6 +126,32 @@ export default defineBackground(() => { return true; // Async response } + if (message.type === "getRelayUrl") { + (async () => { + try { + const relayUrl = await stateManager.getRelayUrl(); + sendResponse({ relayUrl }); + } catch (error) { + logger.debug("Error getting relay URL:", error); + sendResponse({ relayUrl: "" }); + } + })(); + return true; // Async response + } + + if (message.type === "setRelayUrl") { + (async () => { + try { + await handleRelayUrlChange(message.relayUrl); + sendResponse({ relayUrl: message.relayUrl }); + } catch (error) { + logger.debug("Error setting relay URL:", error); + sendResponse({ relayUrl: "" }); + } + })(); + return true; // Async response + } + return false; } ); diff --git a/extension/entrypoints/popup/index.html b/extension/entrypoints/popup/index.html index fa68f1b..e4e891e 100644 --- a/extension/entrypoints/popup/index.html +++ b/extension/entrypoints/popup/index.html @@ -17,6 +17,15 @@

Dev Browser

Inactive

+ +
+ +
+ + +
+

+
diff --git a/extension/entrypoints/popup/main.ts b/extension/entrypoints/popup/main.ts index 98acbb7..3d607a4 100644 --- a/extension/entrypoints/popup/main.ts +++ b/extension/entrypoints/popup/main.ts @@ -1,8 +1,19 @@ -import type { GetStateMessage, SetStateMessage, StateResponse } from "../../utils/types"; +import type { + GetStateMessage, + SetStateMessage, + GetRelayUrlMessage, + SetRelayUrlMessage, + StateResponse, + RelayUrlResponse, +} from "../../utils/types"; +import { validateRelayUrl } from "../../utils/constants"; const toggle = document.getElementById("active-toggle") as HTMLInputElement; const statusText = document.getElementById("status-text") as HTMLSpanElement; const connectionStatus = document.getElementById("connection-status") as HTMLParagraphElement; +const relayUrlInput = document.getElementById("relay-url") as HTMLInputElement; +const saveUrlButton = document.getElementById("save-url") as HTMLButtonElement; +const urlStatus = document.getElementById("url-status") as HTMLParagraphElement; function updateUI(state: StateResponse): void { toggle.checked = state.isActive; @@ -27,8 +38,50 @@ function refreshState(): void { }); } +function loadRelayUrl(): void { + chrome.runtime.sendMessage( + { type: "getRelayUrl" }, + (response) => { + if (response) { + relayUrlInput.value = response.relayUrl; + } + } + ); +} + +function saveRelayUrl(): void { + const relayUrl = relayUrlInput.value.trim(); + + const validationError = validateRelayUrl(relayUrl); + if (validationError) { + urlStatus.textContent = validationError; + urlStatus.className = "url-status error"; + return; + } + + chrome.runtime.sendMessage( + { type: "setRelayUrl", relayUrl }, + (response) => { + if (chrome.runtime.lastError) { + urlStatus.textContent = "Failed to save URL"; + urlStatus.className = "url-status error"; + return; + } + if (response) { + urlStatus.textContent = "Saved! Reconnecting..."; + urlStatus.className = "url-status saved"; + setTimeout(() => { + urlStatus.textContent = ""; + urlStatus.className = "url-status"; + }, 3000); + } + } + ); +} + // Load initial state refreshState(); +loadRelayUrl(); // Poll for state updates while popup is open const pollInterval = setInterval(refreshState, 1000); @@ -50,3 +103,13 @@ toggle.addEventListener("change", () => { } ); }); + +// Handle save URL button +saveUrlButton.addEventListener("click", saveRelayUrl); + +// Handle Enter key in URL input +relayUrlInput.addEventListener("keypress", (event) => { + if (event.key === "Enter") { + saveRelayUrl(); + } +}); diff --git a/extension/entrypoints/popup/style.css b/extension/entrypoints/popup/style.css index 024011e..7feb2cb 100644 --- a/extension/entrypoints/popup/style.css +++ b/extension/entrypoints/popup/style.css @@ -11,7 +11,7 @@ body { } .popup { - width: 200px; + width: 280px; padding: 16px; } @@ -94,3 +94,71 @@ input:checked + .slider::before { .connection-status.connecting { color: #ff9800; } + +/* Settings section */ +.settings-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #eee; +} + +.settings-label { + display: block; + font-size: 12px; + font-weight: 500; + color: #666; + margin-bottom: 8px; +} + +.url-input-row { + display: flex; + gap: 8px; +} + +#relay-url { + flex: 1; + padding: 8px; + font-size: 12px; + border: 1px solid #ddd; + border-radius: 4px; + outline: none; +} + +#relay-url:focus { + border-color: #4caf50; +} + +.save-btn { + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + color: #fff; + background-color: #4caf50; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.save-btn:hover { + background-color: #43a047; +} + +.save-btn:active { + background-color: #388e3c; +} + +.url-status { + margin-top: 8px; + font-size: 11px; + color: #888; + min-height: 14px; +} + +.url-status.saved { + color: #4caf50; +} + +.url-status.error { + color: #f44336; +} diff --git a/extension/services/ConnectionManager.ts b/extension/services/ConnectionManager.ts index 3968954..6f7f48f 100644 --- a/extension/services/ConnectionManager.ts +++ b/extension/services/ConnectionManager.ts @@ -4,14 +4,15 @@ import type { Logger } from "../utils/logger"; import type { ExtensionCommandMessage, ExtensionResponseMessage } from "../utils/types"; +import { DEFAULT_RELAY_URL, wsUrlToHttpUrl } from "../utils/constants"; -const RELAY_URL = "ws://localhost:9222/extension"; const RECONNECT_INTERVAL = 3000; export interface ConnectionManagerDeps { logger: Logger; onMessage: (message: ExtensionCommandMessage) => Promise; onDisconnect: () => void; + getRelayUrl?: () => Promise; } export class ConnectionManager { @@ -21,11 +22,13 @@ export class ConnectionManager { private logger: Logger; private onMessage: (message: ExtensionCommandMessage) => Promise; private onDisconnect: () => void; + private getRelayUrl: () => Promise; constructor(deps: ConnectionManagerDeps) { this.logger = deps.logger; this.onMessage = deps.onMessage; this.onDisconnect = deps.onDisconnect; + this.getRelayUrl = deps.getRelayUrl ?? (async () => DEFAULT_RELAY_URL); } /** @@ -44,9 +47,13 @@ export class ConnectionManager { return false; } + // Get the HTTP URL from the WebSocket URL + const relayUrl = await this.getRelayUrl(); + const httpUrl = wsUrlToHttpUrl(relayUrl); + // Verify server is actually reachable try { - const response = await fetch("http://localhost:9222", { + const response = await fetch(httpUrl, { method: "HEAD", signal: AbortSignal.timeout(1000), }); @@ -130,21 +137,41 @@ export class ConnectionManager { } } + /** + * Force reconnect with new relay URL. + */ + async reconnect(): Promise { + if (this.ws) { + // Remove handlers before closing to prevent onDisconnect side effects + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + this.ws.close(); + this.ws = null; + } + if (this.shouldMaintain) { + await this.tryConnect(); + } + } + /** * Try to connect to relay server once. */ private async tryConnect(): Promise { if (this.isConnected()) return; + const relayUrl = await this.getRelayUrl(); + const httpUrl = wsUrlToHttpUrl(relayUrl); + // Check if server is available try { - await fetch("http://localhost:9222", { method: "HEAD" }); + await fetch(httpUrl, { method: "HEAD" }); } catch { return; } - this.logger.debug("Connecting to relay server..."); - const socket = new WebSocket(RELAY_URL); + this.logger.debug(`Connecting to relay server at ${relayUrl}...`); + const socket = new WebSocket(relayUrl); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -169,7 +196,7 @@ export class ConnectionManager { this.ws = socket; this.setupSocketHandlers(socket); - this.logger.log("Connected to relay server"); + this.logger.log(`Connected to relay server at ${relayUrl}`); } /** diff --git a/extension/services/StateManager.ts b/extension/services/StateManager.ts index 3b0a7da..455799f 100644 --- a/extension/services/StateManager.ts +++ b/extension/services/StateManager.ts @@ -2,7 +2,10 @@ * StateManager - Manages extension active/inactive state with persistence. */ +import { DEFAULT_RELAY_URL } from "../utils/constants"; + const STORAGE_KEY = "devBrowserActiveState"; +const RELAY_URL_KEY = "devBrowserRelayUrl"; export interface ExtensionState { isActive: boolean; @@ -25,4 +28,20 @@ export class StateManager { async setState(state: ExtensionState): Promise { await chrome.storage.local.set({ [STORAGE_KEY]: state }); } + + /** + * Get the relay URL. + * Defaults to localhost:9222 if not configured. + */ + async getRelayUrl(): Promise { + const result = await chrome.storage.local.get(RELAY_URL_KEY); + return (result[RELAY_URL_KEY] as string) ?? DEFAULT_RELAY_URL; + } + + /** + * Set the relay URL. + */ + async setRelayUrl(url: string): Promise { + await chrome.storage.local.set({ [RELAY_URL_KEY]: url.trim() }); + } } diff --git a/extension/utils/constants.ts b/extension/utils/constants.ts new file mode 100644 index 0000000..ce725f2 --- /dev/null +++ b/extension/utils/constants.ts @@ -0,0 +1,48 @@ +/** + * Shared constants for the extension. + */ + +export const DEFAULT_RELAY_URL = "ws://localhost:9222/extension"; + +/** + * Convert a WebSocket URL to an HTTP URL for health checks. + * Handles both ws:// -> http:// and wss:// -> https:// conversions. + */ +export function wsUrlToHttpUrl(wsUrl: string): string { + try { + const url = new URL(wsUrl); + url.protocol = url.protocol === "wss:" ? "https:" : "http:"; + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.href.replace(/\/$/, ""); + } catch { + // Fallback for invalid URLs + return wsUrl + .replace(/^wss:/, "https:") + .replace(/^ws:/, "http:") + .replace(/\/extension$/, ""); + } +} + +/** + * Validate a WebSocket URL. + * Returns null if valid, or an error message if invalid. + */ +export function validateRelayUrl(url: string): string | null { + if (!url) { + return "URL cannot be empty"; + } + + if (!url.startsWith("ws://") && !url.startsWith("wss://")) { + return "URL must start with ws:// or wss://"; + } + + try { + new URL(url); + } catch { + return "Invalid URL format"; + } + + return null; +} diff --git a/extension/utils/types.ts b/extension/utils/types.ts index 3b32d06..2646a31 100644 --- a/extension/utils/types.ts +++ b/extension/utils/types.ts @@ -86,9 +86,23 @@ export interface SetStateMessage { isActive: boolean; } +export interface GetRelayUrlMessage { + type: "getRelayUrl"; +} + +export interface SetRelayUrlMessage { + type: "setRelayUrl"; + relayUrl: string; +} + export interface StateResponse { isActive: boolean; isConnected: boolean; + relayUrl?: string; +} + +export interface RelayUrlResponse { + relayUrl: string; } -export type PopupMessage = GetStateMessage | SetStateMessage; +export type PopupMessage = GetStateMessage | SetStateMessage | GetRelayUrlMessage | SetRelayUrlMessage; diff --git a/skills/dev-browser/scripts/start-relay.ts b/skills/dev-browser/scripts/start-relay.ts index 0bc79e4..9a88027 100644 --- a/skills/dev-browser/scripts/start-relay.ts +++ b/skills/dev-browser/scripts/start-relay.ts @@ -7,9 +7,20 @@ import { serveRelay } from "@/relay.js"; const PORT = parseInt(process.env.PORT || "9222", 10); -const HOST = process.env.HOST || "127.0.0.1"; +const HOST = process.env.HOST || "0.0.0.0"; async function main() { + // Security warning for non-localhost binding + if (HOST !== "127.0.0.1" && HOST !== "localhost") { + console.warn( + "\x1b[33m⚠ WARNING: Relay server binding to %s - accessible from network!\x1b[0m", + HOST + ); + console.warn( + "\x1b[33m Set HOST=127.0.0.1 to restrict to localhost only.\x1b[0m" + ); + } + const server = await serveRelay({ port: PORT, host: HOST,