From fabeac55e69920809bb9c4fb56989d2b96adfe58 Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Sun, 29 Mar 2026 07:01:53 +0800 Subject: [PATCH 1/3] fix(extension): use offscreen document for persistent WebSocket connection Chrome MV3 Service Workers are suspended by the browser when idle, causing the WebSocket connection to the daemon to drop silently. The existing keepalive alarm re-connects after wake, but there is a window where commands fail and the CLI reports 'not connected'. Fix: move the WebSocket into a chrome.offscreen document whose lifetime is tied to the browser window, not the Service Worker. The SW becomes a thin message-relay layer; the offscreen document owns the connection. Changes: - extension/src/offscreen.ts (new): WebSocket host with auto-reconnect - extension/offscreen.html (new): offscreen document entry point - extension/src/background.ts: delegate WS ops to offscreen via messages - extension/manifest.json: add 'offscreen' permission - extension/vite.config.ts: add offscreen as a build entry Tested: extension stays connected after >2h with Chrome idle / SW suspended on Linux. --- extension/dist/background.js | 149 +++++++++---------- extension/dist/offscreen.js | 100 +++++++++++++ extension/manifest.json | 3 +- extension/offscreen.html | 4 + extension/src/background.ts | 268 ++++++++++++++--------------------- extension/src/offscreen.ts | 142 +++++++++++++++++++ extension/vite.config.ts | 7 +- 7 files changed, 426 insertions(+), 247 deletions(-) create mode 100644 extension/dist/offscreen.js create mode 100644 extension/offscreen.html create mode 100644 extension/src/offscreen.ts diff --git a/extension/dist/background.js b/extension/dist/background.js index 4ff776bb..c39a9a4d 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,10 +1,3 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 6e4; - const attached = /* @__PURE__ */ new Set(); const BLANK_PAGE$1 = "data:text/html,"; function isDebuggableUrl$1(url) { @@ -124,19 +117,49 @@ function registerListeners() { }); } -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; +const OFFSCREEN_URL = chrome.runtime.getURL("offscreen.html"); +async function ensureOffscreen() { + if (!chrome.offscreen) return; + const existing = await chrome.offscreen.hasDocument(); + if (!existing) { + await chrome.offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: ["DOM_SCRAPING"], + justification: "Maintain persistent WebSocket connection to opencli daemon" + }); + } +} +async function offscreenConnect() { + await ensureOffscreen(); + try { + await chrome.runtime.sendMessage({ type: "ws-connect" }); + } catch { + } +} +async function wsSend(payload) { + try { + const resp = await chrome.runtime.sendMessage({ type: "ws-send", payload }); + if (!resp?.ok) { + void offscreenConnect(); + } + } catch { + void offscreenConnect(); + } +} +async function wsStatus() { + try { + const resp = await chrome.runtime.sendMessage({ type: "ws-status" }); + return { connected: resp?.connected ?? false, reconnecting: resp?.reconnecting ?? false }; + } catch { + return { connected: false, reconnecting: false }; + } +} const _origLog = console.log.bind(console); const _origWarn = console.warn.bind(console); const _origError = console.error.bind(console); function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); - } catch { - } + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + void wsSend(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); } console.log = (...args) => { _origLog(...args); @@ -150,58 +173,6 @@ console.error = (...args) => { _origError(...args); forwardLog("error", args); }; -async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) return; - } catch { - return; - } - try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - }; - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data); - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; -} -const MAX_EAGER_ATTEMPTS = 6; -function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} const automationSessions = /* @__PURE__ */ new Map(); const WINDOW_IDLE_TIMEOUT = 3e4; function getWorkspaceKey(workspace) { @@ -264,9 +235,9 @@ let initialized = false; function initialize() { if (initialized) return; initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); + chrome.alarms.create("keepalive", { periodInMinutes: 0.25 }); registerListeners(); - void connect(); + void offscreenConnect(); console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { @@ -276,14 +247,34 @@ chrome.runtime.onStartup.addListener(() => { initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") void connect(); + if (alarm.name === "keepalive") { + void offscreenConnect(); + } }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null - }); + wsStatus().then((s) => sendResponse(s)); + return true; + } + if (msg?.type === "ws-message") { + void (async () => { + try { + const command = JSON.parse(msg.data); + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + })(); + return false; + } + if (msg?.type === "ws-connected") { + void wsSend(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); + return false; + } + if (msg?.type === "log") { + void wsSend(JSON.stringify(msg)); + return false; } return false; }); @@ -435,17 +426,13 @@ async function handleNavigate(cmd, workspace) { }; const listener = (id, info, tab2) => { if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { - finish(); - } + if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) finish(); }; chrome.tabs.onUpdated.addListener(listener); checkTimer = setTimeout(async () => { try { const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { - finish(); - } + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); } catch { } }, 100); diff --git a/extension/dist/offscreen.js b/extension/dist/offscreen.js new file mode 100644 index 00000000..16be666b --- /dev/null +++ b/extension/dist/offscreen.js @@ -0,0 +1,100 @@ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const WS_RECONNECT_BASE_DELAY = 2e3; +const WS_RECONNECT_MAX_DELAY = 6e4; + +let ws = null; +let reconnectTimer = null; +let reconnectAttempts = 0; +const MAX_EAGER_ATTEMPTS = 6; +function sendLog(level, msg) { + chrome.runtime.sendMessage({ type: "log", level, msg, ts: Date.now() }).catch(() => { + }); +} +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); +console.log = (...args) => { + _origLog(...args); + sendLog("info", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); +}; +console.warn = (...args) => { + _origWarn(...args); + sendLog("warn", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); +}; +console.error = (...args) => { + _origError(...args); + sendLog("error", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); +}; +async function connect() { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli/offscreen] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.send(JSON.stringify({ type: "hello", version: "__offscreen__" })); + chrome.runtime.sendMessage({ type: "ws-connected" }).catch(() => { + }); + }; + ws.onmessage = (event) => { + chrome.runtime.sendMessage({ type: "ws-message", data: event.data }).catch(() => { + }); + }; + ws.onclose = () => { + console.log("[opencli/offscreen] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; +} +function scheduleReconnect() { + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connect(); + }, delay); +} +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === "ws-connect") { + reconnectTimer = null; + reconnectAttempts = 0; + void connect(); + sendResponse({ ok: true }); + return false; + } + if (msg?.type === "ws-send") { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(msg.payload); + sendResponse({ ok: true }); + } else { + sendResponse({ ok: false, error: "WebSocket not open" }); + } + return false; + } + if (msg?.type === "ws-status") { + sendResponse({ + type: "ws-status-reply", + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null + }); + return false; + } + return false; +}); +void connect(); +console.log("[opencli/offscreen] Offscreen document ready"); diff --git a/extension/manifest.json b/extension/manifest.json index 99efa83d..e8812786 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -8,7 +8,8 @@ "tabs", "cookies", "activeTab", - "alarms" + "alarms", + "offscreen" ], "host_permissions": [ "" diff --git a/extension/offscreen.html b/extension/offscreen.html new file mode 100644 index 00000000..1bfbab9a --- /dev/null +++ b/extension/offscreen.html @@ -0,0 +1,4 @@ + +OpenCLI Offscreen + + diff --git a/extension/src/background.ts b/extension/src/background.ts index c6452262..83deab34 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -3,115 +3,84 @@ * * Connects to the opencli daemon via WebSocket, receives commands, * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results. + * + * WebSocket lives in an Offscreen document (offscreen.ts) so it is never + * killed when the Service Worker is suspended by Chrome MV3. The SW only + * forwards messages to/from the offscreen document. */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; -let ws: WebSocket | null = null; -let reconnectTimer: ReturnType | null = null; -let reconnectAttempts = 0; +// ─── Offscreen document management ────────────────────────────────── -// ─── Console log forwarding ────────────────────────────────────────── -// Hook console.log/warn/error to forward logs to daemon via WebSocket. +const OFFSCREEN_URL = chrome.runtime.getURL('offscreen.html'); -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); +async function ensureOffscreen(): Promise { + // @ts-ignore — chrome.offscreen is typed in newer @types/chrome but may not + // be present in older versions; we guard with existence check at runtime. + if (!chrome.offscreen) return; // unsupported Chrome version, fall back silently + const existing = await (chrome as any).offscreen.hasDocument(); + if (!existing) { + await (chrome as any).offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: ['DOM_SCRAPING'], + justification: 'Maintain persistent WebSocket connection to opencli daemon', + }); + } +} -function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void { - if (!ws || ws.readyState !== WebSocket.OPEN) return; +/** Tell the offscreen doc to (re-)connect. */ +async function offscreenConnect(): Promise { + await ensureOffscreen(); try { - const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); - ws.send(JSON.stringify({ type: 'log', level, msg, ts: Date.now() })); - } catch { /* don't recurse */ } + await chrome.runtime.sendMessage({ type: 'ws-connect' }); + } catch { + // offscreen not ready yet — it will auto-connect on boot anyway + } } -console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); }; -console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); }; -console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); }; - -// ─── WebSocket connection ──────────────────────────────────────────── - -/** - * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket - * connection. fetch() failures are silently catchable; new WebSocket() is not - * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any - * JS handler can intercept it. By keeping the probe inside connect() every - * call site remains unchanged and the guard can never be accidentally skipped. - */ -async function connect(): Promise { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - +/** Send a serialised result/hello string over the WebSocket. */ +async function wsSend(payload: string): Promise { try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); - if (!res.ok) return; // unexpected response — not our daemon + const resp = await chrome.runtime.sendMessage({ type: 'ws-send', payload }) as { ok: boolean }; + if (!resp?.ok) { + // Offscreen WS is down — trigger reconnect + void offscreenConnect(); + } } catch { - return; // daemon not running — skip WebSocket to avoid console noise + void offscreenConnect(); } +} +/** Query live connection status from offscreen. */ +async function wsStatus(): Promise<{ connected: boolean; reconnecting: boolean }> { try { - ws = new WebSocket(DAEMON_WS_URL); + const resp = await chrome.runtime.sendMessage({ type: 'ws-status' }) as any; + return { connected: resp?.connected ?? false, reconnecting: resp?.reconnecting ?? false }; } catch { - scheduleReconnect(); - return; + return { connected: false, reconnecting: false }; } +} - ws.onopen = () => { - console.log('[opencli] Connected to daemon'); - reconnectAttempts = 0; // Reset on successful connection - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - // Send version so the daemon can report mismatches to the CLI - ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); - }; - - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data as string) as Command; - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error('[opencli] Message handling error:', err); - } - }; +// ─── Console log forwarding ────────────────────────────────────────── +// Logs from offscreen arrive as { type:'log', level, msg, ts } messages. +// SW-side logs are forwarded directly via wsSend. - ws.onclose = () => { - console.log('[opencli] Disconnected from daemon'); - ws = null; - scheduleReconnect(); - }; +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); - ws.onerror = () => { - ws?.close(); - }; +function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void { + const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); + void wsSend(JSON.stringify({ type: 'log', level, msg, ts: Date.now() })); } -/** - * After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. - * The keepalive alarm (~24s) will still call connect() periodically, but at a - * much lower frequency — reducing console noise when the daemon is not running. - */ -const MAX_EAGER_ATTEMPTS = 6; // 2s, 4s, 8s, 16s, 32s, 60s — then stop - -function scheduleReconnect(): void { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; // let keepalive alarm handle it - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} +console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); }; +console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); }; +console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); }; // ─── Automation window isolation ───────────────────────────────────── -// All opencli operations happen in a dedicated Chrome window so the -// user's active browsing session is never touched. -// The window auto-closes after 120s of idle (no commands). type AutomationSession = { windowId: number; @@ -120,7 +89,7 @@ type AutomationSession = { }; const automationSessions = new Map(); -const WINDOW_IDLE_TIMEOUT = 30000; // 30s — quick cleanup after command finishes +const WINDOW_IDLE_TIMEOUT = 30000; function getWorkspaceKey(workspace?: string): string { return workspace?.trim() || 'default'; @@ -137,31 +106,22 @@ function resetWindowIdleTimer(workspace: string): void { try { await chrome.windows.remove(current.windowId); console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch { - // Already gone - } + } catch { /* Already gone */ } automationSessions.delete(workspace); }, WINDOW_IDLE_TIMEOUT); } -/** Get or create the dedicated automation window. */ async function getAutomationWindow(workspace: string): Promise { - // Check if our window is still alive const existing = automationSessions.get(workspace); if (existing) { try { await chrome.windows.get(existing.windowId); return existing.windowId; } catch { - // Window was closed by user automationSessions.delete(workspace); } } - // Create a new window with a data: URI that New Tab Override extensions cannot intercept. - // Using about:blank would be hijacked by extensions like "New Tab Override". - // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid - // state value for windows.create(). The window defaults to 'normal' state anyway. const win = await chrome.windows.create({ url: BLANK_PAGE, focused: false, @@ -177,12 +137,10 @@ async function getAutomationWindow(workspace: string): Promise { automationSessions.set(workspace, session); console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); resetWindowIdleTimer(workspace); - // Brief delay to let Chrome load the initial data: URI tab await new Promise(resolve => setTimeout(resolve, 200)); return session.windowId; } -// Clean up when the automation window is closed chrome.windows.onRemoved.addListener((windowId) => { for (const [workspace, session] of automationSessions.entries()) { if (session.windowId === windowId) { @@ -200,9 +158,9 @@ let initialized = false; function initialize(): void { if (initialized) return; initialized = true; - chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds + chrome.alarms.create('keepalive', { periodInMinutes: 0.25 }); // ~15 seconds — faster recovery after SW suspend executor.registerListeners(); - void connect(); + void offscreenConnect(); console.log('[opencli] OpenCLI extension initialized'); } @@ -215,26 +173,54 @@ chrome.runtime.onStartup.addListener(() => { }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'keepalive') void connect(); + if (alarm.name === 'keepalive') { + // Ensure offscreen doc is alive and WS is connected after any SW suspend/resume. + void offscreenConnect(); + } }); -// ─── Popup status API ─────────────────────────────────────────────── +// ─── Message router ────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + // ── Popup status query ── if (msg?.type === 'getStatus') { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null, - }); + wsStatus().then(s => sendResponse(s)); + return true; // async + } + + // ── Incoming WS frame from offscreen ── + if (msg?.type === 'ws-message') { + void (async () => { + try { + const command = JSON.parse(msg.data as string) as Command; + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error('[opencli] Message handling error:', err); + } + })(); + return false; } + + // ── WS connected — send hello with real extension version ── + if (msg?.type === 'ws-connected') { + void wsSend(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + return false; + } + + // ── Log forwarding from offscreen (pass through to WS) ── + if (msg?.type === 'log') { + void wsSend(JSON.stringify(msg)); + return false; + } + return false; }); -// ─── Command dispatcher ───────────────────────────────────────────── +// ─── Command dispatcher ────────────────────────────────────────────── async function handleCommand(cmd: Command): Promise { const workspace = getWorkspaceKey(cmd.workspace); - // Reset idle timer on every command (window stays alive while active) resetWindowIdleTimer(workspace); try { switch (cmd.action) { @@ -266,21 +252,17 @@ async function handleCommand(cmd: Command): Promise { // ─── Action handlers ───────────────────────────────────────────────── -/** Internal blank page used when no user URL is provided. */ const BLANK_PAGE = 'data:text/html,'; -/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */ function isDebuggableUrl(url?: string): boolean { - if (!url) return true; // empty/undefined = tab still loading, allow it + if (!url) return true; return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE; } -/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url: string): boolean { return url.startsWith('http://') || url.startsWith('https://'); } -/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url?: string): string { if (!url) return ''; try { @@ -299,15 +281,7 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } -/** - * Resolve target tab in the automation window. - * If explicit tabId is given, use that directly. - * Otherwise, find or create a tab in the dedicated automation window. - */ async function resolveTabId(tabId: number | undefined, workspace: string): Promise { - // Even when an explicit tabId is provided, validate it is still debuggable. - // This prevents issues when extensions hijack the tab URL to chrome-extension:// - // or when the tab has been closed by the user. if (tabId !== undefined) { try { const tab = await chrome.tabs.get(tabId); @@ -316,25 +290,18 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi if (session && tab.windowId !== session.windowId) { console.warn(`[opencli] Tab ${tabId} belongs to window ${tab.windowId}, not automation window ${session.windowId}, re-resolving`); } else if (!isDebuggableUrl(tab.url)) { - // Tab exists but URL is not debuggable — fall through to auto-resolve console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); } } catch { - // Tab was closed — fall through to auto-resolve console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); } } - // Get (or create) the automation window const windowId = await getAutomationWindow(workspace); - - // Prefer an existing debuggable tab const tabs = await chrome.tabs.query({ windowId }); const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url)); if (debuggableTab?.id) return debuggableTab.id; - // No debuggable tab — another extension may have hijacked the tab URL. - // Try to reuse by navigating to a data: URI (not interceptable by New Tab Override). const reuseTab = tabs.find(t => t.id); if (reuseTab?.id) { await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); @@ -343,12 +310,9 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi const updated = await chrome.tabs.get(reuseTab.id); if (isDebuggableUrl(updated.url)) return reuseTab.id; console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - // Tab was closed during navigation - } + } catch { /* Tab was closed */ } } - // Fallback: create a new tab const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); if (!newTab.id) throw new Error('Failed to create tab in automation window'); return newTab.id; @@ -392,7 +356,6 @@ async function handleNavigate(cmd: Command, workspace: string): Promise const beforeNormalized = normalizeUrlForComparison(beforeTab.url); const targetUrl = cmd.url; - // Fast-path: tab is already at the target URL and fully loaded. if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) { return { id: cmd.id, @@ -401,19 +364,9 @@ async function handleNavigate(cmd: Command, workspace: string): Promise }; } - // Detach any existing debugger before top-level navigation. - // Some sites (observed on creator.xiaohongshu.com flows) can invalidate the - // current inspected target during navigation, which leaves a stale CDP attach - // state and causes the next Runtime.evaluate to fail with - // "Inspected target navigated or closed". Resetting here forces a clean - // re-attach after navigation. await executor.detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - // Wait until navigation completes. Resolve when status is 'complete' AND either: - // - the URL matches the target (handles same-URL / canonicalized navigations), OR - // - the URL differs from the pre-navigation URL (handles redirects). let timedOut = false; await new Promise((resolve) => { let settled = false; @@ -435,23 +388,17 @@ async function handleNavigate(cmd: Command, workspace: string): Promise const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => { if (id !== tabId) return; - if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) { - finish(); - } + if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) finish(); }; chrome.tabs.onUpdated.addListener(listener); - // Also check if the tab already navigated (e.g. instant cache hit) checkTimer = setTimeout(async () => { try { const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) { - finish(); - } + if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) finish(); } catch { /* tab gone */ } }, 100); - // Timeout fallback with warning timeoutTimer = setTimeout(() => { timedOut = true; console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); @@ -471,14 +418,13 @@ async function handleTabs(cmd: Command, workspace: string): Promise { switch (cmd.op) { case 'list': { const tabs = await listAutomationWebTabs(workspace); - const data = tabs - .map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active, - })); + const data = tabs.map((t, i) => ({ + index: i, + tabId: t.id, + url: t.url, + title: t.title, + active: t.active, + })); return { id: cmd.id, ok: true, data }; } case 'new': { @@ -568,11 +514,7 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise { const session = automationSessions.get(workspace); if (session) { - try { - await chrome.windows.remove(session.windowId); - } catch { - // Window may already be closed - } + try { await chrome.windows.remove(session.windowId); } catch { /* already closed */ } if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(workspace); } diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts new file mode 100644 index 00000000..affa1eee --- /dev/null +++ b/extension/src/offscreen.ts @@ -0,0 +1,142 @@ +/** + * OpenCLI — Offscreen Document (WebSocket host). + * + * Lives in an Offscreen document which Chrome never suspends, so the + * WebSocket connection survives across Service Worker sleep/wake cycles. + * + * Message protocol with background.ts: + * + * background → offscreen: + * { type: 'ws-connect' } — (re-)establish WS connection + * { type: 'ws-send', payload: str} — send a raw string over WS + * { type: 'ws-status' } — query connection state + * + * offscreen → background: + * { type: 'ws-message', data: str } — incoming WS frame + * { type: 'ws-status-reply', connected: bool, reconnecting: bool } + * { type: 'log', level, msg, ts } — forward console output + */ + +import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; + +let ws: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempts = 0; + +const MAX_EAGER_ATTEMPTS = 6; + +// ─── Logging ───────────────────────────────────────────────────────── + +function sendLog(level: 'info' | 'warn' | 'error', msg: string): void { + chrome.runtime.sendMessage({ type: 'log', level, msg, ts: Date.now() }).catch(() => {/* SW may be asleep */}); +} + +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); + +console.log = (...args: unknown[]) => { + _origLog(...args); + sendLog('info', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')); +}; +console.warn = (...args: unknown[]) => { + _origWarn(...args); + sendLog('warn', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')); +}; +console.error = (...args: unknown[]) => { + _origError(...args); + sendLog('error', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')); +}; + +// ─── WebSocket ─────────────────────────────────────────────────────── + +async function connect(): Promise { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + + // Skip HTTP ping — fetch to localhost is blocked in offscreen context. + // WebSocket onerror will handle daemon-not-running gracefully. + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log('[opencli/offscreen] Connected to daemon'); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + // Send hello — version comes from background, use a static marker here + ws?.send(JSON.stringify({ type: 'hello', version: '__offscreen__' })); + // Tell background we're up so it can send a proper hello with the real version + chrome.runtime.sendMessage({ type: 'ws-connected' }).catch(() => {}); + }; + + ws.onmessage = (event) => { + chrome.runtime.sendMessage({ type: 'ws-message', data: event.data as string }).catch(() => { + // SW may be sleeping — it will wake via alarm and re-check + }); + }; + + ws.onclose = () => { + console.log('[opencli/offscreen] Disconnected from daemon'); + ws = null; + scheduleReconnect(); + }; + + ws.onerror = () => { + ws?.close(); + }; +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connect(); + }, delay); +} + +// ─── Message listener (from background) ───────────────────────────── + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === 'ws-connect') { + reconnectTimer = null; + reconnectAttempts = 0; + void connect(); + sendResponse({ ok: true }); + return false; + } + + if (msg?.type === 'ws-send') { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(msg.payload as string); + sendResponse({ ok: true }); + } else { + sendResponse({ ok: false, error: 'WebSocket not open' }); + } + return false; + } + + if (msg?.type === 'ws-status') { + sendResponse({ + type: 'ws-status-reply', + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + }); + return false; + } + + return false; +}); + +// ─── Boot ──────────────────────────────────────────────────────────── + +void connect(); +console.log('[opencli/offscreen] Offscreen document ready'); diff --git a/extension/vite.config.ts b/extension/vite.config.ts index f7cd0ecc..da0855a6 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -6,9 +6,12 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, rollupOptions: { - input: resolve(__dirname, 'src/background.ts'), + input: { + background: resolve(__dirname, 'src/background.ts'), + offscreen: resolve(__dirname, 'src/offscreen.ts'), + }, output: { - entryFileNames: 'background.js', + entryFileNames: '[name].js', format: 'es', }, }, From e92cc42aa9befbd625aae48d344714907c4c484e Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 17:36:09 +0800 Subject: [PATCH 2/3] fix(extension): harden offscreen websocket transport --- extension/dist/assets/protocol-Z52ThYIj.js | 8 ++ extension/dist/background.js | 130 +++++++++++++++--- extension/dist/offscreen.js | 64 +++++++-- extension/scripts/package-release.mjs | 46 +++++++ extension/src/background.ts | 147 ++++++++++++++++++--- extension/src/offscreen.ts | 72 ++++++++-- extension/tsconfig.json | 3 +- 7 files changed, 416 insertions(+), 54 deletions(-) create mode 100644 extension/dist/assets/protocol-Z52ThYIj.js diff --git a/extension/dist/assets/protocol-Z52ThYIj.js b/extension/dist/assets/protocol-Z52ThYIj.js new file mode 100644 index 00000000..1af50c65 --- /dev/null +++ b/extension/dist/assets/protocol-Z52ThYIj.js @@ -0,0 +1,8 @@ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +const WS_RECONNECT_BASE_DELAY = 2e3; +const WS_RECONNECT_MAX_DELAY = 6e4; + +export { DAEMON_PING_URL as D, WS_RECONNECT_BASE_DELAY as W, DAEMON_WS_URL as a, WS_RECONNECT_MAX_DELAY as b }; diff --git a/extension/dist/background.js b/extension/dist/background.js index c39a9a4d..1eca0258 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,3 +1,5 @@ +import { D as DAEMON_PING_URL, a as DAEMON_WS_URL, W as WS_RECONNECT_BASE_DELAY, b as WS_RECONNECT_MAX_DELAY } from './assets/protocol-Z52ThYIj.js'; + const attached = /* @__PURE__ */ new Set(); const BLANK_PAGE$1 = "data:text/html,"; function isDebuggableUrl$1(url) { @@ -118,25 +120,116 @@ function registerListeners() { } const OFFSCREEN_URL = chrome.runtime.getURL("offscreen.html"); +let forceLegacyTransport = false; +let legacyWs = null; +let legacyReconnectTimer = null; +let legacyReconnectAttempts = 0; +const MAX_EAGER_ATTEMPTS = 6; +function prefersOffscreenTransport() { + return !forceLegacyTransport && !!chrome.offscreen; +} +async function probeDaemon() { + try { + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + return res.ok; + } catch { + return false; + } +} async function ensureOffscreen() { - if (!chrome.offscreen) return; - const existing = await chrome.offscreen.hasDocument(); - if (!existing) { - await chrome.offscreen.createDocument({ - url: OFFSCREEN_URL, - reasons: ["DOM_SCRAPING"], - justification: "Maintain persistent WebSocket connection to opencli daemon" - }); + if (!chrome.offscreen) return false; + try { + const existing = await chrome.offscreen.hasDocument(); + if (!existing) { + await chrome.offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: ["DOM_SCRAPING"], + justification: "Maintain persistent WebSocket connection to opencli daemon" + }); + } + return true; + } catch (err) { + forceLegacyTransport = true; + console.warn("[opencli] Failed to initialize offscreen transport, falling back to Service Worker transport:", err); + return false; } } async function offscreenConnect() { - await ensureOffscreen(); + const ready = await ensureOffscreen(); + if (!ready) { + await legacyConnect(); + return; + } try { await chrome.runtime.sendMessage({ type: "ws-connect" }); } catch { } } +async function legacyConnect() { + if (legacyWs?.readyState === WebSocket.OPEN || legacyWs?.readyState === WebSocket.CONNECTING) return; + if (!await probeDaemon()) return; + try { + legacyWs = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleLegacyReconnect(); + return; + } + legacyWs.onopen = () => { + console.log("[opencli] Connected to daemon"); + legacyReconnectAttempts = 0; + if (legacyReconnectTimer) { + clearTimeout(legacyReconnectTimer); + legacyReconnectTimer = null; + } + legacyWs?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); + }; + legacyWs.onmessage = async (event) => { + try { + const command = JSON.parse(event.data); + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + legacyWs.onclose = () => { + console.log("[opencli] Disconnected from daemon"); + legacyWs = null; + scheduleLegacyReconnect(); + }; + legacyWs.onerror = () => { + legacyWs?.close(); + }; +} +function scheduleLegacyReconnect() { + if (legacyReconnectTimer) return; + legacyReconnectAttempts++; + if (legacyReconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min( + WS_RECONNECT_BASE_DELAY * Math.pow(2, legacyReconnectAttempts - 1), + WS_RECONNECT_MAX_DELAY + ); + legacyReconnectTimer = setTimeout(() => { + legacyReconnectTimer = null; + void legacyConnect(); + }, delay); +} +async function connectTransport() { + if (prefersOffscreenTransport()) { + await offscreenConnect(); + } else { + await legacyConnect(); + } +} async function wsSend(payload) { + if (!prefersOffscreenTransport()) { + if (legacyWs?.readyState === WebSocket.OPEN) { + legacyWs.send(payload); + } else { + void legacyConnect(); + } + return; + } try { const resp = await chrome.runtime.sendMessage({ type: "ws-send", payload }); if (!resp?.ok) { @@ -147,6 +240,12 @@ async function wsSend(payload) { } } async function wsStatus() { + if (!prefersOffscreenTransport()) { + return { + connected: legacyWs?.readyState === WebSocket.OPEN, + reconnecting: legacyReconnectTimer !== null + }; + } try { const resp = await chrome.runtime.sendMessage({ type: "ws-status" }); return { connected: resp?.connected ?? false, reconnecting: resp?.reconnecting ?? false }; @@ -237,7 +336,7 @@ function initialize() { initialized = true; chrome.alarms.create("keepalive", { periodInMinutes: 0.25 }); registerListeners(); - void offscreenConnect(); + void connectTransport(); console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { @@ -248,7 +347,7 @@ chrome.runtime.onStartup.addListener(() => { }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "keepalive") { - void offscreenConnect(); + void connectTransport(); } }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { @@ -256,7 +355,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { wsStatus().then((s) => sendResponse(s)); return true; } + if (msg?.type === "ws-probe") { + probeDaemon().then((ok) => sendResponse({ ok })); + return true; + } if (msg?.type === "ws-message") { + sendResponse({ ok: true }); void (async () => { try { const command = JSON.parse(msg.data); @@ -268,10 +372,6 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { })(); return false; } - if (msg?.type === "ws-connected") { - void wsSend(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - return false; - } if (msg?.type === "log") { void wsSend(JSON.stringify(msg)); return false; diff --git a/extension/dist/offscreen.js b/extension/dist/offscreen.js index 16be666b..8914daa2 100644 --- a/extension/dist/offscreen.js +++ b/extension/dist/offscreen.js @@ -1,13 +1,13 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 6e4; +import { a as DAEMON_WS_URL, W as WS_RECONNECT_BASE_DELAY, b as WS_RECONNECT_MAX_DELAY } from './assets/protocol-Z52ThYIj.js'; let ws = null; let reconnectTimer = null; let reconnectAttempts = 0; +let pendingFrames = []; +let flushTimer = null; +let flushingFrames = false; const MAX_EAGER_ATTEMPTS = 6; +const FRAME_RETRY_DELAY = 1e3; function sendLog(level, msg) { chrome.runtime.sendMessage({ type: "log", level, msg, ts: Date.now() }).catch(() => { }); @@ -27,8 +27,20 @@ console.error = (...args) => { _origError(...args); sendLog("error", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); }; +async function probeDaemon() { + try { + const resp = await chrome.runtime.sendMessage({ type: "ws-probe" }); + return resp?.ok === true; + } catch { + return false; + } +} async function connect() { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + if (!await probeDaemon()) { + scheduleReconnect(); + return; + } try { ws = new WebSocket(DAEMON_WS_URL); } catch { @@ -42,13 +54,12 @@ async function connect() { clearTimeout(reconnectTimer); reconnectTimer = null; } - ws?.send(JSON.stringify({ type: "hello", version: "__offscreen__" })); - chrome.runtime.sendMessage({ type: "ws-connected" }).catch(() => { - }); + ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); + void flushPendingFrames(); }; ws.onmessage = (event) => { - chrome.runtime.sendMessage({ type: "ws-message", data: event.data }).catch(() => { - }); + pendingFrames.push(event.data); + void flushPendingFrames(); }; ws.onclose = () => { console.log("[opencli/offscreen] Disconnected from daemon"); @@ -59,6 +70,39 @@ async function connect() { ws?.close(); }; } +function scheduleFlush() { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flushPendingFrames(); + }, FRAME_RETRY_DELAY); +} +async function flushPendingFrames() { + if (flushingFrames || pendingFrames.length === 0) return; + flushingFrames = true; + try { + while (pendingFrames.length > 0) { + let delivered = false; + try { + const resp = await chrome.runtime.sendMessage({ + type: "ws-message", + data: pendingFrames[0] + }); + delivered = resp?.ok === true; + } catch { + delivered = false; + } + if (!delivered) { + scheduleFlush(); + break; + } + pendingFrames.shift(); + } + } finally { + flushingFrames = false; + if (pendingFrames.length > 0 && !flushTimer) scheduleFlush(); + } +} function scheduleReconnect() { if (reconnectTimer) return; reconnectAttempts++; diff --git a/extension/scripts/package-release.mjs b/extension/scripts/package-release.mjs index 1a57dca9..34823676 100644 --- a/extension/scripts/package-release.mjs +++ b/extension/scripts/package-release.mjs @@ -94,9 +94,35 @@ async function collectHtmlDependencies(relativeHtmlPath, files, visited) { } } +async function collectScriptDependencies(relativeScriptPath, files, visited) { + if (visited.has(relativeScriptPath)) return; + visited.add(relativeScriptPath); + + const scriptPath = path.join(extensionDir, relativeScriptPath); + const source = await fs.readFile(scriptPath, 'utf8'); + const importRe = /\bimport\s+(?:[^"'()]+?\s+from\s+)?["']([^"']+)["']|\bimport\(\s*["']([^"']+)["']\s*\)/g; + + for (const match of source.matchAll(importRe)) { + const rawRef = match[1] ?? match[2]; + const cleanRef = rawRef?.split('?')[0]; + if (!isLocalAsset(cleanRef)) continue; + + const resolvedRelativePath = cleanRef.startsWith('/') + ? cleanRef.slice(1) + : path.posix.normalize(path.posix.join(path.posix.dirname(relativeScriptPath), cleanRef)); + + addLocalAsset(files, resolvedRelativePath); + + if (resolvedRelativePath.endsWith('.js') || resolvedRelativePath.endsWith('.mjs')) { + await collectScriptDependencies(resolvedRelativePath, files, visited); + } + } +} + async function collectManifestAssets(manifest) { const files = new Set(collectManifestEntrypoints(manifest)); const htmlPages = []; + const scriptEntries = []; if (manifest.action?.default_popup) { htmlPages.push(manifest.action.default_popup); @@ -114,6 +140,26 @@ async function collectManifestAssets(manifest) { } } + if (manifest.background?.service_worker && isLocalAsset(manifest.background.service_worker)) { + scriptEntries.push(manifest.background.service_worker); + } + for (const contentScript of manifest.content_scripts ?? []) { + for (const jsFile of contentScript.js ?? []) { + if (isLocalAsset(jsFile)) scriptEntries.push(jsFile); + } + } + + for (const file of files) { + if (typeof file === 'string' && (file.endsWith('.js') || file.endsWith('.mjs'))) { + scriptEntries.push(file); + } + } + + const scriptVisited = new Set(); + for (const scriptEntry of new Set(scriptEntries)) { + await collectScriptDependencies(scriptEntry, files, scriptVisited); + } + return [...files]; } diff --git a/extension/src/background.ts b/extension/src/background.ts index 83deab34..43a1e2f8 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -10,29 +10,59 @@ */ import type { Command, Result } from './protocol'; +import { DAEMON_PING_URL, DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; // ─── Offscreen document management ────────────────────────────────── const OFFSCREEN_URL = chrome.runtime.getURL('offscreen.html'); +let forceLegacyTransport = false; +let legacyWs: WebSocket | null = null; +let legacyReconnectTimer: ReturnType | null = null; +let legacyReconnectAttempts = 0; +const MAX_EAGER_ATTEMPTS = 6; + +function prefersOffscreenTransport(): boolean { + return !forceLegacyTransport && !!(chrome as any).offscreen; +} + +async function probeDaemon(): Promise { + try { + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + return res.ok; + } catch { + return false; + } +} -async function ensureOffscreen(): Promise { +async function ensureOffscreen(): Promise { // @ts-ignore — chrome.offscreen is typed in newer @types/chrome but may not // be present in older versions; we guard with existence check at runtime. - if (!chrome.offscreen) return; // unsupported Chrome version, fall back silently - const existing = await (chrome as any).offscreen.hasDocument(); - if (!existing) { - await (chrome as any).offscreen.createDocument({ - url: OFFSCREEN_URL, - reasons: ['DOM_SCRAPING'], - justification: 'Maintain persistent WebSocket connection to opencli daemon', - }); + if (!chrome.offscreen) return false; + try { + const existing = await (chrome as any).offscreen.hasDocument(); + if (!existing) { + await (chrome as any).offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: ['DOM_SCRAPING'], + justification: 'Maintain persistent WebSocket connection to opencli daemon', + }); + } + return true; + } catch (err) { + forceLegacyTransport = true; + console.warn('[opencli] Failed to initialize offscreen transport, falling back to Service Worker transport:', err); + return false; } } /** Tell the offscreen doc to (re-)connect. */ async function offscreenConnect(): Promise { - await ensureOffscreen(); + const ready = await ensureOffscreen(); + if (!ready) { + await legacyConnect(); + return; + } try { await chrome.runtime.sendMessage({ type: 'ws-connect' }); } catch { @@ -40,8 +70,81 @@ async function offscreenConnect(): Promise { } } +async function legacyConnect(): Promise { + if (legacyWs?.readyState === WebSocket.OPEN || legacyWs?.readyState === WebSocket.CONNECTING) return; + if (!(await probeDaemon())) return; + + try { + legacyWs = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleLegacyReconnect(); + return; + } + + legacyWs.onopen = () => { + console.log('[opencli] Connected to daemon'); + legacyReconnectAttempts = 0; + if (legacyReconnectTimer) { + clearTimeout(legacyReconnectTimer); + legacyReconnectTimer = null; + } + legacyWs?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + }; + + legacyWs.onmessage = async (event) => { + try { + const command = JSON.parse(event.data as string) as Command; + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error('[opencli] Message handling error:', err); + } + }; + + legacyWs.onclose = () => { + console.log('[opencli] Disconnected from daemon'); + legacyWs = null; + scheduleLegacyReconnect(); + }; + + legacyWs.onerror = () => { + legacyWs?.close(); + }; +} + +function scheduleLegacyReconnect(): void { + if (legacyReconnectTimer) return; + legacyReconnectAttempts++; + if (legacyReconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min( + WS_RECONNECT_BASE_DELAY * Math.pow(2, legacyReconnectAttempts - 1), + WS_RECONNECT_MAX_DELAY, + ); + legacyReconnectTimer = setTimeout(() => { + legacyReconnectTimer = null; + void legacyConnect(); + }, delay); +} + +async function connectTransport(): Promise { + if (prefersOffscreenTransport()) { + await offscreenConnect(); + } else { + await legacyConnect(); + } +} + /** Send a serialised result/hello string over the WebSocket. */ async function wsSend(payload: string): Promise { + if (!prefersOffscreenTransport()) { + if (legacyWs?.readyState === WebSocket.OPEN) { + legacyWs.send(payload); + } else { + void legacyConnect(); + } + return; + } + try { const resp = await chrome.runtime.sendMessage({ type: 'ws-send', payload }) as { ok: boolean }; if (!resp?.ok) { @@ -55,6 +158,13 @@ async function wsSend(payload: string): Promise { /** Query live connection status from offscreen. */ async function wsStatus(): Promise<{ connected: boolean; reconnecting: boolean }> { + if (!prefersOffscreenTransport()) { + return { + connected: legacyWs?.readyState === WebSocket.OPEN, + reconnecting: legacyReconnectTimer !== null, + }; + } + try { const resp = await chrome.runtime.sendMessage({ type: 'ws-status' }) as any; return { connected: resp?.connected ?? false, reconnecting: resp?.reconnecting ?? false }; @@ -160,7 +270,7 @@ function initialize(): void { initialized = true; chrome.alarms.create('keepalive', { periodInMinutes: 0.25 }); // ~15 seconds — faster recovery after SW suspend executor.registerListeners(); - void offscreenConnect(); + void connectTransport(); console.log('[opencli] OpenCLI extension initialized'); } @@ -175,7 +285,7 @@ chrome.runtime.onStartup.addListener(() => { chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'keepalive') { // Ensure offscreen doc is alive and WS is connected after any SW suspend/resume. - void offscreenConnect(); + void connectTransport(); } }); @@ -188,8 +298,15 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return true; // async } + // ── Offscreen asks background to probe daemon reachability ── + if (msg?.type === 'ws-probe') { + probeDaemon().then((ok) => sendResponse({ ok })); + return true; // async + } + // ── Incoming WS frame from offscreen ── if (msg?.type === 'ws-message') { + sendResponse({ ok: true }); void (async () => { try { const command = JSON.parse(msg.data as string) as Command; @@ -202,12 +319,6 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return false; } - // ── WS connected — send hello with real extension version ── - if (msg?.type === 'ws-connected') { - void wsSend(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); - return false; - } - // ── Log forwarding from offscreen (pass through to WS) ── if (msg?.type === 'log') { void wsSend(JSON.stringify(msg)); diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index affa1eee..b6983ba4 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -17,13 +17,17 @@ * { type: 'log', level, msg, ts } — forward console output */ -import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; +let pendingFrames: string[] = []; +let flushTimer: ReturnType | null = null; +let flushingFrames = false; const MAX_EAGER_ATTEMPTS = 6; +const FRAME_RETRY_DELAY = 1000; // ─── Logging ───────────────────────────────────────────────────────── @@ -50,11 +54,25 @@ console.error = (...args: unknown[]) => { // ─── WebSocket ─────────────────────────────────────────────────────── +async function probeDaemon(): Promise { + try { + const resp = await chrome.runtime.sendMessage({ type: 'ws-probe' }) as { ok?: boolean }; + return resp?.ok === true; + } catch { + return false; + } +} + async function connect(): Promise { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - // Skip HTTP ping — fetch to localhost is blocked in offscreen context. - // WebSocket onerror will handle daemon-not-running gracefully. + // Offscreen cannot probe localhost directly, so ask the background SW to do it. + // This preserves the previous "don't spam console with refused WS connects" guard. + if (!(await probeDaemon())) { + scheduleReconnect(); + return; + } + try { ws = new WebSocket(DAEMON_WS_URL); } catch { @@ -69,16 +87,13 @@ async function connect(): Promise { clearTimeout(reconnectTimer); reconnectTimer = null; } - // Send hello — version comes from background, use a static marker here - ws?.send(JSON.stringify({ type: 'hello', version: '__offscreen__' })); - // Tell background we're up so it can send a proper hello with the real version - chrome.runtime.sendMessage({ type: 'ws-connected' }).catch(() => {}); + ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + void flushPendingFrames(); }; ws.onmessage = (event) => { - chrome.runtime.sendMessage({ type: 'ws-message', data: event.data as string }).catch(() => { - // SW may be sleeping — it will wake via alarm and re-check - }); + pendingFrames.push(event.data as string); + void flushPendingFrames(); }; ws.onclose = () => { @@ -92,6 +107,43 @@ async function connect(): Promise { }; } +function scheduleFlush(): void { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flushPendingFrames(); + }, FRAME_RETRY_DELAY); +} + +async function flushPendingFrames(): Promise { + if (flushingFrames || pendingFrames.length === 0) return; + flushingFrames = true; + try { + while (pendingFrames.length > 0) { + let delivered = false; + try { + const resp = await chrome.runtime.sendMessage({ + type: 'ws-message', + data: pendingFrames[0], + }) as { ok?: boolean }; + delivered = resp?.ok === true; + } catch { + delivered = false; + } + + if (!delivered) { + scheduleFlush(); + break; + } + + pendingFrames.shift(); + } + } finally { + flushingFrames = false; + if (pendingFrames.length > 0 && !flushTimer) scheduleFlush(); + } +} + function scheduleReconnect(): void { if (reconnectTimer) return; reconnectAttempts++; diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 93294a53..c2c2762c 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -11,5 +11,6 @@ "declaration": false, "types": ["chrome"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } From b4dbc35d2426fbf3fe3f8c3dd5cea8776de4f19f Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 17:38:13 +0800 Subject: [PATCH 3/3] fix(extension): package offscreen document assets --- extension/scripts/package-release.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/extension/scripts/package-release.mjs b/extension/scripts/package-release.mjs index 34823676..a368c9e4 100644 --- a/extension/scripts/package-release.mjs +++ b/extension/scripts/package-release.mjs @@ -65,6 +65,10 @@ function collectManifestEntrypoints(manifest) { for (const entry of manifest.web_accessible_resources ?? []) { for (const resource of entry.resources ?? []) addLocalAsset(files, resource); } + // MV3 offscreen documents are created at runtime via chrome.offscreen.createDocument() + // and are not referenced directly from manifest entry fields, so include the + // conventional offscreen page explicitly when the permission is present. + if ((manifest.permissions ?? []).includes('offscreen')) files.add('offscreen.html'); if (manifest.default_locale) files.add('_locales'); return [...files]; @@ -130,6 +134,7 @@ async function collectManifestAssets(manifest) { if (manifest.options_page) htmlPages.push(manifest.options_page); if (manifest.devtools_page) htmlPages.push(manifest.devtools_page); if (manifest.side_panel?.default_path) htmlPages.push(manifest.side_panel.default_path); + if ((manifest.permissions ?? []).includes('offscreen')) htmlPages.push('offscreen.html'); for (const page of manifest.sandbox?.pages ?? []) htmlPages.push(page); for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) htmlPages.push(overridePage);